207 lines
7.5 KiB
JavaScript
207 lines
7.5 KiB
JavaScript
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));
|
|
}
|
|
}
|