refactoring
This commit is contained in:
+18
-4
@@ -9,9 +9,16 @@ racket-wiki. Its public additions include `onSelection` on a map and
|
||||
same hit-test and drag lifecycle as selection, so it does not depend on a DOM
|
||||
`dblclick` event that may be suppressed by dragging.
|
||||
|
||||
`cmap-racket-wiki.js` contains the wiki-specific editor model, selection state,
|
||||
content-based initial sizing, resize and relation controls, page concepts and
|
||||
submap concepts. Automatic sizing remains active while text is edited and is
|
||||
`model/concept-repository.js` owns shared concepts, semantic concept relations
|
||||
and concept ownership. `model/concept-map.js` owns one map's concept
|
||||
placements, linking phrases, connectors, metadata and presentation values.
|
||||
Neither model module contains DOM or drawing-engine objects.
|
||||
|
||||
`cmap-view.js` owns the canvas and the concrete `cmap.js` drawing instance.
|
||||
`cmap-racket-wiki.js` contains the wiki-specific editor controller, selection
|
||||
state, content-based initial sizing, resize and relation controls. Its view
|
||||
records connect pure model ids to drawing nodes without making those nodes part
|
||||
of the model. Automatic sizing remains active while text is edited and is
|
||||
disabled when the user resizes a concept manually.
|
||||
|
||||
Selected concepts expose an edit handle. The host opens a modal editor for
|
||||
@@ -42,6 +49,13 @@ Multiple selected items can be moved or deleted together. Grouping is a
|
||||
structural operation: the editor creates an expanded sub-CMap with a named main
|
||||
concept and adopts the selection as its contents. Ungrouping dissolves a
|
||||
selected inline sub-CMap or moves selected child items one level outward.
|
||||
**Make separate CMap** extracts the descendants into a complete stored CMap.
|
||||
The former sub-CMap head remains in the parent as an ordinary concept whose
|
||||
shared `cmapSlug` links to the new map. Ownership from that concept to the
|
||||
extracted concepts is removed; internal child connectors move with the child,
|
||||
while relations crossing the boundary terminate on the retained owner concept.
|
||||
The same action converts an older `derivedView` in place: its slug, title and
|
||||
metadata remain intact, but its document no longer depends on the parent CMap.
|
||||
|
||||
`toDocument` and `loadDocument` round-trip the complete editor model: items,
|
||||
formatting, positions, connectors, recursive submap membership and promoted
|
||||
@@ -49,7 +63,7 @@ map references. The wiki host persists this JSON document through its CMap API.
|
||||
|
||||
## JSON interchange
|
||||
|
||||
`/js/cmap-interchange.js` implements the versioned `racket-wiki-cmap-bundle`
|
||||
`/js/wiki/cmap/interchange.js` implements the versioned `racket-wiki-cmap-bundle`
|
||||
format. A bundle mirrors the normalized database model: `cmaps[]` contains the
|
||||
complete placement and presentation document, `concepts[]` contains shared
|
||||
content once per UUID, and `pages[]` contains the current Markdown, tags and
|
||||
|
||||
+270
-269
@@ -1,3 +1,13 @@
|
||||
import {
|
||||
CmapModel,
|
||||
ConceptMapConcept,
|
||||
ConceptMapConnector,
|
||||
ConceptMapPhrase,
|
||||
PLACEMENT_FIELDS
|
||||
} from "./model/concept-map.js";
|
||||
import { CONCEPT_FIELDS } from "./model/concept-repository.js";
|
||||
import { CmapView } from "./cmap-view.js";
|
||||
|
||||
/*
|
||||
* Racket Wiki editor layer for the bundled racket-wiki CMap component.
|
||||
*
|
||||
@@ -152,14 +162,18 @@
|
||||
resizeConcept: options.resizeConceptLabel || "Resize concept",
|
||||
relation: options.relationLabel || "Relation"
|
||||
};
|
||||
this.map = this.CmapFactory(canvas);
|
||||
this.map.onSelection((component, event) => this.handleMapSelection(component, event));
|
||||
this.map.onActivation((component, event) => this.handleMapActivation(component, event));
|
||||
this.view = new CmapView(
|
||||
canvas,
|
||||
this.CmapFactory,
|
||||
(component, event) => this.handleMapSelection(component, event),
|
||||
(component, event) => this.handleMapActivation(component, event));
|
||||
this.map = this.view.map;
|
||||
this.items = [];
|
||||
this.connectors = [];
|
||||
this.unresolvedConnectors = [];
|
||||
this.conceptMaps = new Map();
|
||||
this.documentMetadata = { tags: [], summary: "", explanationPageSlug: "" };
|
||||
this.model = CmapModel.fromDocument({});
|
||||
this.conceptMaps = this.model.conceptMap.conceptMapsById;
|
||||
this.documentMetadata = this.model.conceptMap.metadata;
|
||||
this.activeMapRoot = null;
|
||||
this.mapHistory = [];
|
||||
this.selectedItem = null;
|
||||
@@ -190,6 +204,229 @@
|
||||
});
|
||||
}
|
||||
|
||||
/** Return the view record that renders one model item id. */
|
||||
itemRecord(id) {
|
||||
return this.items.find((record) => Number(record.id) === Number(id)) || null;
|
||||
}
|
||||
|
||||
/** Return a copy of the records currently rendered by this editor. */
|
||||
itemRecords() {
|
||||
return [...this.items];
|
||||
}
|
||||
|
||||
/** Return rendered records that are visible in the active map context. */
|
||||
visibleItemRecords() {
|
||||
return this.items.filter((record) => this.isEffectiveItemVisible(record));
|
||||
}
|
||||
|
||||
containsItemRecord(record) {
|
||||
return this.items.includes(record);
|
||||
}
|
||||
|
||||
descendantItemRecords(record) {
|
||||
return this.items.filter((candidate) => this.isDescendantOf(candidate, record));
|
||||
}
|
||||
|
||||
setConceptMapReference(reference) {
|
||||
if (!reference?.id) throw new TypeError("A concept-map reference id is required");
|
||||
this.conceptMaps.set(reference.id, reference);
|
||||
return reference;
|
||||
}
|
||||
|
||||
itemCount() {
|
||||
return this.items.length;
|
||||
}
|
||||
|
||||
connectorCount() {
|
||||
return this.connectors.length + this.unresolvedConnectors.length;
|
||||
}
|
||||
|
||||
conceptRepository() {
|
||||
return this.model.repository;
|
||||
}
|
||||
|
||||
conceptMapModel() {
|
||||
return this.model.conceptMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the domain data behind a view record.
|
||||
* The model receives only serializable concept and placement fields.
|
||||
*/
|
||||
registerModelItem(record) {
|
||||
const existing = this.model.conceptMap.item(record.id);
|
||||
if (existing) return existing;
|
||||
const values = { kind: record.kind };
|
||||
for (const field of PLACEMENT_FIELDS) {
|
||||
if (field === "parentSubmapId") {
|
||||
values.parentSubmapId = record.parentSubmap ? record.parentSubmap.id : null;
|
||||
} else if (field === "hiddenContexts") {
|
||||
values.hiddenContexts = Array.from(record.hiddenContexts || []);
|
||||
} else if (field === "x" || field === "y" || field === "width" || field === "height") {
|
||||
values[field] = record.node ? Number(record.node.attr(field)) : Number(record[field]);
|
||||
} else {
|
||||
values[field] = record[field];
|
||||
}
|
||||
}
|
||||
let modelItem;
|
||||
if (record.kind === "phrase") {
|
||||
modelItem = new ConceptMapPhrase(record.id, {
|
||||
...values,
|
||||
label: record.label,
|
||||
synopsis: record.synopsis
|
||||
});
|
||||
} else {
|
||||
const conceptValues = {};
|
||||
for (const field of CONCEPT_FIELDS) conceptValues[field] = record[field];
|
||||
this.model.repository.ensure(record.conceptId, conceptValues);
|
||||
modelItem = new ConceptMapConcept(record.id, record.conceptId, values);
|
||||
}
|
||||
this.model.conceptMap.addItem(modelItem);
|
||||
return modelItem;
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an existing editor record a view onto its pure model item.
|
||||
* DOM handles stay on the record; all serializable fields live in the model.
|
||||
*/
|
||||
bindRecordToModel(record, modelItem) {
|
||||
if (record.modelItem === modelItem) return record;
|
||||
Object.defineProperty(record, "modelItem", { value: modelItem, configurable: true });
|
||||
Object.defineProperty(record, "kind", {
|
||||
configurable: true,
|
||||
get: () => modelItem.kind,
|
||||
set: (value) => { modelItem.kind = value; }
|
||||
});
|
||||
Object.defineProperty(record, "conceptId", {
|
||||
configurable: true,
|
||||
get: () => modelItem.conceptId || null,
|
||||
set: (conceptId) => {
|
||||
if (modelItem instanceof ConceptMapPhrase || !conceptId) return;
|
||||
const current = this.model.repository.concept(modelItem.conceptId);
|
||||
const target = this.model.repository.concept(conceptId) ||
|
||||
this.model.repository.ensure(conceptId, current ? current.toDocument() : {});
|
||||
modelItem.conceptId = target.id;
|
||||
}
|
||||
});
|
||||
for (const field of ["label", "synopsis"]) {
|
||||
Object.defineProperty(record, field, {
|
||||
configurable: true,
|
||||
get: () => modelItem instanceof ConceptMapPhrase ? modelItem[field] :
|
||||
this.model.repository.requireConcept(modelItem.conceptId)[field],
|
||||
set: (value) => {
|
||||
if (modelItem instanceof ConceptMapPhrase) modelItem[field] = String(value || "");
|
||||
else this.model.repository.requireConcept(modelItem.conceptId).update({ [field]: value });
|
||||
}
|
||||
});
|
||||
}
|
||||
for (const field of CONCEPT_FIELDS.filter((name) => name !== "label" && name !== "synopsis")) {
|
||||
Object.defineProperty(record, field, {
|
||||
configurable: true,
|
||||
get: () => modelItem instanceof ConceptMapPhrase ? null :
|
||||
this.model.repository.requireConcept(modelItem.conceptId)[field],
|
||||
set: (value) => {
|
||||
if (!(modelItem instanceof ConceptMapPhrase)) {
|
||||
this.model.repository.requireConcept(modelItem.conceptId).update({ [field]: value });
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
for (const field of PLACEMENT_FIELDS.filter((name) =>
|
||||
name !== "parentSubmapId" && name !== "x" && name !== "y")) {
|
||||
Object.defineProperty(record, field, {
|
||||
configurable: true,
|
||||
get: () => modelItem[field],
|
||||
set: (value) => { modelItem[field] = value; }
|
||||
});
|
||||
}
|
||||
Object.defineProperty(record, "parentSubmap", {
|
||||
configurable: true,
|
||||
get: () => modelItem.parentSubmapId === null ? null :
|
||||
this.itemRecord(modelItem.parentSubmapId),
|
||||
set: (value) => { modelItem.parentSubmapId = value ? Number(value.id) : null; }
|
||||
});
|
||||
return record;
|
||||
}
|
||||
|
||||
/** Register and bind a record that was inserted through the editor view. */
|
||||
attachRecordToModel(record) {
|
||||
return this.bindRecordToModel(record, this.registerModelItem(record));
|
||||
}
|
||||
|
||||
/** Bind a rendered connector to its map-local connector model. */
|
||||
attachConnectorToModel(record) {
|
||||
let modelConnector = this.model.conceptMap.connector(record.id);
|
||||
if (!modelConnector) {
|
||||
modelConnector = this.model.conceptMap.addConnector(new ConceptMapConnector(
|
||||
record.id, record.source.id, record.target.id, record));
|
||||
}
|
||||
Object.defineProperty(record, "modelConnector", {
|
||||
value: modelConnector,
|
||||
configurable: true
|
||||
});
|
||||
for (const field of ["hasArrow", "lineColor", "lineWidth"]) {
|
||||
Object.defineProperty(record, field, {
|
||||
configurable: true,
|
||||
get: () => modelConnector[field],
|
||||
set: (value) => { modelConnector[field] = value; }
|
||||
});
|
||||
}
|
||||
for (const [field, idField] of [["source", "sourceId"], ["target", "targetId"]]) {
|
||||
Object.defineProperty(record, field, {
|
||||
configurable: true,
|
||||
get: () => this.itemRecord(modelConnector[idField]),
|
||||
set: (value) => { modelConnector[idField] = Number(value.id); }
|
||||
});
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
/** Keep the pure model synchronized with geometry owned by the drawing engine. */
|
||||
synchronizeModel() {
|
||||
const itemIds = new Set(this.items.map((record) => Number(record.id)));
|
||||
for (const modelItem of this.model.conceptMap.items()) {
|
||||
if (!itemIds.has(modelItem.id)) this.model.conceptMap.removeItem(modelItem.id);
|
||||
}
|
||||
for (const record of this.items) {
|
||||
const modelItem = this.registerModelItem(record);
|
||||
this.bindRecordToModel(record, modelItem);
|
||||
if (record.node) {
|
||||
modelItem.x = Number(record.node.attr("x"));
|
||||
modelItem.y = Number(record.node.attr("y"));
|
||||
modelItem.width = Number(record.node.attr("width"));
|
||||
modelItem.height = Number(record.node.attr("height"));
|
||||
}
|
||||
}
|
||||
const connectorIds = new Set();
|
||||
for (const connector of this.connectors) {
|
||||
connectorIds.add(Number(connector.id));
|
||||
let modelConnector = this.model.conceptMap.connector(connector.id);
|
||||
if (!modelConnector) {
|
||||
modelConnector = this.model.conceptMap.addConnector(new ConceptMapConnector(
|
||||
connector.id, connector.source.id, connector.target.id, connector));
|
||||
}
|
||||
modelConnector.sourceId = Number(connector.source.id);
|
||||
modelConnector.targetId = Number(connector.target.id);
|
||||
modelConnector.hasArrow = connector.hasArrow;
|
||||
modelConnector.lineColor = connector.lineColor;
|
||||
modelConnector.lineWidth = connector.lineWidth;
|
||||
}
|
||||
for (const connector of this.unresolvedConnectors) {
|
||||
connectorIds.add(Number(connector.id));
|
||||
if (!this.model.conceptMap.connector(connector.id)) {
|
||||
this.model.conceptMap.addConnector(new ConceptMapConnector(
|
||||
connector.id, connector.sourceId, connector.targetId, connector));
|
||||
}
|
||||
}
|
||||
for (const connector of this.model.conceptMap.connectors()) {
|
||||
if (!connectorIds.has(connector.id)) this.model.conceptMap.removeConnector(connector.id);
|
||||
}
|
||||
this.model.conceptMap.metadata = this.documentMetadata;
|
||||
this.model.conceptMap.setConceptMapReferences([...this.conceptMaps.values()]);
|
||||
this.conceptMaps = this.model.conceptMap.conceptMapsById;
|
||||
return this.model;
|
||||
}
|
||||
|
||||
historySnapshot() {
|
||||
return JSON.stringify(this.toDocument());
|
||||
}
|
||||
@@ -266,8 +503,9 @@
|
||||
this.items = [];
|
||||
this.connectors = [];
|
||||
this.unresolvedConnectors = [];
|
||||
this.conceptMaps = new Map();
|
||||
this.documentMetadata = { tags: [], summary: "", explanationPageSlug: "" };
|
||||
this.model = CmapModel.fromDocument({});
|
||||
this.conceptMaps = this.model.conceptMap.conceptMapsById;
|
||||
this.documentMetadata = this.model.conceptMap.metadata;
|
||||
this.activeMapRoot = null;
|
||||
this.mapHistory = [];
|
||||
this.nextId = 1;
|
||||
@@ -423,7 +661,7 @@
|
||||
this.items.filter((item) => item.conceptId === record.conceptId &&
|
||||
item.kind !== "phrase").length + 1 : null;
|
||||
|
||||
const node = this.map.node({
|
||||
const node = this.view.createNode({
|
||||
content: this.itemHtml(record),
|
||||
contentType: "html",
|
||||
x: numberOr(options.x, 80 + ((id * 37) % 420)),
|
||||
@@ -437,6 +675,7 @@
|
||||
});
|
||||
record.node = node;
|
||||
this.items.push(record);
|
||||
this.attachRecordToModel(record);
|
||||
this.refreshConceptUsageIndicators(record.conceptId ? [record.conceptId] : []);
|
||||
this.refreshConceptMapReferences();
|
||||
node.onRendered((_renderedNode, element) => this.decorateItem(record, element));
|
||||
@@ -885,177 +1124,10 @@
|
||||
return record.mapReference;
|
||||
}
|
||||
|
||||
prepareStoredSubmapExtraction(record, targetSlug) {
|
||||
console.warn(`${debugPrefix} destructive submap extraction is disabled; use a shared derived view`, {
|
||||
itemId: record ? record.id : null,
|
||||
targetSlug
|
||||
});
|
||||
return null;
|
||||
/* istanbul ignore next -- retained only to read historical documents */
|
||||
if (!record || record.kind !== "submap" || !targetSlug) return null;
|
||||
prepareStoredSubmapExtraction(record, targetSlug, childMetadata = null) {
|
||||
if (!record || record.kind !== "submap") return null;
|
||||
this.ensureSubmapContents(record);
|
||||
const sourceDocument = this.toDocument();
|
||||
const descendants = this.items.filter((item) => this.isDescendantOf(item, record));
|
||||
const descendantIds = new Set(descendants.map((item) => item.id));
|
||||
const allItemIds = new Set(this.items.map((item) => item.id));
|
||||
if (!descendantIds.size) return null;
|
||||
|
||||
const descendantDocuments = sourceDocument.items.filter((item) => descendantIds.has(item.id));
|
||||
const headSource = sourceDocument.items.find((item) => Number(item.id) === Number(record.id));
|
||||
if (!headSource) return null;
|
||||
const scopedDocuments = [headSource, ...descendantDocuments];
|
||||
const left = Math.min(...scopedDocuments.map((item) => Number(item.x) || 0));
|
||||
const top = Math.min(...scopedDocuments.map((item) => Number(item.y) || 0));
|
||||
const placeInChild = (item, parentSubmapId, submapDepth) => {
|
||||
const x = (Number(item.x) || 0) - left + 80;
|
||||
const y = (Number(item.y) || 0) - top + 80;
|
||||
const rootLayout = item.layouts && item.layouts.root ? item.layouts.root : {};
|
||||
return {
|
||||
...item,
|
||||
parentSubmapId,
|
||||
submapDepth,
|
||||
x,
|
||||
y,
|
||||
layouts: {
|
||||
...(item.layouts || {}),
|
||||
root: {
|
||||
...rootLayout,
|
||||
x,
|
||||
y,
|
||||
width: Number(item.width),
|
||||
height: Number(item.height)
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
const childHead = {
|
||||
...placeInChild(headSource, null, 0),
|
||||
kind: "concept",
|
||||
childMap: null,
|
||||
expanded: false,
|
||||
submapInitialized: false,
|
||||
separateMap: false,
|
||||
mapReference: null,
|
||||
pageSlug: null,
|
||||
// The placement in the parent links to this map. The head on the
|
||||
// child itself must not be a link back to the same map.
|
||||
cmapSlug: null,
|
||||
parentCmapLink: false
|
||||
};
|
||||
const childItems = [childHead, ...descendantDocuments.map((item) =>
|
||||
placeInChild(
|
||||
item,
|
||||
Number(item.parentSubmapId) === Number(record.id) ? null : item.parentSubmapId,
|
||||
Math.max(0, Number(item.submapDepth || 0) - record.submapDepth - 1)
|
||||
))];
|
||||
|
||||
const parentConnectors = [];
|
||||
const childConnectors = [];
|
||||
const parentConnectorKeys = new Set();
|
||||
for (const connector of sourceDocument.connectors) {
|
||||
const sourceInside = descendantIds.has(Number(connector.sourceId));
|
||||
const targetInside = descendantIds.has(Number(connector.targetId));
|
||||
const sourceIsHead = Number(connector.sourceId) === Number(record.id);
|
||||
const targetIsHead = Number(connector.targetId) === Number(record.id);
|
||||
if ((sourceInside && targetInside) ||
|
||||
(sourceIsHead && targetInside) ||
|
||||
(sourceInside && targetIsHead)) {
|
||||
childConnectors.push({ ...connector });
|
||||
continue;
|
||||
}
|
||||
if (sourceInside || targetInside) {
|
||||
const outsideId = Number(sourceInside ? connector.targetId : connector.sourceId);
|
||||
if (!allItemIds.has(outsideId)) {
|
||||
childConnectors.push({ ...connector });
|
||||
continue;
|
||||
}
|
||||
const rewired = {
|
||||
...connector,
|
||||
sourceId: sourceInside ? record.id : connector.sourceId,
|
||||
targetId: targetInside ? record.id : connector.targetId
|
||||
};
|
||||
if (Number(rewired.sourceId) === Number(rewired.targetId)) continue;
|
||||
const key = `${rewired.sourceId}\u0000${rewired.targetId}\u0000${rewired.hasArrow !== false}`;
|
||||
if (parentConnectorKeys.has(key)) continue;
|
||||
parentConnectorKeys.add(key);
|
||||
parentConnectors.push(rewired);
|
||||
continue;
|
||||
}
|
||||
const key = `${connector.sourceId}\u0000${connector.targetId}\u0000${connector.hasArrow !== false}`;
|
||||
if (!parentConnectorKeys.has(key)) {
|
||||
parentConnectorKeys.add(key);
|
||||
parentConnectors.push({ ...connector });
|
||||
}
|
||||
}
|
||||
|
||||
const childConceptIds = new Set(childItems.map((item) => item.conceptId).filter(Boolean));
|
||||
const parentItems = sourceDocument.items
|
||||
.filter((item) => !descendantIds.has(item.id))
|
||||
.map((item) => item.id === record.id ? {
|
||||
...item,
|
||||
kind: "concept",
|
||||
childMap: null,
|
||||
expanded: false,
|
||||
submapInitialized: false,
|
||||
separateMap: false,
|
||||
mapReference: null,
|
||||
cmapSlug: targetSlug,
|
||||
parentCmapLink: false,
|
||||
borderColor: "#57834a"
|
||||
} : item);
|
||||
const parentConceptIds = new Set(parentItems.map((item) => item.conceptId).filter(Boolean));
|
||||
const conceptById = new Map(sourceDocument.concepts.map((concept) => [concept.id, concept]));
|
||||
const childConceptById = new Map(conceptById);
|
||||
const parentConceptById = new Map(conceptById);
|
||||
const headDocument = parentItems.find((item) => item.id === record.id);
|
||||
if (headDocument && headDocument.conceptId) {
|
||||
parentConceptById.set(headDocument.conceptId, {
|
||||
id: headDocument.conceptId,
|
||||
kind: "concept",
|
||||
label: headDocument.label,
|
||||
synopsis: headDocument.synopsis,
|
||||
aspects: headDocument.aspects || [],
|
||||
descriptionPageSlug: headDocument.descriptionPageSlug,
|
||||
pageSlug: null,
|
||||
cmapSlug: targetSlug,
|
||||
externalUrl: headDocument.externalUrl || null,
|
||||
parentCmapLink: false,
|
||||
imageSource: headDocument.imageSource || ""
|
||||
});
|
||||
childConceptById.set(headDocument.conceptId, {
|
||||
id: headDocument.conceptId,
|
||||
kind: "concept",
|
||||
label: childHead.label,
|
||||
synopsis: childHead.synopsis,
|
||||
aspects: childHead.aspects || [],
|
||||
descriptionPageSlug: childHead.descriptionPageSlug,
|
||||
pageSlug: null,
|
||||
cmapSlug: null,
|
||||
externalUrl: childHead.externalUrl || null,
|
||||
parentCmapLink: false,
|
||||
imageSource: childHead.imageSource || ""
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
childDocument: {
|
||||
schemaVersion: 2,
|
||||
concepts: Array.from(childConceptById.values()).filter((concept) => childConceptIds.has(concept.id)),
|
||||
items: childItems,
|
||||
connectors: childConnectors,
|
||||
conceptMaps: sourceDocument.conceptMaps.filter((reference) =>
|
||||
descendantIds.has(Number(reference.rootItemId)))
|
||||
},
|
||||
parentDocument: {
|
||||
...sourceDocument,
|
||||
concepts: Array.from(parentConceptById.values()).filter((concept) => parentConceptIds.has(concept.id)),
|
||||
items: parentItems,
|
||||
connectors: parentConnectors,
|
||||
conceptMaps: sourceDocument.conceptMaps.filter((reference) =>
|
||||
Number(reference.rootItemId) !== record.id &&
|
||||
!descendantIds.has(Number(reference.rootItemId)))
|
||||
}
|
||||
};
|
||||
return this.synchronizeModel().extractSubmap(record.id, targetSlug, childMetadata);
|
||||
}
|
||||
|
||||
replaceDocument(document) {
|
||||
@@ -1084,7 +1156,7 @@
|
||||
setZoom(percent) {
|
||||
const next = Math.max(25, Math.min(300, Number(percent) || 100));
|
||||
this.zoomFactor = next / 100;
|
||||
this.map.zoom(this.zoomFactor);
|
||||
this.view.setZoom(this.zoomFactor);
|
||||
this.ensureCanvasExtent(0, 0);
|
||||
debug("zoom changed", { percent: next, factor: this.zoomFactor });
|
||||
return next;
|
||||
@@ -1095,7 +1167,7 @@
|
||||
}
|
||||
|
||||
surfaceElement() {
|
||||
return this.canvas.querySelector(":scope > .rw-cmap-surface");
|
||||
return this.view.surfaceElement();
|
||||
}
|
||||
|
||||
ensureCanvasExtent(x, y, padding = 180) {
|
||||
@@ -1228,7 +1300,7 @@
|
||||
addConnector(source, target, hasArrow = true, options = {}) {
|
||||
const sourceCenter = this.itemCenter(source);
|
||||
const targetCenter = this.itemCenter(target);
|
||||
const link = this.map.link({
|
||||
const link = this.view.createConnector({
|
||||
content: "",
|
||||
width: 1,
|
||||
height: 1,
|
||||
@@ -1262,6 +1334,7 @@
|
||||
lineWidth: numberOr(Number(options.lineWidth), 2)
|
||||
};
|
||||
this.nextConnectorId = Math.max(this.nextConnectorId, record.id + 1);
|
||||
this.attachConnectorToModel(record);
|
||||
this.connectors.push(record);
|
||||
link.onRendered((_renderedLink, element) => this.decorateConnector(record, element));
|
||||
link.onConnectionChange((_changedLink, type, node) =>
|
||||
@@ -1782,103 +1855,31 @@
|
||||
summary: String(metadata.summary || "").trim(),
|
||||
explanationPageSlug: String(metadata.explanationPageSlug || "").trim()
|
||||
};
|
||||
this.model.conceptMap.metadata = this.documentMetadata;
|
||||
return this.getDocumentMetadata();
|
||||
}
|
||||
|
||||
/** Serialize the pure repository and map model at the API boundary. */
|
||||
toDocument() {
|
||||
this.saveCurrentContextLayout();
|
||||
this.refreshConceptMapReferences();
|
||||
const concepts = Array.from(new Map(this.items
|
||||
.filter((record) => record.conceptId && record.kind !== "phrase")
|
||||
.map((record) => [record.conceptId, {
|
||||
id: record.conceptId,
|
||||
label: record.label,
|
||||
synopsis: record.synopsis,
|
||||
aspects: record.aspects,
|
||||
tags: normalizeConceptTags(record.tags).filter((tag) => tag.type === "person"),
|
||||
descriptionPageSlug: record.descriptionPageSlug,
|
||||
pageSlug: record.pageSlug,
|
||||
cmapSlug: record.cmapSlug,
|
||||
externalUrl: record.externalUrl,
|
||||
imageSource: record.imageSource
|
||||
}])).values());
|
||||
return {
|
||||
schemaVersion: 2,
|
||||
metadata: this.getDocumentMetadata(),
|
||||
concepts,
|
||||
items: this.items.map((record) => ({
|
||||
id: record.id,
|
||||
conceptId: record.conceptId,
|
||||
kind: record.kind,
|
||||
...(record.kind === "phrase" ? {
|
||||
label: record.label,
|
||||
synopsis: record.synopsis
|
||||
} : {}),
|
||||
parentCmapLink: record.parentCmapLink,
|
||||
groupId: record.groupId,
|
||||
childMap: record.childMap,
|
||||
parentSubmapId: record.parentSubmap ? record.parentSubmap.id : null,
|
||||
submapDepth: record.submapDepth,
|
||||
expanded: record.expanded,
|
||||
submapInitialized: record.submapInitialized,
|
||||
separateMap: record.separateMap,
|
||||
mapReference: record.mapReference,
|
||||
hiddenContexts: Array.from(record.hiddenContexts),
|
||||
layouts: record.layouts,
|
||||
backgroundColor: record.backgroundColor,
|
||||
borderColor: record.borderColor,
|
||||
submapBackgroundColor: record.submapBackgroundColor,
|
||||
submapBorderColor: record.submapBorderColor,
|
||||
textColor: record.textColor,
|
||||
fontFamily: record.fontFamily,
|
||||
fontSize: record.fontSize,
|
||||
fontWeight: record.fontWeight,
|
||||
fontStyle: record.fontStyle,
|
||||
synopsisTextColor: record.synopsisTextColor,
|
||||
synopsisFontFamily: record.synopsisFontFamily,
|
||||
synopsisFontSize: record.synopsisFontSize,
|
||||
synopsisFontWeight: record.synopsisFontWeight,
|
||||
synopsisFontStyle: record.synopsisFontStyle,
|
||||
width: Number(record.node.attr("width")),
|
||||
height: Number(record.node.attr("height")),
|
||||
autoWidth: record.autoWidth,
|
||||
autoHeight: record.autoHeight,
|
||||
x: Number(record.node.attr("x")),
|
||||
y: Number(record.node.attr("y"))
|
||||
})),
|
||||
connectors: this.connectors.map((connector) => ({
|
||||
id: connector.id,
|
||||
sourceId: connector.source.id,
|
||||
targetId: connector.target.id,
|
||||
hasArrow: connector.hasArrow,
|
||||
lineColor: connector.lineColor,
|
||||
lineWidth: connector.lineWidth
|
||||
})).concat(this.unresolvedConnectors.map((connector) => ({ ...connector }))),
|
||||
conceptMaps: Array.from(this.conceptMaps.values())
|
||||
};
|
||||
return this.synchronizeModel().toDocument();
|
||||
}
|
||||
|
||||
loadDocument(document = {}) {
|
||||
if (this.items.length || this.connectors.length || this.unresolvedConnectors.length) {
|
||||
throw new Error("A concept map document can only be loaded into an empty editor");
|
||||
}
|
||||
const itemDocuments = Array.isArray(document.items) ? document.items : [];
|
||||
const connectorDocuments = Array.isArray(document.connectors) ? document.connectors : [];
|
||||
const concepts = new Map((Array.isArray(document.concepts) ? document.concepts : [])
|
||||
.filter((concept) => concept && concept.id)
|
||||
.map((concept) => [concept.id, concept]));
|
||||
this.model = CmapModel.fromDocument(document);
|
||||
this.conceptMaps = this.model.conceptMap.conceptMapsById;
|
||||
this.documentMetadata = this.model.conceptMap.metadata;
|
||||
const itemDocuments = this.model.conceptMap.items().map((item) => item.toDocument());
|
||||
const connectorDocuments = this.model.conceptMap.connectors()
|
||||
.map((connector) => connector.toDocument());
|
||||
const records = new Map();
|
||||
const metadata = document.metadata && typeof document.metadata === "object" ?
|
||||
document.metadata : {};
|
||||
this.documentMetadata = {
|
||||
tags: Array.isArray(metadata.tags) ? metadata.tags.map(String)
|
||||
.map((tag) => tag.trim()).filter(Boolean) : [],
|
||||
summary: String(metadata.summary || "").trim(),
|
||||
explanationPageSlug: String(metadata.explanationPageSlug || "").trim()
|
||||
};
|
||||
|
||||
for (const itemDocument of itemDocuments) {
|
||||
const concept = concepts.get(itemDocument.conceptId) || {};
|
||||
const concept = this.model.repository.concept(itemDocument.conceptId)?.toDocument() || {};
|
||||
const record = this.addItem({
|
||||
...itemDocument,
|
||||
...concept,
|
||||
@@ -1924,10 +1925,6 @@
|
||||
}
|
||||
this.addConnector(source, target, connectorDocument.hasArrow !== false, connectorDocument);
|
||||
}
|
||||
this.conceptMaps = new Map(
|
||||
(Array.isArray(document.conceptMaps) ? document.conceptMaps : [])
|
||||
.filter((reference) => reference && reference.id)
|
||||
.map((reference) => [reference.id, reference]));
|
||||
this.reconcilePhraseMembership();
|
||||
this.refreshConceptMapReferences();
|
||||
this.applyCurrentContextLayout();
|
||||
@@ -2812,7 +2809,7 @@
|
||||
item.submapAnchorLineElement = null;
|
||||
}
|
||||
}
|
||||
if (this.map && typeof this.map.destroy === "function") this.map.destroy();
|
||||
this.view.destroy();
|
||||
debug("editor destroyed");
|
||||
}
|
||||
|
||||
@@ -2862,12 +2859,16 @@
|
||||
if (!records.size && !connectors.size) return false;
|
||||
|
||||
this.clearSelection(false);
|
||||
for (const connector of connectors) connector.link.remove();
|
||||
for (const connector of connectors) {
|
||||
connector.link.remove();
|
||||
this.model.conceptMap.removeConnector(connector.id);
|
||||
}
|
||||
this.connectors = this.connectors.filter((connector) => !connectors.has(connector));
|
||||
for (const record of records) {
|
||||
if (record.submapFrameElement) record.submapFrameElement.remove();
|
||||
if (record.mapReference && record.mapReference.id) this.conceptMaps.delete(record.mapReference.id);
|
||||
record.node.remove();
|
||||
this.model.conceptMap.removeItem(record.id);
|
||||
}
|
||||
if (records.size && this.unresolvedConnectors.length) {
|
||||
const deletedIds = new Set(Array.from(records).map((record) => Number(record.id)));
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Present a CMap through the bundled drawing engine.
|
||||
*
|
||||
* CmapView owns the canvas and concrete cmap.js instance. It creates and
|
||||
* destroys drawing objects but contains no concept, placement or persistence
|
||||
* rules; CmapEditor remains responsible for interpreting user interaction.
|
||||
*/
|
||||
export class CmapView {
|
||||
constructor(canvas, CmapFactory, onSelection, onActivation) {
|
||||
if (!(canvas instanceof Object)) throw new TypeError("A CMap canvas is required");
|
||||
if (typeof CmapFactory !== "function") throw new TypeError("A CMap factory is required");
|
||||
this.canvas = canvas;
|
||||
this.map = CmapFactory(canvas);
|
||||
this.map.onSelection(onSelection);
|
||||
this.map.onActivation(onActivation);
|
||||
}
|
||||
|
||||
createNode(attributes) {
|
||||
return this.map.node(attributes);
|
||||
}
|
||||
|
||||
createConnector(attributes) {
|
||||
return this.map.link(attributes);
|
||||
}
|
||||
|
||||
setZoom(factor) {
|
||||
this.map.zoom(factor);
|
||||
}
|
||||
|
||||
surfaceElement() {
|
||||
return this.canvas.querySelector(":scope > .rw-cmap-surface");
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (typeof this.map.destroy === "function") this.map.destroy();
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,11 @@ body.cmap-mode #main {
|
||||
min-width: 16px;
|
||||
}
|
||||
|
||||
#cmap-extract-selected {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cmap-hidden-items {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
@@ -466,6 +471,10 @@ body.cmap-mode #main {
|
||||
z-index: 100000;
|
||||
display: grid;
|
||||
min-width: 210px;
|
||||
max-height: calc(100vh - 16px);
|
||||
max-height: calc(100dvh - 16px);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
padding: 5px;
|
||||
border: 1px solid #8c95a3;
|
||||
border-radius: 6px;
|
||||
|
||||
@@ -0,0 +1,422 @@
|
||||
import { ConceptRepository } from "./concept-repository.js";
|
||||
|
||||
const PLACEMENT_FIELDS = [
|
||||
"parentCmapLink", "groupId", "childMap", "parentSubmapId", "submapDepth",
|
||||
"expanded", "submapInitialized", "separateMap", "mapReference",
|
||||
"hiddenContexts", "layouts", "backgroundColor", "borderColor",
|
||||
"submapBackgroundColor", "submapBorderColor", "textColor", "fontFamily",
|
||||
"fontSize", "fontWeight", "fontStyle", "synopsisTextColor",
|
||||
"synopsisFontFamily", "synopsisFontSize", "synopsisFontWeight",
|
||||
"synopsisFontStyle", "width", "height", "autoWidth", "autoHeight", "x", "y"
|
||||
];
|
||||
|
||||
function copy(value) {
|
||||
return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
/** Join one repository concept to its placement and appearance on a CMap. */
|
||||
export class ConceptMapConcept {
|
||||
constructor(id, conceptId, values = {}) {
|
||||
if (!Number.isInteger(Number(id)) || !conceptId) {
|
||||
throw new TypeError("A concept placement requires an integer id and concept id");
|
||||
}
|
||||
this.id = Number(id);
|
||||
this.conceptId = String(conceptId);
|
||||
this.kind = values.kind || "concept";
|
||||
this.update(values);
|
||||
}
|
||||
|
||||
update(values = {}) {
|
||||
for (const field of PLACEMENT_FIELDS) {
|
||||
if (values[field] !== undefined) this[field] = copy(values[field]);
|
||||
}
|
||||
this.parentCmapLink = Boolean(this.parentCmapLink);
|
||||
this.parentSubmapId = this.parentSubmapId === null || this.parentSubmapId === undefined ?
|
||||
null : Number(this.parentSubmapId);
|
||||
this.submapDepth = Number(this.submapDepth) || 0;
|
||||
this.expanded = Boolean(this.expanded);
|
||||
this.submapInitialized = Boolean(this.submapInitialized);
|
||||
this.separateMap = Boolean(this.separateMap);
|
||||
this.hiddenContexts = this.hiddenContexts instanceof Set ? this.hiddenContexts :
|
||||
new Set(Array.isArray(this.hiddenContexts) ? this.hiddenContexts.map(String) : []);
|
||||
this.layouts = this.layouts && typeof this.layouts === "object" ? this.layouts : {};
|
||||
this.autoWidth = Boolean(this.autoWidth);
|
||||
this.autoHeight = Boolean(this.autoHeight);
|
||||
return this;
|
||||
}
|
||||
|
||||
toDocument() {
|
||||
const document = { id: this.id, conceptId: this.conceptId, kind: this.kind };
|
||||
for (const field of PLACEMENT_FIELDS) {
|
||||
document[field] = field === "hiddenContexts" ? [...this.hiddenContexts] : copy(this[field]);
|
||||
}
|
||||
return document;
|
||||
}
|
||||
}
|
||||
|
||||
/** Represent a map-local linking phrase without creating a repository concept. */
|
||||
export class ConceptMapPhrase {
|
||||
constructor(id, values = {}) {
|
||||
if (!Number.isInteger(Number(id))) throw new TypeError("A phrase requires an integer id");
|
||||
this.id = Number(id);
|
||||
this.kind = "phrase";
|
||||
this.label = String(values.label || "?????");
|
||||
this.synopsis = String(values.synopsis || "");
|
||||
this.update(values);
|
||||
}
|
||||
|
||||
update(values = {}) {
|
||||
if (values.label !== undefined) this.label = String(values.label);
|
||||
if (values.synopsis !== undefined) this.synopsis = String(values.synopsis);
|
||||
for (const field of PLACEMENT_FIELDS) {
|
||||
if (values[field] !== undefined) this[field] = copy(values[field]);
|
||||
}
|
||||
this.parentSubmapId = this.parentSubmapId === null || this.parentSubmapId === undefined ?
|
||||
null : Number(this.parentSubmapId);
|
||||
this.hiddenContexts = this.hiddenContexts instanceof Set ? this.hiddenContexts :
|
||||
new Set(Array.isArray(this.hiddenContexts) ? this.hiddenContexts.map(String) : []);
|
||||
this.layouts = this.layouts && typeof this.layouts === "object" ? this.layouts : {};
|
||||
return this;
|
||||
}
|
||||
|
||||
toDocument() {
|
||||
const document = {
|
||||
id: this.id,
|
||||
conceptId: null,
|
||||
kind: "phrase",
|
||||
label: this.label,
|
||||
synopsis: this.synopsis
|
||||
};
|
||||
for (const field of PLACEMENT_FIELDS) {
|
||||
document[field] = field === "hiddenContexts" ? [...this.hiddenContexts] : copy(this[field]);
|
||||
}
|
||||
return document;
|
||||
}
|
||||
}
|
||||
|
||||
/** Store one rendered connection between two map-local item placements. */
|
||||
export class ConceptMapConnector {
|
||||
constructor(id, sourceId, targetId, values = {}) {
|
||||
if (!Number.isInteger(Number(id)) || !Number.isInteger(Number(sourceId)) ||
|
||||
!Number.isInteger(Number(targetId))) {
|
||||
throw new TypeError("A connector requires integer ids");
|
||||
}
|
||||
this.id = Number(id);
|
||||
this.sourceId = Number(sourceId);
|
||||
this.targetId = Number(targetId);
|
||||
this.relationId = values.relationId || null;
|
||||
this.hasArrow = values.hasArrow !== false;
|
||||
this.lineColor = values.lineColor || "#333";
|
||||
this.lineWidth = Number(values.lineWidth) || 2;
|
||||
}
|
||||
|
||||
toDocument() {
|
||||
return {
|
||||
id: this.id,
|
||||
sourceId: this.sourceId,
|
||||
targetId: this.targetId,
|
||||
...(this.relationId ? { relationId: this.relationId } : {}),
|
||||
hasArrow: this.hasArrow,
|
||||
lineColor: this.lineColor,
|
||||
lineWidth: this.lineWidth
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Own the placements, phrases, connectors and metadata of one CMap. */
|
||||
export class ConceptMap {
|
||||
constructor(values = {}) {
|
||||
this.schemaVersion = Number(values.schemaVersion) || 2;
|
||||
this.metadata = {
|
||||
tags: Array.isArray(values.metadata?.tags) ? values.metadata.tags.map(String) : [],
|
||||
summary: String(values.metadata?.summary || ""),
|
||||
explanationPageSlug: String(values.metadata?.explanationPageSlug || "")
|
||||
};
|
||||
this.derivedView = values.derivedView ? copy(values.derivedView) : null;
|
||||
this.itemsById = new Map();
|
||||
this.connectorsById = new Map();
|
||||
this.conceptMapsById = new Map();
|
||||
}
|
||||
|
||||
addItem(value) {
|
||||
const item = value instanceof ConceptMapConcept || value instanceof ConceptMapPhrase ? value :
|
||||
(value.kind === "phrase" ? new ConceptMapPhrase(value.id, value) :
|
||||
new ConceptMapConcept(value.id, value.conceptId, value));
|
||||
if (this.itemsById.has(item.id)) throw new Error(`Duplicate CMap item id: ${item.id}`);
|
||||
this.itemsById.set(item.id, item);
|
||||
return item;
|
||||
}
|
||||
|
||||
item(id) {
|
||||
return this.itemsById.get(Number(id)) || null;
|
||||
}
|
||||
|
||||
items() {
|
||||
return [...this.itemsById.values()];
|
||||
}
|
||||
|
||||
removeItem(id) {
|
||||
const itemId = Number(id);
|
||||
this.itemsById.delete(itemId);
|
||||
for (const [connectorId, connector] of this.connectorsById) {
|
||||
if (connector.sourceId === itemId || connector.targetId === itemId) {
|
||||
this.connectorsById.delete(connectorId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addConnector(value) {
|
||||
const connector = value instanceof ConceptMapConnector ? value : new ConceptMapConnector(
|
||||
value.id, value.sourceId, value.targetId, value);
|
||||
if (this.connectorsById.has(connector.id)) {
|
||||
throw new Error(`Duplicate CMap connector id: ${connector.id}`);
|
||||
}
|
||||
this.connectorsById.set(connector.id, connector);
|
||||
return connector;
|
||||
}
|
||||
|
||||
connector(id) {
|
||||
return this.connectorsById.get(Number(id)) || null;
|
||||
}
|
||||
|
||||
connectors() {
|
||||
return [...this.connectorsById.values()];
|
||||
}
|
||||
|
||||
removeConnector(id) {
|
||||
this.connectorsById.delete(Number(id));
|
||||
}
|
||||
|
||||
setConceptMapReferences(references = []) {
|
||||
this.conceptMapsById = new Map(references
|
||||
.filter((reference) => reference && reference.id)
|
||||
.map((reference) => [reference.id, copy(reference)]));
|
||||
}
|
||||
|
||||
toDocument() {
|
||||
const document = {
|
||||
schemaVersion: this.schemaVersion,
|
||||
metadata: copy(this.metadata),
|
||||
items: this.items().map((item) => item.toDocument()),
|
||||
connectors: this.connectors().map((connector) => connector.toDocument()),
|
||||
conceptMaps: [...this.conceptMapsById.values()].map(copy)
|
||||
};
|
||||
if (this.derivedView) document.derivedView = copy(this.derivedView);
|
||||
return document;
|
||||
}
|
||||
}
|
||||
|
||||
/** Combine a concept repository with one concrete concept-map document. */
|
||||
export class CmapModel {
|
||||
constructor(repository = new ConceptRepository(), conceptMap = new ConceptMap()) {
|
||||
this.repository = repository;
|
||||
this.conceptMap = conceptMap;
|
||||
}
|
||||
|
||||
static fromDocument(document = {}) {
|
||||
const repository = new ConceptRepository(
|
||||
Array.isArray(document.concepts) ? document.concepts : [],
|
||||
Array.isArray(document.conceptRelations) ? document.conceptRelations : [],
|
||||
Array.isArray(document.conceptOwnerships) ? document.conceptOwnerships : []);
|
||||
const conceptMap = new ConceptMap(document);
|
||||
for (const item of (Array.isArray(document.items) ? document.items : [])) {
|
||||
if (item.kind !== "phrase" && !repository.concept(item.conceptId)) {
|
||||
repository.add({ id: item.conceptId, label: item.label || "Concept" });
|
||||
}
|
||||
conceptMap.addItem(item);
|
||||
}
|
||||
for (const connector of (Array.isArray(document.connectors) ? document.connectors : [])) {
|
||||
conceptMap.addConnector(connector);
|
||||
}
|
||||
conceptMap.setConceptMapReferences(document.conceptMaps);
|
||||
return new CmapModel(repository, conceptMap);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split an embedded submap into a standalone map and its remaining parent.
|
||||
* The owner stays in the parent as an ordinary concept and links to targetSlug.
|
||||
* Descendant placements become the complete contents of the child map.
|
||||
*/
|
||||
extractSubmap(rootItemId, targetSlug = null, childMetadata = null) {
|
||||
const sourceDocument = this.toDocument();
|
||||
const rootId = Number(rootItemId);
|
||||
const rootItem = sourceDocument.items.find((item) => Number(item.id) === rootId);
|
||||
if (!rootItem || rootItem.kind !== "submap") {
|
||||
throw new TypeError("A submap item is required for extraction");
|
||||
}
|
||||
|
||||
const descendantIds = new Set();
|
||||
let foundDescendant = true;
|
||||
while (foundDescendant) {
|
||||
foundDescendant = false;
|
||||
for (const item of sourceDocument.items) {
|
||||
const parentId = Number(item.parentSubmapId);
|
||||
if (parentId !== rootId && !descendantIds.has(parentId)) continue;
|
||||
if (descendantIds.has(Number(item.id))) continue;
|
||||
descendantIds.add(Number(item.id));
|
||||
foundDescendant = true;
|
||||
}
|
||||
}
|
||||
const descendantItems = sourceDocument.items
|
||||
.filter((item) => descendantIds.has(Number(item.id)));
|
||||
if (!descendantItems.some((item) => item.kind !== "phrase")) {
|
||||
throw new Error("The submap has no concepts to extract");
|
||||
}
|
||||
const left = Math.min(...descendantItems.map((item) => Number(item.x) || 0));
|
||||
const top = Math.min(...descendantItems.map((item) => Number(item.y) || 0));
|
||||
const childItems = descendantItems.map((item) => {
|
||||
const x = (Number(item.x) || 0) - left + 80;
|
||||
const y = (Number(item.y) || 0) - top + 80;
|
||||
const parentSubmapId = Number(item.parentSubmapId) === rootId ? null : item.parentSubmapId;
|
||||
const submapDepth = Math.max(
|
||||
0, Number(item.submapDepth || 0) - Number(rootItem.submapDepth || 0) - 1);
|
||||
return {
|
||||
...item,
|
||||
parentSubmapId,
|
||||
submapDepth,
|
||||
x,
|
||||
y,
|
||||
layouts: {
|
||||
...(item.layouts || {}),
|
||||
root: {
|
||||
...(item.layouts?.root || {}),
|
||||
x,
|
||||
y,
|
||||
width: Number(item.width),
|
||||
height: Number(item.height)
|
||||
}
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
const parentItems = sourceDocument.items
|
||||
.filter((item) => !descendantIds.has(Number(item.id)))
|
||||
.map((item) => Number(item.id) === rootId ? {
|
||||
...item,
|
||||
kind: "concept",
|
||||
childMap: null,
|
||||
expanded: false,
|
||||
submapInitialized: false,
|
||||
separateMap: false,
|
||||
mapReference: null
|
||||
} : item);
|
||||
|
||||
const allItemIds = new Set(sourceDocument.items.map((item) => Number(item.id)));
|
||||
const parentConnectors = [];
|
||||
const parentConnectorKeys = new Set();
|
||||
const childConnectors = [];
|
||||
for (const connector of sourceDocument.connectors) {
|
||||
const sourceId = Number(connector.sourceId);
|
||||
const targetId = Number(connector.targetId);
|
||||
if (!allItemIds.has(sourceId) || !allItemIds.has(targetId)) continue;
|
||||
const sourceInside = descendantIds.has(sourceId);
|
||||
const targetInside = descendantIds.has(targetId);
|
||||
if (sourceInside && targetInside) {
|
||||
childConnectors.push({ ...connector });
|
||||
continue;
|
||||
}
|
||||
if (sourceInside || targetInside) {
|
||||
const rewired = {
|
||||
...connector,
|
||||
sourceId: sourceInside ? rootId : sourceId,
|
||||
targetId: targetInside ? rootId : targetId
|
||||
};
|
||||
if (rewired.sourceId === rewired.targetId) continue;
|
||||
const key = `${rewired.sourceId}\u0000${rewired.targetId}\u0000${rewired.hasArrow !== false}`;
|
||||
if (!parentConnectorKeys.has(key)) {
|
||||
parentConnectorKeys.add(key);
|
||||
parentConnectors.push(rewired);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const key = `${sourceId}\u0000${targetId}\u0000${connector.hasArrow !== false}`;
|
||||
if (!parentConnectorKeys.has(key)) {
|
||||
parentConnectorKeys.add(key);
|
||||
parentConnectors.push({ ...connector });
|
||||
}
|
||||
}
|
||||
|
||||
const conceptIdsFor = (items) => new Set(items
|
||||
.filter((item) => item.kind !== "phrase" && item.conceptId)
|
||||
.map((item) => String(item.conceptId)));
|
||||
const childConceptIds = conceptIdsFor(childItems);
|
||||
const parentConceptIds = conceptIdsFor(parentItems);
|
||||
const ownerConceptId = String(rootItem.conceptId);
|
||||
const conceptsFor = (ids) => sourceDocument.concepts
|
||||
.filter((concept) => ids.has(String(concept.id)))
|
||||
.map((concept) => ({ ...concept }));
|
||||
const parentConcepts = conceptsFor(parentConceptIds);
|
||||
if (targetSlug) {
|
||||
const ownerConcept = parentConcepts.find((concept) => concept.id === ownerConceptId);
|
||||
if (ownerConcept) ownerConcept.cmapSlug = String(targetSlug);
|
||||
}
|
||||
|
||||
const relationsFor = (ids) => (sourceDocument.conceptRelations || [])
|
||||
.filter((relation) => ids.has(String(relation.sourceConceptId)) &&
|
||||
ids.has(String(relation.targetConceptId)))
|
||||
.map((relation) => ({ ...relation }));
|
||||
const ownershipsFor = (ids) => (sourceDocument.conceptOwnerships || [])
|
||||
.filter((ownership) => {
|
||||
const parentId = String(ownership.parentConceptId);
|
||||
const childId = String(ownership.childConceptId);
|
||||
const extractedOwnership = parentId === ownerConceptId && childConceptIds.has(childId);
|
||||
return !extractedOwnership && ids.has(parentId) && ids.has(childId);
|
||||
})
|
||||
.map((ownership) => ({ ...ownership }));
|
||||
|
||||
const ownerConcept = sourceDocument.concepts
|
||||
.find((concept) => String(concept.id) === ownerConceptId) || {};
|
||||
const extractedMetadata = childMetadata ? copy(childMetadata) : {
|
||||
tags: Array.isArray(ownerConcept.aspects) ? [...ownerConcept.aspects] : [],
|
||||
summary: String(ownerConcept.synopsis || ""),
|
||||
explanationPageSlug: String(ownerConcept.descriptionPageSlug || "")
|
||||
};
|
||||
const childDocument = {
|
||||
schemaVersion: sourceDocument.schemaVersion,
|
||||
metadata: extractedMetadata,
|
||||
concepts: conceptsFor(childConceptIds),
|
||||
items: childItems,
|
||||
connectors: childConnectors,
|
||||
conceptMaps: sourceDocument.conceptMaps
|
||||
.filter((reference) => descendantIds.has(Number(reference.rootItemId)))
|
||||
};
|
||||
const childRelations = relationsFor(childConceptIds);
|
||||
const childOwnerships = ownershipsFor(childConceptIds);
|
||||
if (childRelations.length) childDocument.conceptRelations = childRelations;
|
||||
if (childOwnerships.length) childDocument.conceptOwnerships = childOwnerships;
|
||||
|
||||
const parentDocument = {
|
||||
...sourceDocument,
|
||||
concepts: parentConcepts,
|
||||
items: parentItems,
|
||||
connectors: parentConnectors,
|
||||
conceptMaps: sourceDocument.conceptMaps.filter((reference) =>
|
||||
Number(reference.rootItemId) !== rootId &&
|
||||
!descendantIds.has(Number(reference.rootItemId)))
|
||||
};
|
||||
const parentRelations = relationsFor(parentConceptIds);
|
||||
const parentOwnerships = ownershipsFor(parentConceptIds);
|
||||
if (parentRelations.length) parentDocument.conceptRelations = parentRelations;
|
||||
else delete parentDocument.conceptRelations;
|
||||
if (parentOwnerships.length) parentDocument.conceptOwnerships = parentOwnerships;
|
||||
else delete parentDocument.conceptOwnerships;
|
||||
|
||||
return { parentDocument, childDocument };
|
||||
}
|
||||
|
||||
toDocument() {
|
||||
const document = this.conceptMap.toDocument();
|
||||
const usedConceptIds = new Set(this.conceptMap.items()
|
||||
.filter((item) => item instanceof ConceptMapConcept)
|
||||
.map((item) => item.conceptId));
|
||||
document.concepts = this.repository.toDocument()
|
||||
.filter((concept) => usedConceptIds.has(concept.id));
|
||||
const relations = this.repository.relationDocuments();
|
||||
const ownerships = this.repository.ownershipDocuments();
|
||||
if (relations.length) document.conceptRelations = relations;
|
||||
if (ownerships.length) document.conceptOwnerships = ownerships;
|
||||
return document;
|
||||
}
|
||||
}
|
||||
|
||||
export { PLACEMENT_FIELDS };
|
||||
@@ -0,0 +1,217 @@
|
||||
const CONCEPT_FIELDS = [
|
||||
"label", "synopsis", "aspects", "tags", "descriptionPageSlug",
|
||||
"pageSlug", "cmapSlug", "externalUrl", "imageSource"
|
||||
];
|
||||
|
||||
function copy(value) {
|
||||
return value === undefined ? undefined : JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
function normalizeTags(tags) {
|
||||
if (!Array.isArray(tags)) return [];
|
||||
return tags.map((tag) => {
|
||||
if (typeof tag === "string") return { type: "label", value: tag.trim() };
|
||||
if (!tag || typeof tag !== "object") return null;
|
||||
const type = String(tag.type || "label").trim() || "label";
|
||||
const value = String(tag.value || tag.name || "").trim();
|
||||
return value ? { type, value } : null;
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the shared content and external references of one concept.
|
||||
* A Concept never contains coordinates, styling or browser objects.
|
||||
*/
|
||||
export class Concept {
|
||||
constructor(id, values = {}) {
|
||||
if (!id) throw new TypeError("A concept id is required");
|
||||
this.id = String(id);
|
||||
this.label = String(values.label || "Concept");
|
||||
this.synopsis = String(values.synopsis || "");
|
||||
this.aspects = Array.isArray(values.aspects) ? values.aspects.map(String) : [];
|
||||
this.tags = normalizeTags(values.tags);
|
||||
this.descriptionPageSlug = values.descriptionPageSlug || null;
|
||||
this.pageSlug = values.pageSlug || null;
|
||||
this.cmapSlug = values.cmapSlug || null;
|
||||
this.externalUrl = values.externalUrl || null;
|
||||
this.imageSource = values.imageSource || "";
|
||||
}
|
||||
|
||||
/** Change shared content without accepting placement or presentation fields. */
|
||||
update(changes = {}) {
|
||||
for (const field of CONCEPT_FIELDS) {
|
||||
if (changes[field] === undefined) continue;
|
||||
if (field === "aspects") {
|
||||
this.aspects = Array.isArray(changes.aspects) ? changes.aspects.map(String) : [];
|
||||
} else if (field === "tags") {
|
||||
this.tags = normalizeTags(changes.tags);
|
||||
} else {
|
||||
this[field] = changes[field];
|
||||
}
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Return the stable JSON representation used at the wiki API boundary. */
|
||||
toDocument() {
|
||||
return {
|
||||
id: this.id,
|
||||
label: this.label,
|
||||
synopsis: this.synopsis,
|
||||
aspects: [...this.aspects],
|
||||
tags: copy(this.tags),
|
||||
descriptionPageSlug: this.descriptionPageSlug,
|
||||
pageSlug: this.pageSlug,
|
||||
cmapSlug: this.cmapSlug,
|
||||
externalUrl: this.externalUrl,
|
||||
imageSource: this.imageSource
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Describe a semantic relation that exists independently of a diagram. */
|
||||
export class ConceptRelation {
|
||||
constructor(id, sourceConceptId, targetConceptId, values = {}) {
|
||||
if (!id || !sourceConceptId || !targetConceptId) {
|
||||
throw new TypeError("A concept relation requires an id and two concepts");
|
||||
}
|
||||
this.id = String(id);
|
||||
this.sourceConceptId = String(sourceConceptId);
|
||||
this.targetConceptId = String(targetConceptId);
|
||||
this.label = String(values.label || "");
|
||||
this.tags = normalizeTags(values.tags);
|
||||
}
|
||||
|
||||
toDocument() {
|
||||
return {
|
||||
id: this.id,
|
||||
sourceConceptId: this.sourceConceptId,
|
||||
targetConceptId: this.targetConceptId,
|
||||
label: this.label,
|
||||
tags: copy(this.tags)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/** Describe semantic parent/child ownership between repository concepts. */
|
||||
export class ConceptOwnership {
|
||||
constructor(parentConceptId, childConceptId) {
|
||||
if (!parentConceptId || !childConceptId || parentConceptId === childConceptId) {
|
||||
throw new TypeError("Concept ownership requires two different concepts");
|
||||
}
|
||||
this.parentConceptId = String(parentConceptId);
|
||||
this.childConceptId = String(childConceptId);
|
||||
}
|
||||
|
||||
toDocument() {
|
||||
return {
|
||||
parentConceptId: this.parentConceptId,
|
||||
childConceptId: this.childConceptId
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Own all shared concepts and their semantic relationships.
|
||||
* Placements refer to this repository by concept id.
|
||||
*/
|
||||
export class ConceptRepository {
|
||||
constructor(concepts = [], relations = [], ownerships = []) {
|
||||
this.conceptsById = new Map();
|
||||
this.relationsById = new Map();
|
||||
this.ownershipsByChildId = new Map();
|
||||
for (const value of concepts) this.add(value);
|
||||
for (const value of relations) this.addRelation(value);
|
||||
for (const value of ownerships) this.addOwnership(value);
|
||||
}
|
||||
|
||||
add(value) {
|
||||
const concept = value instanceof Concept ? value : new Concept(value.id, value);
|
||||
this.conceptsById.set(concept.id, concept);
|
||||
return concept;
|
||||
}
|
||||
|
||||
ensure(id, values = {}) {
|
||||
const key = String(id);
|
||||
const existing = this.conceptsById.get(key);
|
||||
if (existing) {
|
||||
existing.update(values);
|
||||
return existing;
|
||||
}
|
||||
return this.add(new Concept(key, values));
|
||||
}
|
||||
|
||||
concept(id) {
|
||||
return this.conceptsById.get(String(id)) || null;
|
||||
}
|
||||
|
||||
concepts() {
|
||||
return [...this.conceptsById.values()];
|
||||
}
|
||||
|
||||
remove(id) {
|
||||
const key = String(id);
|
||||
this.conceptsById.delete(key);
|
||||
for (const [relationId, relation] of this.relationsById) {
|
||||
if (relation.sourceConceptId === key || relation.targetConceptId === key) {
|
||||
this.relationsById.delete(relationId);
|
||||
}
|
||||
}
|
||||
this.ownershipsByChildId.delete(key);
|
||||
for (const [childId, ownership] of this.ownershipsByChildId) {
|
||||
if (ownership.parentConceptId === key) this.ownershipsByChildId.delete(childId);
|
||||
}
|
||||
}
|
||||
|
||||
addRelation(value) {
|
||||
const relation = value instanceof ConceptRelation ? value : new ConceptRelation(
|
||||
value.id, value.sourceConceptId, value.targetConceptId, value);
|
||||
this.requireConcept(relation.sourceConceptId);
|
||||
this.requireConcept(relation.targetConceptId);
|
||||
this.relationsById.set(relation.id, relation);
|
||||
return relation;
|
||||
}
|
||||
|
||||
addOwnership(value) {
|
||||
const ownership = value instanceof ConceptOwnership ? value : new ConceptOwnership(
|
||||
value.parentConceptId, value.childConceptId);
|
||||
this.requireConcept(ownership.parentConceptId);
|
||||
this.requireConcept(ownership.childConceptId);
|
||||
if (this.isOwnedBy(ownership.parentConceptId, ownership.childConceptId)) {
|
||||
throw new Error("Concept ownership would create a cycle");
|
||||
}
|
||||
this.ownershipsByChildId.set(ownership.childConceptId, ownership);
|
||||
return ownership;
|
||||
}
|
||||
|
||||
isOwnedBy(conceptId, possibleAncestorId) {
|
||||
let current = this.ownershipsByChildId.get(String(conceptId));
|
||||
const seen = new Set();
|
||||
while (current && !seen.has(current.childConceptId)) {
|
||||
if (current.parentConceptId === String(possibleAncestorId)) return true;
|
||||
seen.add(current.childConceptId);
|
||||
current = this.ownershipsByChildId.get(current.parentConceptId);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
requireConcept(id) {
|
||||
const concept = this.concept(id);
|
||||
if (!concept) throw new Error(`Unknown concept: ${id}`);
|
||||
return concept;
|
||||
}
|
||||
|
||||
toDocument() {
|
||||
return this.concepts().map((concept) => concept.toDocument());
|
||||
}
|
||||
|
||||
relationDocuments() {
|
||||
return [...this.relationsById.values()].map((relation) => relation.toDocument());
|
||||
}
|
||||
|
||||
ownershipDocuments() {
|
||||
return [...this.ownershipsByChildId.values()].map((ownership) => ownership.toDocument());
|
||||
}
|
||||
}
|
||||
|
||||
export { CONCEPT_FIELDS, normalizeTags };
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
+1
-251
@@ -1188,78 +1188,8 @@ body.editor-mode .editor-metadata-row {
|
||||
}
|
||||
|
||||
|
||||
/* Wiki graph ------------------------------------------------------------- */
|
||||
#graph-view {
|
||||
max-width: 1100px;
|
||||
}
|
||||
|
||||
.wiki-graph-shell {
|
||||
margin-top: 18px;
|
||||
border: 1px solid var(--wiki-border);
|
||||
background: #fafafa;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.wiki-graph {
|
||||
display: block;
|
||||
width: 100%;
|
||||
min-width: 720px;
|
||||
min-height: 520px;
|
||||
}
|
||||
|
||||
.wiki-graph-edges line {
|
||||
stroke: #b8b8b8;
|
||||
stroke-width: 1.4;
|
||||
}
|
||||
|
||||
.wiki-graph-node {
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.wiki-graph-node circle {
|
||||
fill: #46639b;
|
||||
stroke: white;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.wiki-graph-node text {
|
||||
fill: var(--wiki-text);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.wiki-graph-node:hover circle,
|
||||
.wiki-graph-node:focus circle {
|
||||
fill: #1e2f6d;
|
||||
stroke: #1e2f6d;
|
||||
}
|
||||
|
||||
.wiki-graph-node-cmap circle {
|
||||
fill: #57834a;
|
||||
}
|
||||
|
||||
.wiki-graph-node-cmap:hover circle,
|
||||
.wiki-graph-node-cmap:focus circle {
|
||||
fill: #315f2b;
|
||||
stroke: #315f2b;
|
||||
}
|
||||
|
||||
|
||||
/* 0.2.15 context navigation and sticky-anchor correction ---------------- */
|
||||
.context-link {
|
||||
color: var(--wiki-link);
|
||||
font-size: .76rem;
|
||||
font-weight: 400;
|
||||
letter-spacing: normal;
|
||||
text-transform: none;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.context-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Sticky-anchor correction ---------------------------------------------- */
|
||||
.markdown-body h1,
|
||||
.markdown-body h2,
|
||||
.markdown-body h3,
|
||||
@@ -1269,16 +1199,6 @@ body.editor-mode .editor-metadata-row {
|
||||
scroll-margin-top: 82px;
|
||||
}
|
||||
|
||||
.wiki-graph-node-focus circle {
|
||||
r: 15px;
|
||||
fill: #1e2f6d;
|
||||
stroke: #1e2f6d;
|
||||
}
|
||||
|
||||
.wiki-graph-node-focus text {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
|
||||
/* Recent changes and bookmark overview pages. */
|
||||
.special-page-list,
|
||||
@@ -1444,173 +1364,3 @@ body.editor-mode .editor-metadata-row {
|
||||
.alias-history { color: #666; }
|
||||
.alias-actions { display: flex; gap: .5rem; margin-top: .6rem; }
|
||||
.alias-actions button { font: inherit; }
|
||||
|
||||
/* 0.2.36 context graph overlay and docking ------------------------------ */
|
||||
.page-reading-layout {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.page-reading-layout.context-dock-right {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(360px, 42%);
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.page-reading-layout.context-dock-top {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.page-reading-layout.context-dock-top .context-dock {
|
||||
order: -1;
|
||||
}
|
||||
|
||||
.context-dock {
|
||||
border: 1px solid var(--wiki-border);
|
||||
background: #fafafa;
|
||||
padding: .75rem;
|
||||
position: sticky;
|
||||
top: 84px;
|
||||
max-height: calc(100vh - 100px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.context-dock-header,
|
||||
.context-overlay-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.context-graph-actions {
|
||||
display: flex;
|
||||
gap: .7rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: .82rem;
|
||||
}
|
||||
|
||||
.context-graph-actions a {
|
||||
color: var(--wiki-link);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.context-graph-actions a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.context-graph-small {
|
||||
min-width: 0;
|
||||
min-height: 320px;
|
||||
max-height: 520px;
|
||||
}
|
||||
|
||||
.context-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 500;
|
||||
background: rgb(0 0 0 / 35%);
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.context-overlay-panel {
|
||||
width: min(1180px, 96vw);
|
||||
max-height: 92vh;
|
||||
overflow: auto;
|
||||
background: white;
|
||||
border: 1px solid var(--wiki-border);
|
||||
box-shadow: 0 14px 44px rgb(0 0 0 / 24%);
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.context-overlay-header h2 {
|
||||
margin: 0 0 .2rem;
|
||||
}
|
||||
|
||||
.context-overlay-graph-shell {
|
||||
margin-top: .8rem;
|
||||
}
|
||||
|
||||
.wiki-graph-edges marker path {
|
||||
fill: #9a9a9a;
|
||||
}
|
||||
|
||||
.wiki-graph-edge-incoming {
|
||||
stroke: #7a5d9e !important;
|
||||
}
|
||||
|
||||
.wiki-graph-edge-outgoing {
|
||||
stroke: #46639b !important;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.page-reading-layout.context-dock-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.page-reading-layout.context-dock-right .context-dock {
|
||||
order: -1;
|
||||
width: 100%;
|
||||
position: static;
|
||||
max-height: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.context-overlay {
|
||||
padding: .5rem;
|
||||
}
|
||||
|
||||
.context-overlay-panel {
|
||||
width: 100%;
|
||||
max-height: 96vh;
|
||||
}
|
||||
|
||||
.context-overlay-header {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.context-graph-actions {
|
||||
margin-top: .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Keep the established document width and use otherwise empty desktop
|
||||
space for the docked context view. */
|
||||
#page-view:has(.context-dock-right) {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.page-reading-layout.context-dock-right {
|
||||
grid-template-columns: minmax(0, 900px) minmax(420px, 520px);
|
||||
justify-content: start;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.page-reading-layout.context-dock-right .markdown-body {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.page-reading-layout.context-dock-right .context-dock {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.context-dock .wiki-graph-node text {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.context-dock .wiki-graph-node-focus text {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1450px) {
|
||||
.page-reading-layout.context-dock-right {
|
||||
grid-template-columns: minmax(0, 760px) minmax(360px, 440px);
|
||||
}
|
||||
}
|
||||
|
||||
+26
-50
@@ -19,14 +19,13 @@
|
||||
<a id="bookmarks-link" class="sidebar-icon-link" href="#bookmarks" title="Bookmarks" data-tr-title="bookmarks" aria-label="Bookmarks" data-tr-aria-label="bookmarks"><i data-lucide="star"></i><span class="visually-hidden" data-tr="bookmarks">Bookmarks</span></a>
|
||||
<a id="todo-link" class="sidebar-icon-link" href="#todos" title="Todo list" data-tr-title="todo-list" aria-label="Todo list" data-tr-aria-label="todo-list"><i data-lucide="list-checks"></i><span class="visually-hidden" data-tr="todo-list">Todo list</span></a>
|
||||
<a id="recent-link" class="sidebar-icon-link" href="#recent" title="Recent" data-tr-title="recent" aria-label="Recent" data-tr-aria-label="recent"><i data-lucide="square-stack"></i><span class="visually-hidden" data-tr="recent">Recent</span></a>
|
||||
<a id="graph-link" class="sidebar-icon-link" href="#graph" title="Navigation graph" data-tr-title="navigation-graph" aria-label="Navigation graph" data-tr-aria-label="navigation-graph"><i data-lucide="ship-wheel"></i><span class="visually-hidden" data-tr="navigation-graph">Navigation graph</span></a>
|
||||
<a id="cmaps-link" class="sidebar-icon-link" href="#" title="CMaps" data-tr-title="cmaps" aria-label="CMaps" data-tr-aria-label="cmaps"><i data-lucide="share-2"></i><span class="visually-hidden" data-tr="cmaps">CMaps</span></a>
|
||||
</nav>
|
||||
<form id="search-form" class="wiki-search" role="search">
|
||||
<input id="search-input" type="search" placeholder="Search pages and concept maps" data-tr-placeholder="search-wiki" autocomplete="off" aria-label="Search pages and concept maps" data-tr-aria-label="search-wiki">
|
||||
</form>
|
||||
<section class="sidebar-section toc-section">
|
||||
<div class="sidebar-heading"><span data-tr="contents">Contents</span> <a id="context-link" class="context-link hidden" href="#">(<span data-tr="context">context</span>)</a></div>
|
||||
<div class="sidebar-heading" data-tr="contents">Contents</div>
|
||||
<nav id="toc-list" class="toc-list"></nav>
|
||||
</section>
|
||||
<details class="sidebar-section pages-section">
|
||||
@@ -67,20 +66,7 @@
|
||||
<div id="page-meta" class="muted"></div>
|
||||
</div>
|
||||
</header>
|
||||
<div id="page-reading-layout" class="page-reading-layout">
|
||||
<article id="markdown-preview" class="markdown-body"></article>
|
||||
<aside id="context-dock" class="context-dock hidden" aria-label="Context graph">
|
||||
<div class="context-dock-header">
|
||||
<strong data-tr="context-graph">Context graph</strong>
|
||||
<span class="context-graph-actions">
|
||||
<a id="context-dock-popup" href="#" data-tr="popup">Popup</a>
|
||||
<a id="context-dock-close" href="#" data-tr="close">Close</a>
|
||||
</span>
|
||||
</div>
|
||||
<div id="context-dock-summary" class="muted"></div>
|
||||
<svg id="context-dock-graph" class="wiki-graph context-graph-small" viewBox="0 0 1000 700" role="img" aria-label="Context graph"></svg>
|
||||
</aside>
|
||||
</div>
|
||||
<article id="markdown-preview" class="markdown-body"></article>
|
||||
<section id="page-cmap-connections" class="page-cmap-connections hidden" aria-labelledby="page-cmap-connections-title">
|
||||
<h2 id="page-cmap-connections-title" data-tr="connected-cmap-concepts">Connected CMap concepts</h2>
|
||||
<div id="page-cmap-connections-list"></div>
|
||||
@@ -158,16 +144,6 @@
|
||||
<div id="todo-list" class="todo-items"></div>
|
||||
</section>
|
||||
|
||||
<section id="graph-view" class="hidden">
|
||||
<header class="page-header">
|
||||
<h1 id="graph-title" data-tr="wiki-graph">Wiki graph</h1>
|
||||
<div id="graph-summary" class="muted"></div>
|
||||
</header>
|
||||
<div class="wiki-graph-shell">
|
||||
<svg id="wiki-graph" class="wiki-graph" viewBox="0 0 1000 700" role="img" aria-label="Wiki graph"></svg>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="cmap-view" class="hidden">
|
||||
<header class="cmap-page-header">
|
||||
<div class="cmap-storage-controls">
|
||||
@@ -182,6 +158,7 @@
|
||||
<button id="cmap-wiki-reference" class="cmap-wiki-reference hidden" type="button" data-tr-title="copy-wiki-link" title="Copy wiki link"></button>
|
||||
<span class="cmap-toolbar-separator" aria-hidden="true"></span>
|
||||
<button id="cmap-tools-menu" class="editor-only" type="button" aria-label="Concept map tools" aria-haspopup="menu">⋮</button>
|
||||
<button id="cmap-extract-selected" class="editor-only hidden" type="button" data-tr="promote-submap">Make separate CMap</button>
|
||||
<span id="cmap-save-status" class="muted" role="status"></span>
|
||||
<details id="cmap-hidden-items" class="cmap-hidden-items hidden">
|
||||
<summary data-tr="hidden-concepts">Hidden concepts</summary>
|
||||
@@ -417,27 +394,6 @@
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
||||
<div id="context-overlay" class="context-overlay hidden" role="dialog" aria-modal="true" aria-labelledby="context-overlay-title">
|
||||
<div class="context-overlay-panel">
|
||||
<div class="context-overlay-header">
|
||||
<div>
|
||||
<h2 id="context-overlay-title" data-tr="context-graph">Context graph</h2>
|
||||
<div id="context-overlay-summary" class="muted"></div>
|
||||
</div>
|
||||
<nav class="context-graph-actions">
|
||||
<a id="context-dock-right" href="#" data-tr="dock-right">Dock right</a>
|
||||
<a id="context-dock-top" href="#" data-tr="dock-above">Dock above</a>
|
||||
<a id="context-open-full" href="#" data-tr="open-full-graph">Open full graph</a>
|
||||
<a id="context-overlay-close" href="#" data-tr="close">Close</a>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="wiki-graph-shell context-overlay-graph-shell">
|
||||
<svg id="context-overlay-graph" class="wiki-graph" viewBox="0 0 1000 700" role="img" aria-label="Context graph"></svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<dialog id="cmap-concept-dialog" class="cmap-concept-dialog" aria-labelledby="cmap-concept-dialog-title">
|
||||
<form id="cmap-concept-form" novalidate>
|
||||
<h2 id="cmap-concept-dialog-title" data-tr="edit-concept">Edit concept</h2>
|
||||
@@ -675,11 +631,31 @@
|
||||
<script src="/vendor/lucide.min.js"></script>
|
||||
<script src="/vendor/diff2html-ui-base.min.js"></script>
|
||||
<script src="/cmap/cmap.js?id=__CMAP_CACHE_ID__"></script>
|
||||
<script src="/cmap/cmap-racket-wiki.js?id=__CMAP_CACHE_ID__"></script>
|
||||
<script src="/js/combobox.js?id=__CMAP_CACHE_ID__"></script>
|
||||
<script src="/js/mermaid-racket-wiki.js?id=__CMAP_CACHE_ID__"></script>
|
||||
<script src="/js/cmap-export.js?id=__CMAP_CACHE_ID__"></script>
|
||||
<script src="/js/cmap-interchange.js?id=__CMAP_CACHE_ID__"></script>
|
||||
<script src="/js/wiki.js?id=__CMAP_CACHE_ID__"></script>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"/cmap/cmap-racket-wiki.js": "/cmap/cmap-racket-wiki.js?id=__CMAP_CACHE_ID__",
|
||||
"/cmap/cmap-view.js": "/cmap/cmap-view.js?id=__CMAP_CACHE_ID__",
|
||||
"/cmap/model/concept-repository.js": "/cmap/model/concept-repository.js?id=__CMAP_CACHE_ID__",
|
||||
"/cmap/model/concept-map.js": "/cmap/model/concept-map.js?id=__CMAP_CACHE_ID__",
|
||||
"/js/wiki/cmap/interchange.js": "/js/wiki/cmap/interchange.js?id=__CMAP_CACHE_ID__",
|
||||
"/js/wiki/reference.js": "/js/wiki/reference.js?id=__CMAP_CACHE_ID__",
|
||||
"/js/wiki/routes.js": "/js/wiki/routes.js?id=__CMAP_CACHE_ID__",
|
||||
"/js/wiki/markdown.js": "/js/wiki/markdown.js?id=__CMAP_CACHE_ID__",
|
||||
"/js/wiki/breadcrumb-trail.js": "/js/wiki/breadcrumb-trail.js?id=__CMAP_CACHE_ID__",
|
||||
"/js/wiki/page-outline.js": "/js/wiki/page-outline.js?id=__CMAP_CACHE_ID__",
|
||||
"/js/wiki/admin/overview.js": "/js/wiki/admin/overview.js?id=__CMAP_CACHE_ID__",
|
||||
"/js/wiki/admin/alias-admin.js": "/js/wiki/admin/alias-admin.js?id=__CMAP_CACHE_ID__",
|
||||
"/js/wiki/admin/archived-cmaps-admin.js": "/js/wiki/admin/archived-cmaps-admin.js?id=__CMAP_CACHE_ID__",
|
||||
"/js/wiki/admin/mail-admin.js": "/js/wiki/admin/mail-admin.js?id=__CMAP_CACHE_ID__",
|
||||
"/js/wiki/admin/orphaned-uploads-admin.js": "/js/wiki/admin/orphaned-uploads-admin.js?id=__CMAP_CACHE_ID__",
|
||||
"/js/wiki/admin/user-admin.js": "/js/wiki/admin/user-admin.js?id=__CMAP_CACHE_ID__"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script type="module" src="/js/wiki.js?id=__CMAP_CACHE_ID__"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+198
-4488
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,166 @@
|
||||
import { pageRoute } from "../routes.js";
|
||||
|
||||
/* Page-alias administration view. */
|
||||
|
||||
class AliasAdmin {
|
||||
/**
|
||||
* goal : Create a controller for retained page aliases.
|
||||
* pre : api and translate are application services; reloadPages refreshes
|
||||
* the public page and alias catalogue after an administrative edit.
|
||||
* post : The controller is ready to load and mutate aliases.
|
||||
* result : A directly usable alias administration controller.
|
||||
*/
|
||||
constructor(api, translate, reloadPages) {
|
||||
this.api = api;
|
||||
this.translate = translate;
|
||||
this.reloadPages = reloadPages;
|
||||
this.list = document.getElementById("alias-list");
|
||||
this.status = document.getElementById("alias-admin-status");
|
||||
}
|
||||
|
||||
/**
|
||||
* goal : Load retained aliases and render their references and actions.
|
||||
* pre : The current session has administrator permission.
|
||||
* post : The alias list represents the current server state.
|
||||
* result : A promise that resolves when all alias rows have been rendered.
|
||||
*/
|
||||
async load() {
|
||||
this.status.textContent = "";
|
||||
const result = await this.api("/api/admin/aliases");
|
||||
const aliases = result.aliases || [];
|
||||
this.list.replaceChildren();
|
||||
|
||||
if (aliases.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "muted";
|
||||
empty.textContent = this.translate("no-page-aliases", "No page aliases.");
|
||||
this.list.append(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const alias of aliases) this.list.append(this.#renderAlias(alias));
|
||||
}
|
||||
|
||||
/** Build one alias row, including cleanup and deletion actions. */
|
||||
#renderAlias(alias) {
|
||||
const row = document.createElement("article");
|
||||
row.className = "alias-row";
|
||||
|
||||
const address = document.createElement("div");
|
||||
address.className = "alias-address";
|
||||
const target = document.createElement("a");
|
||||
target.href = pageRoute(alias.targetSlug);
|
||||
target.textContent = alias.targetSlug;
|
||||
address.append(
|
||||
document.createTextNode(
|
||||
`${this.translate("old-address", "Old address")}: ${alias.slug} · ` +
|
||||
`${this.translate("current-address", "Current address")}: `),
|
||||
target
|
||||
);
|
||||
row.append(address);
|
||||
|
||||
const references = alias.references || [];
|
||||
const currentSummary = document.createElement("div");
|
||||
currentSummary.className = "alias-summary";
|
||||
currentSummary.textContent =
|
||||
`${this.translate("current-references", "Current references")}: ${references.length}`;
|
||||
row.append(currentSummary);
|
||||
|
||||
if (references.length > 0) {
|
||||
const referenceList = document.createElement("ul");
|
||||
referenceList.className = "alias-references";
|
||||
for (const reference of references) {
|
||||
const item = document.createElement("li");
|
||||
const link = document.createElement("a");
|
||||
link.href = pageRoute(reference.slug);
|
||||
link.textContent = reference.title;
|
||||
item.append(link);
|
||||
referenceList.append(item);
|
||||
}
|
||||
row.append(referenceList);
|
||||
}
|
||||
|
||||
const historicalCount = Number(alias.historicalReferenceCount || 0);
|
||||
const historicalSummary = document.createElement("div");
|
||||
historicalSummary.className = "alias-summary muted";
|
||||
historicalSummary.textContent =
|
||||
`${this.translate("historical-references", "Historical references")}: ${historicalCount}`;
|
||||
row.append(historicalSummary);
|
||||
|
||||
const historicalReferences = alias.historicalReferences || [];
|
||||
if (historicalReferences.length > 0) {
|
||||
const historyList = document.createElement("ul");
|
||||
historyList.className = "alias-references alias-history";
|
||||
for (const reference of historicalReferences) {
|
||||
const item = document.createElement("li");
|
||||
item.textContent = `${reference.title} · ` +
|
||||
`${this.translate("version", "Version")} ${reference.version}`;
|
||||
historyList.append(item);
|
||||
}
|
||||
row.append(historyList);
|
||||
}
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "alias-actions";
|
||||
actions.append(
|
||||
this.#cleanupButton(alias, references),
|
||||
this.#deleteButton(alias, references, historicalCount)
|
||||
);
|
||||
row.append(actions);
|
||||
return row;
|
||||
}
|
||||
|
||||
/** Build the action that replaces current references to an old address. */
|
||||
#cleanupButton(alias, references) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.textContent = this.translate("cleanup-alias", "Clean up references");
|
||||
button.disabled = references.length === 0;
|
||||
button.addEventListener("click", () => {
|
||||
this.#cleanup(alias).catch((error) => console.error(error));
|
||||
});
|
||||
return button;
|
||||
}
|
||||
|
||||
/** Build the action that removes an unused alias. */
|
||||
#deleteButton(alias, references, historicalCount) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.textContent = this.translate("delete-alias", "Delete alias");
|
||||
button.disabled = references.length > 0;
|
||||
button.addEventListener("click", () => {
|
||||
this.#delete(alias, historicalCount).catch((error) => console.error(error));
|
||||
});
|
||||
return button;
|
||||
}
|
||||
|
||||
/** Confirm, perform and report reference cleanup for one alias. */
|
||||
async #cleanup(alias) {
|
||||
const question = this.translate(
|
||||
"cleanup-alias-confirm",
|
||||
"Replace all current references to this old address with the current address? Historical versions will not be changed.");
|
||||
if (!window.confirm(question)) return;
|
||||
const result = await this.api(`/api/admin/aliases/${alias.id}/cleanup`, {
|
||||
method: "POST"
|
||||
});
|
||||
await this.reloadPages();
|
||||
await this.load();
|
||||
this.status.textContent =
|
||||
`${this.translate("cleanup-complete", "Cleanup complete")}: ${result.changedPages || 0}`;
|
||||
}
|
||||
|
||||
/** Confirm and remove one alias that has no current references. */
|
||||
async #delete(alias, historicalCount) {
|
||||
const warning = historicalCount > 0
|
||||
? this.translate(
|
||||
"delete-alias-history-confirm",
|
||||
"Delete this alias? Historical page versions still contain this old address and those links may stop resolving.")
|
||||
: this.translate("delete-alias-confirm", "Delete this alias?");
|
||||
if (!window.confirm(warning)) return;
|
||||
await this.api(`/api/admin/aliases/${alias.id}`, { method: "DELETE" });
|
||||
await this.reloadPages();
|
||||
await this.load();
|
||||
}
|
||||
}
|
||||
|
||||
export { AliasAdmin };
|
||||
@@ -0,0 +1,103 @@
|
||||
/* Archived CMap administration view. */
|
||||
|
||||
class ArchivedCmapsAdmin {
|
||||
/**
|
||||
* goal : Create the archived-CMap administration controller.
|
||||
* pre : formatDate formats database timestamps; reloadConceptMaps refreshes
|
||||
* ordinary CMap navigation after a restore.
|
||||
* post : The controller is ready to load and restore archived maps.
|
||||
* result : A directly usable archived-CMap controller.
|
||||
*/
|
||||
constructor(api, translate, formatDate, reloadConceptMaps) {
|
||||
this.api = api;
|
||||
this.translate = translate;
|
||||
this.formatDate = formatDate;
|
||||
this.reloadConceptMaps = reloadConceptMaps;
|
||||
this.list = document.getElementById("archived-cmaps-list");
|
||||
this.status = document.getElementById("archived-cmaps-status");
|
||||
}
|
||||
|
||||
/**
|
||||
* goal : Load and render all archived concept maps.
|
||||
* pre : The current session has administrator permission.
|
||||
* post : The archived-CMap list represents the current server state.
|
||||
* result : A promise that resolves when all map rows have been rendered.
|
||||
*/
|
||||
async load() {
|
||||
this.status.textContent = "";
|
||||
const result = await this.api("/api/admin/cmaps/archived");
|
||||
const conceptMaps = Array.isArray(result.conceptMaps) ? result.conceptMaps : [];
|
||||
this.list.replaceChildren();
|
||||
|
||||
if (conceptMaps.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "muted";
|
||||
empty.textContent = this.translate(
|
||||
"no-archived-concept-maps", "No archived CMaps.");
|
||||
this.list.append(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const conceptMap of conceptMaps) {
|
||||
this.list.append(this.#renderConceptMap(conceptMap));
|
||||
}
|
||||
}
|
||||
|
||||
/** Build one archived map row and its restore action. */
|
||||
#renderConceptMap(conceptMap) {
|
||||
const row = document.createElement("article");
|
||||
row.className = "archived-cmap-row";
|
||||
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = conceptMap.title;
|
||||
|
||||
const archivedAt = conceptMap.archivedAt
|
||||
? this.formatDate(conceptMap.archivedAt)
|
||||
: "";
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "muted";
|
||||
meta.textContent = [
|
||||
`cmap:${conceptMap.slug}`,
|
||||
archivedAt
|
||||
? `${this.translate("archived", "Archived")} ${archivedAt}`
|
||||
: this.translate("archived", "Archived"),
|
||||
conceptMap.archivedBy
|
||||
? `${this.translate("by", "by")} ${conceptMap.archivedBy}`
|
||||
: ""
|
||||
].filter(Boolean).join(" · ");
|
||||
|
||||
const restore = document.createElement("button");
|
||||
restore.type = "button";
|
||||
restore.textContent = this.translate("restore-concept-map", "Restore CMap");
|
||||
restore.addEventListener("click", () => {
|
||||
this.#restore(conceptMap, restore).catch((error) => console.error(error));
|
||||
});
|
||||
|
||||
row.append(title, meta, restore);
|
||||
return row;
|
||||
}
|
||||
|
||||
/** Confirm and restore one CMap, then refresh both CMap catalogues. */
|
||||
async #restore(conceptMap, button) {
|
||||
const question = this.translate(
|
||||
"restore-concept-map-confirm", "Restore concept map \"{title}\"?")
|
||||
.replace("{title}", conceptMap.title);
|
||||
if (!window.confirm(question)) return;
|
||||
|
||||
button.disabled = true;
|
||||
try {
|
||||
await this.api(
|
||||
`/api/admin/cmaps/archived/${encodeURIComponent(conceptMap.slug)}/restore`,
|
||||
{ method: "POST" });
|
||||
await this.reloadConceptMaps();
|
||||
await this.load();
|
||||
this.status.textContent = this.translate(
|
||||
"concept-map-restored", "CMap restored.");
|
||||
} catch (error) {
|
||||
button.disabled = false;
|
||||
this.status.textContent = error.message;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { ArchivedCmapsAdmin };
|
||||
@@ -0,0 +1,130 @@
|
||||
/* Mail and password-reset administration view. */
|
||||
|
||||
class MailAdmin {
|
||||
/**
|
||||
* goal : Connect mail settings, test delivery and TLS controls to the API.
|
||||
* pre : The mail administration form exists in the document.
|
||||
* post : Save, test and TLS-change events are handled by this controller.
|
||||
* result : A directly usable mail administration controller.
|
||||
*/
|
||||
constructor(api, translate) {
|
||||
this.api = api;
|
||||
this.translate = translate;
|
||||
this.form = document.getElementById("mail-settings-form");
|
||||
this.status = document.getElementById("mail-settings-status");
|
||||
this.testButton = document.getElementById("mail-test-button");
|
||||
this.tls = document.getElementById("mail-smtp-tls");
|
||||
this.acceptUntrusted = document.getElementById(
|
||||
"mail-smtp-accept-untrusted-certificates");
|
||||
|
||||
this.form.addEventListener("submit", (event) => this.#save(event));
|
||||
this.testButton.addEventListener("click", () => this.#test());
|
||||
this.tls.addEventListener("change", () => this.#syncTlsControls());
|
||||
}
|
||||
|
||||
/**
|
||||
* goal : Load stored mail settings and initialize the test recipient.
|
||||
* pre : The current session has administrator permission.
|
||||
* post : All form controls represent the stored server configuration.
|
||||
* result : A promise that resolves after the form has been initialized.
|
||||
*/
|
||||
async load(currentUserEmail = "") {
|
||||
const settings = await this.api("/api/admin/mail-settings");
|
||||
document.getElementById("mail-public-url").value =
|
||||
settings.publicUrl || window.location.origin;
|
||||
document.getElementById("mail-smtp-host").value = settings.smtpHost || "";
|
||||
document.getElementById("mail-smtp-port").value = settings.smtpPort || "587";
|
||||
document.getElementById("mail-smtp-from").value = settings.smtpFrom || "";
|
||||
document.getElementById("mail-smtp-user").value = settings.smtpUser || "";
|
||||
document.getElementById("mail-smtp-password").value = "";
|
||||
this.tls.checked = settings.smtpTls !== false;
|
||||
this.acceptUntrusted.checked = settings.smtpAcceptUntrustedCertificates === true;
|
||||
document.getElementById("mail-reset-limit").value = settings.resetLimit || "2";
|
||||
document.getElementById("mail-test-recipient").value = currentUserEmail;
|
||||
document.getElementById("mail-password-help").textContent = settings.hasPassword
|
||||
? this.translate(
|
||||
"smtp-password-kept", "A password is stored; leave empty to keep it.")
|
||||
: "";
|
||||
this.status.textContent = "";
|
||||
this.acceptUntrusted.disabled = !this.tls.checked;
|
||||
}
|
||||
|
||||
/** Read the mail form using the public server API field names. */
|
||||
#formData() {
|
||||
return {
|
||||
publicUrl: document.getElementById("mail-public-url").value,
|
||||
smtpHost: document.getElementById("mail-smtp-host").value,
|
||||
smtpPort: document.getElementById("mail-smtp-port").value,
|
||||
smtpFrom: document.getElementById("mail-smtp-from").value,
|
||||
smtpUser: document.getElementById("mail-smtp-user").value,
|
||||
smtpPassword: document.getElementById("mail-smtp-password").value,
|
||||
smtpTls: this.tls.checked,
|
||||
smtpAcceptUntrustedCertificates: this.acceptUntrusted.checked,
|
||||
resetLimit: document.getElementById("mail-reset-limit").value
|
||||
};
|
||||
}
|
||||
|
||||
/** Store the current mail settings and retain an omitted password. */
|
||||
async #save(event) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
await this.api("/api/admin/mail-settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(this.#formData())
|
||||
});
|
||||
document.getElementById("mail-smtp-password").value = "";
|
||||
document.getElementById("mail-password-help").textContent = this.translate(
|
||||
"smtp-password-kept", "A password is stored; leave empty to keep it.");
|
||||
this.status.textContent = this.translate("saved", "Saved");
|
||||
} catch (error) {
|
||||
this.status.textContent = error.message;
|
||||
}
|
||||
}
|
||||
|
||||
/** Send one test message using the unsaved values currently in the form. */
|
||||
async #test() {
|
||||
const recipient = document.getElementById("mail-test-recipient").value.trim();
|
||||
this.testButton.disabled = true;
|
||||
this.status.textContent = this.translate(
|
||||
"sending-test-mail", "Sending test email…");
|
||||
try {
|
||||
await this.api("/api/admin/mail-settings/test", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ ...this.#formData(), recipient })
|
||||
});
|
||||
this.status.textContent = this.translate(
|
||||
"test-mail-sent", "Test email accepted by SMTP server");
|
||||
} catch (error) {
|
||||
console.error("SMTP test failed", error);
|
||||
this.status.textContent = this.#smtpErrorMessage(error);
|
||||
} finally {
|
||||
this.testButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Translate known TLS failures and retain other server error messages. */
|
||||
#smtpErrorMessage(error) {
|
||||
const message = error && error.message ? error.message : String(error);
|
||||
const certificateFailure = message.includes("certificate verify failed") ||
|
||||
message.includes("TLS certificate verification failed");
|
||||
if (certificateFailure) {
|
||||
return this.translate(
|
||||
"smtp-certificate-verification-failed",
|
||||
"The SMTP server certificate could not be verified. Install a valid certificate, or select the local-server exception if this is a trusted local SMTP server.");
|
||||
}
|
||||
if (message.includes("no protocols available")) {
|
||||
return this.translate(
|
||||
"smtp-no-modern-tls",
|
||||
"The SMTP connection attempted an obsolete TLS protocol. Install the current Racket Wiki version, which negotiates modern TLS automatically.");
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
/** Disable the certificate exception whenever STARTTLS is disabled. */
|
||||
#syncTlsControls() {
|
||||
this.acceptUntrusted.disabled = !this.tls.checked;
|
||||
if (this.acceptUntrusted.disabled) this.acceptUntrusted.checked = false;
|
||||
}
|
||||
}
|
||||
|
||||
export { MailAdmin };
|
||||
@@ -0,0 +1,124 @@
|
||||
import { pageRoute } from "../routes.js";
|
||||
|
||||
/* Orphaned-upload administration view. */
|
||||
|
||||
/** Format a byte count for the administrative upload list. */
|
||||
function formatFileSize(size) {
|
||||
const bytes = Number(size || 0);
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
class OrphanedUploadsAdmin {
|
||||
/**
|
||||
* goal : Create the controller for uploads without current references.
|
||||
* pre : api performs authenticated requests and translate resolves UI keys.
|
||||
* post : The controller is connected to the orphaned-upload list.
|
||||
* result : A directly usable orphaned-upload administration controller.
|
||||
*/
|
||||
constructor(api, translate) {
|
||||
this.api = api;
|
||||
this.translate = translate;
|
||||
this.list = document.getElementById("orphaned-uploads-list");
|
||||
}
|
||||
|
||||
/**
|
||||
* goal : Load orphaned uploads and their last historical uses.
|
||||
* pre : The current session has administrator permission.
|
||||
* post : The upload list represents the current server state.
|
||||
* result : A promise that resolves when all upload rows have been rendered.
|
||||
*/
|
||||
async load() {
|
||||
const result = await this.api("/api/admin/uploads/orphaned");
|
||||
const uploads = result.uploads || [];
|
||||
this.list.replaceChildren();
|
||||
|
||||
if (uploads.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "muted";
|
||||
empty.textContent = this.translate("no-orphaned-uploads", "No orphaned uploads.");
|
||||
this.list.append(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const upload of uploads) this.list.append(this.#renderUpload(upload));
|
||||
}
|
||||
|
||||
/** Build one upload row with history, view link and deletion action. */
|
||||
#renderUpload(upload) {
|
||||
const row = document.createElement("article");
|
||||
row.className = "orphaned-upload-row";
|
||||
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = upload.originalName;
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "muted";
|
||||
meta.textContent = `${upload.mimeType} · ${formatFileSize(upload.size)} · ` +
|
||||
`${this.translate("uploaded-by", "uploaded by")} ${upload.uploadedBy}`;
|
||||
|
||||
const history = this.#uploadHistory(upload);
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "orphaned-upload-actions";
|
||||
|
||||
const view = document.createElement("a");
|
||||
view.href = `/uploads/${encodeURIComponent(upload.ownerSlug)}/` +
|
||||
encodeURIComponent(upload.storedName);
|
||||
view.target = "_blank";
|
||||
view.rel = "noopener";
|
||||
view.textContent = this.translate("view", "View");
|
||||
|
||||
const remove = document.createElement("button");
|
||||
remove.className = "danger";
|
||||
remove.textContent = this.translate("delete", "Delete");
|
||||
remove.addEventListener("click", () => {
|
||||
this.#deleteUpload(upload).catch((error) => console.error(error));
|
||||
});
|
||||
|
||||
actions.append(view, remove);
|
||||
row.append(title, meta, history, actions);
|
||||
return row;
|
||||
}
|
||||
|
||||
/** Build the historical-use portion of one upload row. */
|
||||
#uploadHistory(upload) {
|
||||
const history = document.createElement("div");
|
||||
const lastUses = Array.isArray(upload.lastUses) ? upload.lastUses : [];
|
||||
if (lastUses.length === 0) {
|
||||
history.textContent = this.translate(
|
||||
"never-referenced", "Never referenced by a saved page.");
|
||||
return history;
|
||||
}
|
||||
|
||||
const label = document.createElement("div");
|
||||
label.textContent = `${this.translate("last-used", "Last used")}:`;
|
||||
const uses = document.createElement("ul");
|
||||
uses.className = "orphaned-upload-uses";
|
||||
for (const use of lastUses) {
|
||||
const item = document.createElement("li");
|
||||
const link = document.createElement("a");
|
||||
link.href = pageRoute(use.slug);
|
||||
link.textContent = use.title || use.slug;
|
||||
item.append(link);
|
||||
if (use.version) {
|
||||
item.append(document.createTextNode(
|
||||
` · ${this.translate("version", "Version")} ${use.version}`));
|
||||
}
|
||||
uses.append(item);
|
||||
}
|
||||
history.append(label, uses);
|
||||
return history;
|
||||
}
|
||||
|
||||
/** Confirm and remove one still-orphaned upload, then refresh the list. */
|
||||
async #deleteUpload(upload) {
|
||||
const question = this.translate(
|
||||
"delete-orphaned-upload-confirm", "Delete this orphaned upload?");
|
||||
if (!window.confirm(question)) return;
|
||||
await this.api(`/api/admin/uploads/orphaned/${upload.id}`, { method: "DELETE" });
|
||||
await this.load();
|
||||
}
|
||||
}
|
||||
|
||||
export { OrphanedUploadsAdmin };
|
||||
@@ -0,0 +1,25 @@
|
||||
/* Administration overview. */
|
||||
|
||||
/**
|
||||
* goal : Refresh the software and database versions on the admin overview.
|
||||
* pre : api performs authenticated requests and translate resolves UI keys.
|
||||
* post : The overview shows current and required version information.
|
||||
* result : A promise that resolves after the overview has been updated.
|
||||
*/
|
||||
async function loadAdminOverview(api, translate) {
|
||||
const info = await api("/api/admin/info");
|
||||
document.getElementById("admin-software-version").textContent =
|
||||
info.softwareVersion || "?";
|
||||
|
||||
const schemaVersion = info.databaseSchemaVersion ?? "?";
|
||||
const requiredSchemaVersion = info.requiredDatabaseSchemaVersion ?? "?";
|
||||
const databaseVersion = document.getElementById("admin-database-schema-version");
|
||||
if (schemaVersion === requiredSchemaVersion) {
|
||||
databaseVersion.textContent = String(schemaVersion);
|
||||
} else {
|
||||
databaseVersion.textContent =
|
||||
`${schemaVersion} (${translate("required", "required")}: ${requiredSchemaVersion})`;
|
||||
}
|
||||
}
|
||||
|
||||
export { loadAdminOverview };
|
||||
@@ -0,0 +1,148 @@
|
||||
/*
|
||||
* User administration view.
|
||||
*
|
||||
* UserAdmin owns the user list, its editable controls and the new-user form.
|
||||
* The application remains responsible for permissions, routing, breadcrumbs
|
||||
* and making the surrounding view visible.
|
||||
*/
|
||||
|
||||
class UserAdmin {
|
||||
/**
|
||||
* goal : Connect the user administration controls to the wiki API.
|
||||
* pre : api performs authenticated requests; translate resolves UI keys;
|
||||
* the user administration form and list exist in the document.
|
||||
* post : Submitting the new-user form creates a user and reloads the list.
|
||||
* result : A directly usable controller for the user administration view.
|
||||
*/
|
||||
constructor(api, translate) {
|
||||
this.api = api;
|
||||
this.translate = translate;
|
||||
this.form = document.getElementById("new-user-form");
|
||||
this.list = document.getElementById("user-list");
|
||||
|
||||
this.form.addEventListener("submit", (event) => {
|
||||
this.#createUser(event).catch((error) => console.error(error));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* goal : Refresh the editable user list from the server.
|
||||
* pre : The current session has administrator permission.
|
||||
* post : Every current user has editable identity, role and password fields.
|
||||
* result : A promise that resolves when all rows have been rendered.
|
||||
* internals: The API supplies public user records. #renderUser creates each
|
||||
* row and connects its save and delete operations to that record.
|
||||
*/
|
||||
async load() {
|
||||
const result = await this.api("/api/admin/users");
|
||||
this.list.replaceChildren();
|
||||
for (const user of result.users) {
|
||||
this.list.append(this.#renderUser(user));
|
||||
}
|
||||
}
|
||||
|
||||
/** Create a user from the dedicated form and reload the canonical list. */
|
||||
async #createUser(event) {
|
||||
event.preventDefault();
|
||||
const username = document.getElementById("new-username").value;
|
||||
await this.api("/api/admin/users", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
username,
|
||||
displayName: document.getElementById("new-display-name").value || username,
|
||||
email: document.getElementById("new-email").value,
|
||||
password: document.getElementById("new-password").value,
|
||||
role: document.getElementById("new-role").value,
|
||||
enabled: true
|
||||
})
|
||||
});
|
||||
this.form.reset();
|
||||
await this.load();
|
||||
}
|
||||
|
||||
/** Build one editable user row and connect its actions. */
|
||||
#renderUser(user) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "user-row";
|
||||
|
||||
const username = document.createElement("strong");
|
||||
username.textContent = user.username;
|
||||
|
||||
const displayName = document.createElement("input");
|
||||
displayName.value = user.displayName;
|
||||
|
||||
const email = document.createElement("input");
|
||||
email.type = "email";
|
||||
email.value = user.email || "";
|
||||
email.placeholder = this.translate("email-address", "Email address");
|
||||
|
||||
const role = this.#roleSelect(user.role);
|
||||
|
||||
const enabled = document.createElement("input");
|
||||
enabled.type = "checkbox";
|
||||
enabled.checked = user.enabled;
|
||||
|
||||
const password = document.createElement("input");
|
||||
password.type = "password";
|
||||
password.placeholder = this.translate("new-password", "New password");
|
||||
password.autocomplete = "new-password";
|
||||
|
||||
const save = document.createElement("button");
|
||||
save.textContent = this.translate("save", "Save");
|
||||
save.addEventListener("click", () => {
|
||||
this.#updateUser(user, displayName, email, role, enabled, password)
|
||||
.catch((error) => console.error(error));
|
||||
});
|
||||
|
||||
const remove = document.createElement("button");
|
||||
remove.textContent = this.translate("delete", "Delete");
|
||||
remove.className = "danger";
|
||||
remove.addEventListener("click", () => {
|
||||
this.#deleteUser(user).catch((error) => console.error(error));
|
||||
});
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.append(save, remove);
|
||||
row.append(username, displayName, email, role, enabled, password, actions);
|
||||
return row;
|
||||
}
|
||||
|
||||
/** Build the translated role selector for one user row. */
|
||||
#roleSelect(selectedRole) {
|
||||
const select = document.createElement("select");
|
||||
for (const roleName of ["reader", "editor", "admin"]) {
|
||||
const option = document.createElement("option");
|
||||
option.value = roleName;
|
||||
option.textContent = this.translate(`role-${roleName}`, roleName);
|
||||
option.selected = roleName === selectedRole;
|
||||
select.append(option);
|
||||
}
|
||||
return select;
|
||||
}
|
||||
|
||||
/** Store the current controls of one user row. */
|
||||
async #updateUser(user, displayName, email, role, enabled, password) {
|
||||
await this.api(`/api/admin/users/${user.id}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
displayName: displayName.value,
|
||||
email: email.value,
|
||||
role: role.value,
|
||||
enabled: enabled.checked,
|
||||
password: password.value || undefined
|
||||
})
|
||||
});
|
||||
password.value = "";
|
||||
}
|
||||
|
||||
/** Confirm and remove one user, then reload the canonical list. */
|
||||
async #deleteUser(user) {
|
||||
const question = this.translate("delete-user-confirm", "Delete user {username}?")
|
||||
.replace("{username}", user.username);
|
||||
if (!window.confirm(question)) return;
|
||||
await this.api(`/api/admin/users/${user.id}`, { method: "DELETE" });
|
||||
await this.load();
|
||||
}
|
||||
}
|
||||
|
||||
export { UserAdmin };
|
||||
@@ -0,0 +1,116 @@
|
||||
/*
|
||||
* Per-tab wiki page history.
|
||||
*
|
||||
* BreadcrumbTrail owns the ordered page slugs and their session-storage
|
||||
* representation. It deliberately knows nothing about page titles, routes or
|
||||
* DOM elements; the application supplies the current set of valid page slugs.
|
||||
*/
|
||||
|
||||
const storageKey = "racket-wiki-breadcrumb-trail";
|
||||
const trailLimit = 8;
|
||||
|
||||
class BreadcrumbTrail {
|
||||
/**
|
||||
* goal : Create an empty breadcrumb history backed by session storage.
|
||||
* pre : storage implements the Web Storage getItem/setItem interface.
|
||||
* post : No stored value is read until load is called with valid pages.
|
||||
* result : A directly usable breadcrumb history.
|
||||
*/
|
||||
constructor(storage) {
|
||||
this.storage = storage;
|
||||
this.pageSlugs = [];
|
||||
}
|
||||
|
||||
/** Return a copy of the ordered non-home page slugs. */
|
||||
get slugs() {
|
||||
return this.pageSlugs.slice();
|
||||
}
|
||||
|
||||
/**
|
||||
* goal : Restore the trail and discard references to missing pages.
|
||||
* pre : validSlugs contains the current readable page slugs.
|
||||
* post : The normalized trail is stored again for this browser tab.
|
||||
* result : A copy of the restored trail.
|
||||
* internals: JSON is decoded first, then filtered through validSlugs so stale
|
||||
* browser state cannot become application navigation.
|
||||
*/
|
||||
load(validSlugs) {
|
||||
let stored = [];
|
||||
try {
|
||||
stored = JSON.parse(this.storage.getItem(storageKey) || "[]");
|
||||
} catch (_error) {
|
||||
stored = [];
|
||||
}
|
||||
|
||||
const valid = new Set(validSlugs);
|
||||
if (Array.isArray(stored)) {
|
||||
this.pageSlugs = stored.filter((slug) =>
|
||||
typeof slug === "string" && valid.has(slug));
|
||||
} else {
|
||||
this.pageSlugs = [];
|
||||
}
|
||||
this.#save();
|
||||
return this.slugs;
|
||||
}
|
||||
|
||||
/** Empty and persist the breadcrumb history. */
|
||||
clear() {
|
||||
this.pageSlugs = [];
|
||||
this.#save();
|
||||
}
|
||||
|
||||
/**
|
||||
* goal : Record navigation to one wiki page.
|
||||
* pre : pageSlug identifies the visited page; homeSlug may be null.
|
||||
* post : Home clears the trail, revisits truncate it, and new visits append.
|
||||
* result : A copy of the updated trail.
|
||||
* internals: The trail contains only non-home pages and retains at most the
|
||||
* eight most recent entries, matching the visible breadcrumb.
|
||||
*/
|
||||
record(pageSlug, homeSlug = null) {
|
||||
if (!pageSlug) return this.slugs;
|
||||
if (homeSlug && pageSlug === homeSlug) {
|
||||
this.clear();
|
||||
return this.slugs;
|
||||
}
|
||||
|
||||
const existingIndex = this.pageSlugs.indexOf(pageSlug);
|
||||
if (existingIndex >= 0) {
|
||||
this.pageSlugs = this.pageSlugs.slice(0, existingIndex + 1);
|
||||
} else {
|
||||
this.pageSlugs.push(pageSlug);
|
||||
if (this.pageSlugs.length > trailLimit) {
|
||||
this.pageSlugs = this.pageSlugs.slice(-trailLimit);
|
||||
}
|
||||
}
|
||||
this.#save();
|
||||
return this.slugs;
|
||||
}
|
||||
|
||||
/**
|
||||
* goal : Discard breadcrumb entries that follow a selected trail page.
|
||||
* pre : pageSlug is a breadcrumb destination; homeSlug may be null.
|
||||
* post : Home clears the trail; an existing page becomes its final entry.
|
||||
* result : A copy of the updated trail.
|
||||
*/
|
||||
truncate(pageSlug, homeSlug = null) {
|
||||
if (homeSlug && pageSlug === homeSlug) {
|
||||
this.clear();
|
||||
return this.slugs;
|
||||
}
|
||||
|
||||
const index = this.pageSlugs.indexOf(pageSlug);
|
||||
if (index >= 0) {
|
||||
this.pageSlugs = this.pageSlugs.slice(0, index + 1);
|
||||
this.#save();
|
||||
}
|
||||
return this.slugs;
|
||||
}
|
||||
|
||||
/** Persist the current representation for this browser tab. */
|
||||
#save() {
|
||||
this.storage.setItem(storageKey, JSON.stringify(this.pageSlugs));
|
||||
}
|
||||
}
|
||||
|
||||
export { BreadcrumbTrail };
|
||||
@@ -1,10 +1,4 @@
|
||||
/* Versioned JSON interchange for Racket Wiki concept maps. */
|
||||
((root, factory) => {
|
||||
const api = factory();
|
||||
if (typeof module === "object" && module.exports) module.exports = api;
|
||||
if (root) root.RacketWikiCmapInterchange = api;
|
||||
})(typeof window !== "undefined" ? window : globalThis, () => {
|
||||
"use strict";
|
||||
|
||||
const FORMAT = "racket-wiki-cmap-bundle";
|
||||
const FORMAT_VERSION = 1;
|
||||
@@ -141,7 +135,17 @@
|
||||
.filter(([key]) => !PLACEMENT_CONTENT_KEYS.has(key)));
|
||||
});
|
||||
documentCopy.concepts = ids.map((id) => ({ id }));
|
||||
if (!Array.isArray(documentCopy.connectors)) documentCopy.connectors = [];
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -428,9 +432,15 @@
|
||||
return documentValue;
|
||||
}
|
||||
|
||||
return {
|
||||
FORMAT, FORMAT_VERSION, SCHEMA, buildBundle, validateBundle,
|
||||
preparedMapDocument, placementDocument, decodedDocument,
|
||||
attachmentUrls, replaceAttachmentUrls
|
||||
};
|
||||
});
|
||||
export {
|
||||
FORMAT,
|
||||
FORMAT_VERSION,
|
||||
SCHEMA,
|
||||
attachmentUrls,
|
||||
buildBundle,
|
||||
decodedDocument,
|
||||
placementDocument,
|
||||
preparedMapDocument,
|
||||
replaceAttachmentUrls,
|
||||
validateBundle
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,321 @@
|
||||
import {
|
||||
normalizeMentionText,
|
||||
pageReference,
|
||||
slugTitle,
|
||||
splitPageReference,
|
||||
wikiWordTarget
|
||||
} from "./reference.js";
|
||||
import { cmapRoute, pageRoute } from "./routes.js";
|
||||
|
||||
/*
|
||||
* Render-only transformations for racket-wiki Markdown.
|
||||
*
|
||||
* Stored Markdown remains authoritative. These functions prepare wiki syntax
|
||||
* for Marked; the application remains responsible for DOMPurify sanitizing and
|
||||
* for hydrating the generated CMap placeholders.
|
||||
*/
|
||||
|
||||
/** Escape plain text before placing it in generated HTML. */
|
||||
function escapeHtml(text) {
|
||||
return String(text)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
/** Expand Todo markers outside fenced code for rendering. */
|
||||
function expandTodoMarkup(markdown, pageSlug, todoLabel) {
|
||||
let inFence = false;
|
||||
let todoNumber = 0;
|
||||
const label = escapeHtml(todoLabel);
|
||||
|
||||
return (markdown || "").split("\n").map((line) => {
|
||||
if (/^\s*(```|~~~)/.test(line)) {
|
||||
inFence = !inFence;
|
||||
return line;
|
||||
}
|
||||
if (inFence) return line;
|
||||
|
||||
return line.replace(/todo\(([^()\r\n]+)\)/gi, (_match, text) => {
|
||||
todoNumber += 1;
|
||||
const todoText = escapeHtml(text.trim());
|
||||
if (pageSlug) {
|
||||
const href = `#todo/${encodeURIComponent(pageSlug)}/${todoNumber}`;
|
||||
return `<a class="wiki-todo" href="${href}"><strong>${label}:</strong> ${todoText}</a>`;
|
||||
}
|
||||
return `<span class="wiki-todo"><strong>${label}:</strong> ${todoText}</span>`;
|
||||
});
|
||||
}).join("\n");
|
||||
}
|
||||
|
||||
/** Apply racket-wiki image width and alignment suffixes to rendered image tags. */
|
||||
function applyImageWidthMarkup(html) {
|
||||
const imageOptionsSuffix = /(<img\b[^>]*>)\s*\{\s*width\s*=\s*([0-9]+(?:\.[0-9]+)?)(%|px)?(?:\s+(left|center|right))?(?:\s+(float))?(?:\s+float\s*=\s*(left|right))?\s*\}/gi;
|
||||
|
||||
return (html || "").replace(imageOptionsSuffix, (_match, imageTag, amount, unit, align, floatFlag, floatSide) => {
|
||||
const width = `${amount}${unit || "px"}`;
|
||||
const closing = imageTag.endsWith("/>") ? "/>" : ">";
|
||||
let tagBody = imageTag.slice(0, imageTag.length - closing.length);
|
||||
const classes = [];
|
||||
const effectiveFloatSide = floatSide || (floatFlag && (align === "left" || align === "right") ? align : null);
|
||||
|
||||
if (effectiveFloatSide) {
|
||||
classes.push(`wiki-image-float-${effectiveFloatSide}`);
|
||||
} else if (align) {
|
||||
classes.push(`wiki-image-${align}`);
|
||||
}
|
||||
|
||||
if (/\sstyle=(['"])(.*?)\1/i.test(tagBody)) {
|
||||
tagBody = tagBody.replace(/\sstyle=(['"])(.*?)\1/i, (_style, quote, value) =>
|
||||
` style=${quote}${value}; width: ${width}${quote}`);
|
||||
} else {
|
||||
tagBody += ` style="width: ${width}"`;
|
||||
}
|
||||
|
||||
if (classes.length > 0) {
|
||||
if (/\sclass=(['"])(.*?)\1/i.test(tagBody)) {
|
||||
tagBody = tagBody.replace(/\sclass=(['"])(.*?)\1/i, (_class, quote, value) =>
|
||||
` class=${quote}${value} ${classes.join(" ")}${quote}`);
|
||||
} else {
|
||||
tagBody += ` class="${classes.join(" ")}"`;
|
||||
}
|
||||
}
|
||||
|
||||
return `${tagBody}${closing}`;
|
||||
});
|
||||
}
|
||||
|
||||
/** Collect the human-readable aliases through which a page can be mentioned. */
|
||||
function mentionAliases(page) {
|
||||
const aliases = new Set();
|
||||
const rawSlug = page.pageSlug || splitPageReference(page.slug).slug;
|
||||
const values = [page.title, slugTitle(rawSlug), rawSlug.replaceAll("-", " ")];
|
||||
|
||||
for (const value of values) {
|
||||
if (!value) continue;
|
||||
aliases.add(value);
|
||||
for (const wikiWord of value.match(/(?:\p{Lu}\p{Ll}+){2,}/gu) || []) {
|
||||
aliases.add(wikiWord);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(aliases)
|
||||
.map((value) => ({ text: value, key: normalizeMentionText(value) }))
|
||||
.filter((alias) => alias.key.length >= 5);
|
||||
}
|
||||
|
||||
/** Build the namespace-aware and ambiguity-aware lookup for page mentions. */
|
||||
function pageMentionMap(pages, pageAliases, currentSlug = null) {
|
||||
const map = new Map();
|
||||
|
||||
for (const page of pages) {
|
||||
if (page.slug === currentSlug) continue;
|
||||
const namespace = String(page.namespace || "").toLocaleLowerCase();
|
||||
for (const alias of mentionAliases(page)) {
|
||||
const namespacedKey = `${namespace}:${alias.key}`;
|
||||
if (!map.has(namespacedKey)) {
|
||||
map.set(namespacedKey, page);
|
||||
} else if (map.get(namespacedKey)?.slug !== page.slug) {
|
||||
map.set(namespacedKey, null);
|
||||
}
|
||||
|
||||
const rootKey = `:${alias.key}`;
|
||||
if (!map.has(rootKey)) {
|
||||
map.set(rootKey, page);
|
||||
} else if (map.get(rootKey)?.slug !== page.slug) {
|
||||
map.set(rootKey, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const alias of pageAliases) {
|
||||
const targetPage = pages.find((page) => page.slug === alias.targetSlug);
|
||||
if (!targetPage || targetPage.slug === currentSlug) continue;
|
||||
const namespace = String(alias.namespace || "").toLocaleLowerCase();
|
||||
const aliasPage = { title: alias.title, pageSlug: alias.pageSlug, slug: alias.slug };
|
||||
for (const candidate of mentionAliases(aliasPage)) {
|
||||
const namespacedKey = `${namespace}:${candidate.key}`;
|
||||
if (!map.has(namespacedKey)) map.set(namespacedKey, targetPage);
|
||||
const rootKey = `:${candidate.key}`;
|
||||
if (!map.has(rootKey)) map.set(rootKey, targetPage);
|
||||
}
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Resolve a CMap WikiWord only when it identifies exactly one concept map. */
|
||||
function cmapMentionTarget(text, conceptMaps) {
|
||||
const key = normalizeMentionText(text);
|
||||
const matches = conceptMaps.filter((conceptMap) => {
|
||||
const aliases = [conceptMap.title, conceptMap.slug, slugTitle(conceptMap.slug)];
|
||||
return aliases.some((alias) => normalizeMentionText(alias) === key);
|
||||
});
|
||||
return matches.length === 1 ? matches[0] : null;
|
||||
}
|
||||
|
||||
/** Locate ranges in one Markdown line in which WikiWords must not be expanded. */
|
||||
function markdownProtectedRanges(line) {
|
||||
const ranges = [];
|
||||
const patterns = [
|
||||
/todo\([^()\n]*\)/gi,
|
||||
/!?\[[^\]\n]*\]\([^\)\n]*\)/g,
|
||||
/`+[^`\n]*`+/g,
|
||||
/<https?:\/\/[^>\n]+>/gi,
|
||||
/https?:\/\/[^\s<>()]+/gi
|
||||
];
|
||||
|
||||
for (const pattern of patterns) {
|
||||
for (const match of line.matchAll(pattern)) {
|
||||
ranges.push({ start: match.index, end: match.index + match[0].length });
|
||||
}
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
/** Test whether a candidate WikiWord overlaps protected Markdown syntax. */
|
||||
function positionIsProtected(start, end, ranges) {
|
||||
for (const range of ranges) {
|
||||
if (start < range.end && end > range.start) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* goal : Rewrite namespaced Markdown targets to internal hash routes.
|
||||
* pre : markdown is source text; fenced and indented code must remain untouched.
|
||||
* post : Stored Markdown is not changed.
|
||||
* result : Render-only Markdown with explicit wiki routes.
|
||||
*/
|
||||
function expandNamespacedMarkdownLinks(markdown) {
|
||||
const lines = String(markdown || "").split("\n");
|
||||
const result = [];
|
||||
let fence = null;
|
||||
|
||||
for (const line of lines) {
|
||||
const fenceMatch = line.match(/^\s*(```+|~~~+)/);
|
||||
if (fenceMatch) {
|
||||
const marker = fenceMatch[1].charAt(0);
|
||||
fence = fence === null ? marker : (fence === marker ? null : fence);
|
||||
result.push(line);
|
||||
continue;
|
||||
}
|
||||
if (fence !== null || /^\s{4}/.test(line)) {
|
||||
result.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
result.push(line.replace(/(!?\[[^\]\n]*\]\()([\p{L}\p{N}._-]+):([\p{L}\p{N}._-]+)(\))/gu,
|
||||
(_match, before, namespace, slug, after) => {
|
||||
const target = namespace.toLocaleLowerCase() === "cmap" ?
|
||||
cmapRoute(slug) : pageRoute(pageReference(namespace, slug));
|
||||
return `${before}${target}${after}`;
|
||||
}));
|
||||
}
|
||||
return result.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
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;
|
||||
|
||||
for (const line of lines) {
|
||||
const fenceMatch = line.match(/^\s*(```+|~~~+)/);
|
||||
if (fenceMatch) {
|
||||
const marker = fenceMatch[1].charAt(0);
|
||||
if (fence === null) fence = marker;
|
||||
else if (fence === marker) fence = null;
|
||||
result.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (fence !== null || /^\s{4}/.test(line)) {
|
||||
result.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
const protectedRanges = markdownProtectedRanges(line);
|
||||
const replacements = [];
|
||||
for (const match of line.matchAll(wikiWordPattern)) {
|
||||
const start = match.index;
|
||||
const end = start + match[0].length;
|
||||
if (positionIsProtected(start, end, protectedRanges)) continue;
|
||||
const namespace = match[1] || "";
|
||||
if (namespace.toLocaleLowerCase() === "cmap") {
|
||||
const conceptMap = cmapMentionTarget(match[2], conceptMaps);
|
||||
if (conceptMap) replacements.push({ start, end, conceptMap });
|
||||
} else {
|
||||
const page = wikiWordTarget(match[2], aliases, namespace);
|
||||
if (page) replacements.push({ start, end, page });
|
||||
}
|
||||
}
|
||||
|
||||
let expanded = line;
|
||||
for (let index = replacements.length - 1; index >= 0; index -= 1) {
|
||||
const replacement = replacements[index];
|
||||
const link = 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);
|
||||
}
|
||||
result.push(expanded);
|
||||
}
|
||||
|
||||
return result.join("\n");
|
||||
}
|
||||
|
||||
/** Replace standalone CMap embeds outside fenced code with stable tokens. */
|
||||
function extractCmapEmbeds(markdown) {
|
||||
const embeds = [];
|
||||
let fence = null;
|
||||
const lines = String(markdown || "").split("\n").map((line) => {
|
||||
const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/);
|
||||
if (fenceMatch) {
|
||||
const marker = fenceMatch[1].charAt(0);
|
||||
if (fence === null) fence = marker;
|
||||
else if (fence === marker) fence = null;
|
||||
return line;
|
||||
}
|
||||
if (fence !== null) return line;
|
||||
|
||||
const match = line.match(/^\s*\{\{cmap:([^{}\n]+)\}\}\s*$/i);
|
||||
if (!match) return line;
|
||||
const token = `RACKETWIKICMAPEMBED${embeds.length}TOKEN`;
|
||||
embeds.push({ token, reference: match[1].trim() });
|
||||
return token;
|
||||
});
|
||||
return { markdown: lines.join("\n"), embeds };
|
||||
}
|
||||
|
||||
/** Restore extracted CMap tokens as inert placeholders for later hydration. */
|
||||
function restoreCmapEmbeds(html, embeds, loadingLabel) {
|
||||
let result = html;
|
||||
const loading = escapeHtml(loadingLabel);
|
||||
for (const embed of embeds) {
|
||||
const reference = escapeHtml(embed.reference);
|
||||
const placeholder = `<section class="rw-cmap-embed" data-cmap-reference="${reference}"><div class="rw-cmap-embed-loading">${loading}</div></section>`;
|
||||
result = result.replace(`<p>${embed.token}</p>`, placeholder).replace(embed.token, placeholder);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export {
|
||||
applyImageWidthMarkup,
|
||||
cmapMentionTarget,
|
||||
escapeHtml,
|
||||
expandNamespacedMarkdownLinks,
|
||||
expandTodoMarkup,
|
||||
expandWikiMentions,
|
||||
extractCmapEmbeds,
|
||||
restoreCmapEmbeds
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Markdown page-outline calculations.
|
||||
*
|
||||
* This module recognizes source headings and assigns stable HTML identifiers.
|
||||
* It has no application state and does not inspect or modify the DOM.
|
||||
*/
|
||||
|
||||
/**
|
||||
* goal : Produce a unique HTML identifier for a rendered heading.
|
||||
* pre : usedIds contains identifiers already assigned within the document.
|
||||
* post : The returned identifier is added to usedIds.
|
||||
* result : A readable normalized identifier, with a numeric suffix if needed.
|
||||
*/
|
||||
function headingId(text, usedIds) {
|
||||
const base = String(text || "")
|
||||
.trim()
|
||||
.toLocaleLowerCase()
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^\p{L}\p{N}]+/gu, "-")
|
||||
.replace(/^-+|-+$/g, "") || "section";
|
||||
let id = base;
|
||||
let number = 2;
|
||||
|
||||
while (usedIds.has(id)) {
|
||||
id = `${base}-${number}`;
|
||||
number += 1;
|
||||
}
|
||||
usedIds.add(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* goal : Extract ATX headings and their source locations from Markdown.
|
||||
* pre : markdown is source text and may contain fenced code blocks.
|
||||
* post : The source is not changed; headings inside fences are ignored.
|
||||
* result : Ordered level/text/line records for the document outline.
|
||||
*/
|
||||
function markdownHeadings(markdown) {
|
||||
const headings = [];
|
||||
const lines = String(markdown || "").replace(/\r\n/g, "\n").split("\n");
|
||||
const fencePattern = /^\s*(```|~~~)/;
|
||||
let inFence = false;
|
||||
|
||||
for (let lineNumber = 0; lineNumber < lines.length; lineNumber += 1) {
|
||||
const line = lines[lineNumber];
|
||||
if (fencePattern.test(line)) {
|
||||
inFence = !inFence;
|
||||
continue;
|
||||
}
|
||||
if (inFence) continue;
|
||||
|
||||
const match = line.match(/^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$/);
|
||||
if (!match) continue;
|
||||
headings.push({
|
||||
level: match[1].length,
|
||||
text: match[2].trim(),
|
||||
line: lineNumber
|
||||
});
|
||||
}
|
||||
return headings;
|
||||
}
|
||||
|
||||
export { headingId, markdownHeadings };
|
||||
@@ -0,0 +1,102 @@
|
||||
/*
|
||||
* Wiki page references.
|
||||
*
|
||||
* This module owns the syntax used to identify root and namespaced pages.
|
||||
* It has no application or DOM state.
|
||||
*/
|
||||
|
||||
/** Split a wiki reference into its namespace and page slug. */
|
||||
function splitPageReference(reference) {
|
||||
const value = String(reference || "");
|
||||
const separator = value.indexOf(":");
|
||||
if (separator < 0) return { namespace: "", slug: value };
|
||||
return { namespace: value.slice(0, separator), slug: value.slice(separator + 1) };
|
||||
}
|
||||
|
||||
/** Build the compact external reference for a root or namespaced page. */
|
||||
function pageReference(namespace, slug) {
|
||||
const cleanNamespace = String(namespace || "").trim();
|
||||
return cleanNamespace ? `${cleanNamespace}:${slug}` : slug;
|
||||
}
|
||||
|
||||
/** Convert one freely entered reference part to a bounded wiki identifier. */
|
||||
function slugifyReferencePart(value, maximum) {
|
||||
return String(value || "")
|
||||
.normalize("NFKD")
|
||||
.toLocaleLowerCase()
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^\p{L}\p{N}]+/gu, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, maximum)
|
||||
.replace(/-+$/g, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* goal : Convert a freely entered page title/address to a wiki reference.
|
||||
* pre : input may contain a namespace separated by a colon.
|
||||
* post : No page is created and no application state is changed.
|
||||
* result : A normalized reference, or null when the input is empty or invalid.
|
||||
*/
|
||||
function newPageReference(input) {
|
||||
const parts = splitPageReference(String(input || "").trim());
|
||||
const namespace = parts.namespace ? slugifyReferencePart(parts.namespace, 80) : "";
|
||||
const slug = slugifyReferencePart(parts.slug, 120);
|
||||
if (!slug || (parts.namespace && !namespace)) return null;
|
||||
return pageReference(namespace, slug);
|
||||
}
|
||||
|
||||
/** Normalize human-readable text for ambiguity-aware WikiWord matching. */
|
||||
function normalizeMentionText(text) {
|
||||
return String(text || "")
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLocaleLowerCase()
|
||||
.replace(/[^\p{L}\p{N}]+/gu, "");
|
||||
}
|
||||
|
||||
/** Return the separate capitalized words when text is a classic WikiWord. */
|
||||
function wikiWordParts(text) {
|
||||
const value = String(text || "");
|
||||
if (!/^(?:\p{Lu}\p{Ll}+){2,}$/u.test(value)) return [];
|
||||
return value.match(/\p{Lu}\p{Ll}+/gu) || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* goal : Resolve a classic WikiWord to an existing page or new page target.
|
||||
* pre : aliases is an ambiguity-aware map built by wiki-markdown.js.
|
||||
* post : No page is created and aliases is not changed.
|
||||
* result : A slug/title pair, or null when text is not a WikiWord.
|
||||
*/
|
||||
function wikiWordTarget(text, aliases, namespace = "") {
|
||||
const parts = wikiWordParts(text);
|
||||
if (parts.length === 0) return null;
|
||||
|
||||
const aliasKey = `${String(namespace || "").toLocaleLowerCase()}:${normalizeMentionText(text)}`;
|
||||
const existing = aliases.get(aliasKey);
|
||||
if (existing) return { slug: existing.slug, title: existing.title };
|
||||
|
||||
const slug = parts.map((part) => part.toLocaleLowerCase()).join("-");
|
||||
return {
|
||||
slug: pageReference(namespace, slug),
|
||||
title: parts.join(" ")
|
||||
};
|
||||
}
|
||||
|
||||
/** Derive a readable title from the page part of a wiki reference. */
|
||||
function slugTitle(reference) {
|
||||
const slug = splitPageReference(reference || "").slug;
|
||||
return slug
|
||||
.split("-")
|
||||
.filter(Boolean)
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export {
|
||||
newPageReference,
|
||||
normalizeMentionText,
|
||||
pageReference,
|
||||
slugTitle,
|
||||
splitPageReference,
|
||||
wikiWordTarget
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Client-side wiki routes.
|
||||
*
|
||||
* This module owns page/CMap hash construction and all hash recognition. It
|
||||
* returns plain route records and has no knowledge of sessions, permissions,
|
||||
* application state or DOM views.
|
||||
*/
|
||||
|
||||
/** Build the client-side route for a wiki page reference. */
|
||||
function pageRoute(reference) {
|
||||
return `#/${encodeURIComponent(reference)}`;
|
||||
}
|
||||
|
||||
/** Build the client-side route for a concept map. */
|
||||
function cmapRoute(slug) {
|
||||
return `#cmap/${encodeURIComponent(slug)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* goal : Recognize one browser hash as a wiki application route.
|
||||
* pre : hash is the current location hash or another string to inspect.
|
||||
* post : No browser or application state is changed.
|
||||
* result : A named route record; unknown and empty hashes produce home.
|
||||
* internals: Exact special views are recognized first. Parameterized Todo,
|
||||
* CMap, search and page routes are then decoded into their values.
|
||||
*/
|
||||
function parseWikiRoute(hash) {
|
||||
const value = String(hash || "");
|
||||
|
||||
switch (value) {
|
||||
case "#profile": return { name: "profile" };
|
||||
case "#cmaps": return { name: "cmaps" };
|
||||
case "#recent": return { name: "recent" };
|
||||
case "#bookmarks": return { name: "bookmarks" };
|
||||
case "#todos": return { name: "todos" };
|
||||
case "#admin": return { name: "admin" };
|
||||
case "#admin/users": return { name: "admin-users" };
|
||||
case "#admin/mail": return { name: "admin-mail" };
|
||||
case "#admin/aliases": return { name: "admin-aliases" };
|
||||
case "#admin/orphaned-uploads": return { name: "admin-orphaned-uploads" };
|
||||
case "#admin/archived-cmaps": return { name: "admin-archived-cmaps" };
|
||||
}
|
||||
|
||||
const todoMatch = value.match(/^#todo\/([^/]+)\/(\d+)$/);
|
||||
if (todoMatch) {
|
||||
return {
|
||||
name: "todo",
|
||||
slug: decodeURIComponent(todoMatch[1]),
|
||||
number: Number(todoMatch[2])
|
||||
};
|
||||
}
|
||||
|
||||
const cmapMatch = value.match(/^#cmap\/([^/]+)$/);
|
||||
if (cmapMatch) {
|
||||
return { name: "cmap", slug: decodeURIComponent(cmapMatch[1]) };
|
||||
}
|
||||
|
||||
const searchMatch = value.match(/^#search\/(.+)$/);
|
||||
if (searchMatch) {
|
||||
return { name: "search", query: decodeURIComponent(searchMatch[1]) };
|
||||
}
|
||||
|
||||
const pageMatch = value.match(/^#\/([^/]+)$/);
|
||||
if (pageMatch) {
|
||||
return { name: "page", slug: decodeURIComponent(pageMatch[1]) };
|
||||
}
|
||||
|
||||
return { name: "home" };
|
||||
}
|
||||
|
||||
export { cmapRoute, pageRoute, parseWikiRoute };
|
||||
Reference in New Issue
Block a user