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
This commit is contained in:
+216
-3
@@ -51,7 +51,8 @@ if (api.originEntity && isGeneratedNoteId(api.originEntity.noteId)) {
|
||||
// (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", "iconClass"
|
||||
"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",
|
||||
@@ -71,7 +72,14 @@ function shouldRebuild() {
|
||||
}
|
||||
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") return false; // publish/unpublish covers clone+move via label events
|
||||
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()) {
|
||||
@@ -437,6 +445,19 @@ function run() {
|
||||
} 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;
|
||||
@@ -448,8 +469,9 @@ function run() {
|
||||
url: SHARE_BASE + "/" + encodeURIComponent(a.getLabelValue("shareAlias") || a.noteId),
|
||||
summary: a.getLabelValue("summary") || "",
|
||||
tags: tagList(a),
|
||||
category: a.getLabelValue("category") || "",
|
||||
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)
|
||||
}));
|
||||
@@ -461,6 +483,151 @@ function run() {
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
@@ -634,6 +801,50 @@ function run() {
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
@@ -683,10 +894,12 @@ function run() {
|
||||
|
||||
buildTags();
|
||||
buildCategories();
|
||||
buildChildArticleLists();
|
||||
buildStateMap();
|
||||
buildSitemap();
|
||||
ensureSeoMeta();
|
||||
buildBlogData();
|
||||
buildGeoData();
|
||||
hideThumbChildren();
|
||||
removeEmptyCategories();
|
||||
}
|
||||
Reference in New Issue
Block a user