Files
trilium_share_gruvbox/blog_generator.js
T
trilium-share-gruvbox 2818c40f96 feat(embeds): geo/gpx/3D/YouTube/media keyword system + docs, sanitized mirrors
- new snippet geo_map_render.js: [map] inline geomaps (tree order, tint,
  theme-following), [gpx] stats card + elevation profile with hover cursor
  (km from start, d-height, vertical guide), [yt] privacy click-to-load
  facade, [video]/[audio] inline players fed from the new geoData indexes
- 3d_model_viewer.js: [cad] keyword + title/ID resolution via geoData.cad,
  sizes/captions, real occt-wasm ESM pair (fixes 'STEP worker error'),
  code-span-safe keyword scan (docs can show examples)
- blog_generator.js: buildGeoData() v6 – maps (geolocation + geoShape
  point/line/polygon + child GPX tracks), gpx/cad/media indexes,
  branch-change rebuild for route reordering
- generated/geo_data.js placeholder; docs/embed-keywords.md (new) +
  embed-3d-model Option D; README layout/wiring/notes tables updated
- license: runtime components (MapLibre ISC, VersaTiles MIT/ODbL, YouTube)
- hygiene: removed leaked note IDs and instance domain from theme docs
2026-09-28 14:53:51 +02:00

905 lines
39 KiB
JavaScript

const BLOG_ROOT_NOTE_ID = "__BLOG_ROOT_ID__";
const BLOG_SYSTEM_NOTE_ID = "__BLOG_SYSTEM_ID__";
const BLOG_ALIAS = "blog";
const SHARE_BASE = "https://YOUR-INSTANCE.example.com";
const BASE_URL = SHARE_BASE + "/" + BLOG_ALIAS;
const SITE_TITLE = "Your Blog";
const SITE_DESCRIPTION = "Project documentation & technical blog by the author";
const TAGS_SECTION_TITLE = "Tags";
const HIDE_HOME_TITLE_NOTE_ID = "__HIDE_HOME_TITLE_ID__";
const POSTS_PER_PAGE = 10;
function isInSubtree(note, targetId) {
try {
if (note.noteId === targetId) return true;
for (const child of note.getChildNotes()) {
if (isInSubtree(child, targetId)) return true;
}
} catch (e) {
api.log("blog_generator: isInSubtree error: " + e);
}
return false;
}
function isGeneratedNoteId(id) {
try {
if (id === BLOG_ROOT_NOTE_ID) return true;
const n = api.getNote(id);
if (n && n.hasLabel("blogPagedFor")) return true;
const systemNote = api.getNote(BLOG_SYSTEM_NOTE_ID);
if (systemNote) {
const feedNote = systemNote.getChildNotes().find(n => n.title === "feed.xml");
if (feedNote && id === feedNote.noteId) return true;
}
const blogRoot = api.getNote(BLOG_ROOT_NOTE_ID);
if (blogRoot) {
const tagsNote = blogRoot.getChildNotes().find(n => n.title === TAGS_SECTION_TITLE);
if (tagsNote && isInSubtree(tagsNote, id)) return true;
}
} catch (e) {
api.log("blog_generator: isGeneratedNoteId error: " + e);
}
return false;
}
if (api.originEntity && isGeneratedNoteId(api.originEntity.noteId)) {
api.log("blog_generator: ignoring self-trigger from generated note");
return;
}
// ---- P2: only rebuild on events that can affect the generated pages ----
// (avoids full rebuild on every snippet/CSS edit, image upload, self-set shareAlias, ...)
const GEN_RELEVANT_LABELS = new Set([
"publish", "date", "category", "tags", "summary", "state",
"blogGarden", "blogCategory", "blogNoArticles", "blogArticleList", "iconClass",
"geolocation", "geo:position", "geoShape", "viewType", "map:style", "map:scale"
]);
const GEN_SKIP_RELATIONS = new Set([
"shareHtml", "shareCss", "shareTemplate", "shareFavicon", "shareOpenGraphImage",
"runOnNoteContentChange", "runOnAttributeChange", "runOnAttributeCreation",
"runOnNoteTitleChange", "runOnNoteCreation", "runOnChildNoteCreation",
"runOnBranchDeletion", "runOnEntityChange"
]);
function shouldRebuild() {
const o = api.originEntity;
const ev = api.originEvent || "";
if (!o) return true;
if (/contentChange/i.test(ev)) {
// Article body edits don't change the index/feed/tag pages (only summary labels do).
// Keep the refresh only for thumbnail image replacements (index shows the thumb).
const title = o.title || "";
if (!/^(?:thumb|.+_thumb)\.[A-Za-z0-9]+$/i.test(title)) return false;
}
if (o.type === "label" && !GEN_RELEVANT_LABELS.has(o.name)) return false;
if (o.type === "relation" && GEN_SKIP_RELATIONS.has(o.name)) return false;
if (o.type === "branch") {
// Reordering route points (drag in tree) must refresh geo_data
try {
const bn = o.getNote ? o.getNote() : null;
if (bn && (bn.hasLabel("geolocation") || bn.hasLabel("geo:position") || bn.hasLabel("geoShape"))) return true;
} catch (e) {}
return false; // publish/unpublish covers clone+move via label events
}
return true;
}
if (!shouldRebuild()) {
api.log("blog_generator: event skipped (no index impact)");
return;
}
try {
run();
} catch (e) {
api.log("blog_generator: ERROR " + (e && e.stack ? e.stack : e));
}
function run() {
const blogRoot = api.getNote(BLOG_ROOT_NOTE_ID);
if (!blogRoot) {
api.log("blog_generator: root note not found");
return;
}
const articles = [];
function visit(note, depth) {
if (depth > 20) return;
if (note.noteId !== BLOG_ROOT_NOTE_ID && note.getLabelValue("blogGarden") !== null) {
return;
}
const publish = note.getLabelValue("publish");
if (note.noteId !== BLOG_ROOT_NOTE_ID && publish === "true") {
articles.push(note);
return;
}
for (const child of note.getChildNotes()) {
visit(child, depth + 1);
}
}
visit(blogRoot, 0);
articles.sort((a, b) => {
const da = a.getLabelValue("date") || a.dateCreated;
const db = b.getLabelValue("date") || b.dateCreated;
return (db < da) ? -1 : (db > da ? 1 : 0);
});
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function tagList(a) {
return (a.getLabelValue("tags") || "").split(/\s+/).filter(Boolean).map(t => t.replace(/^!+/, ""));
}
function tagHref(t) {
return SHARE_BASE + "/" + encodeURIComponent("tag-" + slugifyTag(t));
}
function thumbSrc(a) {
try {
const thumbs = (a.getChildNotes() || []).filter(n => /^(?:thumb|.+_thumb)\.[A-Za-z0-9]+$/i.test(n.title));
if (thumbs.length) return "api/notes/" + thumbs[0].noteId + "/download";
} catch (e) {}
try {
const m = String(a.getContent() || "").match(/<img[^>]+src="([^"]+)"/);
return m ? m[1] : "";
} catch (e) {
return "";
}
}
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
function dateText(raw) {
const iso = String(raw || "").slice(0, 10);
if (!iso) return "";
const d = new Date(iso);
if (isNaN(d)) return iso;
return WEEKDAYS[d.getUTCDay()] + " " + iso;
}
function renderArticles(list) {
const lines = [];
for (const a of list) {
const date = dateText(a.getLabelValue("date") || a.dateCreated);
const cat = a.getLabelValue("category") || "";
const summary = a.getLabelValue("summary") || "";
const link = `${SHARE_BASE}/${encodeURIComponent(a.getLabelValue("shareAlias") || a.noteId)}`;
const tags = tagList(a);
const thumb = thumbSrc(a);
lines.push('<article class="blog-post">');
lines.push(' <header class="blog-post-header">');
lines.push(` <h2 class="blog-post-title"><a href="${escapeHtml(link)}">${escapeHtml(a.title)}</a></h2>`);
lines.push(' <div class="blog-post-meta">');
if (date) lines.push(` <span class="blog-post-date">${escapeHtml(date)}</span>`);
if (cat) lines.push(` <span class="blog-post-category">${escapeHtml(cat)}</span>`);
lines.push(' </div>');
lines.push(' </header>');
if (summary || thumb) {
lines.push(' <div class="blog-post-inner">');
if (thumb) {
lines.push(` <a class="blog-thumb-link" href="${escapeHtml(link)}" tabindex="-1" aria-hidden="true">`);
lines.push(` <img class="blog-thumb" src="${escapeHtml(thumb)}" alt="" loading="lazy">`);
lines.push(' </a>');
}
lines.push(' <div class="blog-post-body">');
if (summary) lines.push(` <p class="blog-post-summary">${escapeHtml(summary)}</p>`);
if (tags.length) {
lines.push(' <div class="blog-post-tags">' + tags.map(t => `<a class="tag" href="${tagHref(t)}">#${escapeHtml(t)}</a>`).join(" ") + '</div>');
}
lines.push(' </div>');
lines.push(' </div>');
} else if (tags.length) {
lines.push(' <div class="blog-post-tags">' + tags.map(t => `<a class="tag" href="${tagHref(t)}">#${escapeHtml(t)}</a>`).join(" ") + '</div>');
}
lines.push('</article>');
}
return lines.join("\n");
}
function stateIcon(state) {
const s = String(state).toLowerCase();
const icons = {
"seed": "bx bxs-coffee-bean",
"seedling": "bx bxs-coffee-bean",
"sprout": "bx bxs-leaf",
"sprouting": "bx bxs-leaf",
"bud": "bx bxs-leaf",
"evergreen": "bx bxs-tree-alt",
"draft": "bx bx-edit",
"review": "bx bx-search-alt"
};
return icons[s] || "bx bxs-circle";
}
function stateClass(state) {
const s = String(state).toLowerCase();
const growth = ["seed", "seedling", "sprout", "sprouting", "bud", "evergreen"];
if (growth.includes(s)) return "state-growth";
if (s === "draft") return "state-draft";
if (s === "review") return "state-review";
return "state-unknown";
}
function buildGardenTable(catNote, list) {
let html = '<table class="blog-garden-table">\n<thead><tr><th>Title</th><th>Type</th><th>State</th><th>Date</th></tr></thead>\n<tbody>';
for (const a of list) {
const link = SHARE_BASE + "/" + encodeURIComponent(a.getLabelValue("shareAlias") || a.noteId);
const type = a.type || "text";
const state = a.getLabelValue("state") || "";
const date = dateText(a.getLabelValue("date") || a.dateCreated);
html += `<tr><td><a href="${escapeHtml(link)}">${escapeHtml(a.title)}</a></td><td><span class="note-type">${escapeHtml(type)}</span></td><td>${state ? `<span class="note-state ${stateClass(state)}"><i class="${stateIcon(state)}"></i>${escapeHtml(state)}</span>` : ""}</td><td>${escapeHtml(date)}</td></tr>`;
}
html += '</tbody></table>';
return html;
}
function pageUrl(aliasBase, p) {
return SHARE_BASE + "/" + (p <= 1 ? aliasBase : aliasBase + "-" + p);
}
function paginationHtml(page, totalPages, aliasBase) {
if (totalPages <= 1) return "";
let html = '<nav class="blog-pagination">';
if (page > 1) {
html += '<a class="blog-pn prev" href="' + pageUrl(aliasBase, page - 1) + '"><span class="blog-pn-label">Newer</span></a>';
} else {
html += '<span class="blog-pn empty"></span>';
}
html += '<span class="blog-page-num">Page ' + page + ' of ' + totalPages + '</span>';
if (page < totalPages) {
html += '<a class="blog-pn next" href="' + pageUrl(aliasBase, page + 1) + '"><span class="blog-pn-label">Older</span></a>';
} else {
html += '<span class="blog-pn empty"></span>';
}
html += '</nav>';
return html;
}
function syncPageNotes(pageLabel, aliasBase, totalPages, list, garden, hideTitle) {
const systemNote = api.getNote(BLOG_SYSTEM_NOTE_ID);
if (!systemNote) return;
const existing = systemNote.getChildNotes().filter(n => n.getLabelValue("blogPagedFor") === pageLabel);
for (let p = 2; p <= totalPages; p++) {
let note = existing.find(n => n.getLabelValue("blogPagedNumber") === String(p));
const created = !note;
if (!note) {
const res = api.createTextNote(systemNote.noteId, "Blog page " + p + " (" + pageLabel + ")", "");
note = res.note ? res.note : res;
}
const slice = list.slice((p - 1) * POSTS_PER_PAGE, p * POSTS_PER_PAGE);
let body = garden ? buildGardenTable(note, slice) : (renderArticles(slice) || '<p>No articles.</p>');
body += paginationHtml(p, totalPages, aliasBase);
if (note.getContent() !== body) {
note.setContent(body);
note.save();
}
note.setLabel("blogPagedFor", pageLabel);
note.setLabel("blogPagedNumber", String(p));
note.setLabel("shareAlias", aliasBase + "-" + p);
note.setLabel("shareHiddenFromTree", "");
if (hideTitle && !note.hasRelation("shareHtml", HIDE_HOME_TITLE_NOTE_ID)) {
note.setRelation("shareHtml", HIDE_HOME_TITLE_NOTE_ID);
}
note.save();
api.log("blog_generator: page " + pageLabel + " #" + p + (created ? " created" : " updated"));
}
for (const n of existing) {
const num = parseInt(n.getLabelValue("blogPagedNumber") || "999", 10);
if (num > totalPages) {
api.log("blog_generator: removing obsolete page note " + n.title);
n.deleteNote();
}
}
}
function buildIndex() {
const pageCount = Math.max(1, Math.ceil(articles.length / POSTS_PER_PAGE));
const body = (renderArticles(articles.slice(0, POSTS_PER_PAGE)) || '<p>No articles published yet.</p>')
+ paginationHtml(1, pageCount, BLOG_ALIAS);
if (blogRoot.getContent() !== body) {
blogRoot.setContent(body);
blogRoot.save();
api.log("blog_generator: index updated (" + articles.length + " articles, " + pageCount + " pages)");
} else {
api.log("blog_generator: index unchanged");
}
syncPageNotes("index", BLOG_ALIAS, pageCount, articles, false, true);
}
function buildFeedXml() {
const items = articles.map(a => {
const date = a.getLabelValue("date") || a.dateCreated;
const alias = a.getLabelValue("shareAlias") || a.noteId;
const summary = a.getLabelValue("summary") || "";
const pubDate = new Date(date).toUTCString();
const link = `${BASE_URL}/${encodeURIComponent(alias)}`;
return ` <item>\n <title>${escapeHtml(a.title)}</title>\n <link>${link}</link>\n <guid isPermaLink="false">${escapeHtml(a.noteId)}</guid>\n <pubDate>${pubDate}</pubDate>\n <description>${escapeHtml(summary)}</description>\n </item>`;
}).join("\n");
const now = new Date().toUTCString();
return `<?xml version="1.0" encoding="UTF-8"?>\n<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">\n <channel>\n <title>${escapeHtml(SITE_TITLE)}</title>\n <link>${BASE_URL}</link>\n <atom:link href="${SHARE_BASE}/feed" rel="self" type="application/rss+xml"/>\n <description>${escapeHtml(SITE_DESCRIPTION)}</description>\n <language>en</language>\n <lastBuildDate>${now}</lastBuildDate>\n${items}\n </channel>\n</rss>`;
}
function slugifyTag(t) {
return t.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
}
function buildStateMap() {
const systemNote = api.getNote(BLOG_SYSTEM_NOTE_ID);
if (!systemNote) return;
const mapNote = systemNote.getChildNotes().find(n => n.title === "state_map");
if (!mapNote) return;
const map = {};
const seen = new Set();
(function walk(note, depth) {
if (depth > 20) return;
for (const child of note.getChildNotes()) {
if (seen.has(child.noteId)) continue;
seen.add(child.noteId);
const state = child.getLabelValue("state");
if (child.getLabelValue("publish") === "true" && state) {
map[child.noteId] = state;
}
walk(child, depth + 1);
}
})(blogRoot, 0);
const html = `<script>window.blogStates = ${JSON.stringify(map)};</script>`;
if (mapNote.getContent() !== html) {
mapNote.setContent(html);
mapNote.save();
api.log("blog_generator: state map updated");
}
}
function collectAllPublished() {
const out = [];
const seen = new Set();
(function walk(note, depth) {
if (depth > 20) return;
for (const child of note.getChildNotes()) {
if (seen.has(child.noteId)) continue;
seen.add(child.noteId);
if (child.getLabelValue("publish") === "true") out.push(child);
walk(child, depth + 1);
}
})(blogRoot, 0);
out.sort((a, b) => {
const da = a.getLabelValue("date") || a.dateCreated;
const db = b.getLabelValue("date") || b.dateCreated;
return (db < da) ? -1 : (db > da ? 1 : 0);
});
return out;
}
function buildSitemap() {
const systemNote = api.getNote(BLOG_SYSTEM_NOTE_ID);
if (!systemNote) return;
const sitemapNote = systemNote.getChildNotes().find(n => n.title === "sitemap.xml");
if (!sitemapNote) return;
const urls = [SHARE_BASE + "/" + BLOG_ALIAS];
for (const a of collectAllPublished()) {
urls.push(SHARE_BASE + "/" + encodeURIComponent(a.getLabelValue("shareAlias") || a.noteId));
}
for (const cat of collectCategoryNotes()) {
urls.push(SHARE_BASE + "/" + encodeURIComponent(cat.getLabelValue("shareAlias") || cat.noteId));
}
const tagsNote = blogRoot.getChildNotes().find(n => n.title === TAGS_SECTION_TITLE);
if (tagsNote) {
for (const t of tagsNote.getChildNotes()) {
urls.push(SHARE_BASE + "/" + encodeURIComponent(t.getLabelValue("shareAlias") || t.noteId));
}
}
const body = urls.map(u => ` <url><loc>${escapeHtml(u)}</loc></url>`).join("\n");
const xml = `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${body}\n</urlset>`;
if (sitemapNote.getContent() !== xml) {
sitemapNote.setContent(xml);
sitemapNote.save();
api.log("blog_generator: sitemap updated (" + urls.length + " urls)");
}
}
function ensureSeoMeta() {
const seen = new Set();
(function walk(note, depth) {
if (depth > 20) return;
for (const child of note.getChildNotes()) {
if (seen.has(child.noteId)) continue;
seen.add(child.noteId);
const published = child.getLabelValue("publish") === "true";
const isCat = isCategoryNote(child);
if (published || isCat) {
const desc = child.getLabelValue("summary") || (isCat ? "Articles in " + child.title : SITE_DESCRIPTION);
if (child.getLabelValue("shareDescription") !== desc) {
child.setLabel("shareDescription", desc);
child.save();
}
const url = SHARE_BASE + "/" + encodeURIComponent(child.getLabelValue("shareAlias") || child.noteId);
if (child.getLabelValue("shareOpenGraphURL") !== url) {
child.setLabel("shareOpenGraphURL", url);
child.save();
api.log("blog_generator: set shareOpenGraphURL on " + child.title);
}
}
walk(child, depth + 1);
}
})(blogRoot, 0);
}
function plainText(a) {
try {
return String(a.getContent() || "")
.replace(/<script[\s\S]*?<\/script>/gi, " ")
.replace(/<style[\s\S]*?<\/style>/gi, " ")
.replace(/<[^>]+>/g, " ")
.replace(/&[a-z#0-9]+;/gi, " ")
.replace(/\s+/g, " ")
.trim()
.slice(0, 1500);
} catch (e) { return ""; }
}
function isInGardenTree(note) {
try {
for (const p of note.getParentNotes()) {
let cur = p;
for (let d = 0; cur && d < 8; d++) {
if (cur.hasLabel && cur.hasLabel("blogGarden")) return true;
cur = cur.getParentNotes()[0];
}
}
} catch (e) {}
return false;
}
function buildBlogData() {
const systemNote = api.getNote(BLOG_SYSTEM_NOTE_ID);
if (!systemNote) return;
const dataNote = systemNote.getChildNotes().find(n => n.title === "blog_data");
if (!dataNote) return;
const list = collectAllPublished().map(a => ({
id: a.noteId,
title: a.title,
url: SHARE_BASE + "/" + encodeURIComponent(a.getLabelValue("shareAlias") || a.noteId),
summary: a.getLabelValue("summary") || "",
tags: tagList(a),
category: a.getLabelValue("category") || (isInGardenTree(a) ? "Digital Garden" : ""),
date: (a.getLabelValue("date") || a.dateCreated).slice(0, 10),
created: (a.dateCreated || "").slice(0, 10),
dateText: dateText(a.getLabelValue("date") || a.dateCreated),
text: plainText(a)
}));
const html = `<script>window.blogData = ${JSON.stringify({ articles: list })};</script>`;
if (dataNote.getContent() !== html) {
dataNote.setContent(html);
dataNote.save();
api.log("blog_generator: blog_data updated (" + list.length + " articles)");
}
}
// Published notes with viewType=geoMap get their child geolocations exported
// as window.geoData; the "geo map render" snippet turns them into Leaflet maps.
function buildGeoData() {
const systemNote = api.getNote(BLOG_SYSTEM_NOTE_ID);
if (!systemNote) return;
const books = [];
const seen = new Set();
(function walk(note, depth) {
if (depth > 20) return;
for (const child of note.getChildNotes()) {
if (seen.has(child.noteId)) continue;
seen.add(child.noteId);
if (child.getLabelValue("viewType") === "geoMap" && child.getLabelValue("publish") === "true") books.push(child);
walk(child, depth + 1);
}
})(blogRoot, 0);
let dataNote = systemNote.getChildNotes().find(n => n.title === "geo_data");
if (!books.length) {
if (dataNote && dataNote.getContent() !== "") {
dataNote.setContent("");
dataNote.save();
api.log("blog_generator: geo_data cleared");
}
return;
}
if (!dataNote) {
const created = api.createTextNote(systemNote.noteId, "geo_data", "");
dataNote = created.note ? created.note : created;
}
if (!blogRoot.hasRelation("shareHtml", dataNote.noteId)) {
blogRoot.setInheritableRelation("shareHtml", dataNote.noteId);
blogRoot.save();
api.log("blog_generator: geo_data attached to blog root");
}
const maps = books.map(b => {
const pts = [];
let kids = b.getChildNotes();
try {
if (typeof api.getBranchFromChildAndParent === "function") {
kids = kids.slice().sort((c1, c2) => {
const br1 = api.getBranchFromChildAndParent(c1.noteId, b.noteId);
const br2 = api.getBranchFromChildAndParent(c2.noteId, b.noteId);
return (br1 ? br1.position : 0) - (br2 ? br2.position : 0);
});
} else {
api.log("blog_generator: getBranchFromChildAndParent unavailable, using natural order");
}
} catch (e) {
api.log("blog_generator: geo sort failed (" + e + "), using natural order");
}
function parsePairs(s) {
const out = [];
for (const pair of String(s).split(/\s+/)) {
const pm = pair.match(/^(-?\d+(?:\.\d+)?),\s*(-?\d+(?:\.\d+)?)$/);
if (pm) out.push({ lat: parseFloat(pm[1]), lng: parseFloat(pm[2]) });
}
return out;
}
const tracks = [];
for (const c of kids) {
const published = c.getLabelValue("publish") === "true";
const purl = published ? SHARE_BASE + "/" + encodeURIComponent(c.getLabelValue("shareAlias") || c.noteId) : "";
const summary = c.getLabelValue("summary") || "";
const markerCoords = [];
let shapes = [];
try { shapes = c.getLabels("geoShape"); } catch (e) {}
for (const s of shapes) {
const sm = String(s.value).match(/^\s*(point|line|polygon)\s*:\s*([\s\S]*)$/i);
if (!sm) continue;
const cs = parsePairs(sm[2]);
if (!cs.length) continue;
if (sm[1].toLowerCase() === "point") markerCoords.push(...cs);
else tracks.push({ title: c.title, coords: cs.map(p => [p.lng, p.lat]) });
}
const g = c.getLabelValue("geolocation") || c.getLabelValue("geo:position") || "";
markerCoords.push(...parsePairs(g));
markerCoords.forEach((p, i) => {
pts.push({ title: markerCoords.length > 1 ? c.title + " (" + (i + 1) + ")" : c.title, lat: p.lat, lng: p.lng, url: purl, summary: summary });
});
if (c.type === "file" && (/\.gpx$/i.test(c.title || "") || /\.gpx$/i.test(c.getLabelValue("originalFileName") || ""))) {
tracks.push({ title: c.title.replace(/\.gpx$/i, ""), url: "api/notes/" + c.noteId + "/download" });
}
}
return {
id: b.noteId,
title: b.title,
style: b.getLabelValue("map:style") || "",
points: pts,
tracks: tracks
};
});
const gpxIndex = [];
(function walkGpx(note, depth) {
if (depth > 20) return;
for (const child of note.getChildNotes()) {
if (child.type === "file" && (/\.gpx$/i.test(child.title || "") || /\.gpx$/i.test(child.getLabelValue("originalFileName") || ""))) {
gpxIndex.push({ id: child.noteId, title: child.title.replace(/\.gpx$/i, "") });
}
walkGpx(child, depth + 1);
}
})(blogRoot, 0);
const cadIndex = [];
(function walkCad(note, depth) {
if (depth > 20) return;
for (const child of note.getChildNotes()) {
if (child.type === "file") {
const cm = String(child.title || "").match(/\.(stl|step|stp)$/i)
|| String(child.getLabelValue("originalFileName") || "").match(/\.(stl|step|stp)$/i);
if (cm) {
cadIndex.push({ id: child.noteId, title: child.title.replace(/\.(stl|step|stp)$/i, ""), format: cm[1].toLowerCase() });
}
}
walkCad(child, depth + 1);
}
})(blogRoot, 0);
const mediaIndex = [];
(function walkMedia(note, depth) {
if (depth > 20) return;
for (const child of note.getChildNotes()) {
if (child.type === "file") {
const titleStr = String(child.title || "");
const origStr = String(child.getLabelValue("originalFileName") || "");
const wm = titleStr.match(/\.(mp4|webm|mov|m4v|mp3|m4a|ogg|oga|wav|flac|aac|opus)$/i)
|| origStr.match(/\.(mp4|webm|mov|m4v|mp3|m4a|ogg|oga|wav|flac|aac|opus)$/i);
if (wm) {
const fmt = wm[1].toLowerCase();
mediaIndex.push({
id: child.noteId,
title: titleStr.replace(/\.[a-z0-9]+$/i, "") || origStr.replace(/\.[a-z0-9]+$/i, ""),
format: fmt,
kind: ["mp4", "webm", "mov", "m4v"].indexOf(fmt) >= 0 ? "video" : "audio"
});
}
}
walkMedia(child, depth + 1);
}
})(blogRoot, 0);
const html = `<script>window.geoData = ${JSON.stringify({ maps, gpx: gpxIndex, cad: cadIndex, media: mediaIndex, v: 6 })};</script>`;
if (dataNote.getContent() !== html) {
dataNote.setContent(html);
dataNote.save();
api.log("blog_generator: geo_data updated (" + maps.length + " maps)");
}
}
function buildTags() {
const tagsNote = blogRoot.getChildNotes().find(n => n.title === TAGS_SECTION_TITLE);
if (!tagsNote) return;
const map = new Map();
for (const a of articles) {
for (const t of tagList(a)) {
if (!map.has(t)) map.set(t, []);
map.get(t).push(a);
}
}
const tagNames = Array.from(map.keys()).sort();
const existing = tagsNote.getChildNotes();
const byTitle = {};
for (const n of existing) byTitle[n.title] = n;
for (const tagName of tagNames) {
let note = byTitle[tagName];
if (!note) {
const created = api.createTextNote(tagsNote.noteId, tagName, "");
note = created.note ? created.note : created;
}
const tagged = map.get(tagName).slice();
tagged.sort((a, b) => {
const da = a.getLabelValue("date") || a.dateCreated;
const db = b.getLabelValue("date") || b.dateCreated;
return (db < da) ? -1 : (db > da ? 1 : 0);
});
const html = `<h1>Tag: ${escapeHtml(tagName)}</h1>\n` + (renderArticles(tagged) || '<p>No articles.</p>');
if (note.getContent() !== html) {
note.setContent(html);
note.save();
}
if (!note.hasRelation("shareHtml", HIDE_HOME_TITLE_NOTE_ID)) {
note.setRelation("shareHtml", HIDE_HOME_TITLE_NOTE_ID);
note.save();
}
const alias = "tag-" + slugifyTag(tagName);
if (note.getLabelValue("shareAlias") !== alias) {
note.setLabel("shareAlias", alias);
note.save();
}
if (!note.hasLabel("shareHiddenFromTree")) {
note.setLabel("shareHiddenFromTree", "");
note.save();
}
}
// Tags overview page (list all tags as clickable chips)
const tagChildren = tagsNote.getChildNotes().slice().sort((a, b) => a.title.localeCompare(b.title));
let idxHtml = '<div class="blog-subcategories">';
for (const t of tagChildren) {
const url = SHARE_BASE + "/" + encodeURIComponent(t.getLabelValue("shareAlias") || t.noteId);
idxHtml += `<a class="tag" href="${escapeHtml(url)}">#${escapeHtml(t.title)}</a>`;
}
idxHtml += '</div>';
if (tagsNote.getContent() !== idxHtml) {
tagsNote.setContent(idxHtml);
tagsNote.save();
api.log("blog_generator: tags overview updated");
}
for (const n of existing) {
if (!map.has(n.title)) {
api.log("blog_generator: removing obsolete tag note " + n.title);
n.deleteNote();
}
}
}
function isCategoryNote(note) {
if (note.hasLabel("blogCategory")) return true;
const parents = note.getParentNotes();
const isTopLevel = parents.some(p => p.noteId === BLOG_ROOT_NOTE_ID);
if (!isTopLevel) return false;
const sysTitles = ["Tags", ".blog", "Impressum", "About Me", "Search"];
if (sysTitles.includes(note.title)) return false;
return true;
}
function collectCategoryNotes() {
const out = [];
(function walk(note, depth) {
if (depth > 20) return;
if (note.noteId !== BLOG_ROOT_NOTE_ID && isCategoryNote(note)) {
out.push(note);
}
for (const child of note.getChildNotes()) {
walk(child, depth + 1);
}
})(blogRoot, 0);
return out;
}
function buildCategories() {
const byCategoryLabel = new Map();
for (const a of articles) {
const c = a.getLabelValue("category") || "";
if (!c) continue;
if (!byCategoryLabel.has(c)) byCategoryLabel.set(c, []);
byCategoryLabel.get(c).push(a);
}
const marker = "<!-- blog:articles -->";
for (const catNote of collectCategoryNotes()) {
const current = catNote.getContent() || "";
let userText = "";
const markerIdx = current.indexOf(marker);
if (markerIdx >= 0) {
userText = current.slice(0, markerIdx);
} else if (!current.trim().startsWith("<article") && !current.trim().startsWith(marker)) {
userText = current;
}
// Project pages carry only their description — no article overview.
// The label keeps the generator from re-adding the list on every run.
if (catNote.hasLabel("blogNoArticles")) {
if (catNote.getContent() !== userText) {
catNote.setContent(userText);
catNote.save();
api.log("blog_generator: description-only category page: " + catNote.title);
}
continue;
}
const children = catNote.getChildNotes();
const subs = children.filter(c => c.getLabelValue("publish") !== "true");
const merged = new Map();
for (const c of children) {
if (c.getLabelValue("publish") === "true") merged.set(c.noteId, c);
}
for (const a of (byCategoryLabel.get(catNote.title) || [])) {
merged.set(a.noteId, a);
}
const articlesList = Array.from(merged.values()).sort((a, b) => {
const da = a.getLabelValue("date") || a.dateCreated;
const db = b.getLabelValue("date") || b.dateCreated;
return (db < da) ? -1 : (db > da ? 1 : 0);
});
let html = userText;
if (html && !html.endsWith("\n")) html += "\n";
html += marker + "\n";
if (subs.length) {
html += '<div class="blog-subcategories">';
for (const s of subs) {
const url = SHARE_BASE + "/" + encodeURIComponent(s.getLabelValue("shareAlias") || s.noteId);
const icon = s.getLabelValue("iconClass");
html += `<a class="blog-subcategory" href="${escapeHtml(url)}">`;
if (icon) html += `<i class="${escapeHtml(icon)}"></i>`;
html += `<span>${escapeHtml(s.title)}</span></a>`;
}
html += "</div>\n";
}
const pageCount = Math.max(1, Math.ceil(articlesList.length / POSTS_PER_PAGE));
const aliasBase = catNote.getLabelValue("shareAlias") || catNote.noteId;
if (catNote.hasLabel("blogGarden")) {
html += buildGardenTable(catNote, articlesList.slice(0, POSTS_PER_PAGE));
} else {
html += renderArticles(articlesList.slice(0, POSTS_PER_PAGE)) || '<p>No articles yet.</p>';
}
html += paginationHtml(1, pageCount, aliasBase);
if (catNote.getContent() !== html) {
catNote.setContent(html);
catNote.save();
api.log("blog_generator: category page updated: " + catNote.title + " (" + pageCount + " pages)");
}
syncPageNotes("cat:" + catNote.title, aliasBase, pageCount, articlesList, catNote.hasLabel("blogGarden"), false);
}
}
// Opt-in (label "blogArticleList"): append a generated list of the note's
// published child notes after the user's own content. Idempotent via marker;
// placing the marker yourself controls where the list appears. The label
// value (if any, not "true") becomes the list heading.
function buildChildArticleLists() {
const marker = "<!-- blog:child-articles -->";
const seen = new Set();
(function walk(note, depth) {
if (depth > 20) return;
for (const child of note.getChildNotes()) {
if (seen.has(child.noteId)) continue;
seen.add(child.noteId);
if (child.hasLabel("blogArticleList")) {
const kids = child.getChildNotes().filter(c => c.getLabelValue("publish") === "true");
kids.sort((a, b) => {
const da = a.getLabelValue("date") || a.dateCreated;
const db = b.getLabelValue("date") || b.dateCreated;
return (db < da) ? -1 : (db > da ? 1 : 0);
});
const current = child.getContent() || "";
const idx = current.indexOf(marker);
const userText = idx >= 0 ? current.slice(0, idx) : current;
let out;
if (kids.length) {
out = userText;
if (out && !out.endsWith("\n")) out += "\n";
out += marker + "\n";
const heading = child.getLabelValue("blogArticleList") || "";
if (heading && heading !== "true") out += `<h2>${escapeHtml(heading)}</h2>\n`;
out += renderArticles(kids);
} else {
out = idx >= 0 ? current.replace(marker + "\n", "").replace(marker, "") : current;
}
if (out !== current) {
child.setContent(out);
child.save();
api.log("blog_generator: child article list updated: " + child.title + " (" + kids.length + ")");
}
}
walk(child, depth + 1);
}
})(blogRoot, 0);
}
function removeEmptyCategories() {
const blogRoot = api.getNote(BLOG_ROOT_NOTE_ID);
if (!blogRoot) return;
const toCheck = [];
(function collect(note, depth) {
if (depth > 20) return;
if (note.noteId !== blogRoot.noteId && note.hasLabel("blogCategory")) {
toCheck.push(note);
}
for (const child of note.getChildNotes()) {
collect(child, depth + 1);
}
})(blogRoot, 0);
for (const note of toCheck) {
if (note.getChildNotes().length === 0) {
api.log("blog_generator: removing empty category " + note.title);
note.deleteNote();
}
}
}
function hideThumbChildren() {
for (const a of articles) {
try {
for (const c of a.getChildNotes()) {
if (/^(?:thumb|.+_thumb)\.[A-Za-z0-9]+$/i.test(c.title) && !c.hasLabel("shareHiddenFromTree")) {
c.setLabel("shareHiddenFromTree", "");
c.save();
}
}
} catch (e) {}
}
}
buildIndex();
const systemNote = api.getNote(BLOG_SYSTEM_NOTE_ID);
const feedNote = systemNote.getChildNotes().find(n => n.title === "feed.xml");
if (feedNote) {
const xml = buildFeedXml();
if (feedNote.getContent() !== xml) {
feedNote.setContent(xml);
feedNote.save();
api.log("blog_generator: feed updated");
}
}
buildTags();
buildCategories();
buildChildArticleLists();
buildStateMap();
buildSitemap();
ensureSeoMeta();
buildBlogData();
buildGeoData();
hideThumbChildren();
removeEmptyCategories();
}