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