refactoring of cmaps, widgets, etc.
This commit is contained in:
+35
-4
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,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));
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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)
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user