refactoring van de cmap structuren bijna compleet

This commit is contained in:
2026-09-03 16:21:21 +02:00
parent 1abc84489f
commit bd1ef6bed0
75 changed files with 2547 additions and 1967 deletions
@@ -0,0 +1,30 @@
import { CmapAppearance } from "./appearance.js";
/**
* Persist wiki-wide CMap appearance through the backend.
* Styles and the colour palette form one appearance aggregate.
*/
export class CmapAppearanceRepository {
constructor(api) {
if (typeof api !== "function") throw new TypeError("A wiki API function is required");
this.api = api;
}
/** Load and deserialize the complete wiki-wide appearance aggregate. */
async load() {
const result = await this.api("/api/cmap-appearance");
return new CmapAppearance(result);
}
/** Persist one appearance aggregate and refresh it with backend normalization. */
async save(appearance) {
if (!(appearance instanceof CmapAppearance)) {
throw new TypeError("A CmapAppearance is required");
}
const result = await this.api("/api/cmap-appearance", {
method: "PUT",
body: JSON.stringify(appearance.toData())
});
return appearance.replace(result);
}
}
+181
View File
@@ -0,0 +1,181 @@
const STYLE_VALUE_KEYS = [
"backgroundColor", "textColor", "fontFamily", "fontSize", "fontWeight", "fontStyle",
"synopsisTextColor", "synopsisFontFamily", "synopsisFontSize", "synopsisFontWeight",
"synopsisFontStyle", "submapBackgroundColor", "submapBorderColor"
];
/** Return a detached value at the model boundary. */
function copy(value) {
return JSON.parse(JSON.stringify(value));
}
/** Return a six-digit HTML colour or the supplied fallback. */
function cmapColorValue(value, fallback = "#f3f6f8") {
return /^#[0-9a-f]{6}$/i.test(value || "") ? value.toLowerCase() : fallback;
}
/** Convert a stored CSS font size to typographic points. */
function cmapFontSizeInPoints(value, baseSize = 11) {
const size = Number.parseFloat(value);
if (!Number.isFinite(size)) return baseSize;
if (/px$/i.test(value || "")) return size * 0.75;
if (/em$/i.test(value || "")) return size * baseSize;
if (/%$/i.test(value || "")) return (size / 100) * baseSize;
return size;
}
/** Normalize an editable font size to the range accepted by the backend. */
function normalizedCmapFontSize(value, fallback) {
const size = Number(value);
const usableSize = Number.isFinite(size) ? size : fallback;
return Math.max(6, Math.min(54, Math.round(usableSize * 2) / 2));
}
/** Format a typographic point value for a form input. */
function displayCmapFontSize(value) {
return String(Math.round(value * 2) / 2);
}
/**
* Own the wiki-wide CMap styles and colour palette.
* The backend supplies the default style; this model validates UI changes
* against that style and exposes detached values to its consumers.
*/
export class CmapAppearance {
constructor(data) {
this.replace(data);
}
get styles() {
return copy(this._styles);
}
get palette() {
return [...this._palette];
}
get defaultValues() {
return copy(this._styles.find((style) => style.id === "default").values);
}
/** Replace the aggregate with a complete backend representation. */
replace(data) {
const styles = Array.isArray(data?.styles) ? data.styles : [];
const defaultStyle = styles.find((style) => style?.id === "default");
const palette = Array.isArray(data?.palette) ? data.palette : [];
const completeDefault = defaultStyle?.values &&
STYLE_VALUE_KEYS.every((key) => Object.hasOwn(defaultStyle.values, key));
if (!completeDefault || palette.length === 0) {
throw new TypeError("CMap appearance requires a default style and colour palette");
}
const seenIds = new Set();
this._styles = styles.map((style) => {
const id = typeof style?.id === "string" ? style.id.trim() : "";
const hasName = typeof style?.name === "string" && style.name.trim();
const hasNameKey = typeof style?.nameKey === "string" && style.nameKey.trim();
if (!/^[A-Za-z0-9_-]+$/.test(id) || seenIds.has(id) ||
(!hasName && !hasNameKey) || !style.values) {
throw new TypeError("CMap appearance contains an invalid style");
}
seenIds.add(id);
return {
id,
...(hasNameKey ? { nameKey: style.nameKey.trim() } : { name: style.name.trim() }),
protected: id === "default",
values: this.normalizeValues(style.values, defaultStyle.values)
};
});
this._palette = palette.map((color) => {
if (!/^#[0-9a-f]{6}$/i.test(color || "")) {
throw new TypeError("CMap appearance contains an invalid palette colour");
}
return color.toLowerCase();
});
return this;
}
/** Return the complete detached representation for backend storage. */
toData() {
return { styles: this.styles, palette: this.palette };
}
/** Normalize editable values using the backend-provided default style. */
normalizeValues(values, fallbackValues = null) {
if (!values || typeof values !== "object") return null;
const fallback = fallbackValues || this.defaultValues;
const fontSize = normalizedCmapFontSize(values.fontSize, fallback.fontSize);
return {
backgroundColor: cmapColorValue(values.backgroundColor, fallback.backgroundColor),
textColor: cmapColorValue(values.textColor, fallback.textColor),
fontFamily: typeof values.fontFamily === "string" && values.fontFamily.trim() ?
values.fontFamily.trim() : fallback.fontFamily,
fontSize,
fontWeight: String(values.fontWeight) === "400" ? "400" : "700",
fontStyle: values.fontStyle === "italic" ? "italic" : "normal",
synopsisTextColor: cmapColorValue(values.synopsisTextColor, fallback.synopsisTextColor),
synopsisFontFamily: typeof values.synopsisFontFamily === "string" && values.synopsisFontFamily.trim() ?
values.synopsisFontFamily.trim() : fallback.synopsisFontFamily,
synopsisFontSize: normalizedCmapFontSize(
values.synopsisFontSize, Math.max(6, fontSize - 2)),
synopsisFontWeight: String(values.synopsisFontWeight) === "700" ? "700" : "400",
synopsisFontStyle: values.synopsisFontStyle === "italic" ? "italic" : "normal",
submapBackgroundColor: cmapColorValue(
values.submapBackgroundColor, fallback.submapBackgroundColor),
submapBorderColor: cmapColorValue(values.submapBorderColor, fallback.submapBorderColor)
};
}
style(id) {
const style = this._styles.find((candidate) => candidate.id === id);
return style ? copy(style) : null;
}
matchingStyleId(values) {
const normalized = this.normalizeValues(values);
if (!normalized) return "";
const style = this._styles.find((candidate) =>
STYLE_VALUE_KEYS.every((key) => candidate.values[key] === normalized[key]));
return style?.id || "";
}
/** Insert or replace one non-default named style. */
putStyle(style) {
const name = typeof style?.name === "string" ? style.name.trim() : "";
const id = typeof style?.id === "string" ? style.id.trim() : "";
if (!/^[A-Za-z0-9_-]+$/.test(id) || id === "default" || !name) {
throw new TypeError("A custom CMap style requires an id and name");
}
const normalized = {
id,
name,
protected: false,
values: this.normalizeValues(style.values)
};
const existingIndex = this._styles.findIndex((candidate) => candidate.id === normalized.id);
if (existingIndex < 0) this._styles.push(normalized);
else this._styles.splice(existingIndex, 1, normalized);
return copy(normalized);
}
/** Delete a custom style and refuse deletion of protected styles. */
deleteStyle(id) {
const style = this._styles.find((candidate) => candidate.id === id);
if (!style || style.protected) return false;
this._styles = this._styles.filter((candidate) => candidate.id !== id);
return true;
}
setPaletteColor(index, color) {
if (!Number.isInteger(index) || index < 0 || index >= this._palette.length ||
!/^#[0-9a-f]{6}$/i.test(color || "")) return false;
this._palette[index] = color.toLowerCase();
return true;
}
}
export {
cmapColorValue,
cmapFontSizeInPoints,
displayCmapFontSize,
normalizedCmapFontSize
};
@@ -0,0 +1,94 @@
"use strict";
function copy(value) {
return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
}
function normalizeConceptName(value) {
return String(value || "").trim().toLocaleLowerCase();
}
/** Index lightweight concept placements across stored CMaps. */
export class CmapReferenceIndex {
constructor() {
this.byConceptId = new Map();
this.byName = new Map();
this.byPage = new Map();
}
clear() {
this.byConceptId.clear();
this.byName.clear();
this.byPage.clear();
}
load(placements = [], conceptMaps = [], canonicalPageReference = (value) => value,
normalizeName = normalizeConceptName) {
this.clear();
const mapTitles = new Map(conceptMaps
.filter((map) => map && map.slug)
.map((map) => [map.slug, map.title || map.slug]));
const pageByConcept = new Map();
for (const placement of placements) {
if (!placement?.conceptId || !placement.pageSlug) continue;
pageByConcept.set(placement.conceptId,
canonicalPageReference(placement.pageSlug).toLocaleLowerCase());
}
for (const placement of placements) {
if (!placement?.conceptId || !placement.cmapSlug) continue;
const conceptId = String(placement.conceptId);
const slug = String(placement.cmapSlug);
const nameKey = normalizeName(placement.label);
if (nameKey) this.byName.set(nameKey, conceptId);
if (!this.byConceptId.has(conceptId)) this.byConceptId.set(conceptId, new Map());
const maps = this.byConceptId.get(conceptId);
const existing = maps.get(slug);
maps.set(slug, {
slug,
title: mapTitles.get(slug) || placement.cmapTitle || slug,
count: (existing?.count || 0) + (Number(placement.count) || 0)
});
const pageKey = pageByConcept.get(conceptId);
if (!pageKey) continue;
if (!this.byPage.has(pageKey)) this.byPage.set(pageKey, new Map());
const pageConcepts = this.byPage.get(pageKey);
if (!pageConcepts.has(conceptId)) {
pageConcepts.set(conceptId, {
conceptId,
label: placement.label || "Concept",
count: 0,
maps: new Map()
});
}
const concept = pageConcepts.get(conceptId);
const count = Number(placement.count) || 0;
concept.count += count;
const pageMap = concept.maps.get(slug);
concept.maps.set(slug, {
slug,
title: mapTitles.get(slug) || placement.cmapTitle || slug,
count: count + (pageMap?.count || 0)
});
}
return this;
}
mapsFor(conceptId) {
return Array.from(this.byConceptId.get(String(conceptId))?.values() || [])
.map(copy);
}
countFor(conceptId) {
return this.mapsFor(conceptId).reduce((sum, map) => sum + map.count, 0);
}
conceptIdForName(label) {
return this.byName.get(normalizeConceptName(label)) || null;
}
pageConcepts(pageReference) {
return Array.from(this.byPage.get(String(pageReference).toLocaleLowerCase())?.values() || [])
.map((concept) => ({ ...copy(concept), maps: new Map(concept.maps) }));
}
}
+213
View File
@@ -0,0 +1,213 @@
import { CmapModel } from "./concept-map.js";
/** Return a detached copy of data received from or sent to the backend. */
function copy(value) {
return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
}
/** Decode the historical string-wrapped CMap document representation. */
function decodedDocument(value) {
let documentValue = value;
for (let attempt = 0; attempt < 2 && typeof documentValue === "string"; attempt += 1) {
documentValue = JSON.parse(documentValue);
}
if (!documentValue || typeof documentValue !== "object" || Array.isArray(documentValue)) {
throw new Error("The stored CMap document is not a JSON object.");
}
return copy(documentValue);
}
/** Give legacy map-local concept ids a stable identity before model creation. */
function normalizeConceptIdentities(documentValue, cmapSlug) {
const legacyIds = new Map();
const normalize = (conceptId) => {
const prefixedUuid = typeof conceptId === "string" && conceptId.match(
/^concept-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i);
if (prefixedUuid) return prefixedUuid[1].toLowerCase();
if (!conceptId || !/^concept-\d+$/.test(conceptId) || !cmapSlug) return conceptId;
if (!legacyIds.has(conceptId)) {
legacyIds.set(conceptId, `legacy:${cmapSlug}:${conceptId}`);
}
return legacyIds.get(conceptId);
};
for (const concept of (Array.isArray(documentValue.concepts) ? documentValue.concepts : [])) {
concept.id = normalize(concept.id);
}
for (const item of (Array.isArray(documentValue.items) ? documentValue.items : [])) {
item.conceptId = normalize(item.conceptId);
}
return documentValue;
}
/** Convert a public CMap model to the canonical backend document. */
function modelDocument(value) {
if (!(value instanceof CmapModel)) throw new TypeError("A CmapModel is required");
return value.toDocument();
}
/**
* Represent one persisted CMap together with its decoded domain model.
* Backend version fields remain available without exposing the response object.
*/
export class StoredConceptMap {
constructor(record, model) {
if (!record?.slug || !(model instanceof CmapModel)) {
throw new TypeError("A stored CMap requires a slug and CmapModel");
}
this._slug = String(record.slug);
this._title = String(record.title || record.slug);
this._currentVersion = Number(record.currentVersion || record.version) || 0;
this._version = Number(record.version || record.currentVersion) || 0;
this._createdAt = record.createdAt || null;
this._updatedAt = record.updatedAt || null;
this._author = record.author || null;
this._renderedSvg = typeof record.renderedSvg === "string" ? record.renderedSvg : "";
this._model = model;
}
get slug() { return this._slug; }
get title() { return this._title; }
get currentVersion() { return this._currentVersion; }
get version() { return this._version; }
get createdAt() { return this._createdAt; }
get updatedAt() { return this._updatedAt; }
get author() { return this._author; }
get model() { return this._model; }
get renderedSvg() { return this._renderedSvg; }
/** Return a detached storage document for comparisons and interchange. */
toDocument() {
return this._model.toDocument();
}
/** Return the metadata used by CMap selectors and workspace state. */
toSummary() {
return {
slug: this.slug,
title: this.title,
currentVersion: this.currentVersion,
createdAt: this.createdAt,
updatedAt: this.updatedAt,
author: this.author
};
}
}
/**
* Store and retrieve concept maps through the Racket Wiki backend.
* This is the only CMap model class that knows API routes and storage envelopes.
*/
export class CmapRepository {
constructor(api) {
if (typeof api !== "function") throw new TypeError("A wiki API function is required");
this.api = api;
}
/** Return the lightweight CMap records used by selectors. */
async list() {
const result = await this.api("/api/cmaps");
return Array.isArray(result.conceptMaps) ? result.conceptMaps.map(copy) : [];
}
/** Load one current CMap and deserialize its complete domain model. */
async load(slug) {
const record = await this.api(`/api/cmaps/${encodeURIComponent(slug)}`);
return this.storedMap(record, slug);
}
/** Create a persisted CMap from a domain model. */
async create(title, model, slug = null, saveInformation = {}) {
const body = {
title: String(title).trim(),
document: modelDocument(model),
renderedSvg: typeof saveInformation.renderedSvg === "string" ? saveInformation.renderedSvg : ""
};
if (slug) body.slug = String(slug);
const record = await this.api("/api/cmaps", {
method: "POST",
body: JSON.stringify(body)
});
return this.storedMap(record, record.slug || slug);
}
/** Save a new model version and return the freshly versioned stored CMap. */
async save(storedMap, model, saveInformation = {}) {
if (!(storedMap instanceof StoredConceptMap)) {
throw new TypeError("Saving requires a StoredConceptMap");
}
const body = {
title: saveInformation.title || storedMap.title,
baseVersion: storedMap.currentVersion,
summary: saveInformation.summary || "",
saveKind: saveInformation.saveKind || "manual",
snapshot: Boolean(saveInformation.snapshot),
document: modelDocument(model),
renderedSvg: typeof saveInformation.renderedSvg === "string" ? saveInformation.renderedSvg : ""
};
const record = await this.api(`/api/cmaps/${encodeURIComponent(storedMap.slug)}`, {
method: "PUT",
body: JSON.stringify(body)
});
return this.storedMap(record, storedMap.slug);
}
/** Rename a stored CMap using optimistic backend versioning. */
async rename(storedMap, title) {
const record = await this.api(
`/api/cmaps/${encodeURIComponent(storedMap.slug)}/rename`, {
method: "POST",
body: JSON.stringify({
title: String(title).trim(),
baseVersion: storedMap.currentVersion
})
});
return this.storedMap(record, storedMap.slug);
}
/** Archive a stored CMap after the workspace has obtained title confirmation. */
async archive(storedMap, confirmationTitle) {
await this.api(`/api/cmaps/${encodeURIComponent(storedMap.slug)}`, {
method: "DELETE",
body: JSON.stringify({
confirmTitle: confirmationTitle,
baseVersion: storedMap.currentVersion
})
});
}
/** Return version summaries for one stored CMap. */
async history(storedMap) {
const result = await this.api(
`/api/cmaps/${encodeURIComponent(storedMap.slug)}/history`);
return Array.isArray(result.versions) ? result.versions.map(copy) : [];
}
/** Load and deserialize one historical version of a CMap. */
async loadVersion(storedMap, version) {
const record = await this.api(
`/api/cmaps/${encodeURIComponent(storedMap.slug)}/versions/${encodeURIComponent(version)}`);
return this.storedMap(record, storedMap.slug);
}
/** Delete one history version without changing the current CMap. */
async deleteVersion(storedMap, version) {
await this.api(
`/api/cmaps/${encodeURIComponent(storedMap.slug)}/versions/${encodeURIComponent(version)}`,
{ method: "DELETE" });
}
/** Return the backend projection of concept placements across all CMaps. */
async conceptUsage() {
const result = await this.api("/api/cmaps/concept-usage");
return Array.isArray(result.placements) ? result.placements.map(copy) : [];
}
/** Convert one backend record to the public stored-map representation. */
storedMap(record, fallbackSlug = null) {
const slug = record?.slug || fallbackSlug;
const documentValue = decodedDocument(record?.document);
const normalized = normalizeConceptIdentities(documentValue, slug || "");
return new StoredConceptMap({ ...record, slug }, CmapModel.fromDocument(normalized));
}
}
+504
View File
@@ -0,0 +1,504 @@
import { ConceptRepository } from "./concept-repository.js";
const PLACEMENT_FIELDS = [
"parentCmapLink", "groupId", "childMap", "parentSubmapId", "submapDepth",
"expanded", "submapInitialized", "separateMap", "mapReference",
"hiddenContexts", "layouts", "backgroundColor", "borderColor",
"submapBackgroundColor", "submapBorderColor", "textColor", "fontFamily",
"fontSize", "fontWeight", "fontStyle", "synopsisTextColor",
"synopsisFontFamily", "synopsisFontSize", "synopsisFontWeight",
"synopsisFontStyle", "width", "height", "autoWidth", "autoHeight", "x", "y"
];
function copy(value) {
return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
}
/** Join one repository concept to its placement and appearance on a CMap. */
export class ConceptMapConcept {
constructor(id, conceptId, values = {}) {
if (!Number.isInteger(Number(id)) || !conceptId) {
throw new TypeError("A concept placement requires an integer id and concept id");
}
this.id = Number(id);
this.conceptId = String(conceptId);
this.kind = values.kind || "concept";
this.update(values);
}
update(values = {}) {
for (const field of PLACEMENT_FIELDS) {
if (values[field] !== undefined) this[field] = copy(values[field]);
}
this.parentCmapLink = Boolean(this.parentCmapLink);
this.parentSubmapId = this.parentSubmapId === null || this.parentSubmapId === undefined ?
null : Number(this.parentSubmapId);
this.submapDepth = Number(this.submapDepth) || 0;
this.expanded = Boolean(this.expanded);
this.submapInitialized = Boolean(this.submapInitialized);
this.separateMap = Boolean(this.separateMap);
this.hiddenContexts = this.hiddenContexts instanceof Set ? this.hiddenContexts :
new Set(Array.isArray(this.hiddenContexts) ? this.hiddenContexts.map(String) : []);
this.layouts = this.layouts && typeof this.layouts === "object" ? this.layouts : {};
this.autoWidth = Boolean(this.autoWidth);
this.autoHeight = Boolean(this.autoHeight);
return this;
}
toDocument() {
const document = { id: this.id, conceptId: this.conceptId, kind: this.kind };
for (const field of PLACEMENT_FIELDS) {
document[field] = field === "hiddenContexts" ? [...this.hiddenContexts] : copy(this[field]);
}
return document;
}
}
/** Represent a map-local linking phrase without creating a repository concept. */
export class ConceptMapPhrase {
constructor(id, values = {}) {
if (!Number.isInteger(Number(id))) throw new TypeError("A phrase requires an integer id");
this.id = Number(id);
this.kind = "phrase";
this.label = String(values.label || "?????");
this.synopsis = String(values.synopsis || "");
this.update(values);
}
update(values = {}) {
if (values.label !== undefined) this.label = String(values.label);
if (values.synopsis !== undefined) this.synopsis = String(values.synopsis);
for (const field of PLACEMENT_FIELDS) {
if (values[field] !== undefined) this[field] = copy(values[field]);
}
this.parentSubmapId = this.parentSubmapId === null || this.parentSubmapId === undefined ?
null : Number(this.parentSubmapId);
this.hiddenContexts = this.hiddenContexts instanceof Set ? this.hiddenContexts :
new Set(Array.isArray(this.hiddenContexts) ? this.hiddenContexts.map(String) : []);
this.layouts = this.layouts && typeof this.layouts === "object" ? this.layouts : {};
return this;
}
toDocument() {
const document = {
id: this.id,
conceptId: null,
kind: "phrase",
label: this.label,
synopsis: this.synopsis
};
for (const field of PLACEMENT_FIELDS) {
document[field] = field === "hiddenContexts" ? [...this.hiddenContexts] : copy(this[field]);
}
return document;
}
}
/** Store one rendered connection between two map-local item placements. */
export class ConceptMapConnector {
constructor(id, sourceId, targetId, values = {}) {
if (!Number.isInteger(Number(id)) || !Number.isInteger(Number(sourceId)) ||
!Number.isInteger(Number(targetId))) {
throw new TypeError("A connector requires integer ids");
}
this.id = Number(id);
this.sourceId = Number(sourceId);
this.targetId = Number(targetId);
this.relationId = values.relationId || null;
this.hasArrow = values.hasArrow !== false;
this.lineColor = values.lineColor || "#333";
this.lineWidth = Number(values.lineWidth) || 2;
}
toDocument() {
return {
id: this.id,
sourceId: this.sourceId,
targetId: this.targetId,
...(this.relationId ? { relationId: this.relationId } : {}),
hasArrow: this.hasArrow,
lineColor: this.lineColor,
lineWidth: this.lineWidth
};
}
}
/** Own the placements, phrases, connectors and metadata of one CMap. */
export class ConceptMap {
constructor(values = {}) {
this.schemaVersion = Number(values.schemaVersion) || 2;
this.metadata = {
namespace: String(values.metadata?.namespace || "").trim(),
tags: Array.isArray(values.metadata?.tags) ? values.metadata.tags.map(String) : [],
summary: String(values.metadata?.summary || ""),
explanationPageSlug: String(values.metadata?.explanationPageSlug || "")
};
this.derivedView = values.derivedView ? copy(values.derivedView) : null;
this.itemsById = new Map();
this.connectorsById = new Map();
this.conceptMapsById = new Map();
}
addItem(value) {
const item = value instanceof ConceptMapConcept || value instanceof ConceptMapPhrase ? value :
(value.kind === "phrase" ? new ConceptMapPhrase(value.id, value) :
new ConceptMapConcept(value.id, value.conceptId, value));
if (this.itemsById.has(item.id)) throw new Error(`Duplicate CMap item id: ${item.id}`);
this.itemsById.set(item.id, item);
return item;
}
item(id) {
return this.itemsById.get(Number(id)) || null;
}
items() {
return [...this.itemsById.values()];
}
removeItem(id) {
const itemId = Number(id);
this.itemsById.delete(itemId);
for (const [connectorId, connector] of this.connectorsById) {
if (connector.sourceId === itemId || connector.targetId === itemId) {
this.connectorsById.delete(connectorId);
}
}
}
addConnector(value) {
const connector = value instanceof ConceptMapConnector ? value : new ConceptMapConnector(
value.id, value.sourceId, value.targetId, value);
if (this.connectorsById.has(connector.id)) {
throw new Error(`Duplicate CMap connector id: ${connector.id}`);
}
this.connectorsById.set(connector.id, connector);
return connector;
}
connector(id) {
return this.connectorsById.get(Number(id)) || null;
}
connectors() {
return [...this.connectorsById.values()];
}
removeConnector(id) {
this.connectorsById.delete(Number(id));
}
setConceptMapReferences(references = []) {
this.conceptMapsById = new Map(references
.filter((reference) => reference && reference.id)
.map((reference) => [reference.id, copy(reference)]));
}
toDocument() {
const document = {
schemaVersion: this.schemaVersion,
metadata: copy(this.metadata),
items: this.items().map((item) => item.toDocument()),
connectors: this.connectors().map((connector) => connector.toDocument()),
conceptMaps: [...this.conceptMapsById.values()].map(copy)
};
if (this.derivedView) document.derivedView = copy(this.derivedView);
return document;
}
}
/** Combine a concept repository with one concrete concept-map document. */
export class CmapModel {
constructor(repository = new ConceptRepository(), conceptMap = new ConceptMap()) {
this.repository = repository;
this.conceptMap = conceptMap;
}
static fromDocument(document = {}) {
const repository = new ConceptRepository(
Array.isArray(document.concepts) ? document.concepts : [],
Array.isArray(document.conceptRelations) ? document.conceptRelations : [],
Array.isArray(document.conceptOwnerships) ? document.conceptOwnerships : []);
const conceptMap = new ConceptMap(document);
for (const item of (Array.isArray(document.items) ? document.items : [])) {
if (item.kind !== "phrase" && !repository.concept(item.conceptId)) {
repository.add({ id: item.conceptId, label: item.label || "Concept" });
}
conceptMap.addItem(item);
}
for (const connector of (Array.isArray(document.connectors) ? document.connectors : [])) {
conceptMap.addConnector(connector);
}
conceptMap.setConceptMapReferences(document.conceptMaps);
return new CmapModel(repository, conceptMap);
}
/** Return a detached copy of the map metadata. */
metadata() {
return copy(this.conceptMap.metadata);
}
/** Return a detached derived-view reference, or null for an independent map. */
derivedView() {
return copy(this.conceptMap.derivedView);
}
/** Return connectors that cross the active submap boundary. */
boundaryReferencesFor(activeRootId) {
if (activeRootId === null || activeRootId === undefined) return [];
const rootId = Number(activeRootId);
const itemInside = (item) => {
if (!item) return false;
if (item.id === rootId) return true;
let parentId = item.parentSubmapId;
while (parentId !== null && parentId !== undefined) {
if (Number(parentId) === rootId) return true;
parentId = this.conceptMap.item(parentId)?.parentSubmapId;
}
return false;
};
const conceptFor = (item) => item.kind === "phrase" ? null :
this.repository.concept(item.conceptId);
const placement = (item) => ({
id: item.id,
x: Number(item.x) || 0,
y: Number(item.y) || 0,
width: Number(item.width) || 0,
height: Number(item.height) || 0
});
const itemForSide = (item, excludedItem, inside) => {
if (item.kind !== "phrase") return item;
const adjacent = this.conceptMap.connectors()
.filter((candidate) => candidate.sourceId === item.id || candidate.targetId === item.id)
.map((candidate) => this.conceptMap.item(
candidate.sourceId === item.id ? candidate.targetId : candidate.sourceId))
.find((candidate) => candidate && candidate !== excludedItem &&
candidate.kind !== "phrase" && itemInside(candidate) === inside);
return adjacent || item;
};
const result = [];
for (const connector of this.conceptMap.connectors()) {
const source = this.conceptMap.item(connector.sourceId);
const target = this.conceptMap.item(connector.targetId);
if (!source || !target) continue;
const sourceInside = itemInside(source);
const targetInside = itemInside(target);
if (sourceInside === targetInside) continue;
const rawInside = sourceInside ? source : target;
const rawOutside = sourceInside ? target : source;
const inside = itemForSide(rawInside, rawOutside, true);
const outside = itemForSide(rawOutside, rawInside, false);
if (!itemInside(inside)) continue;
result.push({
connector: connector.toDocument(),
inside: placement(inside),
outside: placement(outside),
sourceInside,
conceptId: conceptFor(outside)?.id || null,
label: conceptFor(outside)?.label || outside.label || "External concept",
linkingPhraseLabel: rawInside.kind === "phrase" ? rawInside.label :
(rawOutside.kind === "phrase" ? rawOutside.label : null),
targetMaps: []
});
}
return result;
}
/** Return a new model with changed map metadata and unchanged contents. */
withMetadata(metadata) {
const document = this.toDocument();
document.metadata = copy(metadata);
return CmapModel.fromDocument(document);
}
/**
* Split an embedded submap into standalone child and remaining parent models.
* The owner stays in the parent as an ordinary concept and links to targetSlug.
* Descendant placements become the complete contents of the child map.
*/
extractSubmap(rootItemId, targetSlug = null, childMetadata = null) {
const sourceDocument = this.toDocument();
const rootId = Number(rootItemId);
const rootItem = sourceDocument.items.find((item) => Number(item.id) === rootId);
if (!rootItem || rootItem.kind !== "submap") {
throw new TypeError("A submap item is required for extraction");
}
const descendantIds = new Set();
let foundDescendant = true;
while (foundDescendant) {
foundDescendant = false;
for (const item of sourceDocument.items) {
const parentId = Number(item.parentSubmapId);
if (parentId !== rootId && !descendantIds.has(parentId)) continue;
if (descendantIds.has(Number(item.id))) continue;
descendantIds.add(Number(item.id));
foundDescendant = true;
}
}
const descendantItems = sourceDocument.items
.filter((item) => descendantIds.has(Number(item.id)));
if (!descendantItems.some((item) => item.kind !== "phrase")) {
throw new Error("The submap has no concepts to extract");
}
const left = Math.min(...descendantItems.map((item) => Number(item.x) || 0));
const top = Math.min(...descendantItems.map((item) => Number(item.y) || 0));
const childItems = descendantItems.map((item) => {
const x = (Number(item.x) || 0) - left + 80;
const y = (Number(item.y) || 0) - top + 80;
const parentSubmapId = Number(item.parentSubmapId) === rootId ? null : item.parentSubmapId;
const submapDepth = Math.max(
0, Number(item.submapDepth || 0) - Number(rootItem.submapDepth || 0) - 1);
return {
...item,
parentSubmapId,
submapDepth,
x,
y,
layouts: {
...(item.layouts || {}),
root: {
...(item.layouts?.root || {}),
x,
y,
width: Number(item.width),
height: Number(item.height)
}
}
};
});
const parentItems = sourceDocument.items
.filter((item) => !descendantIds.has(Number(item.id)))
.map((item) => Number(item.id) === rootId ? {
...item,
kind: "concept",
childMap: null,
expanded: false,
submapInitialized: false,
separateMap: false,
mapReference: null
} : item);
const allItemIds = new Set(sourceDocument.items.map((item) => Number(item.id)));
const parentConnectors = [];
const parentConnectorKeys = new Set();
const childConnectors = [];
for (const connector of sourceDocument.connectors) {
const sourceId = Number(connector.sourceId);
const targetId = Number(connector.targetId);
if (!allItemIds.has(sourceId) || !allItemIds.has(targetId)) continue;
const sourceInside = descendantIds.has(sourceId);
const targetInside = descendantIds.has(targetId);
if (sourceInside && targetInside) {
childConnectors.push({ ...connector });
continue;
}
if (sourceInside || targetInside) {
const rewired = {
...connector,
sourceId: sourceInside ? rootId : sourceId,
targetId: targetInside ? rootId : targetId
};
if (rewired.sourceId === rewired.targetId) continue;
const key = `${rewired.sourceId}\u0000${rewired.targetId}\u0000${rewired.hasArrow !== false}`;
if (!parentConnectorKeys.has(key)) {
parentConnectorKeys.add(key);
parentConnectors.push(rewired);
}
continue;
}
const key = `${sourceId}\u0000${targetId}\u0000${connector.hasArrow !== false}`;
if (!parentConnectorKeys.has(key)) {
parentConnectorKeys.add(key);
parentConnectors.push({ ...connector });
}
}
const conceptIdsFor = (items) => new Set(items
.filter((item) => item.kind !== "phrase" && item.conceptId)
.map((item) => String(item.conceptId)));
const childConceptIds = conceptIdsFor(childItems);
const parentConceptIds = conceptIdsFor(parentItems);
const ownerConceptId = String(rootItem.conceptId);
const conceptsFor = (ids) => sourceDocument.concepts
.filter((concept) => ids.has(String(concept.id)))
.map((concept) => ({ ...concept }));
const parentConcepts = conceptsFor(parentConceptIds);
if (targetSlug) {
const ownerConcept = parentConcepts.find((concept) => concept.id === ownerConceptId);
if (ownerConcept) ownerConcept.cmapSlug = String(targetSlug);
}
const relationsFor = (ids) => (sourceDocument.conceptRelations || [])
.filter((relation) => ids.has(String(relation.sourceConceptId)) &&
ids.has(String(relation.targetConceptId)))
.map((relation) => ({ ...relation }));
const ownershipsFor = (ids) => (sourceDocument.conceptOwnerships || [])
.filter((ownership) => {
const parentId = String(ownership.parentConceptId);
const childId = String(ownership.childConceptId);
const extractedOwnership = parentId === ownerConceptId && childConceptIds.has(childId);
return !extractedOwnership && ids.has(parentId) && ids.has(childId);
})
.map((ownership) => ({ ...ownership }));
const ownerConcept = sourceDocument.concepts
.find((concept) => String(concept.id) === ownerConceptId) || {};
const extractedMetadata = childMetadata ? copy(childMetadata) : {
tags: Array.isArray(ownerConcept.aspects) ? [...ownerConcept.aspects] : [],
summary: String(ownerConcept.synopsis || ""),
explanationPageSlug: String(ownerConcept.descriptionPageSlug || "")
};
const childDocument = {
schemaVersion: sourceDocument.schemaVersion,
metadata: extractedMetadata,
concepts: conceptsFor(childConceptIds),
items: childItems,
connectors: childConnectors,
conceptMaps: sourceDocument.conceptMaps
.filter((reference) => descendantIds.has(Number(reference.rootItemId)))
};
const childRelations = relationsFor(childConceptIds);
const childOwnerships = ownershipsFor(childConceptIds);
if (childRelations.length) childDocument.conceptRelations = childRelations;
if (childOwnerships.length) childDocument.conceptOwnerships = childOwnerships;
const parentDocument = {
...sourceDocument,
concepts: parentConcepts,
items: parentItems,
connectors: parentConnectors,
conceptMaps: sourceDocument.conceptMaps.filter((reference) =>
Number(reference.rootItemId) !== rootId &&
!descendantIds.has(Number(reference.rootItemId)))
};
const parentRelations = relationsFor(parentConceptIds);
const parentOwnerships = ownershipsFor(parentConceptIds);
if (parentRelations.length) parentDocument.conceptRelations = parentRelations;
else delete parentDocument.conceptRelations;
if (parentOwnerships.length) parentDocument.conceptOwnerships = parentOwnerships;
else delete parentDocument.conceptOwnerships;
return {
parentModel: CmapModel.fromDocument(parentDocument),
childModel: CmapModel.fromDocument(childDocument)
};
}
toDocument() {
const document = this.conceptMap.toDocument();
const usedConceptIds = new Set(this.conceptMap.items()
.filter((item) => item instanceof ConceptMapConcept)
.map((item) => item.conceptId));
document.concepts = this.repository.toDocument()
.filter((concept) => usedConceptIds.has(concept.id));
const relations = this.repository.relationDocuments();
const ownerships = this.repository.ownershipDocuments();
if (relations.length) document.conceptRelations = relations;
if (ownerships.length) document.conceptOwnerships = ownerships;
return document;
}
}
export { PLACEMENT_FIELDS };
+217
View File
@@ -0,0 +1,217 @@
const CONCEPT_FIELDS = [
"label", "synopsis", "aspects", "tags", "descriptionPageSlug",
"pageSlug", "cmapSlug", "externalUrl", "imageSource"
];
function copy(value) {
return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
}
function normalizeTags(tags) {
if (!Array.isArray(tags)) return [];
return tags.map((tag) => {
if (typeof tag === "string") return { type: "label", value: tag.trim() };
if (!tag || typeof tag !== "object") return null;
const type = String(tag.type || "label").trim() || "label";
const value = String(tag.value || tag.name || "").trim();
return value ? { type, value } : null;
}).filter(Boolean);
}
/**
* Store the shared content and external references of one concept.
* A Concept never contains coordinates, styling or browser objects.
*/
export class Concept {
constructor(id, values = {}) {
if (!id) throw new TypeError("A concept id is required");
this.id = String(id);
this.label = String(values.label || "Concept");
this.synopsis = String(values.synopsis || "");
this.aspects = Array.isArray(values.aspects) ? values.aspects.map(String) : [];
this.tags = normalizeTags(values.tags);
this.descriptionPageSlug = values.descriptionPageSlug || null;
this.pageSlug = values.pageSlug || null;
this.cmapSlug = values.cmapSlug || null;
this.externalUrl = values.externalUrl || null;
this.imageSource = values.imageSource || "";
}
/** Change shared content without accepting placement or presentation fields. */
update(changes = {}) {
for (const field of CONCEPT_FIELDS) {
if (changes[field] === undefined) continue;
if (field === "aspects") {
this.aspects = Array.isArray(changes.aspects) ? changes.aspects.map(String) : [];
} else if (field === "tags") {
this.tags = normalizeTags(changes.tags);
} else {
this[field] = changes[field];
}
}
return this;
}
/** Return the stable JSON representation used at the wiki API boundary. */
toDocument() {
return {
id: this.id,
label: this.label,
synopsis: this.synopsis,
aspects: [...this.aspects],
tags: copy(this.tags),
descriptionPageSlug: this.descriptionPageSlug,
pageSlug: this.pageSlug,
cmapSlug: this.cmapSlug,
externalUrl: this.externalUrl,
imageSource: this.imageSource
};
}
}
/** Describe a semantic relation that exists independently of a diagram. */
export class ConceptRelation {
constructor(id, sourceConceptId, targetConceptId, values = {}) {
if (!id || !sourceConceptId || !targetConceptId) {
throw new TypeError("A concept relation requires an id and two concepts");
}
this.id = String(id);
this.sourceConceptId = String(sourceConceptId);
this.targetConceptId = String(targetConceptId);
this.label = String(values.label || "");
this.tags = normalizeTags(values.tags);
}
toDocument() {
return {
id: this.id,
sourceConceptId: this.sourceConceptId,
targetConceptId: this.targetConceptId,
label: this.label,
tags: copy(this.tags)
};
}
}
/** Describe semantic parent/child ownership between repository concepts. */
export class ConceptOwnership {
constructor(parentConceptId, childConceptId) {
if (!parentConceptId || !childConceptId || parentConceptId === childConceptId) {
throw new TypeError("Concept ownership requires two different concepts");
}
this.parentConceptId = String(parentConceptId);
this.childConceptId = String(childConceptId);
}
toDocument() {
return {
parentConceptId: this.parentConceptId,
childConceptId: this.childConceptId
};
}
}
/**
* Own all shared concepts and their semantic relationships.
* Placements refer to this repository by concept id.
*/
export class ConceptRepository {
constructor(concepts = [], relations = [], ownerships = []) {
this.conceptsById = new Map();
this.relationsById = new Map();
this.ownershipsByChildId = new Map();
for (const value of concepts) this.add(value);
for (const value of relations) this.addRelation(value);
for (const value of ownerships) this.addOwnership(value);
}
add(value) {
const concept = value instanceof Concept ? value : new Concept(value.id, value);
this.conceptsById.set(concept.id, concept);
return concept;
}
ensure(id, values = {}) {
const key = String(id);
const existing = this.conceptsById.get(key);
if (existing) {
existing.update(values);
return existing;
}
return this.add(new Concept(key, values));
}
concept(id) {
return this.conceptsById.get(String(id)) || null;
}
concepts() {
return [...this.conceptsById.values()];
}
remove(id) {
const key = String(id);
this.conceptsById.delete(key);
for (const [relationId, relation] of this.relationsById) {
if (relation.sourceConceptId === key || relation.targetConceptId === key) {
this.relationsById.delete(relationId);
}
}
this.ownershipsByChildId.delete(key);
for (const [childId, ownership] of this.ownershipsByChildId) {
if (ownership.parentConceptId === key) this.ownershipsByChildId.delete(childId);
}
}
addRelation(value) {
const relation = value instanceof ConceptRelation ? value : new ConceptRelation(
value.id, value.sourceConceptId, value.targetConceptId, value);
this.requireConcept(relation.sourceConceptId);
this.requireConcept(relation.targetConceptId);
this.relationsById.set(relation.id, relation);
return relation;
}
addOwnership(value) {
const ownership = value instanceof ConceptOwnership ? value : new ConceptOwnership(
value.parentConceptId, value.childConceptId);
this.requireConcept(ownership.parentConceptId);
this.requireConcept(ownership.childConceptId);
if (this.isOwnedBy(ownership.parentConceptId, ownership.childConceptId)) {
throw new Error("Concept ownership would create a cycle");
}
this.ownershipsByChildId.set(ownership.childConceptId, ownership);
return ownership;
}
isOwnedBy(conceptId, possibleAncestorId) {
let current = this.ownershipsByChildId.get(String(conceptId));
const seen = new Set();
while (current && !seen.has(current.childConceptId)) {
if (current.parentConceptId === String(possibleAncestorId)) return true;
seen.add(current.childConceptId);
current = this.ownershipsByChildId.get(current.parentConceptId);
}
return false;
}
requireConcept(id) {
const concept = this.concept(id);
if (!concept) throw new Error(`Unknown concept: ${id}`);
return concept;
}
toDocument() {
return this.concepts().map((concept) => concept.toDocument());
}
relationDocuments() {
return [...this.relationsById.values()].map((relation) => relation.toDocument());
}
ownershipDocuments() {
return [...this.ownershipsByChildId.values()].map((ownership) => ownership.toDocument());
}
}
export { CONCEPT_FIELDS, normalizeTags };
+534
View File
@@ -0,0 +1,534 @@
/**
* Versioned JSON interchange for the Racket Wiki CMap model.
*
* The bundle separates shared concept content from map-local placement data.
* `buildBundle` follows map and page references, embeds the referenced page
* attachments, and validates the resulting object. Import code calls
* `validateBundle` first and then uses `preparedMapDocument` to join shared
* concepts back into a map document. The helpers in this module deliberately
* return detached JSON values so callers cannot mutate an input map or bundle.
*/
const FORMAT = "racket-wiki-cmap-bundle";
const FORMAT_VERSION = 1;
const SCHEMA = "/schemas/racket-wiki-cmap-bundle-v1.schema.json";
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const TEMPORARY_ID = /^new:[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/;
const SLUG = /^[\p{L}\p{N}][\p{L}\p{N}._-]{0,119}$/u;
const CONCEPT_KEYS = [
"id", "label", "synopsis", "aspects", "tags", "descriptionPageSlug",
"pageSlug", "cmapSlug", "externalUrl", "imageSource"
];
const PLACEMENT_CONTENT_KEYS = new Set(CONCEPT_KEYS.filter((key) => key !== "id"));
/** Return a detached JSON-compatible copy while preserving undefined. */
function clone(value) {
return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
}
/**
* Check a wiki page reference in either `slug` or `namespace:slug` form.
* The check is shared by concept links, map metadata and exported pages.
*/
function validPageReference(value) {
if (typeof value !== "string") return false;
const separator = value.indexOf(":");
if (separator < 0) return SLUG.test(value);
const namespace = value.slice(0, separator);
const slug = value.slice(separator + 1);
return namespace.length <= 80 && SLUG.test(namespace) && SLUG.test(slug);
}
/** Accept only complete HTTP(S) URLs for external concept links. */
function validExternalUrl(value) {
try {
const url = new URL(String(value));
return url.protocol === "http:" || url.protocol === "https:";
} catch (_error) {
return false;
}
}
/**
* Decode the one or two historical JSON string wrappers used by storage.
* Invalid or non-object values become an empty document; bundle validation
* remains responsible for rejecting malformed imported data.
*/
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 : {};
}
/** Copy only shared concept fields into the bundle-level concept table. */
function conceptContent(value) {
const result = {};
for (const key of CONCEPT_KEYS) {
if (Object.prototype.hasOwnProperty.call(value || {}, key)) result[key] = clone(value[key]);
}
return result;
}
/** Index valid document concepts by their stable identity. */
function conceptsById(documentValue) {
return new Map((Array.isArray(documentValue.concepts) ? documentValue.concepts : [])
.filter((concept) => concept && typeof concept.id === "string" && concept.id)
.map((concept) => [concept.id, concept]));
}
/** Return unique concept ids used by non-phrase placements. */
function itemConceptIds(documentValue) {
return [...new Set((Array.isArray(documentValue.items) ? documentValue.items : [])
.filter((item) => item && item.kind !== "phrase" && typeof item.conceptId === "string" && item.conceptId)
.map((item) => item.conceptId))];
}
/** Find linked CMap slugs in the concepts actually placed on a document. */
function linkedMapSlugs(documentValue) {
const concepts = conceptsById(documentValue);
return [...new Set(itemConceptIds(documentValue)
.map((id) => String(concepts.get(id)?.cmapSlug || "").trim())
.filter(Boolean))];
}
/**
* Collect page references from map metadata and placed concepts.
* Unplaced concept records are intentionally ignored because they are not
* part of the visible map dependency graph.
*/
function linkedPageReferences(documentValue) {
const references = new Set();
const metadata = documentValue.metadata && typeof documentValue.metadata === "object" ?
documentValue.metadata : {};
if (typeof metadata.explanationPageSlug === "string" && metadata.explanationPageSlug.trim()) {
references.add(metadata.explanationPageSlug.trim());
}
const concepts = conceptsById(documentValue);
for (const id of itemConceptIds(documentValue)) {
const concept = concepts.get(id) || {};
for (const key of ["pageSlug", "descriptionPageSlug"]) {
if (typeof concept[key] === "string" && concept[key].trim()) references.add(concept[key].trim());
}
}
return references;
}
/**
* Extract local upload URLs from Markdown and HTML-like markup.
* A Set removes duplicates; the final prefix check removes a shorter URL
* accidentally captured from a URL containing a space.
*/
function attachmentUrls(markdown) {
const source = String(markdown || "");
const urls = new Set();
const add = (value) => {
const url = String(value || "").trim();
if (url.startsWith("/uploads/") && url.split("/").length >= 4) urls.add(url);
};
for (const match of source.matchAll(/!?\[[^\]]*\]\((\/uploads\/[^)]*)\)/g)) add(match[1]);
for (const match of source.matchAll(/(?:src|href)\s*=\s*["'](\/uploads\/[^"']+)["']/gi)) add(match[1]);
for (const match of source.matchAll(/\/uploads\/[^\s"'<>\\)]+/g)) add(match[0]);
const collected = [...urls];
return collected.filter((url) => !collected.some((other) =>
other !== url && other.startsWith(`${url} `)));
}
/** Derive a readable fallback filename from an upload URL. */
function attachmentName(url) {
const encoded = String(url || "").split("/").at(-1) || "attachment.bin";
try {
return decodeURIComponent(encoded) || "attachment.bin";
} catch (_error) {
return encoded || "attachment.bin";
}
}
/** Replace upload URLs after imported attachments receive new server URLs. */
function replaceAttachmentUrls(markdown, replacements) {
let result = String(markdown || "");
const entries = replacements instanceof Map ? [...replacements.entries()] :
Object.entries(replacements || {});
entries.sort(([left], [right]) => right.length - left.length);
for (const [source, target] of entries) {
if (!source || source === target) continue;
result = result.split(source).join(String(target));
}
return result;
}
/**
* Strip shared concept fields from placements while preserving layout data.
* It also keeps only connectors whose two local item endpoints still exist,
* because export must not emit dangling layout relations.
*/
function placementDocument(documentValue) {
const documentCopy = clone(decodedDocument(documentValue));
const ids = itemConceptIds(documentCopy);
for (const concept of (Array.isArray(documentCopy.concepts) ? documentCopy.concepts : [])) {
if (concept && typeof concept.id === "string" && !ids.includes(concept.id)) ids.push(concept.id);
}
documentCopy.items = (Array.isArray(documentCopy.items) ? documentCopy.items : []).map((item) => {
if (!item || item.kind === "phrase") return item;
return Object.fromEntries(Object.entries(item)
.filter(([key]) => !PLACEMENT_CONTENT_KEYS.has(key)));
});
documentCopy.concepts = ids.map((id) => ({ id }));
const itemIds = new Set();
for (const item of documentCopy.items) {
const itemId = Number(item?.id);
if (Number.isInteger(itemId)) itemIds.add(itemId);
}
documentCopy.connectors = (Array.isArray(documentCopy.connectors) ?
documentCopy.connectors : []).filter((connector) => {
const sourceExists = itemIds.has(Number(connector?.sourceId));
const targetExists = itemIds.has(Number(connector?.targetId));
return sourceExists && targetExists;
});
return documentCopy;
}
/**
* Convert one wiki page and all uploads referenced by its Markdown into a
* bundle page record. The attachment loader is called once per unique URL.
*/
async function pageRecord(page, requestedReference, loadAttachment) {
const markdown = String(page.markdown || "");
const attachments = [];
for (const url of attachmentUrls(markdown)) {
if (typeof loadAttachment !== "function") {
throw new Error(`Attachment loader is required for ${url}.`);
}
const loaded = await loadAttachment(url, requestedReference);
if (!loaded || typeof loaded.contentBase64 !== "string") {
throw new Error(`Attachment ${url} did not provide base64 content.`);
}
attachments.push({
url,
name: String(loaded.name || attachmentName(url)),
mimeType: String(loaded.mimeType || "application/octet-stream"),
contentBase64: loaded.contentBase64
});
}
return {
reference: String(requestedReference),
title: String(page.title || page.slug || requestedReference),
markdown,
tags: Array.isArray(page.tags) ? page.tags.map(String) : [],
attachments
};
}
/**
* Build and validate a complete export bundle.
*
* @param {object} options Export options and asynchronous repository loaders.
* @param {object} options.rootMap Root map with `slug`, `title` and `document`.
* @param {Function} options.loadConceptMap Loads a linked map by slug.
* @param {Function} options.loadWikiPage Loads a linked wiki page by reference.
* @param {Function} [options.loadAttachment] Loads base64 upload content.
* @param {number} [options.maxDepth=0] Maximum depth for linked CMaps.
* @returns {Promise<object>} A validated, detached interchange bundle.
* @throws {Error} When required loaders are absent or a loaded record is invalid.
* @sideeffects Calls the supplied map, page and attachment loaders.
*
* `collectMap` traverses map dependencies and uses `placementDocument` for
* layout-only map records. It collects shared concepts and page references
* separately; page records are then built and the final aggregate is checked
* by `validateBundle` before it is returned.
*/
async function buildBundle(options) {
if (!options?.rootMap?.slug) throw new Error("A root CMap is required.");
if (typeof options.loadConceptMap !== "function") throw new Error("loadConceptMap is required.");
if (typeof options.loadWikiPage !== "function") throw new Error("loadWikiPage is required.");
const maximumDepth = Math.max(0, Math.min(10, Number(options.maxDepth) || 0));
const maps = [];
const concepts = new Map();
const pageReferences = new Set();
const visited = new Set();
const missingMaps = new Set();
async function collectMap(map, depth) {
if (!map?.slug || visited.has(map.slug)) return;
visited.add(map.slug);
const documentValue = decodedDocument(map.document);
maps.push({
slug: String(map.slug),
title: String(map.title || map.slug),
document: placementDocument(documentValue)
});
for (const concept of conceptsById(documentValue).values()) {
if (!concepts.has(concept.id)) concepts.set(concept.id, conceptContent(concept));
}
for (const reference of linkedPageReferences(documentValue)) pageReferences.add(reference);
const sourceSlug = String(documentValue.derivedView?.sourceCmapSlug || "").trim();
if (sourceSlug && !visited.has(sourceSlug)) {
try {
await collectMap(await options.loadConceptMap(sourceSlug), depth);
} catch (_error) {
missingMaps.add(sourceSlug);
}
}
if (depth >= maximumDepth) return;
for (const slug of linkedMapSlugs(documentValue)) {
if (visited.has(slug)) continue;
try {
await collectMap(await options.loadConceptMap(slug), depth + 1);
} catch (_error) {
missingMaps.add(slug);
}
}
}
await collectMap(options.rootMap, 0);
const pages = [];
const missingPages = [];
for (const reference of [...pageReferences].sort()) {
try {
pages.push(await pageRecord(
await options.loadWikiPage(reference), reference, options.loadAttachment));
} catch (_error) {
missingPages.push(reference);
}
}
const bundle = {
$schema: SCHEMA,
format: FORMAT,
formatVersion: FORMAT_VERSION,
exportedAt: options.exportedAt || new Date().toISOString(),
generator: options.generator || "Racket Wiki",
rootCmapSlug: String(options.rootMap.slug),
cmaps: maps,
concepts: [...concepts.values()],
pages,
missing: { cmaps: [...missingMaps].sort(), pages: missingPages }
};
validateBundle(bundle);
return bundle;
}
/**
* Validate the complete bundle contract and return the original bundle.
*
* @param {object} bundle Candidate bundle to validate.
* @returns {object} The same bundle object after successful validation.
* @throws {Error} With `validationErrors` when one or more contract checks fail.
*
* Validation also builds the concept, map, item and page-reference indexes
* needed to detect duplicate identities, dangling references and missing
* linked pages. Up to twenty errors are included in the message while the
* complete list remains available on `error.validationErrors`.
*/
function validateBundle(bundle) {
const errors = [];
const issue = (path, message) => errors.push(`${path}: ${message}`);
if (!bundle || typeof bundle !== "object" || Array.isArray(bundle)) {
throw new Error("The import must be a JSON object.");
}
if (bundle.format !== FORMAT) issue("format", `must be ${FORMAT}`);
if (bundle.formatVersion !== FORMAT_VERSION) issue("formatVersion", `must be ${FORMAT_VERSION}`);
if (!Array.isArray(bundle.cmaps) || !bundle.cmaps.length) issue("cmaps", "must contain at least one CMap");
if (!Array.isArray(bundle.concepts)) issue("concepts", "must be an array");
if (!Array.isArray(bundle.pages)) issue("pages", "must be an array");
// Validate the shared concept table and collect its page dependencies.
const conceptIds = new Set();
const conceptLabels = new Set();
const usedConceptIds = new Set();
const linkedPageReferences = new Set();
for (const [index, concept] of (Array.isArray(bundle.concepts) ? bundle.concepts : []).entries()) {
const path = `concepts[${index}]`;
if (!concept || typeof concept !== "object" || Array.isArray(concept)) {
issue(path, "must be an object");
continue;
}
if (typeof concept.id !== "string" || !(UUID.test(concept.id) || TEMPORARY_ID.test(concept.id))) {
issue(`${path}.id`, "must be a UUID or a new:<name> temporary id");
} else if (conceptIds.has(concept.id)) {
issue(`${path}.id`, "is duplicated");
} else conceptIds.add(concept.id);
if (typeof concept.label !== "string" || !concept.label.trim()) issue(`${path}.label`, "is required");
else {
const name = concept.label.trim().toLocaleLowerCase();
if (conceptLabels.has(name)) issue(`${path}.label`, "duplicates another concept name");
else conceptLabels.add(name);
}
for (const key of ["pageSlug", "descriptionPageSlug"]) {
if (typeof concept[key] === "string" && concept[key].trim()) {
linkedPageReferences.add(concept[key].trim());
if (!validPageReference(concept[key].trim())) issue(`${path}.${key}`, "must be a valid wiki page reference");
}
}
if (typeof concept.cmapSlug === "string" && concept.cmapSlug.trim() && !SLUG.test(concept.cmapSlug.trim())) {
issue(`${path}.cmapSlug`, "must be a valid CMap slug");
}
if (concept.externalUrl !== undefined && concept.externalUrl !== null &&
(typeof concept.externalUrl !== "string" ||
!concept.externalUrl.trim() || !validExternalUrl(concept.externalUrl.trim()))) {
issue(`${path}.externalUrl`, "must be a complete http or https URL");
}
}
// Validate map identities, placements, connectors and map-level page links.
const mapSlugs = new Set();
for (const [mapIndex, cmap] of (Array.isArray(bundle.cmaps) ? bundle.cmaps : []).entries()) {
const path = `cmaps[${mapIndex}]`;
if (!cmap || typeof cmap !== "object" || Array.isArray(cmap)) {
issue(path, "must be an object");
continue;
}
if (typeof cmap.slug !== "string" || !SLUG.test(cmap.slug)) issue(`${path}.slug`, "must be a valid CMap slug");
else if (mapSlugs.has(cmap.slug)) issue(`${path}.slug`, "is duplicated");
else mapSlugs.add(cmap.slug);
if (typeof cmap.title !== "string" || !cmap.title.trim()) issue(`${path}.title`, "is required");
const documentValue = cmap.document;
if (!documentValue || typeof documentValue !== "object" || Array.isArray(documentValue)) {
issue(`${path}.document`, "must be an object");
continue;
}
const items = Array.isArray(documentValue.items) ? documentValue.items : [];
const metadataReference = documentValue.metadata?.explanationPageSlug;
if (typeof metadataReference === "string" && metadataReference.trim()) {
linkedPageReferences.add(metadataReference.trim());
if (!validPageReference(metadataReference.trim())) {
issue(`${path}.document.metadata.explanationPageSlug`, "must be a valid wiki page reference");
}
}
const documentConceptIds = new Set();
for (const [referenceIndex, reference] of (Array.isArray(documentValue.concepts) ?
documentValue.concepts : []).entries()) {
const referencePath = `${path}.document.concepts[${referenceIndex}].id`;
if (!reference || typeof reference.id !== "string" || !conceptIds.has(reference.id)) {
issue(referencePath, "must reference a concept in concepts[]");
} else if (documentConceptIds.has(reference.id)) issue(referencePath, "is duplicated within the CMap");
else documentConceptIds.add(reference.id);
}
const itemIds = new Set();
for (const [itemIndex, item] of items.entries()) {
const itemPath = `${path}.document.items[${itemIndex}]`;
if (!item || typeof item !== "object" || Array.isArray(item)) {
issue(itemPath, "must be an object");
continue;
}
if (!Number.isInteger(Number(item.id))) issue(`${itemPath}.id`, "must be an integer");
else if (itemIds.has(Number(item.id))) issue(`${itemPath}.id`, "is duplicated within the CMap");
else itemIds.add(Number(item.id));
if (!Number.isFinite(Number(item.x))) issue(`${itemPath}.x`, "must be a number");
if (!Number.isFinite(Number(item.y))) issue(`${itemPath}.y`, "must be a number");
if (item.kind !== "phrase") {
if (typeof item.conceptId !== "string" || !conceptIds.has(item.conceptId)) {
issue(`${itemPath}.conceptId`, "must reference a concept in concepts[]");
} else usedConceptIds.add(item.conceptId);
}
}
for (const [connectorIndex, connector] of (Array.isArray(documentValue.connectors) ?
documentValue.connectors : []).entries()) {
const connectorPath = `${path}.document.connectors[${connectorIndex}]`;
if (!itemIds.has(Number(connector?.sourceId))) issue(`${connectorPath}.sourceId`, "references an unknown item");
if (!itemIds.has(Number(connector?.targetId))) issue(`${connectorPath}.targetId`, "references an unknown item");
}
}
if (typeof bundle.rootCmapSlug !== "string" || !mapSlugs.has(bundle.rootCmapSlug)) {
issue("rootCmapSlug", "must reference a CMap in cmaps[]");
}
for (const id of conceptIds) {
if (!usedConceptIds.has(id)) issue(`concepts[id=${id}]`, "must occur as a diagram placement");
}
// Validate page records and their embedded attachment payloads.
const pageReferences = new Set();
for (const [index, page] of (Array.isArray(bundle.pages) ? bundle.pages : []).entries()) {
const path = `pages[${index}]`;
if (!page || typeof page !== "object" || Array.isArray(page)) {
issue(path, "must be an object");
continue;
}
if (typeof page.reference !== "string" || !validPageReference(page.reference)) issue(`${path}.reference`, "must be a valid wiki page reference");
else if (pageReferences.has(page.reference)) issue(`${path}.reference`, "is duplicated");
else pageReferences.add(page.reference);
if (typeof page.title !== "string" || !page.title.trim()) issue(`${path}.title`, "is required");
if (typeof page.markdown !== "string") issue(`${path}.markdown`, "must be a string");
if (!Array.isArray(page.tags) || !page.tags.every((tag) => typeof tag === "string")) {
issue(`${path}.tags`, "must be an array of strings");
}
if (page.attachments !== undefined && !Array.isArray(page.attachments)) {
issue(`${path}.attachments`, "must be an array");
}
const attachmentReferences = new Set();
for (const [attachmentIndex, attachment] of (Array.isArray(page.attachments) ?
page.attachments : []).entries()) {
const attachmentPath = `${path}.attachments[${attachmentIndex}]`;
if (!attachment || typeof attachment !== "object" || Array.isArray(attachment)) {
issue(attachmentPath, "must be an object");
continue;
}
if (typeof attachment.url !== "string" || !attachment.url.startsWith("/uploads/")) {
issue(`${attachmentPath}.url`, "must be a local /uploads/ URL");
} else if (attachmentReferences.has(attachment.url)) {
issue(`${attachmentPath}.url`, "is duplicated within the page");
} else attachmentReferences.add(attachment.url);
if (typeof attachment.name !== "string" || !attachment.name.trim()) {
issue(`${attachmentPath}.name`, "is required");
}
if (typeof attachment.mimeType !== "string" || !attachment.mimeType.trim()) {
issue(`${attachmentPath}.mimeType`, "is required");
}
if (typeof attachment.contentBase64 !== "string" ||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(attachment.contentBase64)) {
issue(`${attachmentPath}.contentBase64`, "must be valid base64");
}
}
}
// Reconcile all collected page dependencies with present or declared-missing pages.
const explicitlyMissingPages = new Set(Array.isArray(bundle.missing?.pages) ? bundle.missing.pages : []);
for (const reference of linkedPageReferences) {
if (!pageReferences.has(reference) && !explicitlyMissingPages.has(reference)) {
issue(`pages[reference=${reference}]`, "is required by a linked concept or CMap explanation");
}
}
// Report all discovered issues together so import callers can repair a bundle in one pass.
if (errors.length) {
const error = new Error(`Invalid CMap bundle:\n${errors.slice(0, 20).join("\n")}`);
error.validationErrors = errors;
throw error;
}
return bundle;
}
/**
* Rejoin bundle-level concept content with one placement-only map document.
*
* @param {object} bundle A bundle that satisfies `validateBundle`.
* @param {object} cmap One entry from `bundle.cmaps`.
* @returns {object} A detached document suitable for repository import.
* @throws {Error} When the bundle is invalid or a referenced concept is absent.
*/
function preparedMapDocument(bundle, cmap) {
validateBundle(bundle);
const byId = new Map(bundle.concepts.map((concept) => [concept.id, concept]));
const documentValue = clone(cmap.document);
const ids = itemConceptIds(documentValue);
for (const reference of (Array.isArray(documentValue.concepts) ? documentValue.concepts : [])) {
if (reference?.id && !ids.includes(reference.id)) ids.push(reference.id);
}
documentValue.concepts = ids.map((id) => clone(byId.get(id)));
return documentValue;
}
export {
FORMAT,
FORMAT_VERSION,
SCHEMA,
attachmentUrls,
buildBundle,
decodedDocument,
placementDocument,
preparedMapDocument,
replaceAttachmentUrls,
validateBundle
};
+69
View File
@@ -0,0 +1,69 @@
import { buildBundle } from "./interchange.js";
/** Convert an ArrayBuffer to the base64 representation used in JSON bundles. */
function arrayBufferToBase64(buffer) {
const bytes = new Uint8Array(buffer);
let binary = "";
const chunkSize = 0x8000;
for (let offset = 0; offset < bytes.length; offset += chunkSize) {
binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize));
}
return btoa(binary);
}
/**
* Export a stored CMap model and its linked resources as a complete JSON bundle.
* CMaps are loaded through their repository. Page loading, bundle construction
* and attachment encoding remain independent of workspace presentation.
*/
export class CmapJsonExporter {
constructor(cmapRepository, loadWikiPage, fetchFile, generator = "Racket Wiki") {
if (!cmapRepository || typeof cmapRepository.load !== "function" ||
typeof loadWikiPage !== "function" || typeof fetchFile !== "function") {
throw new TypeError("A CMap repository, wiki-page loader and file loader are required");
}
this.cmapRepository = cmapRepository;
this.loadWikiPage = loadWikiPage;
this.fetchFile = fetchFile;
this.generator = generator;
}
/**
* Build a validated JSON bundle without changing the source CMaps.
* Linked CMaps are followed up to maxDepth; linked pages and attachments
* are included by the interchange format.
*/
async export(rootMap, maxDepth = 0) {
return buildBundle({
rootMap: this.bundleMap(rootMap),
maxDepth,
generator: this.generator,
loadConceptMap: async (slug) => this.bundleMap(await this.cmapRepository.load(slug)),
loadWikiPage: this.loadWikiPage,
loadAttachment: (url) => this.loadAttachment(url)
});
}
/** Present one stored model through the public interchange record shape. */
bundleMap(storedMap) {
if (!storedMap?.slug || typeof storedMap.toDocument !== "function") {
throw new TypeError("JSON export requires a stored CMap");
}
return {
slug: storedMap.slug,
title: storedMap.title,
document: storedMap.toDocument()
};
}
/** Load and encode one attachment referenced by an exported wiki page. */
async loadAttachment(url) {
const response = await this.fetchFile(url);
if (!response.ok) throw new Error(`Attachment could not be exported: ${url}`);
const content = await response.arrayBuffer();
return {
mimeType: response.headers.get("content-type") || "application/octet-stream",
contentBase64: arrayBufferToBase64(content)
};
}
}
+166
View File
@@ -0,0 +1,166 @@
import {
preparedMapDocument,
replaceAttachmentUrls,
validateBundle
} from "./interchange.js";
import { CmapModel } from "./concept-map.js";
const MAXIMUM_FILE_SIZE = 256 * 1024 * 1024;
const MAXIMUM_ATTACHMENT_SIZE = 50 * 1024 * 1024;
/** Decode one bundle attachment as a Blob accepted by the upload API. */
function attachmentBlob(attachment) {
const binary = atob(String(attachment.contentBase64 || ""));
if (binary.length > MAXIMUM_ATTACHMENT_SIZE) {
throw new Error(`Attachment exceeds 50 MiB: ${attachment.name}`);
}
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return new Blob([bytes], {
type: attachment.mimeType || "application/octet-stream"
});
}
/**
* Import a validated JSON bundle through the CMap repository and page API.
* Conflict policy is supplied by the workspace; this class performs only the
* deterministic page, attachment and CMap writes and reports their counts.
*/
export class CmapJsonImporter {
constructor(cmapRepository, pageApi, translate) {
if (!cmapRepository || typeof cmapRepository.load !== "function" ||
typeof pageApi !== "function" || typeof translate !== "function") {
throw new TypeError("A CMap repository, page API and translator are required");
}
this.cmapRepository = cmapRepository;
this.pageApi = pageApi;
this.translate = translate;
}
/** Parse and validate a selected JSON file without writing wiki data. */
async read(file) {
if (file.size > MAXIMUM_FILE_SIZE) {
throw new Error(this.translate(
"cmap-json-too-large", "The CMap JSON file exceeds 256 MiB."));
}
const bundle = JSON.parse(await file.text());
return validateBundle(bundle);
}
/** Return the pages and CMaps whose slugs already occur in the wiki. */
conflicts(bundle, pages, conceptMaps) {
const existingPages = new Map(pages.map((page) => [page.slug, page]));
const existingMaps = new Map(conceptMaps.map((cmap) => [cmap.slug, cmap]));
return {
pages: bundle.pages.filter((page) => existingPages.has(page.reference)),
conceptMaps: bundle.cmaps.filter((cmap) => existingMaps.has(cmap.slug))
};
}
/**
* Write all accepted bundle records and return created, updated and skipped counts.
* Existing records are replaced only when replaceExisting is true.
*/
async import(bundle, options = {}) {
validateBundle(bundle);
const pages = Array.isArray(options.pages) ? options.pages : [];
const conceptMaps = Array.isArray(options.conceptMaps) ? options.conceptMaps : [];
const replaceExisting = Boolean(options.replaceExisting);
const summary = String(options.summary || "Imported from CMap JSON");
const existingPages = new Map(pages.map((page) => [page.slug, page]));
const existingMaps = new Map(conceptMaps.map((cmap) => [cmap.slug, cmap]));
const result = {
mapsCreated: 0, mapsUpdated: 0, mapsSkipped: 0,
pagesCreated: 0, pagesUpdated: 0, pagesSkipped: 0,
attachmentsImported: 0
};
for (const page of bundle.pages) {
const existing = existingPages.get(page.reference);
if (existing && !replaceExisting) {
result.pagesSkipped += 1;
continue;
}
await this.importPage(page, existing, summary);
if (existing) result.pagesUpdated += 1;
else result.pagesCreated += 1;
result.attachmentsImported += Array.isArray(page.attachments) ?
page.attachments.length : 0;
}
for (const cmap of bundle.cmaps) {
const existing = existingMaps.get(cmap.slug);
if (existing && !replaceExisting) {
result.mapsSkipped += 1;
continue;
}
await this.importConceptMap(bundle, cmap, existing, summary);
if (existing) result.mapsUpdated += 1;
else result.mapsCreated += 1;
}
return result;
}
/** Store one page and upload the attachments embedded in its Markdown. */
async importPage(page, existing, summary) {
const attachments = Array.isArray(page.attachments) ? page.attachments : [];
const body = {
slug: page.reference,
title: page.title,
markdown: page.markdown,
tags: page.tags,
summary
};
if (existing) {
body.markdown = await this.importAttachments(page);
body.baseVersion = existing.currentVersion;
await this.pageApi(`/api/pages/${encodeURIComponent(page.reference)}`, {
method: "PUT", body: JSON.stringify(body)
});
return;
}
const created = await this.pageApi("/api/pages", {
method: "POST", body: JSON.stringify(body)
});
if (!attachments.length) return;
body.markdown = await this.importAttachments(page);
body.baseVersion = created.currentVersion;
await this.pageApi(`/api/pages/${encodeURIComponent(page.reference)}`, {
method: "PUT", body: JSON.stringify(body)
});
}
/** Upload one page's attachments and rewrite their Markdown URLs. */
async importAttachments(page) {
const replacements = new Map();
for (const attachment of (Array.isArray(page.attachments) ? page.attachments : [])) {
const uploaded = await this.pageApi(
`/api/pages/${encodeURIComponent(page.reference)}/upload`, {
method: "POST",
headers: { "X-File-Name": attachment.name },
body: attachmentBlob(attachment)
});
replacements.set(attachment.url, uploaded.url);
}
return replaceAttachmentUrls(page.markdown, replacements);
}
/** Store one CMap after rejoining shared concepts and placements. */
async importConceptMap(bundle, cmap, existing, summary) {
const documentValue = preparedMapDocument(bundle, cmap);
const model = CmapModel.fromDocument(documentValue);
if (existing) {
const storedMap = await this.cmapRepository.load(cmap.slug);
await this.cmapRepository.save(storedMap, model, {
title: cmap.title,
saveKind: "manual",
summary
});
return;
}
await this.cmapRepository.create(cmap.title, model, cmap.slug);
}
}
+364
View File
@@ -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()
};
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"type": "module"
}
+25
View File
@@ -0,0 +1,25 @@
/** Persist the people referenced by CMap concept tags through the wiki API. */
export class PeopleRepository {
constructor(api) {
this.api = api;
}
async all() {
const result = await this.api("/api/people");
return Array.isArray(result.people) ? result.people : [];
}
create(name) {
return this.api("/api/people", {
method: "POST",
body: JSON.stringify({ name })
});
}
update(person, active) {
return this.api(`/api/people/${person.id}`, {
method: "PUT",
body: JSON.stringify({ name: person.name, active })
});
}
}
@@ -0,0 +1,67 @@
/** Keep one context address unambiguous inside the in-memory lookup table. */
function zoomKey(cmapSlug, contextKey) {
return `${cmapSlug}\u0000${contextKey}`;
}
/**
* Represent wiki and user settings for the CMap workspace.
* The backend is authoritative; this object only caches the active session state.
*/
export class CmapSettingsRepository {
constructor(api) {
if (typeof api !== "function") throw new TypeError("A wiki API function is required");
this.api = api;
this.loaded = false;
this.startCmapSlug = "";
this.pageGuidesVisible = true;
this.zoomLevels = new Map();
}
async load() {
const result = await this.api("/api/cmap-settings");
this.startCmapSlug = typeof result.startCmapSlug === "string" ? result.startCmapSlug : "";
this.pageGuidesVisible = result.pageGuidesVisible !== false;
this.zoomLevels.clear();
for (const entry of (Array.isArray(result.zooms) ? result.zooms : [])) {
const zoom = Number(entry.zoomPercent);
if (entry.cmapSlug && entry.contextKey && zoom >= 25 && zoom <= 300) {
this.zoomLevels.set(zoomKey(entry.cmapSlug, entry.contextKey), zoom);
}
}
this.loaded = true;
return this;
}
zoom(cmapSlug, contextKey) {
return this.zoomLevels.get(zoomKey(cmapSlug, contextKey)) || 100;
}
async setStartCmap(slug) {
const result = await this.api("/api/cmap-settings/start", {
method: "PUT",
body: JSON.stringify({ startCmapSlug: slug || "" })
});
this.startCmapSlug = result.startCmapSlug || "";
return this.startCmapSlug;
}
async setPageGuidesVisible(visible) {
const result = await this.api("/api/cmap-settings/page-guides", {
method: "PUT",
body: JSON.stringify({ pageGuidesVisible: Boolean(visible) })
});
this.pageGuidesVisible = result.pageGuidesVisible !== false;
return this.pageGuidesVisible;
}
async setZoom(cmapSlug, contextKey, zoomPercent) {
if (!cmapSlug) return zoomPercent;
const result = await this.api("/api/cmap-settings/zoom", {
method: "PUT",
body: JSON.stringify({ cmapSlug, contextKey, zoomPercent })
});
const storedZoom = Number(result.zoomPercent);
this.zoomLevels.set(zoomKey(cmapSlug, contextKey), storedZoom);
return storedZoom;
}
}