compleet ontvlochten
This commit is contained in:
@@ -0,0 +1,337 @@
|
||||
"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
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user