"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 }); } }