refactoring of cmaps, widgets, etc.
This commit is contained in:
@@ -0,0 +1,364 @@
|
||||
/* Build a self-contained Markdown report from one stored CMap model and its links. */
|
||||
|
||||
const labels = {
|
||||
en: {
|
||||
exportTitle: "CMap export", generated: "Generated", root: "Root CMap",
|
||||
depth: "Linked CMap depth", pagesIncluded: "Linked wiki pages included",
|
||||
yes: "yes", no: "no", cmap: "CMap", level: "level", address: "Address",
|
||||
tags: "Tags", none: "none", summary: "Summary", noSummary: "No summary supplied.",
|
||||
explanation: "CMap explanation", concepts: "Concepts", relations: "Relations",
|
||||
linkedMaps: "Linked CMaps", noConcepts: "No concepts.", noRelations: "No relations.",
|
||||
synopsis: "Summary", aspects: "Aspects", wikiPage: "Wiki page",
|
||||
conceptExplanation: "Concept explanation", linkedCmap: "Linked CMap", webPage: "Web page",
|
||||
people: "People (responsibility/action)",
|
||||
placements: "Placements", sourceView: "Derived view of", sourceMissing: "Source CMap unavailable",
|
||||
missingPage: "Page unavailable", linkedPages: "Linked wiki pages", page: "Page"
|
||||
},
|
||||
nl: {
|
||||
exportTitle: "CMap-export", generated: "Gegenereerd", root: "Start-CMap",
|
||||
depth: "Diepte gekoppelde CMaps", pagesIncluded: "Gekoppelde wikipagina's opgenomen",
|
||||
yes: "ja", no: "nee", cmap: "CMap", level: "niveau", address: "Adres",
|
||||
tags: "Tags", none: "geen", summary: "Samenvatting", noSummary: "Geen samenvatting opgegeven.",
|
||||
explanation: "CMap-uitleg", concepts: "Concepten", relations: "Relaties",
|
||||
linkedMaps: "Gekoppelde CMaps", noConcepts: "Geen concepten.", noRelations: "Geen relaties.",
|
||||
synopsis: "Samenvatting", aspects: "Aspecten", wikiPage: "Wikipagina",
|
||||
conceptExplanation: "Conceptuitleg", linkedCmap: "Gekoppelde CMap", webPage: "Webpagina",
|
||||
people: "Personen (verantwoordelijkheid/actie)",
|
||||
placements: "Plaatsingen", sourceView: "Afgeleide weergave van", sourceMissing: "Bron-CMap niet beschikbaar",
|
||||
missingPage: "Pagina niet beschikbaar", linkedPages: "Gekoppelde wikipagina's", page: "Pagina"
|
||||
}
|
||||
};
|
||||
|
||||
function decodedDocument(value) {
|
||||
let documentValue = value;
|
||||
for (let attempt = 0; attempt < 2 && typeof documentValue === "string"; attempt += 1) {
|
||||
documentValue = JSON.parse(documentValue);
|
||||
}
|
||||
return documentValue && typeof documentValue === "object" && !Array.isArray(documentValue) ?
|
||||
documentValue : {};
|
||||
}
|
||||
|
||||
function cleanMetadata(documentValue) {
|
||||
const metadata = documentValue.metadata && typeof documentValue.metadata === "object" ?
|
||||
documentValue.metadata : {};
|
||||
return {
|
||||
tags: Array.isArray(metadata.tags) ? metadata.tags.map(String).map((tag) => tag.trim()).filter(Boolean) : [],
|
||||
summary: String(metadata.summary || "").trim(),
|
||||
explanationPageSlug: String(metadata.explanationPageSlug || "").trim()
|
||||
};
|
||||
}
|
||||
|
||||
function itemRecords(documentValue) {
|
||||
const concepts = new Map((Array.isArray(documentValue.concepts) ? documentValue.concepts : [])
|
||||
.filter((concept) => concept && concept.id)
|
||||
.map((concept) => [String(concept.id), concept]));
|
||||
return (Array.isArray(documentValue.items) ? documentValue.items : [])
|
||||
.filter((item) => item && item.id !== undefined)
|
||||
.map((item) => ({
|
||||
...item,
|
||||
...(concepts.get(String(item.conceptId)) || {}),
|
||||
id: item.id,
|
||||
conceptId: item.conceptId
|
||||
}));
|
||||
}
|
||||
|
||||
function derivedDocument(sourceDocument, derivedDocumentValue) {
|
||||
const pointer = derivedDocumentValue.derivedView || {};
|
||||
const rootId = Number(pointer.rootItemId);
|
||||
const allItems = Array.isArray(sourceDocument.items) ? sourceDocument.items : [];
|
||||
const byId = new Map(allItems.map((item) => [Number(item.id), item]));
|
||||
const belongsToRoot = (item) => {
|
||||
if (Number(item.id) === rootId) return true;
|
||||
let parentId = Number(item.parentSubmapId);
|
||||
const seen = new Set();
|
||||
while (Number.isInteger(parentId) && parentId > 0 && !seen.has(parentId)) {
|
||||
if (parentId === rootId) return true;
|
||||
seen.add(parentId);
|
||||
parentId = Number(byId.get(parentId)?.parentSubmapId);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const items = allItems.filter(belongsToRoot);
|
||||
const itemIds = new Set(items.map((item) => Number(item.id)));
|
||||
const conceptIds = new Set(items.map((item) => item.conceptId).filter(Boolean).map(String));
|
||||
const rootItem = itemRecords(sourceDocument).find((item) => Number(item.id) === rootId) ||
|
||||
byId.get(rootId) || {};
|
||||
const ownMetadata = cleanMetadata(derivedDocumentValue);
|
||||
const metadata = {
|
||||
tags: ownMetadata.tags.length ? ownMetadata.tags :
|
||||
(Array.isArray(rootItem.aspects) ? rootItem.aspects.map(String) : []),
|
||||
summary: ownMetadata.summary || String(rootItem.synopsis || "").trim(),
|
||||
explanationPageSlug: ownMetadata.explanationPageSlug || String(rootItem.descriptionPageSlug || "").trim()
|
||||
};
|
||||
return {
|
||||
...sourceDocument,
|
||||
metadata,
|
||||
items,
|
||||
concepts: (Array.isArray(sourceDocument.concepts) ? sourceDocument.concepts : [])
|
||||
.filter((concept) => conceptIds.has(String(concept.id))),
|
||||
connectors: (Array.isArray(sourceDocument.connectors) ? sourceDocument.connectors : [])
|
||||
.filter((connector) => itemIds.has(Number(connector.sourceId)) && itemIds.has(Number(connector.targetId)))
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveMapDocument(map, loadConceptMap) {
|
||||
const ownDocument = decodedDocument(map.document);
|
||||
const pointer = ownDocument.derivedView;
|
||||
if (!pointer || !pointer.sourceCmapSlug || !Number.isInteger(Number(pointer.rootItemId))) {
|
||||
return { document: ownDocument, sourceSlug: "" };
|
||||
}
|
||||
try {
|
||||
const sourceMap = await loadConceptMap(pointer.sourceCmapSlug);
|
||||
return {
|
||||
document: derivedDocument(decodedDocument(sourceMap.document), ownDocument),
|
||||
sourceSlug: pointer.sourceCmapSlug
|
||||
};
|
||||
} catch (_error) {
|
||||
return { document: ownDocument, sourceSlug: pointer.sourceCmapSlug, sourceMissing: true };
|
||||
}
|
||||
}
|
||||
|
||||
function linkedMapSlugs(documentValue) {
|
||||
return [...new Set(itemRecords(documentValue)
|
||||
.map((item) => String(item.cmapSlug || "").trim())
|
||||
.filter(Boolean))];
|
||||
}
|
||||
|
||||
function headingText(value) {
|
||||
return String(value || "").replace(/[\r\n]+/g, " ").replace(/#+/g, "").trim();
|
||||
}
|
||||
|
||||
function inlineText(value) {
|
||||
return String(value || "").replace(/[\r\n]+/g, " ").replace(/([\\`*_[\]])/g, "\\$1").trim();
|
||||
}
|
||||
|
||||
function shiftHeadings(markdown, amount) {
|
||||
let fenced = false;
|
||||
return String(markdown || "").split("\n").map((line) => {
|
||||
if (/^\s*(```|~~~)/.test(line)) {
|
||||
fenced = !fenced;
|
||||
return line;
|
||||
}
|
||||
if (fenced) return line;
|
||||
return line.replace(/^(#{1,6})\s+/, (match, hashes) =>
|
||||
`${"#".repeat(Math.min(6, hashes.length + amount))} `);
|
||||
}).join("\n");
|
||||
}
|
||||
|
||||
function relationLines(documentValue) {
|
||||
const items = itemRecords(documentValue);
|
||||
const byId = new Map(items.map((item) => [Number(item.id), item]));
|
||||
const connectors = (Array.isArray(documentValue.connectors) ? documentValue.connectors : [])
|
||||
.filter((connector) => connector && connector.sourceId !== undefined && connector.targetId !== undefined);
|
||||
const used = new Set();
|
||||
const result = [];
|
||||
const itemLabel = (id) => headingText(byId.get(Number(id))?.label || `[${id}]`);
|
||||
|
||||
for (const phrase of items.filter((item) => item.kind === "phrase")) {
|
||||
const incoming = connectors.filter((connector) => Number(connector.targetId) === Number(phrase.id));
|
||||
const outgoing = connectors.filter((connector) => Number(connector.sourceId) === Number(phrase.id));
|
||||
for (const before of incoming) {
|
||||
for (const after of outgoing) {
|
||||
used.add(before);
|
||||
used.add(after);
|
||||
result.push(`${itemLabel(before.sourceId)} — **${inlineText(phrase.label || "")}** → ${itemLabel(after.targetId)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const connector of connectors) {
|
||||
if (used.has(connector)) continue;
|
||||
result.push(`${itemLabel(connector.sourceId)} ${connector.hasArrow === false ? "—" : "→"} ${itemLabel(connector.targetId)}`);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function conceptEntries(documentValue) {
|
||||
const entries = new Map();
|
||||
for (const item of itemRecords(documentValue)) {
|
||||
if (item.kind === "phrase") continue;
|
||||
const key = item.conceptId ? `concept:${item.conceptId}` : `item:${item.id}`;
|
||||
if (!entries.has(key)) entries.set(key, { ...item, placementCount: 0 });
|
||||
entries.get(key).placementCount += 1;
|
||||
}
|
||||
return [...entries.values()];
|
||||
}
|
||||
|
||||
async function generateMarkdown(options) {
|
||||
const rootMap = options.rootMap;
|
||||
if (!rootMap || !rootMap.slug) throw new Error("A root CMap is required.");
|
||||
if (typeof options.loadConceptMap !== "function") throw new Error("loadConceptMap is required.");
|
||||
const maximumDepth = Math.max(0, Math.min(10, Number(options.maxDepth) || 0));
|
||||
const includeWikiPages = Boolean(options.includeWikiPages);
|
||||
const locale = String(options.language || "nl").toLowerCase().startsWith("nl") ? "nl" : "en";
|
||||
const t = labels[locale];
|
||||
const maps = [];
|
||||
const visited = new Set();
|
||||
|
||||
async function collectMap(map, depth) {
|
||||
if (!map?.slug || visited.has(map.slug)) return;
|
||||
visited.add(map.slug);
|
||||
const resolved = await resolveMapDocument(map, options.loadConceptMap);
|
||||
maps.push({ map, depth, ...resolved });
|
||||
if (depth >= maximumDepth) return;
|
||||
for (const slug of linkedMapSlugs(resolved.document)) {
|
||||
if (visited.has(slug)) continue;
|
||||
try {
|
||||
await collectMap(await options.loadConceptMap(slug), depth + 1);
|
||||
} catch (error) {
|
||||
maps.push({ map: { slug, title: slug }, depth: depth + 1, document: {}, loadError: error });
|
||||
visited.add(slug);
|
||||
}
|
||||
}
|
||||
}
|
||||
await collectMap(rootMap, 0);
|
||||
|
||||
const pageCache = new Map();
|
||||
const explanationReferences = new Set();
|
||||
async function loadPage(reference) {
|
||||
if (!reference || typeof options.loadWikiPage !== "function") return null;
|
||||
if (!pageCache.has(reference)) {
|
||||
pageCache.set(reference, Promise.resolve().then(() => options.loadWikiPage(reference))
|
||||
.catch((error) => ({ slug: reference, title: reference, loadError: error })));
|
||||
}
|
||||
return pageCache.get(reference);
|
||||
}
|
||||
|
||||
for (const entry of maps) {
|
||||
const metadata = cleanMetadata(entry.document);
|
||||
if (metadata.explanationPageSlug) {
|
||||
explanationReferences.add(metadata.explanationPageSlug);
|
||||
await loadPage(metadata.explanationPageSlug);
|
||||
}
|
||||
if (includeWikiPages) {
|
||||
for (const concept of conceptEntries(entry.document)) {
|
||||
await loadPage(concept.pageSlug);
|
||||
await loadPage(concept.descriptionPageSlug);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const lines = [
|
||||
`# ${t.exportTitle}: ${headingText(rootMap.title || rootMap.slug)}`,
|
||||
"",
|
||||
`- **${t.generated}:** ${new Date().toISOString()}`,
|
||||
`- **${t.root}:** \`cmap:${rootMap.slug}\``,
|
||||
`- **${t.depth}:** ${maximumDepth}`,
|
||||
`- **${t.pagesIncluded}:** ${includeWikiPages ? t.yes : t.no}`,
|
||||
""
|
||||
];
|
||||
|
||||
for (const entry of maps) {
|
||||
const metadata = cleanMetadata(entry.document);
|
||||
lines.push(`## ${t.cmap}: ${headingText(entry.map.title || entry.map.slug)} (${t.level} ${entry.depth})`, "");
|
||||
lines.push(`- **${t.address}:** \`cmap:${entry.map.slug}\``);
|
||||
lines.push(`- **${t.tags}:** ${metadata.tags.length ? metadata.tags.map((tag) => `\`${inlineText(tag)}\``).join(", ") : t.none}`);
|
||||
if (entry.sourceSlug) lines.push(`- **${t.sourceView}:** \`cmap:${entry.sourceSlug}\``);
|
||||
if (entry.sourceMissing) lines.push(`- **${t.sourceMissing}:** \`cmap:${entry.sourceSlug}\``);
|
||||
if (entry.loadError) lines.push(`- **Fout:** ${inlineText(entry.loadError.message || entry.loadError)}`);
|
||||
lines.push("", `### ${t.summary}`, "", metadata.summary || t.noSummary, "");
|
||||
|
||||
if (metadata.explanationPageSlug) {
|
||||
const page = await loadPage(metadata.explanationPageSlug);
|
||||
lines.push(`### ${t.explanation}`, "", `**${t.page}:** \`${metadata.explanationPageSlug}\``, "");
|
||||
if (page?.loadError) lines.push(`_${t.missingPage}: ${inlineText(page.loadError.message || page.loadError)}_`, "");
|
||||
else if (page) {
|
||||
if (Array.isArray(page.tags) && page.tags.length) {
|
||||
lines.push(`**${t.tags}:** ${page.tags.map((tag) => `\`${inlineText(tag)}\``).join(", ")}`, "");
|
||||
}
|
||||
lines.push(shiftHeadings(page.markdown || "", 3).trim() || t.noSummary, "");
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(`### ${t.concepts}`, "");
|
||||
const concepts = conceptEntries(entry.document);
|
||||
if (!concepts.length) lines.push(`_${t.noConcepts}_`, "");
|
||||
for (const concept of concepts) {
|
||||
lines.push(`#### ${headingText(concept.label || concept.id || "Concept")}`, "");
|
||||
if (concept.synopsis) lines.push(`- **${t.synopsis}:** ${inlineText(concept.synopsis)}`);
|
||||
if (Array.isArray(concept.aspects) && concept.aspects.length) {
|
||||
lines.push(`- **${t.aspects}:** ${concept.aspects.map(inlineText).join(", ")}`);
|
||||
}
|
||||
const people = (Array.isArray(concept.tags) ? concept.tags : [])
|
||||
.filter((tag) => tag && typeof tag === "object" && tag.type === "person" && tag.value)
|
||||
.map((tag) => tag.value);
|
||||
if (people.length) {
|
||||
lines.push(`- **${t.people}:** ${people.map(inlineText).join(", ")}`);
|
||||
}
|
||||
if (concept.pageSlug) lines.push(`- **${t.wikiPage}:** \`${inlineText(concept.pageSlug)}\``);
|
||||
if (concept.descriptionPageSlug) lines.push(`- **${t.conceptExplanation}:** \`${inlineText(concept.descriptionPageSlug)}\``);
|
||||
if (concept.cmapSlug) lines.push(`- **${t.linkedCmap}:** \`cmap:${inlineText(concept.cmapSlug)}\``);
|
||||
if (concept.externalUrl) lines.push(`- **${t.webPage}:** ${inlineText(concept.externalUrl)}`);
|
||||
if (concept.placementCount > 1) lines.push(`- **${t.placements}:** ${concept.placementCount}`);
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
lines.push(`### ${t.relations}`, "");
|
||||
const relations = relationLines(entry.document);
|
||||
if (!relations.length) lines.push(`_${t.noRelations}_`, "");
|
||||
else lines.push(...relations.map((relation) => `- ${relation}`), "");
|
||||
const linked = linkedMapSlugs(entry.document);
|
||||
if (linked.length) {
|
||||
lines.push(`### ${t.linkedMaps}`, "", ...linked.map((slug) => `- \`cmap:${inlineText(slug)}\``), "");
|
||||
}
|
||||
}
|
||||
|
||||
if (includeWikiPages) {
|
||||
const pages = [];
|
||||
for (const [reference, promise] of pageCache) {
|
||||
if (explanationReferences.has(reference)) continue;
|
||||
pages.push(await promise);
|
||||
}
|
||||
if (pages.length) lines.push(`## ${t.linkedPages}`, "");
|
||||
for (const page of pages) {
|
||||
lines.push(`### ${headingText(page.title || page.slug)}`, "", `- **${t.address}:** \`${inlineText(page.slug)}\``);
|
||||
if (Array.isArray(page.tags) && page.tags.length) {
|
||||
lines.push(`- **${t.tags}:** ${page.tags.map((tag) => `\`${inlineText(tag)}\``).join(", ")}`);
|
||||
}
|
||||
lines.push("");
|
||||
if (page.loadError) lines.push(`_${t.missingPage}: ${inlineText(page.loadError.message || page.loadError)}_`, "");
|
||||
else lines.push(shiftHeadings(page.markdown || "", 2).trim() || t.noSummary, "");
|
||||
}
|
||||
}
|
||||
|
||||
return `${lines.join("\n").replace(/\n{3,}/g, "\n\n").trim()}\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Export a CMap and optionally its linked maps and wiki pages as Markdown.
|
||||
* The exporter reads stored models through their repository and has no
|
||||
* knowledge of dialogs, downloads or other browser presentation.
|
||||
*/
|
||||
export class CmapMarkdownExporter {
|
||||
constructor(cmapRepository, loadWikiPage) {
|
||||
if (!cmapRepository || typeof cmapRepository.load !== "function" ||
|
||||
typeof loadWikiPage !== "function") {
|
||||
throw new TypeError("A CMap repository and wiki-page loader are required");
|
||||
}
|
||||
this.cmapRepository = cmapRepository;
|
||||
this.loadWikiPage = loadWikiPage;
|
||||
}
|
||||
|
||||
/** Build the complete Markdown report without changing source data. */
|
||||
async export(rootMap, options = {}) {
|
||||
return generateMarkdown({
|
||||
rootMap: this.exportMap(rootMap),
|
||||
maxDepth: options.maxDepth,
|
||||
includeWikiPages: options.includeWikiPages,
|
||||
language: options.language,
|
||||
loadConceptMap: async (slug) => this.exportMap(await this.cmapRepository.load(slug)),
|
||||
loadWikiPage: this.loadWikiPage
|
||||
});
|
||||
}
|
||||
|
||||
/** Present one stored model through the record shape consumed by the report builder. */
|
||||
exportMap(storedMap) {
|
||||
if (!storedMap?.slug || typeof storedMap.toDocument !== "function") {
|
||||
throw new TypeError("Markdown export requires a stored CMap");
|
||||
}
|
||||
return {
|
||||
slug: storedMap.slug,
|
||||
title: storedMap.title,
|
||||
document: storedMap.toDocument()
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user