Files
racket-wiki/static/js/wiki/cmap/cmap-map-controller.js
T
2026-09-03 17:13:22 +02:00

338 lines
13 KiB
JavaScript

"use strict";
import { CmapModel } from "../../cmap/model/concept-map.js";
/** Manage loading, persistence workflows and metadata of stored concept maps. */
export class CmapMapController {
constructor({ state, repository, referenceIndex, canonicalPageReference,
normalizeConceptName, renderSelector, getEditor, resetEditor,
getCurrentStorageMap, save, markSaved, cancelAutosave, hasUnsavedChanges,
status, translate, cmapRoute, navigateToHash, navigateToMap, refreshPageConnections }) {
this.state = state;
this.repository = repository;
this.referenceIndex = referenceIndex;
this.canonicalPageReference = canonicalPageReference;
this.normalizeConceptName = normalizeConceptName;
this.renderSelector = renderSelector;
this.getEditor = getEditor;
this.resetEditor = resetEditor;
this.getCurrentStorageMap = getCurrentStorageMap;
this.save = save;
this.markSaved = markSaved;
this.cancelAutosave = cancelAutosave;
this.hasUnsavedChanges = hasUnsavedChanges;
this.status = status;
this.translate = translate;
this.cmapRoute = cmapRoute;
this.navigateToHash = navigateToHash;
this.navigateToMap = navigateToMap;
this.refreshPageConnections = refreshPageConnections;
}
async loadMaps() {
const [conceptMaps, placements] = await Promise.all([
this.repository.list(),
this.repository.conceptUsage().catch((error) => {
console.warn("[racket-wiki:cmap-host 0.2.122] concept usage is unavailable; CMaps continue without global counts", error);
return [];
})
]);
this.state.conceptMaps = conceptMaps;
this.referenceIndex.load(
placements,
conceptMaps,
this.canonicalPageReference,
this.normalizeConceptName);
this.state.cmapConceptUsage = this.referenceIndex.byConceptId;
this.state.cmapConceptIdsByName = this.referenceIndex.byName;
this.state.cmapPageConcepts = this.referenceIndex.byPage;
this.renderSelector();
const editor = this.getEditor();
if (editor) editor.refreshConceptUsageIndicators();
if (this.state.currentPage) this.refreshPageConnections(this.state.currentPage);
return this.state.conceptMaps;
}
updateSummary(conceptMap) {
const summary = typeof conceptMap.toSummary === "function" ?
conceptMap.toSummary() : conceptMap;
const index = this.state.conceptMaps.findIndex((item) => item.slug === conceptMap.slug);
if (index >= 0) {
this.state.conceptMaps[index] = { ...this.state.conceptMaps[index], ...summary };
} else {
this.state.conceptMaps.push(summary);
}
this.renderSelector();
}
async open(slug) {
if (!slug) return;
const loadSequence = ++this.state.cmapLoadSequence;
this.status(this.translate("loading", "Loading…"));
const conceptMap = await this.repository.load(slug);
if (loadSequence !== this.state.cmapLoadSequence) return;
const derivedView = conceptMap.model.derivedView();
let sourceMap = null;
let editorModel = conceptMap.model;
if (derivedView && typeof derivedView === "object" &&
typeof derivedView.sourceCmapSlug === "string" && derivedView.sourceCmapSlug &&
Number.isInteger(Number(derivedView.rootItemId))) {
sourceMap = await this.repository.load(derivedView.sourceCmapSlug);
if (loadSequence !== this.state.cmapLoadSequence) return;
editorModel = sourceMap.model;
}
console.info("[racket-wiki:cmap-host 0.2.122] stored CMap received", {
slug: conceptMap.slug,
version: conceptMap.currentVersion,
itemCount: conceptMap.model.conceptMap.items().length,
connectorCount: conceptMap.model.conceptMap.connectors().length
});
this.state.currentConceptMap = conceptMap;
this.state.currentConceptMapSource = sourceMap;
this.renderSelector();
this.resetEditor(editorModel, false);
if (sourceMap) this.openDerivedView(conceptMap, derivedView);
this.markSaved();
const editor = this.getEditor();
console.info("[racket-wiki:cmap-host 0.2.122] stored CMap loaded", {
slug: conceptMap.slug,
editorAvailable: Boolean(editor),
itemCount: editor ? editor.itemCount() : 0,
connectorCount: editor ? editor.connectorCount() : 0
});
this.status(this.translate("concept-map-loaded", "CMap loaded"), true);
}
openDerivedView(conceptMap, derivedView) {
const editor = this.getEditor();
const root = editor.itemRecord(derivedView.rootItemId);
if (!root || root.kind !== "submap") {
throw new Error("The source sub-CMap no longer exists.");
}
root.separateMap = true;
root.cmapSlug = conceptMap.slug;
root.childMap = conceptMap.title;
if (!root.mapReference) {
root.mapReference = {
id: `cmap-${root.id}`,
title: conceptMap.title,
rootItemId: root.id,
itemIds: editor.descendantItemRecords(root).map((item) => item.id)
};
editor.setConceptMapReference(root.mapReference);
}
editor.openSubmapMap(root);
}
async loadHistoricalVersion(version) {
const conceptMap = this.getCurrentStorageMap();
if (!conceptMap) return;
const historical = await this.repository.loadVersion(conceptMap, version);
const currentSnapshot = JSON.stringify(conceptMap.toDocument());
this.resetEditor(historical.model, false);
this.state.cmapSavedSnapshot = currentSnapshot;
this.status(
this.translate("concept-map-version-loaded", "Version {version} loaded; save to make it current.")
.replace("{version}", String(historical.version)),
true);
}
async create() {
const title = window.prompt(this.translate("concept-map-name", "Concept map name"), "");
if (!title || !title.trim()) return;
const conceptMap = await this.repository.create(title.trim(), new CmapModel());
await this.loadMaps();
location.hash = this.cmapRoute(conceptMap.slug);
}
async promoteSelectedSubmap() {
const editor = this.getEditor();
const record = editor ? editor.selected() : null;
if (!record || record.kind !== "submap") return false;
const hasConcepts = editor.descendantItemRecords(record)
.some((item) => item.kind !== "phrase");
if (!hasConcepts) {
this.status(this.translate("empty-submap", "This sub-CMap has no concepts to move."));
return false;
}
const sourceMap = this.getCurrentStorageMap();
let linkedMap = null;
let title = null;
if (record.cmapSlug) {
linkedMap = this.state.currentConceptMap?.slug === record.cmapSlug ?
this.state.currentConceptMap : await this.repository.load(record.cmapSlug);
const derivedView = linkedMap.model.derivedView();
const matchesSource = sourceMap && derivedView &&
derivedView.sourceCmapSlug === sourceMap.slug &&
Number(derivedView.rootItemId) === Number(record.id);
if (!matchesSource) {
this.status(this.translate("submap-already-independent",
"This linked CMap is already independent."), true);
await this.navigateToMap(record.cmapSlug);
return true;
}
} else {
title = window.prompt(
this.translate("submap-name", "Name of the new concept map"),
record.childMap || record.label);
if (!title || !title.trim()) return false;
}
const buttons = [document.getElementById("cmap-promote-submap"),
document.getElementById("cmap-extract-selected")];
for (const button of buttons) button.disabled = true;
try {
const sourceSaved = await this.save({
force: true,
snapshotVersion: true,
summary: this.translate("before-submap-extraction", "Before extracting sub-CMap")
});
if (!sourceSaved) return false;
let storedMap = linkedMap;
let extraction;
if (storedMap) {
extraction = editor.prepareStoredSubmapExtraction(
record, storedMap.slug, storedMap.model.metadata());
storedMap = await this.repository.save(storedMap, extraction.childModel, {
summary: this.translate("submap-extracted", "Sub-CMap moved to a separate CMap"),
saveKind: "manual"
});
} else {
const prepared = editor.prepareStoredSubmapExtraction(record, null);
if (!prepared) return false;
storedMap = await this.repository.create(title.trim(), prepared.childModel);
extraction = editor.prepareStoredSubmapExtraction(record, storedMap.slug);
}
editor.replaceModel(extraction.parentModel);
const parentSaved = await this.save({
force: true,
historyMode: "autosave",
summary: this.translate("submap-extracted", "Sub-CMap moved to a separate CMap")
});
if (!parentSaved) {
this.status(this.translate("submap-created-parent-unsaved",
"The new CMap was created, but the parent CMap still needs to be saved."));
return false;
}
await this.navigateToMap(storedMap.slug);
return true;
} finally {
const selected = editor.selected();
const canExtractSubmap = selected && selected.kind === "submap";
for (const button of buttons) button.disabled = !canExtractSubmap;
document.getElementById("cmap-extract-selected")
.classList.toggle("hidden", !canExtractSubmap);
}
}
async rename() {
this.cancelAutosave();
if (this.hasUnsavedChanges() && !await this.save({ automatic: true })) return false;
const conceptMap = this.state.currentConceptMap;
if (!conceptMap) return false;
const title = window.prompt(
this.translate("rename-concept-map", "Rename CMap"), conceptMap.title);
if (!title || !title.trim() || title.trim() === conceptMap.title) return false;
try {
this.state.currentConceptMap = await this.repository.rename(conceptMap, title.trim());
await this.loadMaps();
this.status(this.translate("concept-map-renamed", "CMap renamed"), true);
return true;
} catch (error) {
this.status(error.message);
return false;
}
}
activeMetadata() {
const currentModel = this.state.currentConceptMap?.model;
if (currentModel?.derivedView()) {
const metadata = currentModel.metadata();
return {
namespace: metadata.namespace || "",
tags: Array.isArray(metadata.tags) ? metadata.tags : [],
summary: metadata.summary || "",
explanationPageSlug: metadata.explanationPageSlug || ""
};
}
const editor = this.getEditor();
return editor?.getDocumentMetadata ? editor.getDocumentMetadata() :
{ tags: [], summary: "", explanationPageSlug: "" };
}
async saveMetadata(metadata) {
const conceptMap = this.state.currentConceptMap;
const editor = this.getEditor();
if (!conceptMap || !editor) return false;
const legacyExplanation = `cmap:${conceptMap.slug}`;
if (metadata.namespace && metadata.explanationPageSlug === legacyExplanation) {
metadata = { ...metadata, explanationPageSlug: `${metadata.namespace}:${conceptMap.slug}` };
}
if (conceptMap.model.derivedView()) {
const updatedModel = conceptMap.model.withMetadata(metadata);
const updated = await this.repository.save(conceptMap, updatedModel, {
summary: this.translate("updated-concept-map-details", "Updated CMap details"),
saveKind: "manual"
});
this.state.currentConceptMap = updated;
this.updateSummary(updated);
} else {
editor.setDocumentMetadata(metadata);
if (!await this.save({
force: true,
summary: this.translate("updated-concept-map-details", "Updated CMap details")
})) return false;
}
this.status(this.translate("concept-map-details-saved", "CMap details saved"), true);
return true;
}
async archive() {
this.cancelAutosave();
const conceptMap = this.state.currentConceptMap;
if (!conceptMap) return false;
const question = this.translate(
"archive-concept-map-confirm",
"Archive the entire concept map \"{title}\"? It will disappear from normal navigation, but an administrator can restore it.")
.replace("{title}", conceptMap.title);
if (!window.confirm(question)) return false;
const typedTitle = window.prompt(
this.translate("archive-concept-map-type-title",
"Type the complete CMap name to confirm: {title}").replace("{title}", conceptMap.title), "");
if (typedTitle === null) return false;
if (typedTitle !== conceptMap.title) {
this.status(this.translate("archive-concept-map-title-mismatch", "The CMap name did not match; nothing was archived."));
return false;
}
try {
await this.repository.archive(conceptMap, typedTitle);
this.markSaved();
this.state.currentConceptMap = null;
this.state.currentConceptMapSource = null;
await this.loadMaps();
const target = this.state.conceptMaps.length ?
this.cmapRoute(this.state.conceptMaps[0].slug) : "#cmaps";
await this.navigateToHash(target);
this.status(this.translate("concept-map-archived", "CMap archived"), true);
return true;
} catch (error) {
this.status(error.message);
return false;
}
}
async createSnapshot() {
if (!this.getCurrentStorageMap()) return false;
const description = window.prompt(this.translate("snapshot-description", "Snapshot description"), "");
if (description === null) return false;
return this.save({
force: true,
summary: description.trim() || this.translate("snapshot", "Snapshot"),
snapshotVersion: true
});
}
}