369 lines
15 KiB
JavaScript
369 lines
15 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;
|
|
}
|
|
|
|
/**
|
|
* goal : Refresh the CMap catalogue and its lightweight global reference index.
|
|
* post : Workspace CMap lists, selector and usage indicators reflect the repository.
|
|
* result : The current list of stored CMaps.
|
|
*/
|
|
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();
|
|
}
|
|
|
|
/**
|
|
* goal : Open a stored CMap or its source map when it is a derived submap view.
|
|
* pre : slug identifies a stored CMap; a later request supersedes this load safely.
|
|
* post : Active map state, editor document and saved snapshot are synchronized.
|
|
*/
|
|
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);
|
|
}
|
|
|
|
/** Restore the source submap context for a stored derived CMap. */
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* goal : Load one immutable CMap version for review or subsequent explicit save.
|
|
* post : The editor shows the historical model while the current map remains dirty.
|
|
*/
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* goal : Extract the selected inline submap into a complete stored CMap.
|
|
* post : Parent and child map are saved consistently; cross-boundary relations remain on the parent.
|
|
* result : Whether extraction completed or was deliberately cancelled.
|
|
*/
|
|
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: "" };
|
|
}
|
|
|
|
/**
|
|
* goal : Persist map metadata through either the source editor or a derived map model.
|
|
* post : Namespace, tags, summary and explanation-page reference are stored together.
|
|
* result : False when there is no editable map or saving fails.
|
|
*/
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* goal : Archive the active CMap after explicit title confirmation.
|
|
* post : Active map state is cleared and navigation continues to another CMap or overview.
|
|
* result : False when confirmation is cancelled, mismatched or persistence fails.
|
|
*/
|
|
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;
|
|
}
|
|
}
|
|
|
|
/** Create an explicit, named immutable snapshot of the active stored CMap. */
|
|
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
|
|
});
|
|
}
|
|
}
|