refactoring of cmaps, widgets, etc.

This commit is contained in:
2026-09-02 16:06:26 +02:00
parent 38f255c1a4
commit f0562a06cc
52 changed files with 3626 additions and 2642 deletions
@@ -0,0 +1,292 @@
import { pageReference, newPageReference, splitPageReference } from "../../reference.js";
import { pageRoute } from "../../routes.js";
import { ComboBox } from "../../../widgets/combobox.js";
import { TabSet } from "../../../widgets/tab-set.js";
/**
* Edit the content and placement-specific appearance of a CMap concept.
*
* The dialog owns its fields, tabs, image reader and input validation. It
* returns normalized values to the workspace, which performs the actual CMap
* editor transaction and persistence.
*/
export class CmapConceptDialog {
constructor(dialog, tr, appearanceEditor, peopleDialog, normalizeExternalUrl) {
this.dialog = dialog;
this.tr = tr;
this.appearanceEditor = appearanceEditor;
this.peopleDialog = peopleDialog;
this.normalizeExternalUrl = normalizeExternalUrl;
this.form = dialog.querySelector("form");
this.pageCombobox = new ComboBox(dialog.querySelector("#cmap-concept-page-combobox"));
this.cmapCombobox = new ComboBox(dialog.querySelector("#cmap-concept-cmap-combobox"));
this.tabs = new TabSet(dialog.querySelector(".cmap-concept-tabs"))
.onSelect(() => {
dialog.querySelector(".cmap-concept-dialog-body").scrollTop = 0;
});
this.record = null;
this.createContext = null;
this.imageSource = "";
this.imageRead = Promise.resolve();
this.saveHandler = null;
this.installEvents();
}
onSave(handler) {
this.saveHandler = handler;
return this;
}
isOpen() {
return this.dialog.open;
}
/** Open the dialog for an existing concept or sub-CMap placement. */
open(record, resources) {
if (!record || record.kind === "phrase") return;
this.record = record;
this.createContext = null;
this.imageSource = record.imageSource || "";
this.imageRead = Promise.resolve();
this.dialog.querySelector("#cmap-concept-dialog-title").textContent =
this.tr("edit-concept", "Edit concept");
this.dialog.querySelector("#cmap-concept-label").value = record.label || "";
this.dialog.querySelector("#cmap-concept-synopsis").value = record.synopsis || "";
this.dialog.querySelector("#cmap-concept-aspects").value = (record.aspects || []).join(", ");
const selectedPeople = (Array.isArray(record.tags) ? record.tags : [])
.filter((tag) => tag && typeof tag === "object" && tag.type === "person")
.map((tag) => tag.value);
this.peopleDialog.showSelection(selectedPeople);
this.setLinkFields(record, resources);
this.appearanceEditor.showRecord(record);
this.dialog.querySelector("#cmap-concept-image").value = "";
this.updateImagePreview();
this.tabs.select("content");
this.dialog.showModal();
const label = this.dialog.querySelector("#cmap-concept-label");
label.focus();
label.select();
}
/** Open the dialog for a new concept at the supplied editor context. */
openNew(createContext, resources) {
this.record = null;
this.createContext = createContext;
this.imageSource = "";
this.imageRead = Promise.resolve();
this.dialog.querySelector("#cmap-concept-dialog-title").textContent =
this.tr("add-concept", "Add concept");
this.dialog.querySelector("#cmap-concept-label").value = "New concept";
this.dialog.querySelector("#cmap-concept-synopsis").value = "";
this.dialog.querySelector("#cmap-concept-aspects").value = "";
this.peopleDialog.showSelection([]);
this.setLinkFields({ kind: "concept", pageSlug: null, cmapSlug: null }, resources);
this.appearanceEditor.showNewConcept();
this.dialog.querySelector("#cmap-concept-image").value = "";
this.updateImagePreview();
this.tabs.select("content");
this.dialog.showModal();
const label = this.dialog.querySelector("#cmap-concept-label");
label.focus();
label.select();
}
setLinkFields(record, resources) {
const description = this.dialog.querySelector("#cmap-concept-description-page");
const descriptionLink = this.dialog.querySelector("#cmap-concept-description-link");
description.value = record.descriptionPageSlug || "";
descriptionLink.href = record.descriptionPageSlug ? pageRoute(record.descriptionPageSlug) : "#";
descriptionLink.classList.toggle("hidden", !record.descriptionPageSlug);
const externalUrl = this.dialog.querySelector("#cmap-concept-external-url");
externalUrl.value = record.externalUrl || "";
externalUrl.setCustomValidity("");
this.populatePageOptions(record, resources.pages);
this.populateCmapOptions(record, resources.conceptMaps, resources.parentMapAvailable);
}
populatePageOptions(record, pages) {
const sorted = [...pages].sort((a, b) => a.title.localeCompare(b.title));
const entries = sorted.map((page) => this.comboboxEntry(page));
if (record.pageSlug && !sorted.some((page) => page.slug === record.pageSlug)) {
entries.push({ value: record.pageSlug, label: record.pageSlug });
}
this.pageCombobox.setOptions(entries, record.pageSlug || "");
this.dialog.querySelector("#cmap-concept-page-row")
.classList.toggle("hidden", record.kind === "submap");
}
populateCmapOptions(record, conceptMaps, parentMapAvailable) {
const entries = [];
if (parentMapAvailable) {
entries.push({
value: "__parent__",
label: `${this.tr("parent-concept-map", "Parent concept map")}`
});
}
for (const conceptMap of [...conceptMaps].sort((a, b) => a.title.localeCompare(b.title))) {
entries.push(this.comboboxEntry(conceptMap));
}
if (record.cmapSlug && !conceptMaps.some((item) => item.slug === record.cmapSlug)) {
entries.push({ value: record.cmapSlug, label: record.cmapSlug });
}
const selected = record.parentCmapLink ? "__parent__" : (record.cmapSlug || "");
this.cmapCombobox.setOptions(entries, selected);
this.dialog.querySelector("#cmap-concept-cmap-row")
.classList.toggle("hidden", record.kind === "submap");
}
comboboxEntry(record) {
return {
value: record.slug,
label: record.title || record.slug,
description: record.slug
};
}
updateImagePreview() {
const row = this.dialog.querySelector("#cmap-concept-image-preview-row");
const preview = this.dialog.querySelector("#cmap-concept-image-preview");
if (!this.imageSource) {
row.classList.add("hidden");
preview.removeAttribute("src");
return;
}
preview.src = this.imageSource;
row.classList.remove("hidden");
}
readImage(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.addEventListener("load", () => resolve(String(reader.result || "")), { once: true });
reader.addEventListener("error", () => reject(
reader.error || new Error("Image could not be read.")), { once: true });
reader.readAsDataURL(file);
});
}
/** Validate the form and return normalized concept values, or null on error. */
values() {
const labelInput = this.dialog.querySelector("#cmap-concept-label");
const label = labelInput.value.trim();
if (!label) {
this.tabs.select("content");
labelInput.focus();
return null;
}
const record = this.record;
const pageInput = this.dialog.querySelector("#cmap-concept-page");
const cmapInput = this.dialog.querySelector("#cmap-concept-cmap");
const selectedPage = record && record.kind === "submap" ? "" : this.pageCombobox.value();
const selectedCmap = record && record.kind === "submap" ? "" : this.cmapCombobox.value();
const linkedPage = selectedPage === null ? newPageReference(pageInput.value) : selectedPage;
if (selectedPage === null && !linkedPage) {
this.tabs.select("content");
pageInput.setCustomValidity(
this.tr("invalid-new-page", "Enter a page title or valid wiki address."));
pageInput.reportValidity();
return null;
}
if (selectedCmap === null) {
this.tabs.select("content");
cmapInput.setCustomValidity(this.tr(
"select-listed-concept-map", "Select a CMap from the list or clear the field."));
cmapInput.reportValidity();
return null;
}
const externalInput = this.dialog.querySelector("#cmap-concept-external-url");
const externalUrl = this.normalizeExternalUrl(externalInput.value);
if (externalUrl === null) {
this.tabs.select("content");
externalInput.setCustomValidity(this.tr(
"invalid-external-web-page", "Enter a complete http or https web address."));
externalInput.reportValidity();
externalInput.focus();
return null;
}
const descriptionInput = this.dialog.querySelector("#cmap-concept-description-page");
const descriptionText = descriptionInput.value.trim();
const descriptionPage = descriptionText ? newPageReference(descriptionText) :
pageReference("cmap", splitPageReference(newPageReference(label) || "concept").slug);
if (!descriptionPage) {
this.tabs.select("content");
descriptionInput.setCustomValidity(
this.tr("invalid-description-page", "Enter a valid description page address."));
descriptionInput.reportValidity();
return null;
}
const linkedCmapValue = selectedCmap || "";
return {
label,
synopsis: this.dialog.querySelector("#cmap-concept-synopsis").value,
aspects: this.dialog.querySelector("#cmap-concept-aspects").value.split(",")
.map((aspect) => aspect.trim()).filter(Boolean),
personNames: this.peopleDialog.selectedNames(),
descriptionPageSlug: descriptionPage,
pageSlug: linkedPage || null,
cmapSlug: linkedCmapValue && linkedCmapValue !== "__parent__" ? linkedCmapValue : null,
externalUrl: externalUrl || null,
parentCmapLink: linkedCmapValue === "__parent__",
imageSource: this.imageSource,
appearance: this.appearanceEditor.placementChanges(
Boolean(record && record.kind === "submap"))
};
}
installEvents() {
this.dialog.querySelector("#cmap-concept-page").addEventListener("change", () => {
if (this.pageCombobox.value()) this.cmapCombobox.clear();
});
this.dialog.querySelector("#cmap-concept-cmap").addEventListener("change", () => {
if (this.cmapCombobox.value()) this.pageCombobox.clear();
});
for (const id of ["#cmap-concept-page", "#cmap-concept-cmap"]) {
this.dialog.querySelector(id).addEventListener("input", (event) =>
event.target.setCustomValidity(""));
}
this.dialog.querySelector("#cmap-concept-description-page")
.addEventListener("input", (event) => event.target.setCustomValidity(""));
this.dialog.querySelector("#cmap-concept-external-url")
.addEventListener("input", (event) => event.target.setCustomValidity(""));
this.dialog.querySelector("#cmap-concept-description-link")
.addEventListener("click", () => this.dialog.close());
this.dialog.querySelector("#cmap-concept-cancel")
.addEventListener("click", () => this.dialog.close());
this.dialog.querySelector("#cmap-concept-image").addEventListener("change", (event) => {
const file = event.target.files && event.target.files[0];
if (!file) return;
const record = this.record;
this.imageRead = this.readImage(file)
.then((imageSource) => {
if (this.record !== record) return;
this.imageSource = imageSource;
this.updateImagePreview();
})
.catch((error) => {
console.error(error);
window.alert(error.message);
});
});
this.dialog.querySelector("#cmap-concept-image-remove").addEventListener("click", () => {
this.imageSource = "";
this.imageRead = Promise.resolve();
this.dialog.querySelector("#cmap-concept-image").value = "";
this.updateImagePreview();
});
this.form.addEventListener("submit", async (event) => {
event.preventDefault();
await this.imageRead;
const values = this.values();
if (!values || !this.saveHandler) return;
const saved = await this.saveHandler(this.record, this.createContext, values);
if (saved) this.dialog.close();
});
this.dialog.addEventListener("close", () => {
this.record = null;
this.createContext = null;
this.imageSource = "";
this.imageRead = Promise.resolve();
});
}
}
@@ -0,0 +1,107 @@
import { StatusField } from "../../../widgets/status-field.js";
/**
* Run the user-facing Markdown and JSON export actions for one CMap.
*
* Export generation is supplied by the workspace. This controller owns export
* options, progress messages, clipboard handling and browser downloads.
*/
export class CmapExportDialog {
constructor(dialog, tr, buildMarkdown, buildJson) {
this.dialog = dialog;
this.tr = tr;
this.buildMarkdown = buildMarkdown;
this.buildJson = buildJson;
this.form = dialog.querySelector("form");
this.depth = dialog.querySelector("#cmap-export-depth");
this.pages = dialog.querySelector("#cmap-export-pages");
this.status = new StatusField(dialog.querySelector("#cmap-export-status"));
this.conceptMapSlug = "concept-map";
dialog.querySelector("#cmap-export-cancel").addEventListener("click", () => dialog.close());
dialog.querySelector("#cmap-export-copy").addEventListener("click", () => {
this.exportMarkdown(false);
});
dialog.querySelector("#cmap-export-json").addEventListener("click", () => {
this.exportJson();
});
this.form.addEventListener("submit", (event) => {
event.preventDefault();
this.exportMarkdown(true);
});
}
open(conceptMapSlug) {
this.conceptMapSlug = conceptMapSlug;
this.status.clear();
this.dialog.showModal();
this.depth.focus();
}
options() {
return {
depth: Math.max(0, Math.min(10, Number(this.depth.value) || 0)),
includeWikiPages: this.pages.checked
};
}
async exportJson() {
this.status.set(this.tr("preparing-cmap-json", "Preparing complete CMap JSON…"));
try {
const bundle = await this.buildJson(this.options().depth);
this.download(`${JSON.stringify(bundle, null, 2)}\n`,
`${bundle.rootCmapSlug}-cmap.json`, "application/json;charset=utf-8");
this.status.set(this.tr("cmap-json-downloaded", "CMap JSON downloaded."));
return true;
} catch (error) {
this.status.set(error.message);
return false;
}
}
async exportMarkdown(download) {
this.status.set(this.tr("preparing-markdown-export", "Preparing Markdown export…"));
try {
const markdown = await this.buildMarkdown(this.options());
if (download) {
this.download(markdown, `${this.conceptMapSlug}-report.md`,
"text/markdown;charset=utf-8");
this.status.set(this.tr("markdown-export-downloaded", "Markdown export downloaded."));
} else {
await this.copy(markdown);
this.status.set(this.tr("markdown-export-copied", "Markdown export copied."));
}
return true;
} catch (error) {
this.status.set(error.message);
return false;
}
}
download(text, filename, contentType) {
const blob = new Blob([text], { type: contentType });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.append(link);
link.click();
link.remove();
window.setTimeout(() => URL.revokeObjectURL(url), 0);
}
async copy(text) {
try {
await navigator.clipboard.writeText(text);
} catch (_error) {
const input = document.createElement("textarea");
input.value = text;
input.style.position = "fixed";
input.style.left = "-10000px";
document.body.append(input);
input.select();
document.execCommand("copy");
input.remove();
}
}
}
@@ -0,0 +1,115 @@
/**
* Present and maintain the stored history of one CMap.
*
* History records come from CmapRepository. Loading a version is delegated to
* the workspace because it controls unsaved-change transitions and the editor.
*/
export class CmapHistoryDialog {
constructor(dialog, repository, tr, pageDisplayDate, can, loadVersion, showStatus) {
this.dialog = dialog;
this.repository = repository;
this.tr = tr;
this.pageDisplayDate = pageDisplayDate;
this.can = can;
this.loadVersion = loadVersion;
this.showStatus = showStatus;
this.list = dialog.querySelector("#cmap-history-list");
dialog.querySelector("#cmap-history-close").addEventListener("click", () => dialog.close());
}
async open(conceptMap) {
const versions = await this.repository.history(conceptMap);
this.list.replaceChildren();
if (!versions.length) this.showEmptyMessage();
for (const version of versions) this.list.append(this.versionRow(conceptMap, version));
this.dialog.showModal();
}
showEmptyMessage() {
const empty = document.createElement("p");
empty.className = "muted cmap-history-empty";
empty.textContent = this.tr(
"no-concept-map-history", "No snapshots or manual saves yet.");
this.list.append(empty);
}
versionRow(conceptMap, version) {
const row = document.createElement("div");
row.className = "cmap-history-row";
const label = document.createElement("div");
const heading = document.createElement("strong");
heading.textContent = `${this.tr("version", "Version")} ${version.version}${version.title}`;
const meta = document.createElement("div");
meta.className = "muted";
meta.textContent = `${this.pageDisplayDate(version.createdAt)} · ${version.author} · ${this.versionSummary(version)}`;
label.append(heading, document.createElement("br"), meta);
const actions = document.createElement("div");
actions.className = "cmap-history-actions";
actions.append(this.loadButton(conceptMap, version));
if (this.can("editor")) actions.append(this.deleteButton(conceptMap, version, row));
row.append(label, actions);
return row;
}
versionSummary(version) {
const knownSummaries = {
create: this.tr("concept-map-created-version", "CMap created"),
rename: this.tr("concept-map-renamed-version", "CMap renamed")
};
let summary = knownSummaries[version.action] || version.summary;
if (version.action === "snapshot") {
const snapshotLabel = this.tr("snapshot", "Snapshot");
if (version.summary === "Current state when CMap history was enabled") {
summary = this.tr("concept-map-initial-version", "Initial available version");
} else {
summary = version.summary === snapshotLabel ?
snapshotLabel : `${snapshotLabel}${version.summary}`;
}
}
if (version.summary === "Automatic save") {
summary = this.tr("automatic-save", "Automatic save");
}
if (version.summary === "Manual save") summary = this.tr("manual-save", "Manual save");
return summary;
}
loadButton(conceptMap, version) {
const load = document.createElement("button");
load.type = "button";
load.textContent = version.version === conceptMap.currentVersion ?
this.tr("current-version", "Current") : this.tr("load-version", "Load version");
load.disabled = version.version === conceptMap.currentVersion;
load.addEventListener("click", () => {
this.dialog.close();
this.loadVersion(version.version);
});
return load;
}
deleteButton(conceptMap, version, row) {
const remove = document.createElement("button");
remove.type = "button";
remove.textContent = this.tr("delete-history-item", "Delete");
remove.addEventListener("click", async () => {
const question = this.tr(
"delete-concept-map-history-confirm",
"Delete CMap history version {version}? The current CMap will not be changed.")
.replace("{version}", String(version.version));
if (!window.confirm(question)) return;
remove.disabled = true;
try {
await this.repository.deleteVersion(conceptMap, version.version);
row.remove();
if (!this.list.querySelector(".cmap-history-row")) this.showEmptyMessage();
this.showStatus(
this.tr("concept-map-history-deleted", "History item deleted"), true);
} catch (error) {
remove.disabled = false;
this.showStatus(error.message);
}
});
return remove;
}
}
@@ -0,0 +1,66 @@
/**
* Edit CMap metadata without knowing how the active CMap is persisted.
*
* The controller owns field population and validation. Its save handler
* receives normalized metadata and reports success with a boolean result.
*/
export class CmapMetadataDialog {
constructor(dialog, tr, normalizePageReference) {
this.dialog = dialog;
this.tr = tr;
this.normalizePageReference = normalizePageReference;
this.form = dialog.querySelector("form");
this.summary = dialog.querySelector("#cmap-metadata-summary");
this.tags = dialog.querySelector("#cmap-metadata-tags");
this.explanationPage = dialog.querySelector("#cmap-metadata-explanation-page");
this.saveHandler = null;
dialog.querySelector("#cmap-metadata-cancel")
.addEventListener("click", () => dialog.close());
this.explanationPage.addEventListener("input", () =>
this.explanationPage.setCustomValidity(""));
this.form.addEventListener("submit", (event) => {
event.preventDefault();
this.save().catch((error) => window.alert(error.message));
});
}
onSave(handler) {
this.saveHandler = handler;
return this;
}
open(metadata) {
this.summary.value = metadata.summary || "";
this.tags.value = (metadata.tags || []).join(", ");
this.explanationPage.value = metadata.explanationPageSlug || "";
this.explanationPage.setCustomValidity("");
this.dialog.showModal();
this.summary.focus();
}
metadata() {
const explanationInput = this.explanationPage.value.trim();
const explanationPageSlug = explanationInput ?
this.normalizePageReference(explanationInput) : "";
if (explanationInput && !explanationPageSlug) {
this.explanationPage.setCustomValidity(
this.tr("invalid-description-page", "Enter a valid description page address."));
this.explanationPage.reportValidity();
return null;
}
return {
tags: this.tags.value.split(",").map((tag) => tag.trim()).filter(Boolean),
summary: this.summary.value.trim(),
explanationPageSlug
};
}
async save() {
const metadata = this.metadata();
if (!metadata || !this.saveHandler) return false;
const saved = await this.saveHandler(metadata);
if (saved) this.dialog.close();
return saved;
}
}
@@ -0,0 +1,158 @@
/**
* Manage the shared people list used by the concept picker and people dialog.
*
* The controller loads and updates people through the wiki API, renders both
* views from the same collection and exposes only the selected person names to
* the concept editor.
*/
export class CmapPeopleDialog {
constructor(dialog, picker, repository, tr) {
this.dialog = dialog;
this.picker = picker;
this.repository = repository;
this.tr = tr;
this.people = [];
this.options = picker.querySelector("#cmap-person-tag-options");
this.managementList = dialog.querySelector("#cmap-people-list");
this.conceptInput = picker.querySelector("#cmap-person-new-name");
this.managementInput = dialog.querySelector("#cmap-people-new-name");
picker.querySelector("#cmap-person-add").addEventListener("click", () => {
this.createPerson(this.conceptInput, true)
.catch((error) => window.alert(error.message));
});
this.conceptInput.addEventListener("keydown", (event) => {
if (event.key !== "Enter") return;
event.preventDefault();
this.createPerson(this.conceptInput, true)
.catch((error) => window.alert(error.message));
});
dialog.querySelector("#cmap-people-add").addEventListener("click", () => {
this.createPerson(this.managementInput, false)
.then(() => this.renderManagement())
.catch((error) => window.alert(error.message));
});
this.managementInput.addEventListener("keydown", (event) => {
if (event.key !== "Enter") return;
event.preventDefault();
this.createPerson(this.managementInput, false)
.then(() => this.renderManagement())
.catch((error) => window.alert(error.message));
});
this.options.addEventListener("change", () => this.renderOptions(this.selectedNames()));
dialog.querySelector("#cmap-people-close").addEventListener("click", () => dialog.close());
}
async load() {
this.people = await this.repository.all();
return this.people;
}
selectedNames() {
return Array.from(this.options.querySelectorAll("input[type='checkbox']:checked"))
.map((input) => input.dataset.personName)
.filter(Boolean);
}
showSelection(selectedNames = []) {
this.renderOptions(selectedNames);
this.picker.open = false;
}
/** Render the picker while retaining inactive or missing selected people. */
renderOptions(selectedNames = []) {
const selected = new Set(selectedNames.map((name) => name.toLocaleLowerCase()));
this.options.replaceChildren();
const visiblePeople = this.people.filter((person) =>
person.active || selected.has(String(person.name).toLocaleLowerCase()));
for (const person of visiblePeople) {
this.options.append(this.personOption(person.name, person.active, selected));
}
const knownNames = new Set(
this.people.map((person) => String(person.name).toLocaleLowerCase()));
for (const selectedName of selectedNames) {
if (!knownNames.has(String(selectedName).toLocaleLowerCase())) {
this.options.append(this.personOption(selectedName, false, selected));
}
}
if (!this.options.childElementCount) {
const empty = document.createElement("span");
empty.className = "muted";
empty.textContent = this.tr("no-active-people", "No active people yet.");
this.options.append(empty);
}
const summary = this.picker.querySelector("summary");
summary.textContent = selected.size ?
this.tr("people-selected", "{count} people selected")
.replace("{count}", String(selected.size)) :
this.tr("select-people", "Select people");
}
personOption(name, active, selected) {
const label = document.createElement("label");
label.className = "cmap-person-tag-option";
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.dataset.personName = name;
checkbox.checked = selected.has(String(name).toLocaleLowerCase());
const text = document.createElement("span");
text.textContent = active ? name : `${name} (${this.tr("inactive", "inactive")})`;
label.append(checkbox, text);
return label;
}
async createPerson(input, selectInConceptDialog) {
const name = input.value.trim();
if (!name) {
input.focus();
return null;
}
const selected = selectInConceptDialog ? this.selectedNames() : [];
const person = await this.repository.create(name);
input.value = "";
await this.load();
if (selectInConceptDialog) this.renderOptions([...selected, person.name]);
return person;
}
renderManagement() {
this.managementList.replaceChildren();
for (const person of this.people) {
const row = document.createElement("div");
row.className = "cmap-person-admin-row";
const name = document.createElement("strong");
name.textContent = person.name;
const activeLabel = document.createElement("label");
const active = document.createElement("input");
active.type = "checkbox";
active.checked = Boolean(person.active);
const activeText = document.createElement("span");
activeText.textContent = this.tr("active", "Active");
activeLabel.append(active, activeText);
const save = document.createElement("button");
save.type = "button";
save.textContent = this.tr("save", "Save");
save.addEventListener("click", async () => {
save.disabled = true;
try {
await this.repository.update(person, active.checked);
await this.load();
this.renderManagement();
} catch (error) {
window.alert(error.message);
save.disabled = false;
}
});
row.append(name, activeLabel, save);
this.managementList.append(row);
}
}
async open() {
await this.load();
this.renderManagement();
this.dialog.showModal();
}
}
@@ -0,0 +1,44 @@
/**
* Ask how pending CMap changes should be handled before navigation.
*
* Each call to choose resolves once with "save", "discard" or "cancel".
* Saving and discarding remain workspace responsibilities because they change
* the active editor and persistence state.
*/
export class CmapUnsavedDialog {
constructor(dialog) {
this.dialog = dialog;
this.resolveChoice = null;
dialog.querySelector("#cmap-unsaved-save")
.addEventListener("click", () => this.finish("save"));
dialog.querySelector("#cmap-unsaved-discard")
.addEventListener("click", () => this.finish("discard"));
dialog.querySelector("#cmap-unsaved-cancel")
.addEventListener("click", () => this.finish("cancel"));
dialog.addEventListener("cancel", (event) => {
event.preventDefault();
this.finish("cancel");
});
}
choose() {
if (this.resolveChoice) return Promise.resolve("cancel");
this.dialog.showModal();
return new Promise((resolve) => {
this.resolveChoice = resolve;
});
}
chooseSave() {
this.finish("save");
}
finish(choice) {
const resolve = this.resolveChoice;
if (!resolve) return;
this.resolveChoice = null;
this.dialog.close();
resolve(choice);
}
}
-446
View File
@@ -1,446 +0,0 @@
/* Versioned JSON interchange for Racket Wiki concept maps. */
const FORMAT = "racket-wiki-cmap-bundle";
const FORMAT_VERSION = 1;
const SCHEMA = "/schemas/racket-wiki-cmap-bundle-v1.schema.json";
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const TEMPORARY_ID = /^new:[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/;
const SLUG = /^[\p{L}\p{N}][\p{L}\p{N}._-]{0,119}$/u;
const CONCEPT_KEYS = [
"id", "label", "synopsis", "aspects", "tags", "descriptionPageSlug",
"pageSlug", "cmapSlug", "externalUrl", "imageSource"
];
const PLACEMENT_CONTENT_KEYS = new Set(CONCEPT_KEYS.filter((key) => key !== "id"));
function clone(value) {
return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
}
function validPageReference(value) {
if (typeof value !== "string") return false;
const separator = value.indexOf(":");
if (separator < 0) return SLUG.test(value);
const namespace = value.slice(0, separator);
const slug = value.slice(separator + 1);
return namespace.length <= 80 && SLUG.test(namespace) && SLUG.test(slug);
}
function validExternalUrl(value) {
try {
const url = new URL(String(value));
return url.protocol === "http:" || url.protocol === "https:";
} catch (_error) {
return false;
}
}
function decodedDocument(value) {
let documentValue = value;
for (let attempt = 0; attempt < 2 && typeof documentValue === "string"; attempt += 1) {
documentValue = JSON.parse(documentValue);
}
return documentValue && typeof documentValue === "object" && !Array.isArray(documentValue) ?
documentValue : {};
}
function conceptContent(value) {
const result = {};
for (const key of CONCEPT_KEYS) {
if (Object.prototype.hasOwnProperty.call(value || {}, key)) result[key] = clone(value[key]);
}
return result;
}
function conceptsById(documentValue) {
return new Map((Array.isArray(documentValue.concepts) ? documentValue.concepts : [])
.filter((concept) => concept && typeof concept.id === "string" && concept.id)
.map((concept) => [concept.id, concept]));
}
function itemConceptIds(documentValue) {
return [...new Set((Array.isArray(documentValue.items) ? documentValue.items : [])
.filter((item) => item && item.kind !== "phrase" && typeof item.conceptId === "string" && item.conceptId)
.map((item) => item.conceptId))];
}
function linkedMapSlugs(documentValue) {
const concepts = conceptsById(documentValue);
return [...new Set(itemConceptIds(documentValue)
.map((id) => String(concepts.get(id)?.cmapSlug || "").trim())
.filter(Boolean))];
}
function linkedPageReferences(documentValue) {
const references = new Set();
const metadata = documentValue.metadata && typeof documentValue.metadata === "object" ?
documentValue.metadata : {};
if (typeof metadata.explanationPageSlug === "string" && metadata.explanationPageSlug.trim()) {
references.add(metadata.explanationPageSlug.trim());
}
const concepts = conceptsById(documentValue);
for (const id of itemConceptIds(documentValue)) {
const concept = concepts.get(id) || {};
for (const key of ["pageSlug", "descriptionPageSlug"]) {
if (typeof concept[key] === "string" && concept[key].trim()) references.add(concept[key].trim());
}
}
return references;
}
function attachmentUrls(markdown) {
const source = String(markdown || "");
const urls = new Set();
const add = (value) => {
const url = String(value || "").trim();
if (url.startsWith("/uploads/") && url.split("/").length >= 4) urls.add(url);
};
for (const match of source.matchAll(/!?\[[^\]]*\]\((\/uploads\/[^)]*)\)/g)) add(match[1]);
for (const match of source.matchAll(/(?:src|href)\s*=\s*["'](\/uploads\/[^"']+)["']/gi)) add(match[1]);
for (const match of source.matchAll(/\/uploads\/[^\s"'<>\\)]+/g)) add(match[0]);
const collected = [...urls];
return collected.filter((url) => !collected.some((other) =>
other !== url && other.startsWith(`${url} `)));
}
function attachmentName(url) {
const encoded = String(url || "").split("/").at(-1) || "attachment.bin";
try {
return decodeURIComponent(encoded) || "attachment.bin";
} catch (_error) {
return encoded || "attachment.bin";
}
}
function replaceAttachmentUrls(markdown, replacements) {
let result = String(markdown || "");
const entries = replacements instanceof Map ? [...replacements.entries()] :
Object.entries(replacements || {});
entries.sort(([left], [right]) => right.length - left.length);
for (const [source, target] of entries) {
if (!source || source === target) continue;
result = result.split(source).join(String(target));
}
return result;
}
function placementDocument(documentValue) {
const documentCopy = clone(decodedDocument(documentValue));
const ids = itemConceptIds(documentCopy);
for (const concept of (Array.isArray(documentCopy.concepts) ? documentCopy.concepts : [])) {
if (concept && typeof concept.id === "string" && !ids.includes(concept.id)) ids.push(concept.id);
}
documentCopy.items = (Array.isArray(documentCopy.items) ? documentCopy.items : []).map((item) => {
if (!item || item.kind === "phrase") return item;
return Object.fromEntries(Object.entries(item)
.filter(([key]) => !PLACEMENT_CONTENT_KEYS.has(key)));
});
documentCopy.concepts = ids.map((id) => ({ id }));
const itemIds = new Set();
for (const item of documentCopy.items) {
const itemId = Number(item?.id);
if (Number.isInteger(itemId)) itemIds.add(itemId);
}
documentCopy.connectors = (Array.isArray(documentCopy.connectors) ?
documentCopy.connectors : []).filter((connector) => {
const sourceExists = itemIds.has(Number(connector?.sourceId));
const targetExists = itemIds.has(Number(connector?.targetId));
return sourceExists && targetExists;
});
return documentCopy;
}
async function pageRecord(page, requestedReference, loadAttachment) {
const markdown = String(page.markdown || "");
const attachments = [];
for (const url of attachmentUrls(markdown)) {
if (typeof loadAttachment !== "function") {
throw new Error(`Attachment loader is required for ${url}.`);
}
const loaded = await loadAttachment(url, requestedReference);
if (!loaded || typeof loaded.contentBase64 !== "string") {
throw new Error(`Attachment ${url} did not provide base64 content.`);
}
attachments.push({
url,
name: String(loaded.name || attachmentName(url)),
mimeType: String(loaded.mimeType || "application/octet-stream"),
contentBase64: loaded.contentBase64
});
}
return {
reference: String(requestedReference),
title: String(page.title || page.slug || requestedReference),
markdown,
tags: Array.isArray(page.tags) ? page.tags.map(String) : [],
attachments
};
}
async function buildBundle(options) {
if (!options?.rootMap?.slug) throw new Error("A root CMap is required.");
if (typeof options.loadConceptMap !== "function") throw new Error("loadConceptMap is required.");
if (typeof options.loadWikiPage !== "function") throw new Error("loadWikiPage is required.");
const maximumDepth = Math.max(0, Math.min(10, Number(options.maxDepth) || 0));
const maps = [];
const concepts = new Map();
const pageReferences = new Set();
const visited = new Set();
const missingMaps = new Set();
async function collectMap(map, depth) {
if (!map?.slug || visited.has(map.slug)) return;
visited.add(map.slug);
const documentValue = decodedDocument(map.document);
maps.push({
slug: String(map.slug),
title: String(map.title || map.slug),
document: placementDocument(documentValue)
});
for (const concept of conceptsById(documentValue).values()) {
if (!concepts.has(concept.id)) concepts.set(concept.id, conceptContent(concept));
}
for (const reference of linkedPageReferences(documentValue)) pageReferences.add(reference);
const sourceSlug = String(documentValue.derivedView?.sourceCmapSlug || "").trim();
if (sourceSlug && !visited.has(sourceSlug)) {
try {
await collectMap(await options.loadConceptMap(sourceSlug), depth);
} catch (_error) {
missingMaps.add(sourceSlug);
}
}
if (depth >= maximumDepth) return;
for (const slug of linkedMapSlugs(documentValue)) {
if (visited.has(slug)) continue;
try {
await collectMap(await options.loadConceptMap(slug), depth + 1);
} catch (_error) {
missingMaps.add(slug);
}
}
}
await collectMap(options.rootMap, 0);
const pages = [];
const missingPages = [];
for (const reference of [...pageReferences].sort()) {
try {
pages.push(await pageRecord(
await options.loadWikiPage(reference), reference, options.loadAttachment));
} catch (_error) {
missingPages.push(reference);
}
}
const bundle = {
$schema: SCHEMA,
format: FORMAT,
formatVersion: FORMAT_VERSION,
exportedAt: options.exportedAt || new Date().toISOString(),
generator: options.generator || "Racket Wiki",
rootCmapSlug: String(options.rootMap.slug),
cmaps: maps,
concepts: [...concepts.values()],
pages,
missing: { cmaps: [...missingMaps].sort(), pages: missingPages }
};
validateBundle(bundle);
return bundle;
}
function validateBundle(bundle) {
const errors = [];
const issue = (path, message) => errors.push(`${path}: ${message}`);
if (!bundle || typeof bundle !== "object" || Array.isArray(bundle)) {
throw new Error("The import must be a JSON object.");
}
if (bundle.format !== FORMAT) issue("format", `must be ${FORMAT}`);
if (bundle.formatVersion !== FORMAT_VERSION) issue("formatVersion", `must be ${FORMAT_VERSION}`);
if (!Array.isArray(bundle.cmaps) || !bundle.cmaps.length) issue("cmaps", "must contain at least one CMap");
if (!Array.isArray(bundle.concepts)) issue("concepts", "must be an array");
if (!Array.isArray(bundle.pages)) issue("pages", "must be an array");
const conceptIds = new Set();
const conceptLabels = new Set();
const usedConceptIds = new Set();
const linkedPageReferences = new Set();
for (const [index, concept] of (Array.isArray(bundle.concepts) ? bundle.concepts : []).entries()) {
const path = `concepts[${index}]`;
if (!concept || typeof concept !== "object" || Array.isArray(concept)) {
issue(path, "must be an object");
continue;
}
if (typeof concept.id !== "string" || !(UUID.test(concept.id) || TEMPORARY_ID.test(concept.id))) {
issue(`${path}.id`, "must be a UUID or a new:<name> temporary id");
} else if (conceptIds.has(concept.id)) {
issue(`${path}.id`, "is duplicated");
} else conceptIds.add(concept.id);
if (typeof concept.label !== "string" || !concept.label.trim()) issue(`${path}.label`, "is required");
else {
const name = concept.label.trim().toLocaleLowerCase();
if (conceptLabels.has(name)) issue(`${path}.label`, "duplicates another concept name");
else conceptLabels.add(name);
}
for (const key of ["pageSlug", "descriptionPageSlug"]) {
if (typeof concept[key] === "string" && concept[key].trim()) {
linkedPageReferences.add(concept[key].trim());
if (!validPageReference(concept[key].trim())) issue(`${path}.${key}`, "must be a valid wiki page reference");
}
}
if (typeof concept.cmapSlug === "string" && concept.cmapSlug.trim() && !SLUG.test(concept.cmapSlug.trim())) {
issue(`${path}.cmapSlug`, "must be a valid CMap slug");
}
if (concept.externalUrl !== undefined && concept.externalUrl !== null &&
(typeof concept.externalUrl !== "string" ||
!concept.externalUrl.trim() || !validExternalUrl(concept.externalUrl.trim()))) {
issue(`${path}.externalUrl`, "must be a complete http or https URL");
}
}
const mapSlugs = new Set();
for (const [mapIndex, cmap] of (Array.isArray(bundle.cmaps) ? bundle.cmaps : []).entries()) {
const path = `cmaps[${mapIndex}]`;
if (!cmap || typeof cmap !== "object" || Array.isArray(cmap)) {
issue(path, "must be an object");
continue;
}
if (typeof cmap.slug !== "string" || !SLUG.test(cmap.slug)) issue(`${path}.slug`, "must be a valid CMap slug");
else if (mapSlugs.has(cmap.slug)) issue(`${path}.slug`, "is duplicated");
else mapSlugs.add(cmap.slug);
if (typeof cmap.title !== "string" || !cmap.title.trim()) issue(`${path}.title`, "is required");
const documentValue = cmap.document;
if (!documentValue || typeof documentValue !== "object" || Array.isArray(documentValue)) {
issue(`${path}.document`, "must be an object");
continue;
}
const items = Array.isArray(documentValue.items) ? documentValue.items : [];
const metadataReference = documentValue.metadata?.explanationPageSlug;
if (typeof metadataReference === "string" && metadataReference.trim()) {
linkedPageReferences.add(metadataReference.trim());
if (!validPageReference(metadataReference.trim())) {
issue(`${path}.document.metadata.explanationPageSlug`, "must be a valid wiki page reference");
}
}
const documentConceptIds = new Set();
for (const [referenceIndex, reference] of (Array.isArray(documentValue.concepts) ?
documentValue.concepts : []).entries()) {
const referencePath = `${path}.document.concepts[${referenceIndex}].id`;
if (!reference || typeof reference.id !== "string" || !conceptIds.has(reference.id)) {
issue(referencePath, "must reference a concept in concepts[]");
} else if (documentConceptIds.has(reference.id)) issue(referencePath, "is duplicated within the CMap");
else documentConceptIds.add(reference.id);
}
const itemIds = new Set();
for (const [itemIndex, item] of items.entries()) {
const itemPath = `${path}.document.items[${itemIndex}]`;
if (!item || typeof item !== "object" || Array.isArray(item)) {
issue(itemPath, "must be an object");
continue;
}
if (!Number.isInteger(Number(item.id))) issue(`${itemPath}.id`, "must be an integer");
else if (itemIds.has(Number(item.id))) issue(`${itemPath}.id`, "is duplicated within the CMap");
else itemIds.add(Number(item.id));
if (!Number.isFinite(Number(item.x))) issue(`${itemPath}.x`, "must be a number");
if (!Number.isFinite(Number(item.y))) issue(`${itemPath}.y`, "must be a number");
if (item.kind !== "phrase") {
if (typeof item.conceptId !== "string" || !conceptIds.has(item.conceptId)) {
issue(`${itemPath}.conceptId`, "must reference a concept in concepts[]");
} else usedConceptIds.add(item.conceptId);
}
}
for (const [connectorIndex, connector] of (Array.isArray(documentValue.connectors) ?
documentValue.connectors : []).entries()) {
const connectorPath = `${path}.document.connectors[${connectorIndex}]`;
if (!itemIds.has(Number(connector?.sourceId))) issue(`${connectorPath}.sourceId`, "references an unknown item");
if (!itemIds.has(Number(connector?.targetId))) issue(`${connectorPath}.targetId`, "references an unknown item");
}
}
if (typeof bundle.rootCmapSlug !== "string" || !mapSlugs.has(bundle.rootCmapSlug)) {
issue("rootCmapSlug", "must reference a CMap in cmaps[]");
}
for (const id of conceptIds) {
if (!usedConceptIds.has(id)) issue(`concepts[id=${id}]`, "must occur as a diagram placement");
}
const pageReferences = new Set();
for (const [index, page] of (Array.isArray(bundle.pages) ? bundle.pages : []).entries()) {
const path = `pages[${index}]`;
if (!page || typeof page !== "object" || Array.isArray(page)) {
issue(path, "must be an object");
continue;
}
if (typeof page.reference !== "string" || !validPageReference(page.reference)) issue(`${path}.reference`, "must be a valid wiki page reference");
else if (pageReferences.has(page.reference)) issue(`${path}.reference`, "is duplicated");
else pageReferences.add(page.reference);
if (typeof page.title !== "string" || !page.title.trim()) issue(`${path}.title`, "is required");
if (typeof page.markdown !== "string") issue(`${path}.markdown`, "must be a string");
if (!Array.isArray(page.tags) || !page.tags.every((tag) => typeof tag === "string")) {
issue(`${path}.tags`, "must be an array of strings");
}
if (page.attachments !== undefined && !Array.isArray(page.attachments)) {
issue(`${path}.attachments`, "must be an array");
}
const attachmentReferences = new Set();
for (const [attachmentIndex, attachment] of (Array.isArray(page.attachments) ?
page.attachments : []).entries()) {
const attachmentPath = `${path}.attachments[${attachmentIndex}]`;
if (!attachment || typeof attachment !== "object" || Array.isArray(attachment)) {
issue(attachmentPath, "must be an object");
continue;
}
if (typeof attachment.url !== "string" || !attachment.url.startsWith("/uploads/")) {
issue(`${attachmentPath}.url`, "must be a local /uploads/ URL");
} else if (attachmentReferences.has(attachment.url)) {
issue(`${attachmentPath}.url`, "is duplicated within the page");
} else attachmentReferences.add(attachment.url);
if (typeof attachment.name !== "string" || !attachment.name.trim()) {
issue(`${attachmentPath}.name`, "is required");
}
if (typeof attachment.mimeType !== "string" || !attachment.mimeType.trim()) {
issue(`${attachmentPath}.mimeType`, "is required");
}
if (typeof attachment.contentBase64 !== "string" ||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(attachment.contentBase64)) {
issue(`${attachmentPath}.contentBase64`, "must be valid base64");
}
}
}
const explicitlyMissingPages = new Set(Array.isArray(bundle.missing?.pages) ? bundle.missing.pages : []);
for (const reference of linkedPageReferences) {
if (!pageReferences.has(reference) && !explicitlyMissingPages.has(reference)) {
issue(`pages[reference=${reference}]`, "is required by a linked concept or CMap explanation");
}
}
if (errors.length) {
const error = new Error(`Invalid CMap bundle:\n${errors.slice(0, 20).join("\n")}`);
error.validationErrors = errors;
throw error;
}
return bundle;
}
function preparedMapDocument(bundle, cmap) {
validateBundle(bundle);
const byId = new Map(bundle.concepts.map((concept) => [concept.id, concept]));
const documentValue = clone(cmap.document);
const ids = itemConceptIds(documentValue);
for (const reference of (Array.isArray(documentValue.concepts) ? documentValue.concepts : [])) {
if (reference?.id && !ids.includes(reference.id)) ids.push(reference.id);
}
documentValue.concepts = ids.map((id) => clone(byId.get(id)));
return documentValue;
}
export {
FORMAT,
FORMAT_VERSION,
SCHEMA,
attachmentUrls,
buildBundle,
decodedDocument,
placementDocument,
preparedMapDocument,
replaceAttachmentUrls,
validateBundle
};
File diff suppressed because it is too large Load Diff
+28 -9
View File
@@ -182,6 +182,14 @@ function positionIsProtected(start, end, ranges) {
return false;
}
/** Encode the initial WikiWord letter so a later render pass cannot link it. */
function literalWikiWord(namespace, wikiWord) {
const characters = Array.from(wikiWord);
const initial = `&#${characters[0].codePointAt(0)};`;
const prefix = namespace ? `${namespace}:` : "";
return `${prefix}${initial}${characters.slice(1).join("")}`;
}
/**
* goal : Rewrite namespaced Markdown targets to internal hash routes.
* pre : markdown is source text; fenced and indented code must remain untouched.
@@ -219,15 +227,18 @@ function expandNamespacedMarkdownLinks(markdown) {
/**
* goal : Expand classic WikiWords to temporary Markdown links.
* pre : pages, pageAliases and conceptMaps are current catalogues.
* post : Code, Todo markers, URLs and existing Markdown links remain unchanged.
* result : Render-only Markdown with WikiWord links.
* post : Code, Todo markers, URLs and existing Markdown links remain unchanged;
* an exclamation mark directly before a WikiWord suppresses its link.
* result : Render-only Markdown with WikiWord links. Literal WikiWords contain
* an equivalent HTML character reference and remain literal when the
* transformation is applied again.
*/
function expandWikiMentions(markdown, pages, pageAliases, conceptMaps, currentSlug = null) {
const aliases = pageMentionMap(pages, pageAliases, currentSlug);
const lines = String(markdown || "").split("\n");
const result = [];
let fence = null;
const wikiWordPattern = /(?<![\p{L}\p{N}._-])(?:([\p{L}\p{N}._-]+):)?((?:\p{Lu}\p{Ll}+){2,})(?![\p{L}\p{N}._-])/gu;
const wikiWordPattern = /(?<![\p{L}\p{N}._-])(!?)(?:([\p{L}\p{N}._-]+):)?((?:\p{Lu}\p{Ll}+){2,})(?![\p{L}\p{N}_-]|\.[\p{L}\p{N}_-])/gu;
for (const line of lines) {
const fenceMatch = line.match(/^\s*(```+|~~~+)/);
@@ -250,12 +261,20 @@ function expandWikiMentions(markdown, pages, pageAliases, conceptMaps, currentSl
const start = match.index;
const end = start + match[0].length;
if (positionIsProtected(start, end, protectedRanges)) continue;
const namespace = match[1] || "";
if (match[1] === "!") {
replacements.push({
start,
end,
text: literalWikiWord(match[2] || "", match[3])
});
continue;
}
const namespace = match[2] || "";
if (namespace.toLocaleLowerCase() === "cmap") {
const conceptMap = cmapMentionTarget(match[2], conceptMaps);
const conceptMap = cmapMentionTarget(match[3], conceptMaps);
if (conceptMap) replacements.push({ start, end, conceptMap });
} else {
const page = wikiWordTarget(match[2], aliases, namespace);
const page = wikiWordTarget(match[3], aliases, namespace);
if (page) replacements.push({ start, end, page });
}
}
@@ -263,10 +282,10 @@ function expandWikiMentions(markdown, pages, pageAliases, conceptMaps, currentSl
let expanded = line;
for (let index = replacements.length - 1; index >= 0; index -= 1) {
const replacement = replacements[index];
const link = replacement.conceptMap ?
const rendered = replacement.text || (replacement.conceptMap ?
`[${replacement.conceptMap.title}](${cmapRoute(replacement.conceptMap.slug)})` :
`[${replacement.page.title}](${pageRoute(replacement.page.slug)})`;
expanded = expanded.slice(0, replacement.start) + link + expanded.slice(replacement.end);
`[${replacement.page.title}](${pageRoute(replacement.page.slug)})`);
expanded = expanded.slice(0, replacement.start) + rendered + expanded.slice(replacement.end);
}
result.push(expanded);
}