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