diff --git a/static/js/wiki/cmap/cmap-concept-controller.js b/static/js/wiki/cmap/cmap-concept-controller.js new file mode 100644 index 0000000..7962260 --- /dev/null +++ b/static/js/wiki/cmap/cmap-concept-controller.js @@ -0,0 +1,236 @@ +"use strict"; + +/** Manage concept dialogs, selection actions and CMap concept page metadata. */ +export class CmapConceptController { + constructor({ state, api, translate, splitPageReference, pageReference, + getEditor, getCurrentStorageMap, save, loadPages, getDialog, status, + conceptIdForLabel, getNamespace, getPlacementContext }) { + this.state = state; + this.api = api; + this.translate = translate; + this.splitPageReference = splitPageReference; + this.pageReference = pageReference; + this.getEditor = getEditor; + this.getCurrentStorageMap = getCurrentStorageMap; + this.save = save; + this.loadPages = loadPages; + this.getDialog = getDialog; + this.status = status; + this.conceptIdForLabel = conceptIdForLabel; + this.getNamespace = getNamespace; + this.getPlacementContext = getPlacementContext; + } + + addNode(options = {}) { + const editor = this.getEditor(); + if (!editor) return null; + const conceptId = options.conceptId || this.conceptIdForLabel(options.label); + return editor.addItem(conceptId ? { ...options, conceptId } : options); + } + + dialogResources() { + const editor = this.getEditor(); + const namespaceModel = this.state.currentConceptMapSource?.model || + this.state.currentConceptMap?.model; + return { + cmapNamespace: namespaceModel?.metadata()?.namespace || "", + pages: this.state.pages, + conceptMaps: this.state.conceptMaps, + parentMapAvailable: Boolean(editor && editor.activeMapRoot) + }; + } + + openDialog(record) { + this.getDialog()?.open(record, this.dialogResources()); + } + + openNewDialog(context = {}) { + this.getDialog()?.openNew(context, this.dialogResources()); + } + + async saveConcept(record, createContext, values) { + const editor = this.getEditor(); + if ((!record && !createContext) || !editor) return false; + const existingNonPersonTags = record && Array.isArray(record.tags) ? + record.tags.filter((tag) => !(tag && typeof tag === "object" && tag.type === "person")) : []; + const personTags = values.personNames.map((name) => ({ type: "person", value: name })); + const changes = { + kind: record && record.kind === "submap" ? "submap" : + (values.pageSlug ? "page" : "concept"), + label: values.label, + synopsis: values.synopsis, + aspects: values.aspects, + tags: [...existingNonPersonTags, ...personTags], + descriptionPageSlug: values.descriptionPageSlug, + pageSlug: values.pageSlug, + cmapSlug: values.cmapSlug, + externalUrl: values.externalUrl, + parentCmapLink: values.parentCmapLink, + imageSource: values.imageSource, + ...values.appearance + }; + const existingConceptId = this.conceptIdForLabel(values.label); + if (!record || record.kind !== "submap") { + changes.borderColor = values.pageSlug ? "#4479a1" : + ((values.cmapSlug || values.parentCmapLink) ? "#57834a" : "#a97c00"); + } + if (record) { + if (existingConceptId && record.conceptId !== existingConceptId) { + const previousConceptId = record.conceptId; + record.conceptId = existingConceptId; + editor.refreshConceptUsageIndicators([previousConceptId, existingConceptId].filter(Boolean)); + } + editor.updateItem(record, changes); + editor.selectItem(record); + } else { + if (existingConceptId) changes.conceptId = existingConceptId; + if (createContext.point) { + changes.x = Math.max(0, createContext.point.x - 70); + changes.y = Math.max(0, createContext.point.y - 30); + } + if (createContext.parentSubmap) { + changes.parentSubmap = createContext.parentSubmap; + changes.submapDepth = createContext.parentSubmap.submapDepth + 1; + } + const newRecord = this.addNode(changes); + if (createContext.source && newRecord) editor.finishRelation(createContext.source, newRecord); + else if (newRecord) editor.selectItem(newRecord); + } + editor.commitHistory(); + if (this.getCurrentStorageMap()) await this.save({ automatic: true }); + return true; + } + + updateSelectionToolbar(editor, selectedRecords = [], connector = null) { + const toolbar = document.getElementById("cmap-selection-toolbar"); + const selected = Array.isArray(selectedRecords) ? selectedRecords : []; + toolbar.dataset.selectionCount = String(selected.length); + for (const button of toolbar.querySelectorAll("[data-cmap-layout]")) { + button.disabled = !editor || !editor.canLayoutSelection(button.dataset.cmapLayout); + } + const actionButtons = new Map(Array.from( + toolbar.querySelectorAll("[data-cmap-selection-action]"), + (button) => [button.dataset.cmapSelectionAction, button])); + actionButtons.get("edit").disabled = !editor || selected.length !== 1 || Boolean(connector); + actionButtons.get("group").disabled = !editor || !editor.canGroupSelection(); + actionButtons.get("ungroup").disabled = !editor || !editor.canUngroupSelection(); + actionButtons.get("hide").disabled = !editor || !editor.canHideSelectionInCurrentContext(); + document.getElementById("cmap-move-selected-to-namespace").disabled = !editor || + !this.getNamespace() || !selected.some((item) => item.kind !== "phrase"); + } + + pageMatchesReference(page, reference) { + if (page.slug === reference) return true; + const parts = this.splitPageReference(reference); + return String(page.namespace || "").toLocaleLowerCase() === parts.namespace.toLocaleLowerCase() && + String(page.pageSlug || this.splitPageReference(page.slug).slug).toLocaleLowerCase() === + parts.slug.toLocaleLowerCase(); + } + + async moveSelectedDescriptionsToNamespace() { + const editor = this.getEditor(); + const namespace = this.getNamespace(); + if (!editor || !namespace) return false; + const records = editor.selectedAll().filter((record) => record.kind !== "phrase"); + if (!records.length) return false; + const changes = []; + for (const record of records) { + const current = record.descriptionPageSlug; + if (!current) continue; + const parts = this.splitPageReference(current); + const target = this.pageReference(namespace, parts.slug); + if (target === current) continue; + const existing = this.state.pages.find((page) => this.pageMatchesReference(page, current)); + const conflict = this.state.pages.find((page) => this.pageMatchesReference(page, target)); + if (existing && conflict && existing !== conflict) { + this.status(this.translate("namespace-move-conflict", "Cannot move {page}: the target page already exists.") + .replace("{page}", target)); + return false; + } + changes.push({ record, current, target, existing }); + } + if (!changes.length) return false; + for (const change of changes) { + if (change.existing) { + const page = await this.api(`/api/pages/${encodeURIComponent(change.current)}/rename`, { + method: "POST", + body: JSON.stringify({ + title: change.existing.title, + namespace, + slug: this.splitPageReference(change.current).slug, + summary: this.translate("move-description-page-summary", "Moved description page to CMap namespace") + }) + }); + change.target = page.slug; + } + editor.updateItem(change.record, { descriptionPageSlug: change.target }); + } + await this.loadPages(); + editor.commitHistory(); + if (this.getCurrentStorageMap()) await this.save({ automatic: true }); + this.status(this.translate("descriptions-moved-to-namespace", "Selected descriptions moved to {namespace}.") + .replace("{namespace}", namespace), true); + return true; + } + + placementOptions() { + const context = this.getPlacementContext() || {}; + const options = {}; + if (context.point) { + options.x = Math.max(0, context.point.x - 70); + options.y = Math.max(0, context.point.y - 30); + } + if (context.parentSubmap) { + options.parentSubmap = context.parentSubmap; + options.submapDepth = context.parentSubmap.submapDepth + 1; + } + return options; + } + + editSelected(record = null) { + const editor = this.getEditor(); + const selectedRecord = record || editor?.selected(); + if (!selectedRecord) { + window.alert(this.translate("select-one-concept", "Select one concept first.")); + return; + } + if (selectedRecord.kind === "phrase") { + editor.editPhraseInline(selectedRecord); + return; + } + this.openDialog(selectedRecord); + } + + groupSelection() { + const editor = this.getEditor(); + if (!editor || !editor.canGroupSelection()) return false; + const label = window.prompt( + this.translate("group-submap-name", "Name of the main concept for the new sub-CMap"), + this.translate("sub-concept-map", "Sub concept map")); + if (!label || !label.trim()) return false; + return editor.groupSelection({ + label: label.trim(), + conceptId: this.conceptIdForLabel(label), + childMap: label.trim(), + synopsis: this.translate("grouped-submap-synopsis", "Grouped sub-concept map.") + }); + } + + populateSubmap(record, editor) { + const baseX = Number(record.node.attr("x")); + const baseY = Number(record.node.attr("y")); + const detail = editor.addSubmapItem(record, { + label: "Detail concept", synopsis: "Concept inside the expanded submap.", + x: baseX + 315, y: baseY + 170, backgroundColor: "#fff4cf", borderColor: "#a97c00" + }); + editor.connectWithPhrase(record, detail, "contains", false); + if (record.submapDepth < 2) { + const nested = editor.addSubmapItem(record, { + label: "Nested submap", synopsis: "This submap can also be expanded.", kind: "submap", + childMap: `${record.childMap || record.label}/nested`, x: baseX + 60, y: baseY + 310, + backgroundColor: "#edf7e8", borderColor: "#57834a" + }); + editor.connectWithPhrase(detail, nested, "contains", false); + } + } +} diff --git a/static/js/wiki/cmap/cmap-editor-ui-controller.js b/static/js/wiki/cmap/cmap-editor-ui-controller.js index 23916f4..ec5dc90 100644 --- a/static/js/wiki/cmap/cmap-editor-ui-controller.js +++ b/static/js/wiki/cmap/cmap-editor-ui-controller.js @@ -30,7 +30,6 @@ export class CmapEditorUiController { this.bind("#cmap-group-selected", "click", () => this.actions.group?.()); this.bind("#cmap-ungroup-selected", "click", (_event, editor) => editor?.ungroupSelection()); this.bind("#cmap-hide-selected", "click", (_event, editor) => editor?.hideSelectionInCurrentContext()); - this.bind("#cmap-select-all", "click", (_event, editor) => editor?.selectAll()); this.bind("#cmap-zoom-out", "click", () => this.actions.zoom?.(-10)); this.bind("#cmap-zoom-in", "click", () => this.actions.zoom?.(10)); this.bind("#cmap-zoom-reset", "click", () => this.actions.zoom?.(100, true)); diff --git a/static/js/wiki/cmap/cmap-map-controller.js b/static/js/wiki/cmap/cmap-map-controller.js new file mode 100644 index 0000000..55b170e --- /dev/null +++ b/static/js/wiki/cmap/cmap-map-controller.js @@ -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 + }); + } +} diff --git a/static/js/wiki/cmap/cmap-workspace-controller.js b/static/js/wiki/cmap/cmap-workspace-controller.js index f6e0a9d..0e86514 100644 --- a/static/js/wiki/cmap/cmap-workspace-controller.js +++ b/static/js/wiki/cmap/cmap-workspace-controller.js @@ -5,7 +5,6 @@ import { } from "../reference.js"; import { cmapRoute, pageRoute } from "../routes.js"; import { cmapMentionTarget, escapeHtml } from "../markdown.js"; -import { CmapModel } from "../../cmap/model/concept-map.js"; import { CmapAppearanceRepository } from "../../cmap/model/appearance-repository.js"; import { CmapRepository } from "../../cmap/model/cmap-repository.js"; import { CmapJsonExporter } from "../../cmap/model/json-exporter.js"; @@ -30,6 +29,8 @@ import { CmapNavigationController } from "./cmap-navigation-controller.js"; import { CmapTransferController } from "./cmap-transfer-controller.js"; import { CmapEditorUiController } from "./cmap-editor-ui-controller.js"; import { CmapEditorHost } from "./cmap-editor-host.js"; +import { CmapMapController } from "./cmap-map-controller.js"; +import { CmapConceptController } from "./cmap-concept-controller.js"; import { CmapEmbedView } from "./cmap-embed-view.js"; import { CmapEditorPresentation } from "./cmap-editor-presentation.js"; @@ -199,6 +200,43 @@ export class CmapWorkspaceController { factory: (canvas, options) => window.RacketWikiCmap.createEditor(canvas, options), createOptions: () => ({}) }); + const mapController = new CmapMapController({ + state, + repository: cmapRepository, + referenceIndex: cmapReferenceIndex, + canonicalPageReference, + normalizeConceptName: normalizeCmapConceptName, + renderSelector: renderConceptMapSelector, + getEditor: () => cmapPrototypeState().editor, + resetEditor: resetCmapPrototype, + getCurrentStorageMap: currentCmapStorageMap, + save: saveStoredConceptMap, + markSaved: markCurrentCmapSaved, + cancelAutosave: cancelCmapAutosave, + hasUnsavedChanges: cmapHasUnsavedChanges, + status: showCmapStatus, + translate: tr, + cmapRoute, + navigateToHash, + navigateToMap: (slug) => navigateToHash(cmapRoute(slug)), + refreshPageConnections: renderPageCmapConnections + }); + const conceptController = new CmapConceptController({ + state, + api, + translate: tr, + splitPageReference, + pageReference, + getEditor: () => cmapPrototypeState().editor, + getCurrentStorageMap: currentCmapStorageMap, + save: saveStoredConceptMap, + loadPages, + getDialog: () => conceptDialog, + status: showCmapStatus, + conceptIdForLabel: sharedCmapConceptId, + getNamespace: diagramNamespace, + getPlacementContext: () => cmapContextCreateContext + }); let cmapEmbedHydrationTimer = null; const CMAP_AUTOSAVE_DELAY = 1500; @@ -255,10 +293,7 @@ export class CmapWorkspaceController { } function addCmapPrototypeNode(options = {}) { - const prototype = cmapPrototypeState(); - if (!prototype.editor) return null; - const conceptId = options.conceptId || sharedCmapConceptId(options.label); - return prototype.editor.addItem(conceptId ? { ...options, conceptId } : options); + return conceptController.addNode(options); } function normalizeCmapConceptName(label) { @@ -315,15 +350,7 @@ export class CmapWorkspaceController { } function currentConceptDialogResources() { - const prototype = cmapPrototypeState(); - const namespaceModel = state.currentConceptMapSource?.model || - state.currentConceptMap?.model; - return { - cmapNamespace: namespaceModel?.metadata()?.namespace || "", - pages: state.pages, - conceptMaps: state.conceptMaps, - parentMapAvailable: Boolean(prototype.editor && prototype.editor.activeMapRoot) - }; + return conceptController.dialogResources(); } /** @@ -332,74 +359,16 @@ export class CmapWorkspaceController { * post : A modal form shows concept text, presentation and image fields. */ function openCmapConceptDialog(record) { - if (conceptDialog) conceptDialog.open(record, currentConceptDialogResources()); + return conceptController.openDialog(record); } function openNewCmapConceptDialog(context = {}) { - if (conceptDialog) { - conceptDialog.openNew(context, currentConceptDialogResources()); - } + return conceptController.openNewDialog(context); } /** Apply validated concept-dialog values as one editor history transaction. */ async function saveCmapConcept(record, createContext, values) { - const prototype = cmapPrototypeState(); - if ((!record && !createContext) || !prototype.editor) return false; - const existingNonPersonTags = record && Array.isArray(record.tags) ? - record.tags.filter((tag) => - !(tag && typeof tag === "object" && tag.type === "person")) : []; - const personTags = values.personNames - .map((name) => ({ type: "person", value: name })); - const changes = { - kind: record && record.kind === "submap" ? - "submap" : (values.pageSlug ? "page" : "concept"), - label: values.label, - synopsis: values.synopsis, - aspects: values.aspects, - tags: [...existingNonPersonTags, ...personTags], - descriptionPageSlug: values.descriptionPageSlug, - pageSlug: values.pageSlug, - cmapSlug: values.cmapSlug, - externalUrl: values.externalUrl, - parentCmapLink: values.parentCmapLink, - imageSource: values.imageSource, - ...values.appearance - }; - const existingConceptId = sharedCmapConceptId(values.label); - if (!record || record.kind !== "submap") { - changes.borderColor = values.pageSlug ? "#4479a1" : - ((values.cmapSlug || values.parentCmapLink) ? "#57834a" : "#a97c00"); - } - - if (record) { - if (existingConceptId && record.conceptId !== existingConceptId) { - const previousConceptId = record.conceptId; - record.conceptId = existingConceptId; - prototype.editor.refreshConceptUsageIndicators( - [previousConceptId, existingConceptId].filter(Boolean)); - } - prototype.editor.updateItem(record, changes); - prototype.editor.selectItem(record); - } else { - if (existingConceptId) changes.conceptId = existingConceptId; - if (createContext.point) { - changes.x = Math.max(0, createContext.point.x - 70); - changes.y = Math.max(0, createContext.point.y - 30); - } - if (createContext.parentSubmap) { - changes.parentSubmap = createContext.parentSubmap; - changes.submapDepth = createContext.parentSubmap.submapDepth + 1; - } - const newRecord = addCmapPrototypeNode(changes); - if (createContext.source && newRecord) { - prototype.editor.finishRelation(createContext.source, newRecord); - } else if (newRecord) { - prototype.editor.selectItem(newRecord); - } - } - prototype.editor.commitHistory(); - if (currentCmapStorageMap()) await saveStoredConceptMap({ automatic: true }); - return true; + return conceptController.saveConcept(record, createContext, values); } function applyCmapZoom(value) { @@ -480,23 +449,7 @@ export class CmapWorkspaceController { } function updateCmapSelectionToolbar(editor, selectedRecords = [], connector = null) { - const toolbar = $("cmap-selection-toolbar"); - const selected = Array.isArray(selectedRecords) ? selectedRecords : []; - toolbar.dataset.selectionCount = String(selected.length); - for (const button of toolbar.querySelectorAll("[data-cmap-layout]")) { - button.disabled = !editor || !editor.canLayoutSelection(button.dataset.cmapLayout); - } - const actionButtons = new Map(Array.from( - toolbar.querySelectorAll("[data-cmap-selection-action]"), - (button) => [button.dataset.cmapSelectionAction, button] - )); - actionButtons.get("edit").disabled = !editor || selected.length !== 1 || Boolean(connector); - actionButtons.get("group").disabled = !editor || !editor.canGroupSelection(); - actionButtons.get("ungroup").disabled = !editor || !editor.canUngroupSelection(); - actionButtons.get("hide").disabled = !editor || !editor.canHideSelectionInCurrentContext(); - const namespaceButton = $("cmap-move-selected-to-namespace"); - namespaceButton.disabled = !editor || !diagramNamespace() || - !selected.some((item) => item.kind !== "phrase"); + return conceptController.updateSelectionToolbar(editor, selectedRecords, connector); } function diagramNamespace() { @@ -504,58 +457,8 @@ export class CmapWorkspaceController { return model?.metadata()?.namespace || ""; } - function pageMatchesReference(page, reference) { - if (page.slug === reference) return true; - const parts = splitPageReference(reference); - return String(page.namespace || "").toLocaleLowerCase() === parts.namespace.toLocaleLowerCase() && - String(page.pageSlug || splitPageReference(page.slug).slug).toLocaleLowerCase() === - parts.slug.toLocaleLowerCase(); - } - async function moveSelectedDescriptionsToNamespace() { - const editor = cmapPrototypeState().editor; - const namespace = diagramNamespace(); - if (!editor || !namespace) return false; - const records = editor.selectedAll().filter((record) => record.kind !== "phrase"); - if (!records.length) return false; - const changes = []; - for (const record of records) { - const current = record.descriptionPageSlug; - if (!current) continue; - const parts = splitPageReference(current); - const target = pageReference(namespace, parts.slug); - if (target === current) continue; - const existing = state.pages.find((page) => pageMatchesReference(page, current)); - const conflict = state.pages.find((page) => pageMatchesReference(page, target)); - if (existing && conflict && existing !== conflict) { - showCmapStatus(tr("namespace-move-conflict", "Cannot move {page}: the target page already exists.") - .replace("{page}", target)); - return false; - } - changes.push({ record, current, target, existing }); - } - if (!changes.length) return false; - for (const change of changes) { - if (change.existing) { - const page = await api(`/api/pages/${encodeURIComponent(change.current)}/rename`, { - method: "POST", - body: JSON.stringify({ - title: change.existing.title, - namespace, - slug: splitPageReference(change.current).slug, - summary: tr("move-description-page-summary", "Moved description page to CMap namespace") - }) - }); - change.target = page.slug; - } - editor.updateItem(change.record, { descriptionPageSlug: change.target }); - } - await loadPages(); - editor.commitHistory(); - if (currentCmapStorageMap()) await saveStoredConceptMap({ automatic: true }); - showCmapStatus(tr("descriptions-moved-to-namespace", "Selected descriptions moved to {namespace}.") - .replace("{namespace}", namespace), true); - return true; + return conceptController.moveSelectedDescriptionsToNamespace(); } function openCmapContextMenu(clientX, clientY, createContext = {}) { @@ -564,17 +467,7 @@ export class CmapWorkspaceController { } function cmapPlacementOptions() { - const context = cmapContextCreateContext || {}; - const options = {}; - if (context.point) { - options.x = Math.max(0, context.point.x - 70); - options.y = Math.max(0, context.point.y - 30); - } - if (context.parentSubmap) { - options.parentSubmap = context.parentSubmap; - options.submapDepth = context.parentSubmap.submapDepth + 1; - } - return options; + return conceptController.placementOptions(); } /** @@ -583,17 +476,7 @@ export class CmapWorkspaceController { * post : A concept dialog or the inline phrase editor is opened. */ function editSelectedCmapNode(record = null) { - const prototype = cmapPrototypeState(); - const selectedRecord = record || (prototype.editor ? prototype.editor.selected() : null); - if (!selectedRecord) { - window.alert(tr("select-one-concept", "Select one concept first.")); - return; - } - if (selectedRecord.kind === "phrase") { - prototype.editor.editPhraseInline(selectedRecord); - return; - } - openCmapConceptDialog(selectedRecord); + return conceptController.editSelected(record); } function cmapKeyboardEditingTarget(target) { @@ -684,46 +567,11 @@ export class CmapWorkspaceController { } function groupSelectedCmapItems() { - const prototype = cmapPrototypeState(); - if (!prototype.editor || !prototype.editor.canGroupSelection()) return false; - const label = window.prompt( - tr("group-submap-name", "Name of the main concept for the new sub-CMap"), - tr("sub-concept-map", "Sub concept map")); - if (!label || !label.trim()) return false; - return prototype.editor.groupSelection({ - label: label.trim(), - conceptId: sharedCmapConceptId(label), - childMap: label.trim(), - synopsis: tr("grouped-submap-synopsis", "Grouped sub-concept map.") - }); + return conceptController.groupSelection(); } function populateCmapPrototypeSubmap(record, editor) { - const baseX = Number(record.node.attr("x")); - const baseY = Number(record.node.attr("y")); - const detail = editor.addSubmapItem(record, { - label: "Detail concept", - synopsis: "Concept inside the expanded submap.", - x: baseX + 315, - y: baseY + 170, - backgroundColor: "#fff4cf", - borderColor: "#a97c00" - }); - editor.connectWithPhrase(record, detail, "contains", false); - - if (record.submapDepth < 2) { - const nested = editor.addSubmapItem(record, { - label: "Nested submap", - synopsis: "This submap can also be expanded.", - kind: "submap", - childMap: `${record.childMap || record.label}/nested`, - x: baseX + 60, - y: baseY + 310, - backgroundColor: "#edf7e8", - borderColor: "#57834a" - }); - editor.connectWithPhrase(detail, nested, "contains", false); - } + return conceptController.populateSubmap(record, editor); } /** @@ -945,27 +793,7 @@ export class CmapWorkspaceController { } async function loadConceptMaps() { - const [conceptMaps, placements] = await Promise.all([ - cmapRepository.list(), - cmapRepository.conceptUsage().catch((error) => { - console.warn("[racket-wiki:cmap-host 0.2.122] concept usage is unavailable; CMaps continue without global counts", error); - return []; - }) - ]); - state.conceptMaps = conceptMaps; - cmapReferenceIndex.load( - placements, - conceptMaps, - canonicalPageReference, - normalizeCmapConceptName); - state.cmapConceptUsage = cmapReferenceIndex.byConceptId; - state.cmapConceptIdsByName = cmapReferenceIndex.byName; - state.cmapPageConcepts = cmapReferenceIndex.byPage; - renderConceptMapSelector(); - const prototype = cmapPrototypeState(); - if (prototype.editor) prototype.editor.refreshConceptUsageIndicators(); - if (state.currentPage) renderPageCmapConnections(state.currentPage); - return state.conceptMaps; + return mapController.loadMaps(); } function currentCmapSnapshot() { @@ -1005,15 +833,7 @@ export class CmapWorkspaceController { } function updateStoredConceptMapSummary(conceptMap) { - const summary = typeof conceptMap.toSummary === "function" ? - conceptMap.toSummary() : conceptMap; - const index = state.conceptMaps.findIndex((item) => item.slug === conceptMap.slug); - if (index >= 0) { - state.conceptMaps[index] = { ...state.conceptMaps[index], ...summary }; - } else { - state.conceptMaps.push(summary); - } - renderConceptMapSelector(); + return mapController.updateSummary(conceptMap); } async function requestCmapTransition(action) { @@ -1021,204 +841,27 @@ export class CmapWorkspaceController { } async function openStoredConceptMap(slug) { - if (!slug) return; - const loadSequence = ++state.cmapLoadSequence; - showCmapStatus(tr("loading", "Loading…")); - const conceptMap = await cmapRepository.load(slug); - if (loadSequence !== 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 cmapRepository.load(derivedView.sourceCmapSlug); - if (loadSequence !== 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 - }); - state.currentConceptMap = conceptMap; - state.currentConceptMapSource = sourceMap; - renderConceptMapSelector(); - resetCmapPrototype(editorModel, false); - if (sourceMap) { - const editor = cmapPrototypeState().editor; - 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); - } - markCurrentCmapSaved(); - const loadedEditor = cmapPrototypeState().editor; - console.info("[racket-wiki:cmap-host 0.2.122] stored CMap loaded", { - slug: conceptMap.slug, - editorAvailable: Boolean(loadedEditor), - itemCount: loadedEditor ? loadedEditor.itemCount() : 0, - connectorCount: loadedEditor ? loadedEditor.connectorCount() : 0 - }); - showCmapStatus(tr("concept-map-loaded", "CMap loaded"), true); + return mapController.open(slug); } async function loadHistoricalConceptMapVersion(version) { - const conceptMap = currentCmapStorageMap(); - if (!conceptMap) return; - const historical = await cmapRepository.loadVersion(conceptMap, version); - const currentSnapshot = JSON.stringify(conceptMap.toDocument()); - resetCmapPrototype(historical.model, false); - state.cmapSavedSnapshot = currentSnapshot; - showCmapStatus( - tr("concept-map-version-loaded", "Version {version} loaded; save to make it current.") - .replace("{version}", String(historical.version)), - true); + return mapController.loadHistoricalVersion(version); } async function createStoredConceptMap() { - const title = window.prompt(tr("concept-map-name", "Concept map name"), ""); - if (!title || !title.trim()) return; - const model = new CmapModel(); - const conceptMap = await cmapRepository.create(title.trim(), model); - await loadConceptMaps(); - location.hash = cmapRoute(conceptMap.slug); + return mapController.create(); } async function promoteSelectedSubmapToStoredMap() { - const prototype = cmapPrototypeState(); - const editor = prototype.editor; - 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) { - showCmapStatus(tr("empty-submap", "This sub-CMap has no concepts to move.")); - return false; - } - - const sourceMap = currentCmapStorageMap(); - let linkedMap = null; - let title = null; - if (record.cmapSlug) { - linkedMap = state.currentConceptMap?.slug === record.cmapSlug ? - state.currentConceptMap : await cmapRepository.load(record.cmapSlug); - const derivedView = linkedMap.model.derivedView(); - const matchesSource = sourceMap && derivedView && - derivedView.sourceCmapSlug === sourceMap.slug && - Number(derivedView.rootItemId) === Number(record.id); - if (!matchesSource) { - showCmapStatus(tr("submap-already-independent", - "This linked CMap is already independent."), true); - await navigateToHash(cmapRoute(record.cmapSlug)); - return true; - } - } else { - title = window.prompt( - tr("submap-name", "Name of the new concept map"), - record.childMap || record.label); - if (!title || !title.trim()) return false; - } - - const buttons = [$("cmap-promote-submap"), $("cmap-extract-selected")]; - for (const button of buttons) button.disabled = true; - try { - const sourceSaved = await saveStoredConceptMap({ - force: true, - snapshotVersion: true, - summary: tr("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 cmapRepository.save( - storedMap, - extraction.childModel, - { - summary: tr("submap-extracted", "Sub-CMap moved to a separate CMap"), - saveKind: "manual" - }); - } else { - const prepared = editor.prepareStoredSubmapExtraction(record, null); - if (!prepared) return false; - storedMap = await cmapRepository.create( - title.trim(), prepared.childModel); - extraction = editor.prepareStoredSubmapExtraction(record, storedMap.slug); - } - - editor.replaceModel(extraction.parentModel); - const parentSaved = await saveStoredConceptMap({ - force: true, - historyMode: "autosave", - summary: tr("submap-extracted", "Sub-CMap moved to a separate CMap") - }); - if (!parentSaved) { - showCmapStatus(tr("submap-created-parent-unsaved", - "The new CMap was created, but the parent CMap still needs to be saved.")); - return false; - } - await navigateToHash(cmapRoute(storedMap.slug)); - return true; - } finally { - const selected = editor.selected(); - const canExtractSubmap = selected && selected.kind === "submap"; - for (const button of buttons) button.disabled = !canExtractSubmap; - $("cmap-extract-selected").classList.toggle("hidden", !canExtractSubmap); - } + return mapController.promoteSelectedSubmap(); } async function renameStoredConceptMap() { - cancelCmapAutosave(); - if (cmapHasUnsavedChanges() && !await saveStoredConceptMap({ automatic: true })) return false; - const conceptMap = state.currentConceptMap; - if (!conceptMap) return false; - const title = window.prompt( - tr("rename-concept-map", "Rename CMap"), - conceptMap.title); - if (!title || !title.trim() || title.trim() === conceptMap.title) return false; - try { - state.currentConceptMap = await cmapRepository.rename(conceptMap, title.trim()); - await loadConceptMaps(); - showCmapStatus(tr("concept-map-renamed", "CMap renamed"), true); - return true; - } catch (error) { - showCmapStatus(error.message); - return false; - } + return mapController.rename(); } function activeCmapMetadata() { - const currentModel = 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 = cmapPrototypeState().editor; - return editor?.getDocumentMetadata ? editor.getDocumentMetadata() : - { tags: [], summary: "", explanationPageSlug: "" }; + return mapController.activeMetadata(); } function openCmapMetadataDialog() { @@ -1228,34 +871,7 @@ export class CmapWorkspaceController { } async function saveCmapMetadata(metadata) { - const conceptMap = state.currentConceptMap; - const editor = cmapPrototypeState().editor; - 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 cmapRepository.save(conceptMap, updatedModel, { - summary: tr("updated-concept-map-details", "Updated CMap details"), - saveKind: "manual" - }); - state.currentConceptMap = updated; - updateStoredConceptMapSummary(updated); - } else { - editor.setDocumentMetadata(metadata); - if (!await saveStoredConceptMap({ - force: true, - summary: tr("updated-concept-map-details", "Updated CMap details") - })) return false; - } - showCmapStatus(tr("concept-map-details-saved", "CMap details saved"), true); - return true; + return mapController.saveMetadata(metadata); } function openCmapExportDialog() { @@ -1301,38 +917,7 @@ export class CmapWorkspaceController { async function deleteStoredConceptMap() { cancelCmapAutosave(); await storage.waitForSave(); - const conceptMap = state.currentConceptMap; - if (!conceptMap) return false; - const question = tr( - "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( - tr( - "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) { - showCmapStatus(tr("archive-concept-map-title-mismatch", "The CMap name did not match; nothing was archived.")); - return false; - } - try { - await cmapRepository.archive(conceptMap, typedTitle); - markCurrentCmapSaved(); - state.currentConceptMap = null; - state.currentConceptMapSource = null; - await loadConceptMaps(); - const target = state.conceptMaps.length ? cmapRoute(state.conceptMaps[0].slug) : "#cmaps"; - await navigateToHash(target); - showCmapStatus(tr("concept-map-archived", "CMap archived"), true); - return true; - } catch (error) { - showCmapStatus(error.message); - return false; - } + return mapController.archive(); } async function saveStoredConceptMap({ automatic = false, force = false, @@ -1342,14 +927,7 @@ export class CmapWorkspaceController { } async function createConceptMapSnapshot() { - if (!currentCmapStorageMap()) return false; - const description = window.prompt(tr("snapshot-description", "Snapshot description"), ""); - if (description === null) return false; - return saveStoredConceptMap({ - force: true, - summary: description.trim() || tr("snapshot", "Snapshot"), - snapshotVersion: true - }); + return mapController.createSnapshot(); } async function showCmapPrototype(requestedSlug = null) {