Files
racket-wiki/static/js/wiki/cmap/cmap-storage-controller.js
T
2026-09-03 17:24:56 +02:00

169 lines
5.7 KiB
JavaScript

"use strict";
/** Coordinate CMap persistence, dirty state, autosave and transitions. */
export class CmapStorageController {
constructor({ repository, getEditor, getCurrentMap, getViewVisible, renderSvg,
reloadMaps, status, translate, unsavedDialog, canEdit, onMapSaved }) {
this.repository = repository;
this.getEditor = getEditor;
this.getCurrentMap = getCurrentMap;
this.getViewVisible = getViewVisible;
this.renderSvg = renderSvg;
this.reloadMaps = reloadMaps;
this.status = status;
this.translate = translate;
this.unsavedDialog = unsavedDialog;
this.canEdit = canEdit;
this.onMapSaved = onMapSaved;
this.savedSnapshot = null;
this.autosaveTimer = null;
this.savePromise = null;
this.pendingTransition = null;
}
currentSnapshot() {
const editor = this.getEditor();
return editor ? JSON.stringify(editor.toDocument()) : null;
}
markSaved(snapshot = this.currentSnapshot()) {
this.savedSnapshot = snapshot;
}
hasUnsavedChanges() {
if (!this.getViewVisible()) return false;
const current = this.currentSnapshot();
if (current === null || this.savedSnapshot === null || current === this.savedSnapshot) return false;
const editor = this.getEditor();
if (editor && !editor.canUndo() && !editor.history.hasPendingCommit()) {
this.savedSnapshot = current;
return false;
}
return true;
}
cancelAutosave() {
if (this.autosaveTimer !== null) {
window.clearTimeout(this.autosaveTimer);
this.autosaveTimer = null;
}
}
/**
* goal : Queue one delayed autosave for the current dirty CMap.
* post : Any preceding autosave timer is replaced; non-editable or clean maps are not queued.
*/
scheduleAutosave() {
this.cancelAutosave();
if (!this.canEdit() || !this.getCurrentMap() || !this.hasUnsavedChanges()) return;
this.status(this.translate("autosave-pending", "Changes waiting to be saved"));
this.autosaveTimer = window.setTimeout(() => {
this.autosaveTimer = null;
this.save({ automatic: true }).catch((error) => console.error(error));
}, 1500);
}
/**
* goal : Guard a route or view transition while unsaved CMap changes exist.
* result : True only when the action ran after saving, discarding or finding no changes.
*/
async requestTransition(action) {
if (!this.hasUnsavedChanges()) {
await action();
return true;
}
if (this.pendingTransition) return false;
const transition = (async () => {
const choice = await this.unsavedDialog.choose();
if (choice === "save" && !await this.save()) return false;
if (choice === "discard") this.markSaved();
if (choice === "cancel") return false;
await action();
return true;
})();
this.pendingTransition = transition;
try {
return await transition;
} finally {
if (this.pendingTransition === transition) this.pendingTransition = null;
}
}
hasPendingTransition() {
return this.pendingTransition !== null;
}
hasSaveInProgress() {
return this.savePromise !== null;
}
async waitForSave() {
if (this.savePromise) await this.savePromise;
}
/**
* goal : Persist the active editor model and its derived SVG render in one ordered workflow.
* post : A successful save updates the current map, saved snapshot and CMap catalogue.
* result : False for cancellation, unavailable editor or persistence failure.
*/
async save({ automatic = false, force = false, summary = null,
snapshotVersion = false, historyMode = null } = {}) {
const editor = this.getEditor();
if (!editor) return false;
this.cancelAutosave();
if (automatic && !this.getCurrentMap()) return false;
const effectiveHistoryMode = historyMode ||
(snapshotVersion ? "snapshot" : (automatic ? "autosave" : "manual"));
if (this.savePromise) {
const succeeded = await this.savePromise;
if (!succeeded) return false;
if (force || this.hasUnsavedChanges() || effectiveHistoryMode !== "autosave") {
return this.save({ automatic, force, summary, snapshotVersion, historyMode });
}
return true;
}
const snapshot = this.currentSnapshot();
if (snapshot === null) return false;
if (!force && snapshot === this.savedSnapshot && effectiveHistoryMode === "autosave") return true;
const model = editor.currentModel();
const renderedSvg = typeof this.renderSvg === "function" ? this.renderSvg() : "";
const storedMap = this.getCurrentMap();
let succeeded = false;
this.status(this.translate("saving", "Saving…"));
this.savePromise = (async () => {
try {
if (!storedMap) {
const title = window.prompt(this.translate("concept-map-name", "Concept map name"), "");
if (!title || !title.trim()) return false;
const created = await this.repository.create(title.trim(), model, null, { renderedSvg });
if (this.onMapSaved) this.onMapSaved(created);
} else {
const saved = await this.repository.save(storedMap, model, {
summary: summary || this.translate("manual-save", "Manual save"),
snapshot: snapshotVersion,
saveKind: effectiveHistoryMode,
renderedSvg
});
if (this.onMapSaved) this.onMapSaved(saved);
}
this.markSaved(snapshot);
await this.reloadMaps();
succeeded = true;
return true;
} catch (error) {
this.status(error.message);
return false;
}
})().finally(() => {
this.savePromise = null;
if (succeeded && this.hasUnsavedChanges()) this.scheduleAutosave();
});
return this.savePromise;
}
destroy() {
this.cancelAutosave();
}
}