refactoring of cmaps, widgets, etc.

This commit is contained in:
2026-09-02 16:06:26 +02:00
parent 38f255c1a4
commit f0562a06cc
52 changed files with 3626 additions and 2642 deletions
+35 -4
View File
@@ -12,9 +12,26 @@ same hit-test and drag lifecycle as selection, so it does not depend on a DOM
`model/concept-repository.js` owns shared concepts, semantic concept relations
and concept ownership. `model/concept-map.js` owns one map's concept
placements, linking phrases, connectors, metadata and presentation values.
Neither model module contains DOM or drawing-engine objects.
`model/cmap-repository.js` owns the server boundary: it decodes stored CMaps,
normalizes legacy concept identities and converts backend documents to and
from `CmapModel`. It is the only browser-side CMap model module that knows the
`/api/cmaps` routes. `model/appearance.js` owns the shared named styles, colour
palette, validation and style matching. `model/appearance-repository.js`
serializes that model through the appearance API. `model/settings-repository.js`
owns the wiki start CMap and the authenticated user's A4-guide and
per-CMap-context zoom settings. `model/people-repository.js` is the server
boundary for the people referenced by concept tags.
The repository objects keep only an in-memory session copy; the backend remains
authoritative. None of these model modules contains DOM or drawing-engine
objects, and CMap state is not persisted in browser storage.
`cmap-view.js` owns the canvas and the concrete `cmap.js` drawing instance.
`view/appearance-editor.js` presents the appearance model in the concept dialog
and coordinates explicit style and palette changes with its repository.
The concrete dialog controllers live in `../js/wiki/cmap/dialogs/`. They own
their fields, validation and browser events. The workspace supplies the active
CMap and performs editor transitions, while the controllers use repository
APIs for stored CMaps and people instead of calling backend routes directly.
`cmap-racket-wiki.js` contains the wiki-specific editor controller, selection
state, content-based initial sizing, resize and relation controls. Its view
records connect pure model ids to drawing nodes without making those nodes part
@@ -59,12 +76,19 @@ metadata remain intact, but its document no longer depends on the parent CMap.
`toDocument` and `loadDocument` round-trip the complete editor model: items,
formatting, positions, connectors, recursive submap membership and promoted
map references. The wiki host persists this JSON document through its CMap API.
map references. The public `currentModel`, `loadModel` and `replaceModel`
methods let the workspace pass complete domain models to and from the editor.
The CMap repository, rather than the workspace, persists those models.
## JSON interchange
`/js/wiki/cmap/interchange.js` implements the versioned `racket-wiki-cmap-bundle`
format. A bundle mirrors the normalized database model: `cmaps[]` contains the
`model/interchange.js` defines and validates the versioned
`racket-wiki-cmap-bundle` format. `model/json-exporter.js` builds complete bundles
through `CmapRepository` and the supplied page and attachment loaders.
`model/json-importer.js` reads those bundles, stores CMaps through the same
repository and performs the required page and attachment writes. The workspace
decides only how conflicts are presented to the user.
A bundle mirrors the normalized database model: `cmaps[]` contains the
complete placement and presentation document, `concepts[]` contains shared
content once per UUID, and `pages[]` contains the current Markdown, tags and
referenced attachments of linked wiki and explanation pages. Attachments retain
@@ -85,6 +109,13 @@ a temporary identity into a UUID through the central Racket UUID helper. UUIDs
already present in an export remain unchanged. Imported CMap slugs likewise
remain unchanged.
`model/markdown-exporter.js` reads stored models through `CmapRepository` but
produces a readable
report instead of an importable bundle. It is independent of the JSON
exporter because the report follows linked maps and optional wiki pages for a
different purpose. Additional output formats can therefore be implemented as
separate exporters without adding format-specific code to the workspace.
The editor keeps up to one hundred complete document states for Undo and Redo.
One drag or resize gesture forms one history step. The public `undo`, `redo`,
`canUndo`, `canRedo` and `resetHistory` methods are also used by the wiki host
+19
View File
@@ -1153,6 +1153,12 @@ import { CmapView } from "./cmap-view.js";
return true;
}
/** Replace the editor contents with a public CMap domain model. */
replaceModel(model) {
if (!(model instanceof CmapModel)) throw new TypeError("A CmapModel is required");
return this.replaceDocument(model.toDocument());
}
setZoom(percent) {
const next = Math.max(25, Math.min(300, Number(percent) || 100));
this.zoomFactor = next / 100;
@@ -1866,6 +1872,19 @@ import { CmapView } from "./cmap-view.js";
return this.synchronizeModel().toDocument();
}
/** Return the synchronized domain model currently edited by this view. */
currentModel() {
this.saveCurrentContextLayout();
this.refreshConceptMapReferences();
return this.synchronizeModel();
}
/** Load a public CMap domain model into an empty editor. */
loadModel(model) {
if (!(model instanceof CmapModel)) throw new TypeError("A CmapModel is required");
this.loadDocument(model.toDocument());
}
loadDocument(document = {}) {
if (this.items.length || this.connectors.length || this.unresolvedConnectors.length) {
throw new Error("A concept map document can only be loaded into an empty editor");
@@ -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
};
+206
View File
@@ -0,0 +1,206 @@
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._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; }
/** 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) {
const body = { title: String(title).trim(), document: modelDocument(model) };
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)
};
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));
}
}
+22 -2
View File
@@ -232,8 +232,25 @@ export class CmapModel {
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 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 a standalone map and its remaining parent.
* 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.
*/
@@ -401,7 +418,10 @@ export class CmapModel {
if (parentOwnerships.length) parentDocument.conceptOwnerships = parentOwnerships;
else delete parentDocument.conceptOwnerships;
return { parentDocument, childDocument };
return {
parentModel: CmapModel.fromDocument(parentDocument),
childModel: CmapModel.fromDocument(childDocument)
};
}
toDocument() {
+446
View File
@@ -0,0 +1,446 @@
/* Versioned JSON interchange for the Racket Wiki CMap model. */
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"));
function clone(value) {
return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
}
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);
}
function validExternalUrl(value) {
try {
const url = new URL(String(value));
return url.protocol === "http:" || url.protocol === "https:";
} catch (_error) {
return false;
}
}
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 conceptContent(value) {
const result = {};
for (const key of CONCEPT_KEYS) {
if (Object.prototype.hasOwnProperty.call(value || {}, key)) result[key] = clone(value[key]);
}
return result;
}
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]));
}
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))];
}
function linkedMapSlugs(documentValue) {
const concepts = conceptsById(documentValue);
return [...new Set(itemConceptIds(documentValue)
.map((id) => String(concepts.get(id)?.cmapSlug || "").trim())
.filter(Boolean))];
}
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;
}
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} `)));
}
function attachmentName(url) {
const encoded = String(url || "").split("/").at(-1) || "attachment.bin";
try {
return decodeURIComponent(encoded) || "attachment.bin";
} catch (_error) {
return encoded || "attachment.bin";
}
}
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;
}
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;
}
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
};
}
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;
}
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");
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");
}
}
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");
}
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");
}
}
}
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");
}
}
if (errors.length) {
const error = new Error(`Invalid CMap bundle:\n${errors.slice(0, 20).join("\n")}`);
error.validationErrors = errors;
throw error;
}
return bundle;
}
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()
};
}
}
+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 })
});
}
}
+67
View File
@@ -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;
}
}
+398
View File
@@ -0,0 +1,398 @@
import {
cmapColorValue,
cmapFontSizeInPoints,
displayCmapFontSize
} from "../model/appearance.js";
/**
* Present and edit CMap appearance in the concept dialog.
* The editor translates DOM changes to CmapAppearance operations and asks the
* repository to persist the complete aggregate after style or palette changes.
*/
export class CmapAppearanceEditor {
constructor(appearance, repository, tr) {
this.appearance = appearance;
this.repository = repository;
this.tr = tr;
this.$ = (id) => document.getElementById(id);
this.installColorPickers();
this.installStyleControls();
}
/** Fill appearance fields from one existing concept placement. */
showRecord(record) {
const fallback = this.appearance.defaultValues;
const titleFontSize = cmapFontSizeInPoints(record.fontSize, fallback.fontSize);
this.$("cmap-concept-background-label").textContent = record.kind === "submap" ?
this.tr("main-concept-background-color", "Main concept background color") :
this.tr("background-color", "Background color");
this.$("cmap-submap-style-fields").classList.toggle("hidden", record.kind !== "submap");
this.apply({
backgroundColor: cmapColorValue(record.backgroundColor, fallback.backgroundColor),
textColor: cmapColorValue(record.textColor, fallback.textColor),
fontFamily: record.fontFamily || fallback.fontFamily,
fontSize: titleFontSize,
fontWeight: String(record.fontWeight || fallback.fontWeight) === "400" ? "400" : "700",
fontStyle: record.fontStyle === "italic" ? "italic" : "normal",
synopsisTextColor: cmapColorValue(
record.synopsisTextColor || record.textColor, fallback.synopsisTextColor),
synopsisFontFamily: record.synopsisFontFamily || record.fontFamily || fallback.synopsisFontFamily,
synopsisFontSize: cmapFontSizeInPoints(
record.synopsisFontSize || "0.84em", titleFontSize),
synopsisFontWeight: String(
record.synopsisFontWeight || record.fontWeight || fallback.synopsisFontWeight) === "700" ?
"700" : "400",
synopsisFontStyle: (record.synopsisFontStyle || record.fontStyle) === "italic" ?
"italic" : "normal",
submapBackgroundColor: cmapColorValue(
record.submapBackgroundColor, fallback.submapBackgroundColor),
submapBorderColor: cmapColorValue(record.submapBorderColor, fallback.submapBorderColor)
});
this.renderStyleOptions();
}
/** Fill appearance fields for a new ordinary concept using the model default. */
showNewConcept() {
this.$("cmap-concept-background-label").textContent =
this.tr("background-color", "Background color");
this.$("cmap-submap-style-fields").classList.add("hidden");
this.apply(this.appearance.defaultValues);
this.renderStyleOptions();
}
/** Return normalized placement values from the appearance form. */
placementChanges(isSubmap) {
const values = this.capture();
const changes = {
backgroundColor: values.backgroundColor,
textColor: values.textColor,
fontFamily: values.fontFamily,
fontSize: `${values.fontSize}pt`,
fontWeight: values.fontWeight,
fontStyle: values.fontStyle,
synopsisTextColor: values.synopsisTextColor,
synopsisFontFamily: values.synopsisFontFamily,
synopsisFontSize: `${values.synopsisFontSize}pt`,
synopsisFontWeight: values.synopsisFontWeight,
synopsisFontStyle: values.synopsisFontStyle
};
if (isSubmap) {
changes.submapBackgroundColor = values.submapBackgroundColor;
changes.submapBorderColor = values.submapBorderColor;
}
return changes;
}
capture() {
return this.appearance.normalizeValues({
backgroundColor: this.$("cmap-concept-background").value,
textColor: this.$("cmap-concept-text-color").value,
fontFamily: this.$("cmap-concept-font-family").value,
fontSize: this.$("cmap-concept-font-size").value,
fontWeight: this.$("cmap-concept-bold").checked ? "700" : "400",
fontStyle: this.$("cmap-concept-italic").checked ? "italic" : "normal",
synopsisTextColor: this.$("cmap-concept-synopsis-text-color").value,
synopsisFontFamily: this.$("cmap-concept-synopsis-font-family").value,
synopsisFontSize: this.$("cmap-concept-synopsis-font-size").value,
synopsisFontWeight: this.$("cmap-concept-synopsis-bold").checked ? "700" : "400",
synopsisFontStyle: this.$("cmap-concept-synopsis-italic").checked ? "italic" : "normal",
submapBackgroundColor: this.$("cmap-submap-background").value,
submapBorderColor: this.$("cmap-submap-border").value
});
}
apply(values) {
const style = this.appearance.normalizeValues(values);
if (!style) return;
this.$("cmap-concept-background").value = style.backgroundColor;
this.$("cmap-concept-text-color").value = style.textColor;
this.selectFont(style.fontFamily);
this.$("cmap-concept-font-size").value = displayCmapFontSize(style.fontSize);
this.$("cmap-concept-bold").checked = style.fontWeight === "700";
this.$("cmap-concept-italic").checked = style.fontStyle === "italic";
this.$("cmap-concept-synopsis-text-color").value = style.synopsisTextColor;
this.selectFont(style.synopsisFontFamily, "cmap-concept-synopsis-font-family");
this.$("cmap-concept-synopsis-font-size").value =
displayCmapFontSize(style.synopsisFontSize);
this.$("cmap-concept-synopsis-bold").checked = style.synopsisFontWeight === "700";
this.$("cmap-concept-synopsis-italic").checked = style.synopsisFontStyle === "italic";
this.$("cmap-submap-background").value = style.submapBackgroundColor;
this.$("cmap-submap-border").value = style.submapBorderColor;
this.updateColorControls();
}
selectFont(fontFamily, selectId = "cmap-concept-font-family") {
const select = this.$(selectId);
const value = fontFamily || this.appearance.defaultValues.fontFamily;
const existing = Array.from(select.options).find((option) => option.value === value);
if (!existing) {
const option = document.createElement("option");
option.value = value;
option.textContent = value;
select.append(option);
}
select.value = value;
}
styleName(style) {
return style.nameKey ? this.tr(style.nameKey, style.nameKey) : style.name;
}
renderStyleOptions(selectedId = this.appearance.matchingStyleId(this.capture())) {
const styles = this.appearance.styles;
const usableId = styles.some((style) => style.id === selectedId) ? selectedId : "";
for (const select of [
this.$("cmap-concept-quick-style"),
this.$("cmap-concept-style-preset")
]) {
select.replaceChildren();
const custom = document.createElement("option");
custom.value = "";
custom.textContent = this.tr("custom-style", "Custom");
select.append(custom);
for (const style of styles) {
const option = document.createElement("option");
option.value = style.id;
option.textContent = this.styleName(style);
select.append(option);
}
select.value = usableId;
}
this.updateDeleteButton();
}
syncStyleSelection() {
const matchingId = this.appearance.matchingStyleId(this.capture());
this.$("cmap-concept-quick-style").value = matchingId;
this.$("cmap-concept-style-preset").value = matchingId;
this.updateDeleteButton();
}
updateDeleteButton() {
const selected = this.appearance.style(this.$("cmap-concept-style-preset").value);
this.$("cmap-delete-style").disabled = !selected || selected.protected;
}
applySelectedStyle(event) {
const selectedId = event?.currentTarget?.value ??
this.$("cmap-concept-style-preset").value;
this.$("cmap-concept-quick-style").value = selectedId;
this.$("cmap-concept-style-preset").value = selectedId;
const style = this.appearance.style(selectedId);
if (style) this.apply(style.values);
this.updateDeleteButton();
}
newStyleId() {
try {
if (window.crypto && typeof window.crypto.randomUUID === "function") {
return `custom-${window.crypto.randomUUID()}`;
}
} catch (_error) {
// A timestamp remains sufficient when randomUUID is unavailable.
}
return `custom-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
async saveCurrentStyle() {
const selected = this.appearance.style(this.$("cmap-concept-style-preset").value);
const proposedName = selected && !selected.nameKey ? selected.name : "";
const name = window.prompt(this.tr("style-name-prompt", "Name for this style"), proposedName);
if (name === null) return;
const cleanName = name.trim();
if (!cleanName) {
window.alert(this.tr("style-name-required", "Enter a style name."));
return;
}
const existing = this.appearance.styles.find((style) =>
this.styleName(style).toLocaleLowerCase() === cleanName.toLocaleLowerCase());
if (existing?.protected) {
window.alert(this.tr(
"default-style-protected", "The default style cannot be changed or deleted."));
return;
}
if (existing && !window.confirm(this.tr(
"replace-style-confirm", 'Replace the existing style "{name}"?')
.replace("{name}", this.styleName(existing)))) return;
const previousAppearance = this.appearance.toData();
const replacement = this.appearance.putStyle({
id: existing?.id || this.newStyleId(),
name: cleanName,
values: this.capture()
});
if (!await this.store()) {
this.appearance.replace(previousAppearance);
return;
}
this.renderStyleOptions(replacement.id);
}
async deleteSelectedStyle() {
const selected = this.appearance.style(this.$("cmap-concept-style-preset").value);
if (!selected) return;
if (selected.protected) {
window.alert(this.tr(
"default-style-protected", "The default style cannot be changed or deleted."));
return;
}
if (!window.confirm(this.tr(
"delete-style-confirm", 'Delete style "{name}"?')
.replace("{name}", this.styleName(selected)))) return;
const previousAppearance = this.appearance.toData();
this.appearance.deleteStyle(selected.id);
if (!await this.store()) {
this.appearance.replace(previousAppearance);
return;
}
this.renderStyleOptions();
}
async store() {
try {
await this.repository.save(this.appearance);
return true;
} catch (error) {
console.warn("The CMap appearance could not be stored in the database.", error);
window.alert(this.tr(
"cmap-appearance-storage-failed",
"The CMap appearance could not be stored in the wiki database."));
return false;
}
}
openNativeColorPicker(initialColor, onInput, onCommit = null) {
const picker = document.createElement("input");
picker.type = "color";
picker.className = "cmap-native-color-picker";
picker.value = cmapColorValue(initialColor, "#ffffff");
document.body.append(picker);
let removed = false;
const cleanup = () => {
if (removed) return;
removed = true;
picker.remove();
};
picker.addEventListener("input", () => onInput(cmapColorValue(picker.value, "#ffffff")));
picker.addEventListener("change", () => {
if (onCommit) onCommit(cmapColorValue(picker.value, "#ffffff"));
window.setTimeout(cleanup, 0);
}, { once: true });
picker.addEventListener("blur", () => window.setTimeout(cleanup, 100), { once: true });
try {
if (typeof picker.showPicker === "function") picker.showPicker();
else picker.click();
} catch (_error) {
picker.click();
}
}
updateColorControl(input) {
const swatch = input.closest(".cmap-color-control")?.querySelector(".cmap-color-swatch");
if (swatch) swatch.style.backgroundColor = cmapColorValue(input.value, "#ffffff");
}
updateColorControls() {
for (const input of this.$("cmap-concept-panel-appearance").querySelectorAll(
".cmap-color-input")) {
this.updateColorControl(input);
}
}
updatePaletteChoices(index, color) {
for (const choice of document.querySelectorAll(
`.cmap-color-palette button[data-cmap-color-index="${index}"]`)) {
choice.style.backgroundColor = color;
choice.title = `${color}${this.tr("change-palette-color", "double-click to change")}`;
choice.setAttribute("aria-label", color);
}
}
installColorPickers() {
for (const control of document.querySelectorAll(".cmap-color-control")) {
const input = control.querySelector(".cmap-color-input");
const swatch = control.querySelector(".cmap-color-swatch");
swatch.title = this.tr(
"color-swatch-help", "Click for the palette; double-click for a custom color");
const palette = document.createElement("span");
palette.className = "cmap-color-palette hidden";
this.appearance.palette.forEach((color, index) => {
const choice = document.createElement("button");
choice.type = "button";
choice.dataset.cmapColorIndex = String(index);
this.updatePaletteChoice(choice, color);
let clickTimer = null;
choice.addEventListener("click", () => {
if (clickTimer !== null) window.clearTimeout(clickTimer);
clickTimer = window.setTimeout(() => {
clickTimer = null;
input.value = this.appearance.palette[index];
input.dispatchEvent(new Event("input", { bubbles: true }));
palette.classList.add("hidden");
}, 240);
});
choice.addEventListener("dblclick", (event) => {
event.preventDefault();
if (clickTimer !== null) window.clearTimeout(clickTimer);
clickTimer = null;
const previousAppearance = this.appearance.toData();
const updateColor = (newColor) => {
this.appearance.setPaletteColor(index, newColor);
this.updatePaletteChoices(index, newColor);
};
this.openNativeColorPicker(this.appearance.palette[index], updateColor, async () => {
if (!await this.store()) {
this.appearance.replace(previousAppearance);
this.updatePaletteChoices(index, previousAppearance.palette[index]);
}
});
});
palette.append(choice);
});
control.append(palette);
swatch.addEventListener("click", () => {
for (const other of document.querySelectorAll(".cmap-color-palette")) {
if (other !== palette) other.classList.add("hidden");
}
palette.classList.toggle("hidden");
});
swatch.addEventListener("dblclick", (event) => {
event.preventDefault();
palette.classList.add("hidden");
this.openNativeColorPicker(input.value, (newColor) => {
input.value = newColor;
input.dispatchEvent(new Event("input", { bubbles: true }));
});
});
input.addEventListener("input", () => this.updateColorControl(input));
this.updateColorControl(input);
}
document.addEventListener("pointerdown", (event) => {
if (event.target.closest(".cmap-color-control")) return;
for (const palette of document.querySelectorAll(".cmap-color-palette")) {
palette.classList.add("hidden");
}
});
}
updatePaletteChoice(choice, color) {
choice.style.backgroundColor = color;
choice.title = `${color}${this.tr("change-palette-color", "double-click to change")}`;
choice.setAttribute("aria-label", color);
}
installStyleControls() {
this.$("cmap-concept-quick-style").addEventListener(
"change", (event) => this.applySelectedStyle(event));
this.$("cmap-concept-style-preset").addEventListener(
"change", (event) => this.applySelectedStyle(event));
this.$("cmap-save-style").addEventListener("click", () => this.saveCurrentStyle());
this.$("cmap-delete-style").addEventListener("click", () => this.deleteSelectedStyle());
for (const eventName of ["input", "change"]) {
this.$("cmap-concept-panel-appearance").addEventListener(eventName, (event) => {
if (!event.target.closest(".cmap-style-manager")) this.syncStyleSelection();
});
}
}
}