refactoring of cmaps, widgets, etc.

This commit is contained in:
2026-09-02 16:06:26 +02:00
parent 38f255c1a4
commit f0562a06cc
52 changed files with 3626 additions and 2642 deletions
+206
View File
@@ -0,0 +1,206 @@
import { CmapModel } from "./concept-map.js";
/** Return a detached copy of data received from or sent to the backend. */
function copy(value) {
return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
}
/** Decode the historical string-wrapped CMap document representation. */
function decodedDocument(value) {
let documentValue = value;
for (let attempt = 0; attempt < 2 && typeof documentValue === "string"; attempt += 1) {
documentValue = JSON.parse(documentValue);
}
if (!documentValue || typeof documentValue !== "object" || Array.isArray(documentValue)) {
throw new Error("The stored CMap document is not a JSON object.");
}
return copy(documentValue);
}
/** Give legacy map-local concept ids a stable identity before model creation. */
function normalizeConceptIdentities(documentValue, cmapSlug) {
const legacyIds = new Map();
const normalize = (conceptId) => {
const prefixedUuid = typeof conceptId === "string" && conceptId.match(
/^concept-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i);
if (prefixedUuid) return prefixedUuid[1].toLowerCase();
if (!conceptId || !/^concept-\d+$/.test(conceptId) || !cmapSlug) return conceptId;
if (!legacyIds.has(conceptId)) {
legacyIds.set(conceptId, `legacy:${cmapSlug}:${conceptId}`);
}
return legacyIds.get(conceptId);
};
for (const concept of (Array.isArray(documentValue.concepts) ? documentValue.concepts : [])) {
concept.id = normalize(concept.id);
}
for (const item of (Array.isArray(documentValue.items) ? documentValue.items : [])) {
item.conceptId = normalize(item.conceptId);
}
return documentValue;
}
/** Convert a public CMap model to the canonical backend document. */
function modelDocument(value) {
if (!(value instanceof CmapModel)) throw new TypeError("A CmapModel is required");
return value.toDocument();
}
/**
* Represent one persisted CMap together with its decoded domain model.
* Backend version fields remain available without exposing the response object.
*/
export class StoredConceptMap {
constructor(record, model) {
if (!record?.slug || !(model instanceof CmapModel)) {
throw new TypeError("A stored CMap requires a slug and CmapModel");
}
this._slug = String(record.slug);
this._title = String(record.title || record.slug);
this._currentVersion = Number(record.currentVersion || record.version) || 0;
this._version = Number(record.version || record.currentVersion) || 0;
this._createdAt = record.createdAt || null;
this._updatedAt = record.updatedAt || null;
this._author = record.author || null;
this._model = model;
}
get slug() { return this._slug; }
get title() { return this._title; }
get currentVersion() { return this._currentVersion; }
get version() { return this._version; }
get createdAt() { return this._createdAt; }
get updatedAt() { return this._updatedAt; }
get author() { return this._author; }
get model() { return this._model; }
/** Return a detached storage document for comparisons and interchange. */
toDocument() {
return this._model.toDocument();
}
/** Return the metadata used by CMap selectors and workspace state. */
toSummary() {
return {
slug: this.slug,
title: this.title,
currentVersion: this.currentVersion,
createdAt: this.createdAt,
updatedAt: this.updatedAt,
author: this.author
};
}
}
/**
* Store and retrieve concept maps through the Racket Wiki backend.
* This is the only CMap model class that knows API routes and storage envelopes.
*/
export class CmapRepository {
constructor(api) {
if (typeof api !== "function") throw new TypeError("A wiki API function is required");
this.api = api;
}
/** Return the lightweight CMap records used by selectors. */
async list() {
const result = await this.api("/api/cmaps");
return Array.isArray(result.conceptMaps) ? result.conceptMaps.map(copy) : [];
}
/** Load one current CMap and deserialize its complete domain model. */
async load(slug) {
const record = await this.api(`/api/cmaps/${encodeURIComponent(slug)}`);
return this.storedMap(record, slug);
}
/** Create a persisted CMap from a domain model. */
async create(title, model, slug = null) {
const body = { title: String(title).trim(), document: modelDocument(model) };
if (slug) body.slug = String(slug);
const record = await this.api("/api/cmaps", {
method: "POST",
body: JSON.stringify(body)
});
return this.storedMap(record, record.slug || slug);
}
/** Save a new model version and return the freshly versioned stored CMap. */
async save(storedMap, model, saveInformation = {}) {
if (!(storedMap instanceof StoredConceptMap)) {
throw new TypeError("Saving requires a StoredConceptMap");
}
const body = {
title: saveInformation.title || storedMap.title,
baseVersion: storedMap.currentVersion,
summary: saveInformation.summary || "",
saveKind: saveInformation.saveKind || "manual",
snapshot: Boolean(saveInformation.snapshot),
document: modelDocument(model)
};
const record = await this.api(`/api/cmaps/${encodeURIComponent(storedMap.slug)}`, {
method: "PUT",
body: JSON.stringify(body)
});
return this.storedMap(record, storedMap.slug);
}
/** Rename a stored CMap using optimistic backend versioning. */
async rename(storedMap, title) {
const record = await this.api(
`/api/cmaps/${encodeURIComponent(storedMap.slug)}/rename`, {
method: "POST",
body: JSON.stringify({
title: String(title).trim(),
baseVersion: storedMap.currentVersion
})
});
return this.storedMap(record, storedMap.slug);
}
/** Archive a stored CMap after the workspace has obtained title confirmation. */
async archive(storedMap, confirmationTitle) {
await this.api(`/api/cmaps/${encodeURIComponent(storedMap.slug)}`, {
method: "DELETE",
body: JSON.stringify({
confirmTitle: confirmationTitle,
baseVersion: storedMap.currentVersion
})
});
}
/** Return version summaries for one stored CMap. */
async history(storedMap) {
const result = await this.api(
`/api/cmaps/${encodeURIComponent(storedMap.slug)}/history`);
return Array.isArray(result.versions) ? result.versions.map(copy) : [];
}
/** Load and deserialize one historical version of a CMap. */
async loadVersion(storedMap, version) {
const record = await this.api(
`/api/cmaps/${encodeURIComponent(storedMap.slug)}/versions/${encodeURIComponent(version)}`);
return this.storedMap(record, storedMap.slug);
}
/** Delete one history version without changing the current CMap. */
async deleteVersion(storedMap, version) {
await this.api(
`/api/cmaps/${encodeURIComponent(storedMap.slug)}/versions/${encodeURIComponent(version)}`,
{ method: "DELETE" });
}
/** Return the backend projection of concept placements across all CMaps. */
async conceptUsage() {
const result = await this.api("/api/cmaps/concept-usage");
return Array.isArray(result.placements) ? result.placements.map(copy) : [];
}
/** Convert one backend record to the public stored-map representation. */
storedMap(record, fallbackSlug = null) {
const slug = record?.slug || fallbackSlug;
const documentValue = decodedDocument(record?.document);
const normalized = normalizeConceptIdentities(documentValue, slug || "");
return new StoredConceptMap({ ...record, slug }, CmapModel.fromDocument(normalized));
}
}