diff --git a/HANDOFF.md b/HANDOFF.md index 52dbffd..fab2569 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -1,6 +1,6 @@ # Overdracht racket-wiki -Bijgewerkt: 22 augustus 2026 +Bijgewerkt: 25 augustus 2026 ## Project in het kort @@ -12,19 +12,20 @@ Bijgewerkt: 22 augustus 2026 - EasyMDE/Marked voor Markdown, DOMPurify voor sanitizing, highlight.js voor code en diff2html voor versieverschillen; - lokale, tijdens setup gedownloade frontendbibliotheken, zodat normaal gebruik geen CDN nodig heeft. -De ontwikkelversie is **0.2.99** (`info.rkt`). De huidige PostgreSQL-schemaversie is **12** (`private/migrations.rkt`). +De ontwikkelversie is **0.2.122** (`info.rkt`). De huidige PostgreSQL-schemaversie is **21** (`private/migrations.rkt`). ## Belangrijk: huidige werkboom De branch is `main` en volgt `origin/main`. De laatste commit is: ```text -63b7ca0 cmap functions, email, architecture documentation. +20c1584 Lots of changes to the cmap stuff ``` -Er staan belangrijke, nog niet gecommitte wijzigingen in de werkboom. Die vormen samen de ontwikkeling van 0.2.95 t/m 0.2.99 en moeten worden behouden. Bij de laatste inventarisatie waren gewijzigd: +Er staan belangrijke, nog niet gecommitte wijzigingen voor versie 0.2.100 in de werkboom en die moeten worden behouden. Bij de laatste inventarisatie waren onder meer gewijzigd of toegevoegd: ```text +HANDOFF.md README.md architecture/pages/data-en-versies.md architecture/pages/gedrag.md @@ -32,22 +33,18 @@ architecture/pages/testbaarheid.md info.rkt private/cmap-storage.rkt private/migrations.rkt +private/people.rkt +scrbl/racket-wiki.scrbl server.rkt static/cmap/cmap-racket-wiki.js static/cmap/cmap.css -static/cmap/cmap.js -static/css/wiki.css static/index.html static/js/wiki.js +static/js/cmap-export.js +test/cmap-export.test.js translate.rkt ``` -Nieuw en nog untracked: - -```text -migrate-cmap-subpages.rkt -``` - Voer dus geen reset, checkout of brede formattering uit voordat deze wijzigingen zijn beoordeeld en veilig gecommitte. `wiki-data/` en `compiled/` zijn lokale/gegenereerde directories en staan in `.gitignore`. ## Snel starten @@ -110,25 +107,20 @@ De browserfrontend heeft bewust geen buildstap. Wijzig bronbestanden rechtstreek - Pagina's hebben een aparte `namespace` en stabiele `slug`; de combinatie is uniek. Een titelwijziging verandert de slug niet. - Elke paginasave wijzigt `pages` en schrijft in dezelfde transactie een onveranderlijke `page_versions`-rij. - Pagina-updates en CMap-updates gebruiken optimistic locking en horen bij een verouderde basisversie HTTP 409 te geven. -- Pagina's en CMaps worden gearchiveerd (soft delete); histories blijven behouden. +- Pagina's en CMaps worden gearchiveerd (soft delete); histories blijven behouden. Een volledige + CMap vereist titel- en versiebevestiging en kan onder **Admin → Gearchiveerde CMaps** worden hersteld. - Bijlagen staan als `BYTEA` in PostgreSQL. Alleen vertrouwde rasterformaten worden inline geserveerd; andere bestanden worden downloads. - Markdown gaat vóór invoegen in de DOM door DOMPurify. - Verhoog `current-schema-version` alleen samen met een nieuwe, opeenvolgende migratie. Een database met een nieuwer schema dan de code moet geweigerd blijven. -## Lopend werk: 0.2.95–0.2.99 +## Lopend werk: 0.2.100 -De niet-gecommitte reeks bouwt vooral de persistente CMap-functionaliteit uit: +De laatste commit bevat de CMap-uitbreidingen van 0.2.95–0.2.99. De niet-gecommitte 0.2.100-reeks voegt hieraan toe: -- stabiele, wiki-brede `conceptId`-identiteiten en gekoppelde plaatsingen met een eigen layout; -- conceptaspecten, beschrijvingspagina's, stijlpresets, afzonderlijke titel-/synopsisopmaak en een bewerkbaar kleurenpalet; -- directe relaties met Alt, gelinkte copy/paste en robuust herstel bij relaties met ontbrekende eindpunten; -- afgeleide, opgeslagen sub-CMapweergaven die naar de canonieke parentgraph verwijzen; -- keuze van een start-CMap en per-view verborgen concepten; -- autosave zonder historievervuiling, maximaal vijf handmatige saves en onbeperkte snapshots; -- verwijderen van afzonderlijke handmatige CMap-history-items/snapshots; -- een paneel op wikipagina's met alle gekoppelde CMap-concepten en plaatsingstellingen. +- Markdown-export met instelbare diepte voor gekoppelde CMaps, optionele wikipagina's en CMap-tags, samenvattingen en uitleg; +- wiki-brede persoonstags met een activeerbaar/deactiveerbaar personenregister (schema 13). -Migratie 12 schoont oude CMap-autosavehistorie op en beperkt herkenbare handmatige saves tot vijf. `migrate-cmap-subpages.rkt` is een aparte contentmigratie voor oudere inline sub-CMaps; dit is geen databaseschemamigratie. +Migratie 12 schoont oude CMap-autosavehistorie op en beperkt herkenbare handmatige saves tot vijf. Migratie 13 maakt het personenregister en neemt bestaande persoonstags uit CMaps over. Migraties 14–17 vormden de tussenstappen naar gedeelde conceptidentiteit. Migratie 18 normaliseert de actuele CMap-documenten definitief op getrimde, hoofdletterongevoelige conceptnaam: alle ids worden fysiek herschreven, dubbele definities worden verwijderd, de repository wordt opnieuw opgebouwd en de tijdelijke aliastabel wordt verwijderd. Migratie 19 verwijdert daarna alle gedupliceerde conceptinhoud uit plaatsingsitems. Migratie 20 reduceert ook `concepts[]` in actuele CMap-documenten tot `{id}`-verwijzingen; alleen `concept_definitions` bewaart nog inhoud. `migrate-cmap-subpages.rkt` is een aparte contentmigratie voor oudere inline sub-CMaps; dit is geen databaseschemamigratie. Gebruik die contentmigratie altijd eerst als dry-run: @@ -144,19 +136,22 @@ Zonder `--apply` wordt niets gewijzigd. Met `--apply` gebeurt de omzetting in é De automatische testdekking is beperkt. Op 22 augustus 2026 zijn de volgende controles succesvol uitgevoerd: ```sh -raco test architecture/import.rkt -# 12 tests passed +raco test private/people.rkt architecture/import.rkt +# 13 tests passed raco make main.rkt server.rkt setup-vendor.rkt \ migrate-cmap-subpages.rkt architecture/import.rkt node --check static/js/wiki.js +node --check static/js/cmap-export.js node --check static/js/combobox.js node --check static/cmap/cmap.js node --check static/cmap/cmap-racket-wiki.js + +node --test test/cmap-export.test.js ``` -Er is nog geen geautomatiseerde backendintegratietest tegen PostgreSQL en geen browsertestsuite. De huidige 0.2.95–0.2.99-werkboom is dus wel compileerbaar en syntactisch geldig, maar nog niet in deze overdracht end-to-end gevalideerd. +Er is nog geen geautomatiseerde backendintegratietest tegen PostgreSQL en geen browsertestsuite. De huidige 0.2.100-werkboom is dus wel compileerbaar en syntactisch geldig, maar nog niet in deze overdracht end-to-end gevalideerd. Voer vóór commit/release minimaal deze smoke tests uit op een kopie of aparte testdatabase: @@ -205,6 +200,6 @@ Accepteer onbetrouwbare certificaten alleen bewust voor een vertrouwde lokale SM ## Aanbevolen eerstvolgende stap -Maak eerst een databasebackup en test de volledige niet-gecommitte 0.2.95–0.2.99-reeks met bovenstaande smoke tests. Beoordeel daarna de diff als één samenhangende CMap-wijziging, werk zo nodig `README.md`, Scribble-documentatie en architectuurpagina's gelijk bij, en commit pas wanneer migratie 12 en `migrate-cmap-subpages.rkt` op representatieve data zijn geverifieerd. +Maak eerst een databasebackup en test de niet-gecommitte 0.2.100-reeks met bovenstaande smoke tests. Beoordeel daarna de diff als één samenhangende CMap-wijziging en commit pas wanneer migratie 13, Markdown-export en personenbeheer op representatieve data zijn geverifieerd. Voor functionele details en releasehistorie is `README.md` de uitgebreidste bron. De bestanden onder `architecture/pages/` beschrijven ontwerpbeslissingen en kwaliteitsafspraken; `architecture/pages/code-regels.md` is de beste start voor projectconventies. diff --git a/README.md b/README.md index 9615630..c40ecf8 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Version 0.2.31 adds page namespaces as database metadata and extends wiki references to forms such as `RWS:ModelTreeWalker` and `[roadmap](racket:roadmap)`. Todo items and bookmarks are grouped by namespace. The source has also been documented more thoroughly, especially `static/js/wiki.js`. -Current development version: **0.2.99**. +Current development version: **0.2.100**. A small self-hosted wiki with a Racket backend and an HTML5/CSS/JavaScript frontend. @@ -16,7 +16,7 @@ The backend uses Racket `web-server-lib` for HTTP routing, `db-lib` for PostgreS Passwords use PBKDF2-HMAC-SHA256 through the libcrypto/OpenSSL provider. Session tokens are random client values whose SHA-256 hashes are stored in PostgreSQL; authenticated write requests also use a per-session CSRF token. -The editor uses EasyMDE 2.21.0. EasyMDE bundles CodeMirror for editing and Marked for Markdown rendering. The application uses `easyMDE.markdown(...)` for the editor preview, normal page display and historical page display. DOMPurify sanitizes rendered Markdown, highlight.js highlights fenced code blocks, and diff2html renders page-version differences. +The editor uses EasyMDE 2.21.0. EasyMDE bundles CodeMirror for editing and Marked for Markdown rendering. The application uses `easyMDE.markdown(...)` for the editor preview, normal page display and historical page display. DOMPurify sanitizes rendered Markdown, Mermaid 11.17.1 renders `mermaid` fenced code blocks, highlight.js highlights other fenced code blocks, and diff2html renders page-version differences. Lucide supplies the sidebar and EasyMDE toolbar icons. Its pinned browser build is installed locally by the setup procedure, so normal wiki use does not contact a CDN. @@ -41,6 +41,8 @@ pages page_versions attachments concept_maps +concept_map_versions +people wiki_schema ``` @@ -102,6 +104,7 @@ lucide.min.js easymde.min.js easymde.min.css purify.min.js +mermaid.min.js highlight.min.js highlight-scheme.min.js highlight-github.min.css @@ -111,6 +114,19 @@ diff2html.min.css EasyMDE already bundles CodeMirror and Marked, so racket-wiki does not download a separate Markdown renderer or CodeMirror build. +Mermaid diagrams use ordinary fenced Markdown and are rendered in page view, +editor preview and page history: + +````text +```mermaid +flowchart LR + A[Markdown] --> B[Diagram] +``` +```` + +Mermaid runs locally with its strict security level; normal wiki use does not +contact a CDN. + The setup downloader validates HTTPS certificates and hostnames. The running wiki serves these files locally from the configured data directory, so no CDN is required after setup. `setup-vendor.rkt` remains available as a manual/recovery command. It now writes to the same data directory as the web setup: @@ -941,3 +957,219 @@ that page. Concepts are grouped by stable `conceptId`; each row shows the concep placement count and clickable chips for every CMap containing it. Repeated placements in one CMap show their local count. The panel follows page aliases and uses only current, non-archived CMap documents—not historical versions. + +### 0.2.100 + +Stored CMaps now have export metadata of their own: comma-separated tags, a summary and an optional +wiki page containing the longer CMap explanation. **Export Markdown** creates a report-oriented +Markdown file or copies the same source to the clipboard. The export includes concepts, readable +relations, CMap metadata and explanations, and follows linked stored CMaps to a selected depth from +zero through ten. A separate option includes linked wiki pages and concept-description pages. Cycles +and repeated links are emitted only once, and derived sub-CMap views export only their own subtree. + +A person is represented as a typed concept tag rather than a separate responsibility object. A +concept can carry several person tags; linked placements sharing the same `conceptId` also share +those tags. The concept card displays the people marker and the Markdown report identifies those +people as the responsibility/action owners. Schema migration 13 adds a wiki-wide `people` registry +and imports names already present in CMap documents. The concept editor offers active people in a +multi-select dropdown. **Manage people** adds names and activates or deactivates them; deactivation +never removes the tag from existing CMaps. + +### 0.2.101 + +Concept content is now truly shared between placements on different CMaps. Schema migration 14 +creates a global `concept_definitions` repository keyed by stable `conceptId`; opening a CMap +hydrates its label, synopsis, aspects, tags, links, image and description-page reference from that +repository. Saving any placement updates that shared definition, so another CMap can no longer keep +a different explanation for the same concept. Position, dimensions, colours and typography remain +stored per placement and may still differ between maps. During migration, the most recently edited +CMap supplies the initial definition when older copies disagree. + +### 0.2.102 + +Sub-CMap heads now follow the same identity rule as every other concept placement. Their structural +role is local to the map; it no longer excludes them from `concept_definitions`, shared-content +updates, copy-by-reference or global placement counts. Migration 15 imports definitions that older +documents stored only on sub-CMap items. Loading also hydrates item-only concept ids, so an existing +head such as **Processen LBS** immediately receives the same synopsis as its other placements while +retaining its own position, dimensions, colours and typography. + +### 0.2.103 + +Concept identity now also follows stored CMap links. When a concept placement links to a derived +concept map, that placement and the target map's head are canonicalized to one `conceptId`. +Migration 16 records old ids in `concept_aliases` and merges their shared definition, retaining the +most recently edited content. The shared fields are deliberately limited to name, aspects, people, +concept-description page, synopsis, image, linked wiki page and linked concept map. Node kind, +sub-CMap navigation state, coordinates, dimensions, colours and typography remain placement data. + +### 0.2.104 + +Concept names are now wiki-wide identity keys after trimming whitespace and case-folding. Migration +17 merges pre-existing definitions such as the two independently created **Processen LBS** records, +rewrites every current CMap placement and concept reference to the canonical id, removes the duplicate +definition and selects the most recently edited shared content. A retained alias only lets an old +editor or historical identifier resolve safely. Creation and save paths apply the same rule, so adding +the same concept name on another map directly reuses the existing id. Relations remain attached to +their placements while all item-level layout remains independent. + +### 0.2.105 + +Schema migration 18 replaces the temporary alias approach with direct normalization of current +CMap data. It derives one canonical id per trimmed, case-folded concept name from the actual item +placements, rewrites every current `items[].conceptId` and `concepts[].id`, removes duplicate concept +definitions and rebuilds `concept_definitions` from the normalized documents. Connectors keep their +item endpoints, so every relation remains attached to the corresponding placement. The temporary +`concept_aliases` table is dropped. Historical CMap snapshots remain immutable and are not part of +the current concept repository. + +### 0.2.106 + +Concept placements no longer persist denormalized copies of concept content. Migration 19 removes +name, summary, aspects, people, description-page reference, image and linked page/CMap fields from +every non-phrase `items[]` record. Those fields now exist only in `concepts[]` and the global +`concept_definitions` row. Items retain structural state, coordinates, dimensions, colours and +typography. This prevents a root map and submap context from rendering different stale summaries for +the same single placement. Linking phrases retain their label because they describe relations. + +### 0.2.107 + +Schema migration 20 makes the central concept definition the only persisted source of concept +content. Current CMap documents retain only unique `{id}` references in `concepts[]`; non-phrase +`items[]` contain only placement, grouping and presentation data. API reads hydrate referenced +definitions for the editor and defensively remove legacy item content. Existing historical +snapshots remain unchanged. + +### 0.2.108 + +Concept cards now treat their complete shared content as a minimum-height constraint in every map +context. Context-specific width, typography and larger manual height remain presentation settings, +but an old undersized sub-CMap layout can no longer clip the synopsis and make one concept appear to +have different content on two maps. + +### 0.2.109 + +The concept editor now changes title and synopsis font sizes in 0.5-point increments. Existing +finer-grained values are displayed at the nearest half point, and typed values are normalized to +the same step when saved. + +### 0.2.110 + +The wiki Todo view now also includes every current concept whose aspects contain `TODO`, matched +case-insensitively after trimming whitespace. Concept todos are derived directly from the global +concept definition, occur once regardless of placement count, show the shared synopsis and link to +the concept description page, linked page/CMap or current placement map. Removing the aspect removes +the item without maintaining a second index. + +### 0.2.111 + +The linked-concept clipboard now belongs to the shared browser module instead of one disposable CMap +editor. Copy and the new Cut action therefore survive navigation to another root or derived CMap, +and Paste creates a placement from the stored reference without requiring the source concept to +already occur on the target map. `Ctrl/Cmd+X`, `Ctrl/Cmd+C` and `Ctrl/Cmd+V` mirror the context-menu +actions. Cut removes the source placement and its local relations but never deletes the global +concept. Derived CMaps now use their source slug when reconciling live and stored usage, preventing +one placement from being displayed as `(2)` merely because the same source document is open through +a sub-CMap route. + +### 0.2.112 + +Het archiveren van een volledige CMap staat nu als afzonderlijke gevarenactie helemaal onderaan het +CMap-menu. Na de expliciete vraag moet de gebruiker ook de volledige kaartnaam exact intypen; de +server controleert daarnaast de titel en de actuele versie voordat hij archiveert. Beheerders kunnen +onder **Admin → Gearchiveerde CMaps** alle gearchiveerde kaarten met archiefdatum en gebruiker zien en +herstellen. Herstel behoudt de inhoud, relaties, identiteit en volledige versiegeschiedenis. + +### 0.2.113 + +Asynchrone automatische maatcorrecties tijdens het renderen van een CMap gelden niet langer als +gebruikerswijziging. De renderer levert de toestand direct voor en na zo'n correctie aan de host; +alleen wanneer de opgeslagen baseline nog exact gelijk is aan de voorafgaande toestand schuift die +baseline mee. Een ongewijzigde kaart kan daardoor zonder opslagmelding worden verlaten, terwijl een +echte tussentijdse bewerking, verplaatsing of handmatige formaatwijziging zichtbaar blijft. + +### 0.2.114 + +Conceptidentiteiten zijn voortaan kale, globaal unieke UUID's en bevatten dus geen CMap-slug of +`concept-`-voorvoegsel meer. Schemamigratie 21 behoudt het UUID-deel van bestaande +`concept-`-identiteiten en kent met Rackets `uuid`-module een UUID v4 toe aan iedere oude +lokale of `legacy:`-identiteit. Zij herschrijft alle actuele plaatsingen, conceptverwijzingen en +centrale definities in één transactie; relaties blijven gekoppeld via hun lokale item-id en +historische CMap-snapshots blijven onveranderlijk. De slug blijft de stabiele, leesbare identiteit +van de CMap zelf en wordt niet gewijzigd. Nieuwe browserplaatsingen gebruiken Web Crypto; iedere +UUID die de server voor migratie of invoer moet toekennen komt uit dezelfde Racket-helper. + +### 0.2.122 + +Relatiepijlen lopen visueel altijd achter conceptkaarten langs. De grafische basis gebruikt nu +afzonderlijke lagen voor relaties, concepten en tijdelijke verbindingshendels, waardoor ook het +selecteren of naar voren halen van een relatie haar niet over een concept heen tekent. De hit-test +volgt dezelfde volgorde: op een kruising krijgt het zichtbare concept de klik, niet de erachter +liggende pijl. + +### 0.2.121 + +De selectietoolbar kan geselecteerde concepten nu ook horizontaal verdelen. Het meest linkse en +meest rechtse concept blijven staan; de tussenliggende concepten worden zo geplaatst dat alle +horizontale tussenruimtes gelijk zijn. Net als verticaal verdelen vereist dit minimaal drie +zichtbare geselecteerde concepten en wordt de handeling opgenomen in Undo/Redo en autosave. + +### 0.2.120 + +Bij een meervoudige CMap-selectie is het laatst geselecteerde concept nu het referentieobject voor +alle maat- en uitlijncommando's. De breedte, hoogte, randen en middellijnen van dat concept bepalen +de doelgeometrie; het referentieconcept zelf blijft daardoor ongewijzigd. Binnen een bestaande +selectie kan het referentieobject met een gewone klik worden gewisseld en het heeft een blauwe +selectie-accentkleur, tegenover paars voor de overige selectie. Verticaal verdelen blijft de +bovenste en onderste selectie als grenzen gebruiken. + +### 0.2.119 + +Links van het CMap-diagram staat nu een vaste selectietoolbar. Naast bewerken, groeperen, +ontgroeperen en verbergen bevat zij layoutcommando's voor gelijke breedte, gelijke hoogte, gelijke +afmetingen, links/midden/rechts uitlijnen, boven/midden/onder uitlijnen en verticaal verdelen. +Groottecommando's gebruiken de grootste geselecteerde afmeting om inhoud niet af te snijden. +Verticaal verdelen houdt het bovenste en onderste element vast en maakt de tussenruimtes gelijk. +Layoutacties worden als gewone edits opgenomen in Undo/Redo en autosave. + +### 0.2.118 + +De terugknop van een afzonderlijk geopende sub-CMap gaat nu precies één kaartniveau terug. Bij een +afgeleide CMap werd voorheen direct naar de bronroute genavigeerd, ook wanneer `mapHistory` nog een +dieper geopende sub-CMap bevatte. Lokale submapgeschiedenis heeft nu voorrang; pas vanaf de wortel +van de afgeleide kaart navigeert de volgende terugactie naar de bron-CMap. + +### 0.2.117 + +JSON-bundles nemen nu ook de werkelijk gebruikte attachments van gekoppelde wiki- en +uitlegpagina's mee. Iedere bijlage staat met de oorspronkelijke upload-URL, bestandsnaam, +MIME-type en base64-inhoud in `pages[].attachments`. Bij import worden de bestanden via de +normale upload-API opgeslagen en worden de Markdown-URL's herschreven naar hun nieuwe +pagina-eigen URL. Het uitwisselingsformaat blijft versie 1; oudere bundles zonder +`attachments` blijven geldig. De maximale lokale bundle-invoer is 256 MiB, terwijl iedere +afzonderlijke attachment onder de bestaande uploadgrens van 50 MiB blijft. + +### 0.2.116 + +Het verslepen van een bron- of doeluiteinde van een CMap-pijl werkt nu de logische connector +(`sourceId`/`targetId`) direct bij. Voorheen veranderde alleen de lijn in de tekenlaag, waarna het +inklappen en opnieuw uitklappen van een submap de oude relatie opnieuw projecteerde. Ongeldige +losse eindpunten worden teruggezet op de bestaande relatie. Een regressietest dekt expliciet +bewerken, inklappen, uitklappen en serialiseren. + +### 0.2.115 + +CMaps kunnen nu naast een leesbaar Markdownrapport ook als een versieerbare +`racket-wiki-cmap-bundle` in JSON worden geëxporteerd en opnieuw geïmporteerd. De bundle bewaart +CMap-slugs, de volledige diagramopmaak, submaps, verbindingszinnen en connectoren; gedeelde +conceptinhoud staat één keer per UUID in `concepts[]`. Gekoppelde wiki-, conceptuitleg- en +CMap-uitlegpagina's gaan met hun actuele Markdown en tags mee. Gekoppelde CMaps volgen de gekozen +diepte en de bron van een afgeleide CMap wordt altijd als noodzakelijke dependency opgenomen. + +De import valideert het complete bestand voordat er API-mutaties plaatsvinden. Ieder concept moet +op minstens één diagram voorkomen, item-id's moeten per kaart uniek zijn en connectoren moeten naar +bestaande items wijzen. Externe generators zoals ChatGPT mogen voor nieuwe concepten een tijdelijke +id `new:` gebruiken; de server kent bij opslag een Racket-UUID toe. Bij slugconflicten kiest de +gebruiker of bestaande CMaps en pagina's worden vervangen of behouden. Het formele contract staat in +`static/schemas/racket-wiki-cmap-bundle-v1.schema.json`. Versiegeschiedenis en aliassen vallen +buiten formaatversie 1. diff --git a/architecture/pages/data-en-versies.md b/architecture/pages/data-en-versies.md index 5d8a7e1..aa78917 100644 --- a/architecture/pages/data-en-versies.md +++ b/architecture/pages/data-en-versies.md @@ -17,6 +17,8 @@ De database bevat zowel de huidige toestand als de auditgeschiedenis. De browser | `attachment_references` | Huidige en historische verwijzingen vanuit paginaversies | | `concept_maps` | Actuele titel, JSONB-document en versieteller per CMap | | `concept_map_versions` | Expliciete CMap-snapshots en maximaal vijf handmatige opslagversies per CMap | +| `concept_definitions` | Wiki-brede inhoud per stabiele `conceptId`; plaatsing en opmaak blijven in het CMap-document | +| `people` | Wiki-breed register van actieve en inactieve namen voor getypeerde persoonstags | | `password_reset_tokens` | Gehashte, tijdelijke en eenmalige herstelcodes | | `wiki_settings` | Beheerinstellingen, momenteel vooral e-mail | | `wiki_schema` | Geïnstalleerde migratieversie | @@ -39,7 +41,7 @@ Een paginaopslag is één atomaire transactie: 6. Vervang huidige bijlageverwijzingen en leg historische verwijzingen voor de nieuwe versie vast. 7. Commit alles, of niets. -Een CMap-opslag volgt voor de actuele projectie hetzelfde kernpatroon: rij vergrendelen, versienummer vergelijken en actuele JSONB bijwerken. Autosave verhoogt wel het optimistische versienummer, maar maakt geen historieregel. Een expliciete opslag voegt een volledige toestand aan `concept_map_versions` toe en verwijdert oudere handmatige versies boven de grens van vijf. Benoemde snapshots vallen niet onder die grens. +Een CMap-opslag volgt voor de actuele projectie hetzelfde kernpatroon: rij vergrendelen, versienummer vergelijken en actuele JSONB bijwerken. In dezelfde transactie worden de aangeleverde inhoudelijke velden op `conceptId` in `concept_definitions` gezet. Een `conceptId` is een kale, lowercase en globaal unieke UUID; hij bevat bewust geen CMap-slug. De getrimde, hoofdletterongevoelige conceptnaam is wiki-breed uniek; een tweede plaatsing met dezelfde naam krijgt direct de bestaande id. Het opgeslagen CMap-document bevat zelf geen inhoudelijke kopie: `items[]` bewaart alleen structuur en opmaak en `concepts[]` bevat uitsluitend `{id}`-verwijzingen. `concept_definitions` is de enige persistente bron voor naam, samenvatting, aspecten, personen, koppelingen, afbeelding en toelichtingspagina. Bij uitlezen hydrateert de server die verwijzingen voor de editor. Ieder item behalve een verbindingszin is een plaatsing van een concept; `submap`, `page` en `concept` zijn lokale structurele rollen en geen afzonderlijke conceptsoorten. Positie, afmetingen, kleuren, typografie en structurele rol kunnen per plaatsing verschillen. De CMap zelf houdt haar stabiele, leesbare `concept_maps.slug`; die slug identificeert de kaart en staat los van de concept-UUID's. Autosave verhoogt wel het optimistische versienummer, maar maakt geen historieregel. Een expliciete opslag voegt een volledige toestand aan `concept_map_versions` toe en verwijdert oudere handmatige versies boven de grens van vijf. Benoemde snapshots vallen niet onder die grens. ## Waarom volledige snapshots @@ -49,10 +51,16 @@ De prijs is lineaire databasegroei met het aantal versies maal de documentgroott ## Afgeleide gegevens -`pages.search_document`, `todo_items` en `attachment_references.current_reference` zijn afgeleide gegevens. Zij moeten in dezelfde transactie als hun bron worden aangepast. Een los herstelcommando mag ze opnieuw kunnen opbouwen, maar gewone reads mogen niet afhankelijk zijn van een toevallig later achtergrondproces. +`pages.search_document`, `todo_items` en `attachment_references.current_reference` zijn afgeleide gegevens. Zij moeten in dezelfde transactie als hun bron worden aangepast. Een los herstelcommando mag ze opnieuw kunnen opbouwen, maar gewone reads mogen niet afhankelijk zijn van een toevallig later achtergrondproces. Concepten met het aspect `TODO` worden daarentegen rechtstreeks uit `concept_definitions` geselecteerd en met de pagina-todo's samengevoegd; daarvoor bestaat bewust geen tweede index. De gecombineerde navigatiegraaf is momenteel volledig afgeleid in de browser. Zij wordt niet als databasegraaf bewaard. +## CMap-uitwisselingsformaat + +Het versieerbare JSON-formaat `racket-wiki-cmap-bundle` volgt dezelfde scheiding als de database. `cmaps[]` bevat per stabiele slug het volledige structuur- en opmaakdocument, `concepts[]` bevat de gedeelde inhoud per UUID en `pages[]` bevat de actuele titel, Markdown, tags en daarin gebruikte attachments van gekoppelde wiki- en uitlegpagina's. De binaire inhoud staat base64-gecodeerd bij de pagina. Daardoor kan één concept op meerdere kaarten dezelfde inhoud houden terwijl positie, formaat, kleuren en typografie per plaatsing behouden blijven. Verbindingszinnen en connectoren verwijzen naar lokale item-id's en blijven dus bij hun diagramcontext. + +Een nieuw extern gegenereerd concept mag een tijdelijke `new:`-identiteit hebben, maar moet altijd in minstens één `items[]`-plaatsing met coördinaten voorkomen. De opslaglaag vervangt die tijdelijke identiteit bij de eerste import door een Racket-gegenereerde UUID en hergebruikt die via de wiki-breed unieke conceptnaam in volgende kaarten. Het JSON Schema staat onder `/schemas/racket-wiki-cmap-bundle-v1.schema.json`. Pagina- en CMap-versiegeschiedenis horen niet bij versie 1; alleen de actuele gekoppelde pagina-inhoud en haar actuele attachmentverwijzingen worden meegenomen. + ## Bijlagen Een upload hoort bij de id van de eigenaarpagina en krijgt een veilige opgeslagen naam. PostgreSQL bewaart zowel metadata als bytes. Markdown verwijst via de pagina en opgeslagen naam. Huidige en historische referenties worden afzonderlijk gevolgd, zodat beheer kan zien of een bestand alleen nog in oude versies voorkomt. @@ -77,6 +85,8 @@ De volgende regels mogen niet alleen in de browser staan: - iedere actuele paginaversie heeft een overeenkomstige onveranderlijke snapshot; - CMap-autosave wijzigt alleen de actuele projectie; CMap-historie bevat alleen snapshots en maximaal vijf handmatige versies; +- alle actuele plaatsingen met dezelfde `conceptId` lezen hun inhoud uit dezelfde `concept_definitions`-rij; +- iedere actuele `conceptId` is een kale lowercase UUID; de CMap-slug blijft afzonderlijk bewaard; - versienummers nemen per object strikt toe; - een stale editor overschrijft geen nieuwere toestand; - e-mailadressen zijn hoofdletterongevoelig uniek wanneer ingevuld; diff --git a/architecture/pages/gedrag.md b/architecture/pages/gedrag.md index e9c17e9..7f95f46 100644 --- a/architecture/pages/gedrag.md +++ b/architecture/pages/gedrag.md @@ -46,6 +46,32 @@ Iedere serveropslag controleert opnieuw het basisversienummer en schrijft de act Wanneer een actueel CMap-concept een `pageSlug` heeft, toont de gelezen wikipagina onder de inhoud een verwijzing naar dat concept. Plaatsingen worden op `conceptId` gegroepeerd en iedere betrokken CMap is rechtstreeks aanklikbaar. Alleen actuele, niet-gearchiveerde CMap-documenten tellen mee; historische CMap-versies leveren geen navigatielinks op. +Een CMap bewaart exportmetadata met tags, een samenvatting en een optionele uitlegpagina. De Markdown-export volgt gekoppelde CMaps tot de gekozen diepte, breekt cycli af en kan gekoppelde wiki- en conceptbeschrijvingspagina's als bijlage opnemen. Een persoon bij een concept is een getypeerde tag met type `person`. De personenlijst is wiki-breed; deactiveren verbergt een naam voor nieuwe selecties maar verandert bestaande CMap-documenten niet. + +Een versleept bron- of doeluiteinde van een CMap-connector wijzigt zowel de zichtbare lijn als de +logische `sourceId`/`targetId`-relatie. Submapprojectie mag alleen zichtbare eindpunten vervangen en +mag een handmatige relatiebewerking bij inklappen of uitklappen niet terugdraaien. Een eindpunt dat +op lege ruimte of op het andere uiteinde wordt losgelaten, keert terug naar de laatste geldige +relatie. + +Bij navigatie in afzonderlijk geopende sub-CMaps gaat de CMap-terugknop één niveau per actie terug. +Een lokale ouder in `mapHistory` heeft voorrang op navigatie naar de bronroute van een afgeleide +CMap. Alleen wanneer de wortel van die afgeleide kaart actief is, gaat terug naar de bron-CMap. + +De vaste selectietoolbar naast het diagram biedt veelgebruikte selectieacties en groepslayout. +Het laatst geselecteerde concept is het blauwe referentieobject: zijn maat, randen en middellijnen +bepalen het doel voor gelijke breedte/hoogte en uitlijnen. Een klik op een reeds geselecteerd concept +wisselt dit referentieobject zonder de meervoudige selectie op te heffen. Verticaal verdelen houdt +het bovenste en onderste element vast; horizontaal verdelen houdt het meest linkse en rechtse +element vast. De overige elementen krijgen in de gekozen richting gelijke tussenruimte. Iedere +layoutactie wordt via dezelfde historie en autosave als slepen en handmatig schalen bewaard. + +Relatiepijlen worden altijd in een lagere tekenlaag dan concepten en verbindingszinnen geplaatst. +Ook wanneer een relatie wordt geselecteerd of naar voren wordt gehaald, blijft zij achter alle +conceptkaarten. De hit-test gebruikt dezelfde laagprioriteit, zodat een achterliggende lijn geen +klik kan afvangen op de kaart die haar bedekt. Tijdelijke eindpunthendels blijven wel bovenop liggen +zolang een relatie wordt versleept. + ## Zoeken, Recent en navigatiegraaf Paginazoeken gebruikt een opgeslagen gewogen `tsvector` en GIN-index. CMap-zoeken verzamelt titel, slug, labels en synopses uit JSONB. Recent voegt de nieuwste pagina- en CMapwijzigingen samen. diff --git a/architecture/pages/testbaarheid.md b/architecture/pages/testbaarheid.md index 27e77d4..8bd5339 100644 --- a/architecture/pages/testbaarheid.md +++ b/architecture/pages/testbaarheid.md @@ -6,6 +6,10 @@ De backendfuncties zijn meestal procedureel en krijgen `config` expliciet mee. D Versie 0.2.93 introduceert wel tests in `architecture/import.rkt`. Die controleren de manifestset, interne paginalinks, CMap-embeds, unieke node-id's, connector-endpoints en de bronmarkeringen waarmee lokaal gewijzigde importinhoud wordt beschermd. Dit is een begin, geen voldoende dekking van de wiki. +Versie 0.2.100 voegt pure Node-tests toe voor de Markdown-export: dieptebegrenzing, cycli, optionele wikipagina's, persoonstags, leesbare relaties en afgeleide sub-CMapweergaven. De extractie van persoonsnamen uit CMap-documenten heeft daarnaast een kleine RackUnit-test; database- en browsergedrag blijven integratietestwerk. + +Versie 0.2.115 voegt pure roundtrip- en validatietests toe voor het JSON-uitwisselingsformaat. Zij controleren dat coördinaten, afmetingen, kleuren, typografie, groepering en connectoren in de diagramlaag blijven, dat gedeelde conceptinhoud apart wordt opgeslagen en bij import wordt teruggekoppeld, en dat losse concepten, dubbele namen/slugs en ongeldige connector-endpoints worden geweigerd. + ## Testpiramide voor racket-wiki | Laag | Doel | Voorbeelden | @@ -47,6 +51,21 @@ Introduceer geen groot mockframework. Gewone procedures en expliciete parameters Voor frontendcode zijn browsercomponenttests met een echte DOM geschikter dan het namaken van ieder element. De CMap-adapter moet met een klein vast document getest kunnen worden zonder de hele wiki te starten. +De JavaScript-regressietest `test/cmap-connector-rewire.test.js` simuleert het verslepen van een +connector naar een andere verbindingszin, klapt de submap in en uit en controleert daarna zowel de +tekenlaag als de geserialiseerde `sourceId`. + +`test/cmap-submap-navigation.test.js` opent twee geneste afzonderlijke sub-CMaps en bewijst dat één +terugactie alleen het diepste niveau verlaat en de tussenliggende kaart actief houdt. + +`test/cmap-selection-layout.test.js` controleert dat het laatst geselecteerde referentieconcept de +doelmaat en uitlijning bepaalt, naast horizontale en verticale verdeling, documentserialisatie en +opname van layoutcommando's in Undo. + +`test/cmap-layering.test.js` rendert concepten en een kruisende relatie in een kleine test-DOM. De +test bewijst dat de relatie ook na `toFront()` een lagere z-index houdt en dat de hit-test op het +kruispunt het concept selecteert in plaats van de verborgen relatie. + ## Contract- en structuurcontroles Naast functionele tests hoort de releasecontrole automatisch te verifiëren: diff --git a/info.rkt b/info.rkt index 93f07a8..99bc419 100644 --- a/info.rkt +++ b/info.rkt @@ -1,7 +1,7 @@ #lang info (define pkg-authors '(hnmdijkema)) -(define version "0.2.99") +(define version "0.2.122") (define license 'MIT) (define collection "racket-wiki") (define pkg-desc @@ -17,6 +17,7 @@ "net-lib" "net-cookies-lib" "openssl-lib" + "uuid" "web-server-lib")) (define build-deps diff --git a/private/cmap-storage.rkt b/private/cmap-storage.rkt index c5e2ca3..2aac04d 100644 --- a/private/cmap-storage.rkt +++ b/private/cmap-storage.rkt @@ -6,12 +6,17 @@ (require db json + racket/list racket/string + "concept-id.rkt" "database.rkt" + "people.rkt" "storage.rkt") (provide list-concept-maps + list-archived-concept-maps list-concept-usage + list-concept-todos list-recent-concept-maps search-concept-maps read-concept-map @@ -21,7 +26,8 @@ create-concept-map! rename-concept-map! update-concept-map! - archive-concept-map!) + archive-concept-map! + restore-concept-map!) (define maximum-document-size (* 10 1024 1024)) @@ -51,6 +57,210 @@ (error 'text->document "stored concept map document is not a JSON object")) document)) +(define (document-concepts document) + (define concepts (hash-ref document 'concepts '())) + (if (list? concepts) concepts '())) + +(define concept-content-keys + '(id label synopsis aspects tags descriptionPageSlug pageSlug cmapSlug externalUrl + imageSource)) + +(define placement-content-keys + '(label synopsis aspects tags descriptionPageSlug pageSlug cmapSlug externalUrl + imageSource)) + +(define (valid-external-url? value) + (and (string? value) + (regexp-match? #px"(?i:^https?://[^\\s]+$)" value))) + +(define (concept-content concept) + (when (and (hash-has-key? concept 'externalUrl) + (let ([value (hash-ref concept 'externalUrl)]) + (and value + (not (eq? value 'null)) + (not (and (string? value) (string=? value ""))) + (not (valid-external-url? value))))) + (error 'concept-content "externalUrl must be a complete http or https URL")) + (for/fold ([content (hash)]) ([key (in-list concept-content-keys)] + #:when (hash-has-key? concept key)) + (hash-set content key + (if (eq? key 'tags) + (let ([tags (hash-ref concept key)]) + (if (list? tags) + (filter (λ (tag) + (and (hash? tag) + (equal? (hash-ref tag 'type #f) "person"))) + tags) + '())) + (hash-ref concept key))))) + +(define (document-item-concept-ids document) + (remove-duplicates + (for/list ([item (in-list (let ([items (hash-ref document 'items '())]) + (if (list? items) items '())))] + #:when (and (hash? item) + (string? (hash-ref item 'conceptId #f)) + (not (equal? (hash-ref item 'kind "concept") "phrase")))) + (hash-ref item 'conceptId)))) + +(define (remove-hash-keys value keys) + (for/fold ([result value]) ([key (in-list keys)]) + (hash-remove result key))) + +;; A persisted CMap owns structure and presentation only. Full concept content +;; is stored once, in concept_definitions. The concepts array is retained as an +;; explicit set of references so the JSON document remains self-describing. +(define (concept-map-storage-document document) + (define items (hash-ref document 'items '())) + (define placement-items + (for/list ([item (in-list (if (list? items) items '()))]) + (if (and (hash? item) + (not (equal? (hash-ref item 'kind "concept") "phrase"))) + (remove-hash-keys item placement-content-keys) + item))) + (define concept-ids + (remove-duplicates + (append + (for/list ([concept (in-list (document-concepts document))] + #:when (and (hash? concept) + (string? (hash-ref concept 'id #f)) + (not (string=? (hash-ref concept 'id) "")))) + (hash-ref concept 'id)) + (document-item-concept-ids document)))) + (for ([concept-id (in-list concept-ids)]) + (unless (concept-id? concept-id) + (error 'concept-map-storage-document "invalid concept UUID: ~a" concept-id))) + (hash-set + (hash-set document 'items placement-items) + 'concepts + (for/list ([concept-id (in-list concept-ids)]) + (hash 'id concept-id)))) + +(define (concept-definition-by-id db concept-id) + (define row + (query-maybe-row + db + "SELECT document::text FROM concept_definitions WHERE id = $1" + concept-id)) + (and row (concept-content (text->document (vector-ref row 0))))) + +(define (document-concept-id-map db document) + (define concepts-by-id + (for/hash ([concept (in-list (document-concepts document))] + #:when (and (hash? concept) (string? (hash-ref concept 'id #f)))) + (values (hash-ref concept 'id) concept))) + (define items (hash-ref document 'items '())) + (define sources + (append + (document-concepts document) + (for/list ([item (in-list (if (list? items) items '()))] + #:when (and (hash? item) (string? (hash-ref item 'conceptId #f)))) + (hash-set item 'id (hash-ref item 'conceptId))))) + (define by-name (make-hash)) + (for/fold ([by-id (hash)]) ([source (in-list sources)] + #:when (and (hash? source) + (string? (hash-ref source 'id #f)))) + (define concept-id (hash-ref source 'id)) + (define repository-concept (hash-ref concepts-by-id concept-id #f)) + (define label + (cond [(and repository-concept (string? (hash-ref repository-concept 'label #f))) + (hash-ref repository-concept 'label)] + [(string? (hash-ref source 'label #f)) (hash-ref source 'label)] + [else ""])) + (define name-key (string-downcase (string-trim label))) + (define stored-id + (and (not (string=? name-key "")) + (query-maybe-value + db + #<> 'label')) = $1 +ORDER BY updated_at DESC, id DESC +LIMIT 1 +SQL + name-key))) + (define canonical-id + (or stored-id + (and (not (string=? name-key "")) (hash-ref by-name name-key #f)) + (hash-ref by-id concept-id #f) + (normalized-or-new-concept-id concept-id))) + (unless (string=? name-key "") (hash-set! by-name name-key canonical-id)) + (hash-set by-id concept-id canonical-id))) + +(define (canonicalize-document-concepts db document) + (define id-map (document-concept-id-map db document)) + (define canonical-concepts + (for/fold ([by-id (hash)]) ([concept (in-list (document-concepts document))] + #:when (and (hash? concept) + (string? (hash-ref concept 'id #f)))) + (define original-id (hash-ref concept 'id)) + (define canonical-id (hash-ref id-map original-id original-id)) + (define stored-definition + (and (not (string=? canonical-id original-id)) + (concept-definition-by-id db canonical-id))) + (hash-set by-id canonical-id + (hash-set (or stored-definition (concept-content concept)) + 'id canonical-id)))) + (define items (hash-ref document 'items '())) + (define canonical-items + (for/list ([item (in-list (if (list? items) items '()))]) + (define concept-id (and (hash? item) (hash-ref item 'conceptId #f))) + (if (string? concept-id) + (hash-set item 'conceptId (hash-ref id-map concept-id concept-id)) + item))) + (hash-set (hash-set document 'concepts (hash-values canonical-concepts)) + 'items canonical-items)) + +;; Content belongs to a concept, not to one of its placements. Position, size, +;; colour and typography remain in the CMap item/layout document. +(define (sync-concept-definitions! db document author now) + (for ([concept (in-list (document-concepts document))] + #:when (and (hash? concept) + (string? (hash-ref concept 'id #f)) + (not (string=? (hash-ref concept 'id) "")))) + (unless (concept-id? (hash-ref concept 'id)) + (error 'sync-concept-definitions! "invalid concept UUID: ~a" + (hash-ref concept 'id))) + (query-exec + db + #<text (concept-content concept)) + now + author))) + +(define (hydrate-concept-definitions db document) + (define canonical-document (canonicalize-document-concepts db document)) + (define local-concepts + (for/hash ([concept (in-list (document-concepts canonical-document))] + #:when (and (hash? concept) (string? (hash-ref concept 'id #f)))) + (values (hash-ref concept 'id) (concept-content concept)))) + (define concept-ids + (remove-duplicates + (append (hash-keys local-concepts) (document-item-concept-ids canonical-document)))) + (define hydrated + (for/list ([concept-id (in-list concept-ids)]) + (define row + (query-maybe-row + db + "SELECT document::text FROM concept_definitions WHERE id = $1" + concept-id)) + (if row + (concept-content (text->document (vector-ref row 0))) + (hash-ref local-concepts concept-id (hash 'id concept-id))))) + ;; Sanitize legacy documents on the read path too. An editor can therefore + ;; never receive item-level content that competes with the central record. + (hash-set (concept-map-storage-document canonical-document) + 'concepts hydrated)) + (define (row->concept-map row [include-document? #t]) (define result (hash 'slug (vector-ref row 0) @@ -119,6 +329,37 @@ SQL " ORDER BY lower(title), title"))))) (row->concept-map row #f))))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : List archived concept-map metadata for administration. +; pre : Database schema migration 9 has been installed. +; post : No database state is changed and documents are not transferred. +; result : A newest-archived-first list including archive audit fields. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(define (list-archived-concept-maps config) + (call-with-wiki-database + config + (λ (db) + (for/list ((row (in-list + (query-rows + db + #<concept-map row #f) + 'archivedAt (if (sql-null? (vector-ref row 8)) + 'null + (vector-ref row 8))) + 'archivedBy (or (and (not (sql-null? (vector-ref row 9))) + (vector-ref row 9)) + "")))))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; goal : Count current concept placements across every active CMap. ; pre : Concept-map documents use an items array when placements exist. @@ -137,15 +378,13 @@ SQL WITH placements AS ( SELECT cm.slug, cm.title AS cmap_title, - CASE - WHEN item ->> 'conceptId' ~ '^concept-[0-9]+$' - THEN concat('legacy:', cm.slug, ':', item ->> 'conceptId') - ELSE item ->> 'conceptId' - END AS concept_id, - coalesce(nullif(repository.document ->> 'label', ''), + item ->> 'conceptId' AS concept_id, + coalesce(nullif(definition.document ->> 'label', ''), + nullif(repository.document ->> 'label', ''), nullif(item ->> 'label', ''), 'Concept') AS concept_label, - coalesce(nullif(repository.document ->> 'pageSlug', ''), + coalesce(nullif(definition.document ->> 'pageSlug', ''), + nullif(repository.document ->> 'pageSlug', ''), nullif(item ->> 'pageSlug', '')) AS page_slug FROM concept_maps cm CROSS JOIN LATERAL jsonb_array_elements( @@ -167,9 +406,11 @@ WITH placements AS ( WHERE concept_value ->> 'id' = item ->> 'conceptId' LIMIT 1 ) repository ON TRUE + LEFT JOIN concept_definitions definition + ON definition.id = item ->> 'conceptId' WHERE cm.archived = FALSE AND nullif(item ->> 'conceptId', '') IS NOT NULL - AND coalesce(item ->> 'kind', 'concept') NOT IN ('phrase', 'submap') + AND coalesce(item ->> 'kind', 'concept') <> 'phrase' ) SELECT concept_id, slug, cmap_title, concept_label, page_slug, count(*) FROM placements @@ -186,6 +427,70 @@ SQL (vector-ref row 4)) 'count (vector-ref row 5)))))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +; goal : List globally defined concepts carrying the TODO aspect. +; pre : Concept definitions and current CMap documents are available. +; post : No database state is changed and every concept occurs at most once. +; result : Concept todo hashes with shared content and a current map location. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(define (list-concept-todos config) + (call-with-wiki-database + config + (λ (db) + (for/list ([row (in-list + (query-rows + db + #<> 'label', ''), 'Concept') AS label, + coalesce(definition.document ->> 'synopsis', '') AS synopsis, + nullif(definition.document ->> 'descriptionPageSlug', '') AS description_page_slug, + nullif(definition.document ->> 'pageSlug', '') AS page_slug, + nullif(definition.document ->> 'cmapSlug', '') AS linked_cmap_slug, + nullif(definition.document ->> 'externalUrl', '') AS external_url, + placement.cmap_slug, + placement.cmap_title +FROM concept_definitions definition +JOIN LATERAL ( + SELECT cm.slug AS cmap_slug, cm.title AS cmap_title + FROM concept_maps cm + CROSS JOIN LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(cm.document -> 'items') = 'array' + THEN cm.document -> 'items' ELSE '[]'::jsonb END + ) AS item + WHERE cm.archived = FALSE + AND item ->> 'conceptId' = definition.id + AND coalesce(item ->> 'kind', 'concept') <> 'phrase' + ORDER BY lower(cm.title), cm.title, cm.slug + LIMIT 1 +) placement ON TRUE +WHERE EXISTS ( + SELECT 1 + FROM jsonb_array_elements_text( + CASE WHEN jsonb_typeof(definition.document -> 'aspects') = 'array' + THEN definition.document -> 'aspects' ELSE '[]'::jsonb END + ) AS aspect(value) + WHERE lower(trim(aspect.value)) = 'todo' +) +ORDER BY lower(coalesce(definition.document ->> 'label', '')), + coalesce(definition.document ->> 'label', ''), + definition.id +SQL + ))]) + (define (nullable index) + (define value (vector-ref row index)) + (if (sql-null? value) 'null value)) + (hash 'type "concept" + 'conceptId (vector-ref row 0) + 'title (vector-ref row 1) + 'text (vector-ref row 2) + 'descriptionPageSlug (nullable 3) + 'pageSlug (nullable 4) + 'cmapSlug (nullable 5) + 'externalUrl (nullable 6) + 'placementCmapSlug (vector-ref row 7) + 'placementCmapTitle (vector-ref row 8)))))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; goal : List recently edited current concept maps. ; pre : Database schema migration 9 has been installed. @@ -230,14 +535,17 @@ WITH q AS ( cm.title, coalesce(( SELECT string_agg( - concat_ws(' ', item ->> 'label', item ->> 'synopsis'), + concat_ws(' ', coalesce(definition.document, concept) ->> 'label', + coalesce(definition.document, concept) ->> 'synopsis'), ' ') FROM jsonb_array_elements( CASE - WHEN jsonb_typeof(cm.document -> 'items') = 'array' - THEN cm.document -> 'items' + WHEN jsonb_typeof(cm.document -> 'concepts') = 'array' + THEN cm.document -> 'concepts' ELSE '[]'::jsonb - END) AS item + END) AS concept + LEFT JOIN concept_definitions definition + ON definition.id = concept ->> 'id' ), '') AS concept_text FROM concept_maps cm WHERE cm.archived = FALSE @@ -288,7 +596,11 @@ SQL "SELECT " concept-map-columns " FROM concept_maps WHERE slug = $1 AND archived = FALSE") slug)) - (if row (row->concept-map row) #f))))) + (if row + (hash-update (row->concept-map row) + 'document + (λ (document) (hydrate-concept-definitions db document))) + #f))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; goal : Create a persistent concept map. @@ -301,7 +613,6 @@ SQL (define clean-slug (string-trim slug)) (define clean-title (string-trim title)) (validate-concept-map-input 'create-concept-map! clean-slug clean-title document) - (define document-text (document->text document)) (define now (current-seconds)) (call-with-wiki-database config @@ -309,6 +620,11 @@ SQL (call-with-transaction db (λ () + (define canonical-document (canonicalize-document-concepts db document)) + (define document-text + (document->text (concept-map-storage-document canonical-document))) + (sync-concept-definitions! db canonical-document author now) + (sync-person-tags! db canonical-document) (query-value db #<text document)) (call-with-wiki-database config (λ (db) @@ -407,6 +722,10 @@ SQL (error 'update-concept-map! "version-conflict")) (define next-version (+ current-version 1)) (define now (current-seconds)) + (define canonical-document (canonicalize-document-concepts db document)) + (define document-text + (document->text (concept-map-storage-document canonical-document))) + (sync-concept-definitions! db canonical-document author now) (query-exec db #<number (format "~a" base-version)))) + (unless (and supplied-version (= supplied-version (vector-ref row 2))) + (error 'archive-concept-map! "version-conflict")) + (define now (current-seconds)) + (query-exec + db + #<symbol (required-string who (hash-ref style 'nameKey) "style name key" 40)))) + (unless (or name (member name-key permitted-name-keys)) + (error who "a style needs a name")) + (hash 'id id + (if (member name-key permitted-name-keys) 'nameKey 'name) + (if (member name-key permitted-name-keys) (symbol->string name-key) name) + 'protected (string=? id "default") + 'values (normalize-style-values who (hash-ref style 'values #f))))) + (unless (hash-ref seen "default" #f) (error who "the default style is required")) + normalized) + +(define (read-cmap-styles config) + (call-with-wiki-database + config + (lambda (db) + (define stored + (query-maybe-value db "SELECT value FROM wiki_settings WHERE key = $1" setting-key)) + (if stored + (normalize-cmap-styles (string->jsexpr stored) 'read-cmap-styles) + (let ([encoded (jsexpr->string (normalize-cmap-styles initial-cmap-styles))]) + (query-exec + db + "INSERT INTO wiki_settings(key, value, updated_at) VALUES ($1, $2, $3) ON CONFLICT(key) DO NOTHING" + setting-key encoded (current-seconds)) + (normalize-cmap-styles + (string->jsexpr + (query-value db "SELECT value FROM wiki_settings WHERE key = $1" setting-key)) + 'read-cmap-styles)))))) + +(define (save-cmap-styles! config styles) + (define normalized (normalize-cmap-styles styles 'save-cmap-styles!)) + (define encoded (jsexpr->string normalized)) + (when (> (bytes-length (string->bytes/utf-8 encoded)) (* 128 1024)) + (error 'save-cmap-styles! "style data is too large")) + (call-with-wiki-database + config + (lambda (db) + (query-exec + db + "INSERT INTO wiki_settings(key, value, updated_at) VALUES ($1, $2, $3) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at" + setting-key encoded (current-seconds)))) + normalized) + +(module+ test + (require rackunit) + (define values + (hash 'backgroundColor "#FFF4CF" 'textColor "#222222" + 'fontFamily "Arial" 'fontSize 11 'fontWeight "700" 'fontStyle "normal" + 'synopsisTextColor "#4d4d4d" 'synopsisFontFamily "Arial" + 'synopsisFontSize 9 'synopsisFontWeight "400" 'synopsisFontStyle "normal" + 'submapBackgroundColor "#edf7e8" 'submapBorderColor "#57834a")) + (define normalized + (normalize-cmap-styles + (list (hash 'id "default" 'nameKey "style-default" 'values values)))) + (check-equal? (hash-ref (hash-ref (first normalized) 'values) 'backgroundColor) "#fff4cf") + (check-equal? (length (normalize-cmap-styles initial-cmap-styles)) 5) + (check-exn exn:fail? (lambda () (normalize-cmap-styles '()))) + (check-exn exn:fail? + (lambda () + (normalize-cmap-styles + (list (hash 'id "custom" 'name "Custom" 'values values)))))) diff --git a/private/concept-id.rkt b/private/concept-id.rkt new file mode 100644 index 0000000..e2be4d2 --- /dev/null +++ b/private/concept-id.rkt @@ -0,0 +1,48 @@ +#lang racket/base + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Canonical concept identity helpers. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(require racket/string + uuid) + +(provide concept-id? + normalize-concept-id + new-concept-id + normalized-or-new-concept-id) + +(define prefixed-uuid-concept-id-pattern + #px"(?i:^concept-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$)") + +;; Concept ids are stored as plain UUID strings. Validation accepts uppercase +;; input, while normalization always produces the canonical lowercase form. +(define (concept-id? value) + (uuid-string? value)) + +(define (normalize-concept-id value) + (cond + [(uuid-string? value) (string-downcase value)] + [(and (string? value) + (regexp-match prefixed-uuid-concept-id-pattern value)) + => (lambda (match) (string-downcase (cadr match)))] + [else #f])) + +(define (new-concept-id) + (uuid-string)) + +(define (normalized-or-new-concept-id value) + (or (normalize-concept-id value) + (new-concept-id))) + +(module+ test + (require rackunit) + + (define sample "a3c4f0d1-22e5-4c42-9a40-cc864993f785") + (check-true (concept-id? sample)) + (check-true (concept-id? (string-upcase sample))) + (check-false (concept-id? (string-append "concept-" sample))) + (check-equal? (normalize-concept-id (string-upcase sample)) sample) + (check-equal? (normalize-concept-id (string-append "concept-" sample)) sample) + (check-false (normalize-concept-id "legacy:a:concept-1")) + (check-true (concept-id? (new-concept-id)))) diff --git a/private/migrations.rkt b/private/migrations.rkt index b243956..6152701 100644 --- a/private/migrations.rkt +++ b/private/migrations.rkt @@ -9,6 +9,7 @@ racket/path racket/string "attachment-references.rkt" + "concept-id.rkt" "config.rkt" "todo.rkt") @@ -20,7 +21,7 @@ ;; Supporting functions ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(define current-schema-version 12) +(define current-schema-version 21) (define schema-1-statements (list @@ -421,6 +422,791 @@ SQL ) (record-schema-version! db 12)) +(define (migrate-12->13! db) + (query-exec db + #<> 'value') AS name + FROM concept_maps cm + CROSS JOIN LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(cm.document -> 'concepts') = 'array' + THEN cm.document -> 'concepts' ELSE '[]'::jsonb END + ) AS concept + CROSS JOIN LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(concept -> 'tags') = 'array' + THEN concept -> 'tags' ELSE '[]'::jsonb END + ) AS tag + WHERE tag ->> 'type' = 'person' AND trim(coalesce(tag ->> 'value', '')) <> '' +) +INSERT INTO people(name, active, created_at, updated_at) +SELECT name, TRUE, $1, $1 FROM person_names +ON CONFLICT (lower(name)) DO NOTHING +SQL + (current-seconds)) + (record-schema-version! db 13)) + +(define (migrate-13->14! db) + ;; Concept identity is shared between CMaps. Older generated numeric ids were + ;; only unique inside one map, so namespace those before building the global + ;; repository. + (query-exec + db + #< 'concepts') = 'array' THEN + (SELECT coalesce(jsonb_agg( + CASE WHEN concept ->> 'id' ~ '^concept-[0-9]+$' + THEN jsonb_set(concept, '{id}', to_jsonb(concat('legacy:', cm.slug, ':', concept ->> 'id'))) + ELSE concept END + ), '[]'::jsonb) + FROM jsonb_array_elements(cm.document -> 'concepts') AS concept) + ELSE coalesce(cm.document -> 'concepts', '[]'::jsonb) + END, + 'items', CASE + WHEN jsonb_typeof(cm.document -> 'items') = 'array' THEN + (SELECT coalesce(jsonb_agg( + CASE WHEN item ->> 'conceptId' ~ '^concept-[0-9]+$' + THEN jsonb_set(item, '{conceptId}', to_jsonb(concat('legacy:', cm.slug, ':', item ->> 'conceptId'))) + ELSE item END + ), '[]'::jsonb) + FROM jsonb_array_elements(cm.document -> 'items') AS item) + ELSE coalesce(cm.document -> 'items', '[]'::jsonb) + END +) +WHERE jsonb_typeof(cm.document) = 'object' +SQL + ) + (query-exec + db + #<> 'id' AS id, + concept AS document, + cm.updated_at, + cm.updated_by, + row_number() OVER ( + PARTITION BY concept ->> 'id' + ORDER BY cm.updated_at DESC, cm.id DESC + ) AS position + FROM concept_maps cm + CROSS JOIN LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(cm.document -> 'concepts') = 'array' + THEN cm.document -> 'concepts' ELSE '[]'::jsonb END + ) AS concept + WHERE cm.archived = FALSE AND nullif(concept ->> 'id', '') IS NOT NULL +) +INSERT INTO concept_definitions(id, document, updated_at, updated_by) +SELECT id, document, updated_at, updated_by +FROM candidates +WHERE position = 1 +ON CONFLICT (id) DO NOTHING +SQL + ) + (record-schema-version! db 14)) + +(define (migrate-14->15! db) + ;; Schema 14 originally filled the shared repository only from the concepts + ;; array. Sub-CMap heads are placements too, but older documents often keep + ;; their content solely on the item. Import those missing definitions. + (query-exec + db + #<> 'conceptId' AS id, + jsonb_strip_nulls(jsonb_build_object( + 'id', item -> 'conceptId', + 'label', coalesce(repository.document, item) -> 'label', + 'synopsis', coalesce(repository.document, item) -> 'synopsis', + 'aspects', coalesce(repository.document, item) -> 'aspects', + 'tags', coalesce(repository.document, item) -> 'tags', + 'descriptionPageSlug', coalesce(repository.document, item) -> 'descriptionPageSlug', + 'pageSlug', coalesce(repository.document, item) -> 'pageSlug', + 'cmapSlug', coalesce(repository.document, item) -> 'cmapSlug', + 'parentCmapLink', coalesce(repository.document, item) -> 'parentCmapLink', + 'imageSource', coalesce(repository.document, item) -> 'imageSource' + )) AS document, + cm.updated_at, + cm.updated_by, + row_number() OVER ( + PARTITION BY item ->> 'conceptId' + ORDER BY cm.updated_at DESC, cm.id DESC + ) AS position + FROM concept_maps cm + CROSS JOIN LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(cm.document -> 'items') = 'array' + THEN cm.document -> 'items' ELSE '[]'::jsonb END + ) AS item + LEFT JOIN LATERAL ( + SELECT concept AS document + FROM jsonb_array_elements( + CASE WHEN jsonb_typeof(cm.document -> 'concepts') = 'array' + THEN cm.document -> 'concepts' ELSE '[]'::jsonb END + ) AS concept + WHERE concept ->> 'id' = item ->> 'conceptId' + LIMIT 1 + ) repository ON TRUE + WHERE cm.archived = FALSE + AND nullif(item ->> 'conceptId', '') IS NOT NULL + AND coalesce(item ->> 'kind', 'concept') <> 'phrase' +) +INSERT INTO concept_definitions(id, document, updated_at, updated_by) +SELECT id, document, updated_at, updated_by +FROM candidates +WHERE position = 1 +ON CONFLICT (id) DO NOTHING +SQL + ) + (record-schema-version! db 15)) + +(define (migrate-15->16! db) + ;; A placement linked to a derived CMap and the head concept of that CMap + ;; denote one concept. Retain aliases so old documents and open editors can + ;; be canonicalized without making diagram structure part of the concept. + (query-exec + db + #< canonical_id) +) +SQL + ) + (query-exec db + "CREATE INDEX IF NOT EXISTS concept_aliases_canonical_idx ON concept_aliases(canonical_id)") + (query-exec + db + #<> 'conceptId' AS canonical_id + FROM concept_maps target + JOIN concept_maps source + ON source.slug = target.document #>> '{derivedView,sourceCmapSlug}' + CROSS JOIN LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(source.document -> 'items') = 'array' + THEN source.document -> 'items' ELSE '[]'::jsonb END + ) AS root_item + WHERE target.archived = FALSE + AND source.archived = FALSE + AND root_item ->> 'id' = target.document #>> '{derivedView,rootItemId}' + AND nullif(root_item ->> 'conceptId', '') IS NOT NULL + UNION ALL + SELECT target.slug AS target_slug, + root_item ->> 'conceptId' AS canonical_id + FROM concept_maps target + CROSS JOIN LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(target.document -> 'items') = 'array' + THEN target.document -> 'items' ELSE '[]'::jsonb END + ) AS root_item + LEFT JOIN LATERAL ( + SELECT concept AS document + FROM jsonb_array_elements( + CASE WHEN jsonb_typeof(target.document -> 'concepts') = 'array' + THEN target.document -> 'concepts' ELSE '[]'::jsonb END + ) AS concept + WHERE concept ->> 'id' = root_item ->> 'conceptId' + LIMIT 1 + ) repository ON TRUE + WHERE target.archived = FALSE + AND target.document -> 'derivedView' IS NULL + AND nullif(root_item ->> 'conceptId', '') IS NOT NULL + AND coalesce(root_item ->> 'kind', 'concept') <> 'phrase' + AND nullif(root_item ->> 'parentSubmapId', '') IS NULL + AND lower(trim(coalesce(repository.document ->> 'label', root_item ->> 'label', ''))) + = lower(trim(target.title)) +), linked_placements AS ( + SELECT DISTINCT item ->> 'conceptId' AS alias_id, + derived_roots.canonical_id + FROM concept_maps cm + CROSS JOIN LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(cm.document -> 'items') = 'array' + THEN cm.document -> 'items' ELSE '[]'::jsonb END + ) AS item + JOIN derived_roots ON derived_roots.target_slug = item ->> 'cmapSlug' + WHERE cm.archived = FALSE + AND nullif(item ->> 'conceptId', '') IS NOT NULL + AND item ->> 'conceptId' <> derived_roots.canonical_id +) +INSERT INTO concept_aliases(alias_id, canonical_id, created_at, created_by) +SELECT alias_id, canonical_id, $1, 'system:concept-identity-migration' +FROM linked_placements +ON CONFLICT (alias_id) DO UPDATE +SET canonical_id = EXCLUDED.canonical_id +SQL + (current-seconds)) + ;; Preserve the newest available content when two ids are merged. + (query-exec + db + #<17! db) + ;; Within one wiki a normalized concept name denotes one concept. Merge ids + ;; that predate this invariant, while preserving a canonical id already + ;; established through a linked CMap when one exists. + (query-exec + db + #<> 'label')) AS name_key, + coalesce(existing_alias.canonical_id, definition.id) AS resolved_id + FROM concept_definitions definition + LEFT JOIN concept_aliases existing_alias + ON existing_alias.alias_id = definition.id + WHERE nullif(trim(definition.document ->> 'label'), '') IS NOT NULL +), ranked AS ( + SELECT named.*, + first_value(resolved_id) OVER ( + PARTITION BY name_key + ORDER BY updated_at DESC, id DESC + ) AS canonical_id, + row_number() OVER ( + PARTITION BY name_key + ORDER BY updated_at DESC, id DESC + ) AS content_position + FROM named +), new_aliases AS ( + SELECT DISTINCT id AS alias_id, canonical_id + FROM ranked + WHERE id <> canonical_id +), stored_aliases AS ( + INSERT INTO concept_aliases(alias_id, canonical_id, created_at, created_by) + SELECT alias_id, canonical_id, $1, 'system:concept-name-migration' + FROM new_aliases + ON CONFLICT (alias_id) DO UPDATE + SET canonical_id = EXCLUDED.canonical_id + RETURNING alias_id +), winners AS ( + SELECT DISTINCT ON (canonical_id) + canonical_id, document, updated_at, updated_by + FROM ranked + WHERE content_position = 1 + ORDER BY canonical_id, updated_at DESC, id DESC +) +INSERT INTO concept_definitions(id, document, updated_at, updated_by) +SELECT canonical_id, + jsonb_set(document - 'kind' - 'parentCmapLink', '{id}', to_jsonb(canonical_id)), + updated_at, + updated_by +FROM winners +ON CONFLICT (id) DO UPDATE +SET document = EXCLUDED.document, + updated_at = EXCLUDED.updated_at, + updated_by = EXCLUDED.updated_by +SQL + (current-seconds)) + (query-exec + db + #< 'items') = 'array' THEN + (SELECT coalesce(jsonb_agg( + CASE WHEN alias.canonical_id IS NOT NULL + THEN jsonb_set(item, '{conceptId}', to_jsonb(alias.canonical_id)) + ELSE item END + ORDER BY ordinal + ), '[]'::jsonb) + FROM jsonb_array_elements(cm.document -> 'items') WITH ORDINALITY AS entry(item, ordinal) + LEFT JOIN concept_aliases alias ON alias.alias_id = item ->> 'conceptId') + ELSE coalesce(cm.document -> 'items', '[]'::jsonb) + END, + 'concepts', CASE + WHEN jsonb_typeof(cm.document -> 'concepts') = 'array' THEN + (SELECT coalesce(jsonb_agg(rewritten ORDER BY ordinal), '[]'::jsonb) + FROM ( + SELECT DISTINCT ON (coalesce(alias.canonical_id, concept ->> 'id')) + CASE WHEN alias.canonical_id IS NOT NULL + THEN jsonb_set(concept, '{id}', to_jsonb(alias.canonical_id)) + ELSE concept END AS rewritten, + ordinal + FROM jsonb_array_elements(cm.document -> 'concepts') WITH ORDINALITY + AS entry(concept, ordinal) + LEFT JOIN concept_aliases alias ON alias.alias_id = concept ->> 'id' + ORDER BY coalesce(alias.canonical_id, concept ->> 'id'), ordinal DESC + ) definitions) + ELSE coalesce(cm.document -> 'concepts', '[]'::jsonb) + END +) +WHERE EXISTS ( + SELECT 1 + FROM jsonb_array_elements( + CASE WHEN jsonb_typeof(cm.document -> 'items') = 'array' + THEN cm.document -> 'items' ELSE '[]'::jsonb END + ) AS item + JOIN concept_aliases alias ON alias.alias_id = item ->> 'conceptId' +) +OR EXISTS ( + SELECT 1 + FROM jsonb_array_elements( + CASE WHEN jsonb_typeof(cm.document -> 'concepts') = 'array' + THEN cm.document -> 'concepts' ELSE '[]'::jsonb END + ) AS concept + JOIN concept_aliases alias ON alias.alias_id = concept ->> 'id' +) +SQL + ) + (query-exec + db + #<18! db) + ;; Normalize the current documents themselves. Compatibility aliases are not + ;; part of the final model: every active placement is rewritten to the one + ;; id selected for its normalized name, then duplicate definitions are gone. + (query-exec + db + #<> 'conceptId' AS concept_id, + lower(trim(coalesce(repository.document ->> 'label', item ->> 'label', ''))) AS name_key, + cm.updated_at, + cm.id AS map_id + FROM concept_maps cm + CROSS JOIN LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(cm.document -> 'items') = 'array' + THEN cm.document -> 'items' ELSE '[]'::jsonb END + ) AS item + LEFT JOIN LATERAL ( + SELECT concept AS document + FROM jsonb_array_elements( + CASE WHEN jsonb_typeof(cm.document -> 'concepts') = 'array' + THEN cm.document -> 'concepts' ELSE '[]'::jsonb END + ) AS concept + WHERE concept ->> 'id' = item ->> 'conceptId' + LIMIT 1 + ) repository ON TRUE + WHERE cm.archived = FALSE + AND nullif(item ->> 'conceptId', '') IS NOT NULL + AND coalesce(item ->> 'kind', 'concept') <> 'phrase' +), latest_name_per_id AS ( + SELECT DISTINCT ON (concept_id) + concept_id, name_key, updated_at, map_id + FROM placements + WHERE name_key <> '' + ORDER BY concept_id, updated_at DESC, map_id DESC +), ranked AS ( + SELECT concept_id, + first_value(concept_id) OVER ( + PARTITION BY name_key + ORDER BY updated_at DESC, map_id DESC, concept_id DESC + ) AS canonical_id + FROM latest_name_per_id +) +INSERT INTO concept_id_merge(old_id, canonical_id) +SELECT concept_id, canonical_id +FROM ranked +WHERE concept_id <> canonical_id +SQL + ) + (query-exec + db + #< 'items') = 'array' THEN + (SELECT coalesce(jsonb_agg( + CASE WHEN merge.canonical_id IS NOT NULL + THEN jsonb_set(item, '{conceptId}', to_jsonb(merge.canonical_id)) + ELSE item END + ORDER BY ordinal + ), '[]'::jsonb) + FROM jsonb_array_elements(cm.document -> 'items') WITH ORDINALITY AS entry(item, ordinal) + LEFT JOIN concept_id_merge merge ON merge.old_id = item ->> 'conceptId') + ELSE coalesce(cm.document -> 'items', '[]'::jsonb) + END, + 'concepts', CASE + WHEN jsonb_typeof(cm.document -> 'concepts') = 'array' THEN + (SELECT coalesce(jsonb_agg(rewritten ORDER BY ordinal), '[]'::jsonb) + FROM ( + SELECT DISTINCT ON (coalesce(merge.canonical_id, concept ->> 'id')) + CASE WHEN merge.canonical_id IS NOT NULL + THEN jsonb_set(concept, '{id}', to_jsonb(merge.canonical_id)) + ELSE concept END AS rewritten, + ordinal + FROM jsonb_array_elements(cm.document -> 'concepts') WITH ORDINALITY + AS entry(concept, ordinal) + LEFT JOIN concept_id_merge merge ON merge.old_id = concept ->> 'id' + ORDER BY coalesce(merge.canonical_id, concept ->> 'id'), ordinal DESC + ) definitions) + ELSE coalesce(cm.document -> 'concepts', '[]'::jsonb) + END +) +WHERE EXISTS ( + SELECT 1 + FROM jsonb_array_elements( + CASE WHEN jsonb_typeof(cm.document -> 'items') = 'array' + THEN cm.document -> 'items' ELSE '[]'::jsonb END + ) AS item + JOIN concept_id_merge merge ON merge.old_id = item ->> 'conceptId' +) +OR EXISTS ( + SELECT 1 + FROM jsonb_array_elements( + CASE WHEN jsonb_typeof(cm.document -> 'concepts') = 'array' + THEN cm.document -> 'concepts' ELSE '[]'::jsonb END + ) AS concept + JOIN concept_id_merge merge ON merge.old_id = concept ->> 'id' +) +SQL + ) + (query-exec db "DELETE FROM concept_definitions") + (query-exec + db + #<> 'id' AS id, + concept - 'kind' - 'parentCmapLink' AS document, + cm.updated_at, + cm.updated_by, + cm.id AS map_id + FROM concept_maps cm + CROSS JOIN LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(cm.document -> 'concepts') = 'array' + THEN cm.document -> 'concepts' ELSE '[]'::jsonb END + ) AS concept + WHERE cm.archived = FALSE AND nullif(concept ->> 'id', '') IS NOT NULL + UNION ALL + SELECT item ->> 'conceptId' AS id, + jsonb_strip_nulls(jsonb_build_object( + 'id', item -> 'conceptId', + 'label', item -> 'label', + 'synopsis', item -> 'synopsis', + 'aspects', item -> 'aspects', + 'tags', item -> 'tags', + 'descriptionPageSlug', item -> 'descriptionPageSlug', + 'pageSlug', item -> 'pageSlug', + 'cmapSlug', item -> 'cmapSlug', + 'imageSource', item -> 'imageSource' + )) AS document, + cm.updated_at, + cm.updated_by, + cm.id AS map_id + FROM concept_maps cm + CROSS JOIN LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(cm.document -> 'items') = 'array' + THEN cm.document -> 'items' ELSE '[]'::jsonb END + ) AS item + WHERE cm.archived = FALSE + AND nullif(item ->> 'conceptId', '') IS NOT NULL + AND coalesce(item ->> 'kind', 'concept') <> 'phrase' +), ranked AS ( + SELECT candidates.*, + row_number() OVER ( + PARTITION BY id + ORDER BY updated_at DESC, map_id DESC + ) AS position + FROM candidates +) +INSERT INTO concept_definitions(id, document, updated_at, updated_by) +SELECT id, jsonb_set(document, '{id}', to_jsonb(id)), updated_at, updated_by +FROM ranked +WHERE position = 1 +SQL + ) + (query-exec db "DROP TABLE IF EXISTS concept_aliases") + (record-schema-version! db 18)) + +(define (migrate-18->19! db) + ;; Concept content must have exactly one source. Remove the old denormalized + ;; copies from placements; linking phrases retain their own text because + ;; they are relations rather than concepts. + (query-exec + db + #<> 'kind', 'concept') = 'phrase' + THEN item + ELSE item + - 'label' + - 'synopsis' + - 'aspects' + - 'tags' + - 'descriptionPageSlug' + - 'pageSlug' + - 'cmapSlug' + - 'imageSource' + END + ORDER BY ordinal + ), '[]'::jsonb) + FROM jsonb_array_elements( + CASE WHEN jsonb_typeof(cm.document -> 'items') = 'array' + THEN cm.document -> 'items' ELSE '[]'::jsonb END + ) WITH ORDINALITY AS entry(item, ordinal)), + TRUE +) +WHERE jsonb_typeof(cm.document) = 'object' +SQL + ) + (record-schema-version! db 19)) + +(define (migrate-19->20! db) + ;; Current CMaps store concept references only. Full concept content lives in + ;; concept_definitions and is hydrated into API responses on read. Historical + ;; snapshots remain immutable. + (query-exec + db + #<> 'kind', 'concept') = 'phrase' + THEN item + ELSE item + - 'label' + - 'synopsis' + - 'aspects' + - 'tags' + - 'descriptionPageSlug' + - 'pageSlug' + - 'cmapSlug' + - 'imageSource' + END + ORDER BY ordinal + ), '[]'::jsonb) + FROM jsonb_array_elements( + CASE WHEN jsonb_typeof(cm.document -> 'items') = 'array' + THEN cm.document -> 'items' ELSE '[]'::jsonb END + ) WITH ORDINALITY AS entry(item, ordinal)), + 'concepts', + (SELECT coalesce(jsonb_agg(jsonb_build_object('id', concept_id) + ORDER BY ordinal), '[]'::jsonb) + FROM ( + SELECT DISTINCT ON (concept_id) concept_id, ordinal + FROM ( + SELECT concept ->> 'id' AS concept_id, ordinal + FROM jsonb_array_elements( + CASE WHEN jsonb_typeof(cm.document -> 'concepts') = 'array' + THEN cm.document -> 'concepts' ELSE '[]'::jsonb END + ) WITH ORDINALITY AS concept_entry(concept, ordinal) + UNION ALL + SELECT item ->> 'conceptId' AS concept_id, 1000000000 + ordinal + FROM jsonb_array_elements( + CASE WHEN jsonb_typeof(cm.document -> 'items') = 'array' + THEN cm.document -> 'items' ELSE '[]'::jsonb END + ) WITH ORDINALITY AS item_entry(item, ordinal) + WHERE coalesce(item ->> 'kind', 'concept') <> 'phrase' + ) all_references + WHERE nullif(concept_id, '') IS NOT NULL + ORDER BY concept_id, ordinal + ) concept_references) +) +WHERE jsonb_typeof(cm.document) = 'object' +SQL + ) + (record-schema-version! db 20)) + +(define (migrate-20->21! db) + ;; Current concept identity is a plain globally unique UUID. Preserve the + ;; UUID portion of newer concept- identifiers and allocate a UUID only + ;; for genuinely old/local ids. CMap slugs and historical snapshots are not + ;; identity aliases and deliberately remain unchanged. + (query-exec + db + #<> 'id' AS old_id + FROM concept_maps cm + CROSS JOIN LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(cm.document -> 'concepts') = 'array' + THEN cm.document -> 'concepts' ELSE '[]'::jsonb END + ) AS concept + UNION + SELECT item ->> 'conceptId' AS old_id + FROM concept_maps cm + CROSS JOIN LATERAL jsonb_array_elements( + CASE WHEN jsonb_typeof(cm.document -> 'items') = 'array' + THEN cm.document -> 'items' ELSE '[]'::jsonb END + ) AS item + WHERE coalesce(item ->> 'kind', 'concept') <> 'phrase' +) +SELECT old_id +FROM all_current_ids +WHERE nullif(old_id, '') IS NOT NULL +ORDER BY old_id +SQL + )) + ;; Reserve every already canonical UUID before generating replacements, so a + ;; random id can never collide with a UUID encountered later in the query. + (define used-ids (make-hash)) + (for ([old-id (in-list old-ids)]) + (define normalized (normalize-concept-id old-id)) + (when normalized (hash-set! used-ids normalized #t))) + (define (fresh-unused-id) + (let loop () + (define candidate (new-concept-id)) + (if (hash-has-key? used-ids candidate) + (loop) + (begin + (hash-set! used-ids candidate #t) + candidate)))) + (for ([old-id (in-list old-ids)]) + (query-exec + db + "INSERT INTO concept_uuid_rekey(old_id, new_id) VALUES ($1, $2)" + old-id + (or (normalize-concept-id old-id) (fresh-unused-id)))) + (query-exec + db + #< 'items') = 'array' + THEN cm.document -> 'items' ELSE '[]'::jsonb END + ) WITH ORDINALITY AS entry(item, ordinal) + LEFT JOIN concept_uuid_rekey rekey ON rekey.old_id = item ->> 'conceptId'), + 'concepts', + (SELECT coalesce(jsonb_agg(jsonb_build_object('id', new_id) + ORDER BY ordinal), '[]'::jsonb) + FROM ( + SELECT DISTINCT ON (rekey.new_id) rekey.new_id, ordinal + FROM jsonb_array_elements( + CASE WHEN jsonb_typeof(cm.document -> 'concepts') = 'array' + THEN cm.document -> 'concepts' ELSE '[]'::jsonb END + ) WITH ORDINALITY AS entry(concept, ordinal) + JOIN concept_uuid_rekey rekey ON rekey.old_id = concept ->> 'id' + ORDER BY rekey.new_id, ordinal + ) rewritten_concepts) +) +WHERE jsonb_typeof(cm.document) = 'object' +SQL + ) + (query-exec + db + #<12! db)) + (define after-concept-map-history-cleanup (database-schema-version db)) + (when (= after-concept-map-history-cleanup 12) + (migrate-12->13! db)) + (define after-people (database-schema-version db)) + (when (= after-people 13) + (migrate-13->14! db)) + (define after-concept-definitions (database-schema-version db)) + (when (= after-concept-definitions 14) + (migrate-14->15! db)) + (define after-submap-concepts (database-schema-version db)) + (when (= after-submap-concepts 15) + (migrate-15->16! db)) + (define after-concept-link-aliases (database-schema-version db)) + (when (= after-concept-link-aliases 16) + (migrate-16->17! db)) + (define after-concept-name-merge (database-schema-version db)) + (when (= after-concept-name-merge 17) + (migrate-17->18! db)) + (define after-concept-normalization (database-schema-version db)) + (when (= after-concept-normalization 18) + (migrate-18->19! db)) + (define after-placement-content-cleanup (database-schema-version db)) + (when (= after-placement-content-cleanup 19) + (migrate-19->20! db)) + (define after-central-concept-references (database-schema-version db)) + (when (= after-central-concept-references 20) + (migrate-20->21! db)) (define resulting-version (database-schema-version db)) (when (> resulting-version current-schema-version) (error 'migrate-database! diff --git a/private/people.rkt b/private/people.rkt new file mode 100644 index 0000000..c7a8c66 --- /dev/null +++ b/private/people.rkt @@ -0,0 +1,119 @@ +#lang racket/base + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Wiki-wide registry for person tags used by CMap concepts. +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(require db + racket/list + racket/string + "database.rkt") + +(provide list-people + create-person! + update-person! + sync-person-tags!) + +(define (row->person row) + (hash 'id (vector-ref row 0) + 'name (vector-ref row 1) + 'active (vector-ref row 2))) + +(define (list-people config [include-inactive? #t]) + (call-with-wiki-database + config + (lambda (db) + (for/list ((row (in-list + (query-rows + db + (string-append + "SELECT id, name, active FROM people" + (if include-inactive? "" " WHERE active = TRUE") + " ORDER BY lower(name), name"))))) + (row->person row))))) + +(define (clean-person-name who name) + (unless (string? name) + (raise-argument-error who "string?" name)) + (define clean (string-trim name)) + (when (or (string=? clean "") (> (string-length clean) 200)) + (error who "person name must contain between 1 and 200 characters")) + clean) + +(define (create-person! config name) + (define clean-name (clean-person-name 'create-person! name)) + (call-with-wiki-database + config + (lambda (db) + (define now (current-seconds)) + (row->person + (query-row + db + #<person row))))) + +(define (person-tag-names document) + (remove-duplicates + (for*/list ((concept (in-list + (append (hash-ref document 'concepts '()) + (hash-ref document 'items '())))) + #:when (hash? concept) + (tag (in-list (hash-ref concept 'tags '()))) + #:when (and (hash? tag) + (string=? (hash-ref tag 'type "") "person") + (string? (hash-ref tag 'value #f)) + (not (string=? (string-trim (hash-ref tag 'value)) "")))) + (string-trim (hash-ref tag 'value))) + string-ci=?)) + +;; Called inside the concept-map write transaction. New names become active; +;; an explicitly deactivated existing name remains deactivated. +(define (sync-person-tags! db document) + (define now (current-seconds)) + (for ((name (in-list (person-tag-names document)))) + (query-exec + db + #<string/latin-1 (request-method req)) "GET") + (require-role + config req 'reader + (lambda (_session) + (json-response (hash 'styles (or (read-cmap-styles config) 'null))))) + (require-write-role + config req 'editor + (lambda (_session) + (with-handlers ([exn:fail? (lambda (e) (json-error 400 (exn-message e)))]) + (define body (request-json req)) + (json-response + (hash 'styles + (save-cmap-styles! config (hash-ref body 'styles '()))))))))) + +(define (admin-restore-concept-map-handler config req slug) + (require-write-role + config req 'admin + (λ (session) + (with-handlers ((exn:fail? (λ (e) (json-error 400 (exn-message e))))) + (json-response + (restore-concept-map! + config slug (wiki-user-username (wiki-session-user session)))))))) (define (mail-settings->jsexpr settings) (hash 'publicUrl (hash-ref settings "public-url") @@ -1025,8 +1104,18 @@ CSS (λ (req) (concept-map-list-handler config req))] [("api" "cmaps") #:method "post" (λ (req) (concept-map-create-handler config req))] + [("api" "cmap-styles") #:method "get" + (λ (req) (cmap-styles-handler config req))] + [("api" "cmap-styles") #:method "put" + (λ (req) (cmap-styles-handler config req))] [("api" "cmaps" "concept-usage") #:method "get" (λ (req) (concept-usage-handler config req))] + [("api" "people") #:method "get" + (λ (req) (people-list-handler config req))] + [("api" "people") #:method "post" + (λ (req) (people-create-handler config req))] + [("api" "people" (integer-arg)) #:method "put" + (λ (req id) (people-update-handler config req id))] [("api" "cmaps" (string-arg)) #:method "get" (λ (req slug) (concept-map-get-handler config req slug))] [("api" "cmaps" (string-arg) "history") #:method "get" @@ -1061,6 +1150,10 @@ CSS (λ (req slug stored-name) (upload-get-handler config req slug stored-name))] [("api" "admin" "info") #:method "get" (λ (req) (admin-info-handler config req))] + [("api" "admin" "cmaps" "archived") #:method "get" + (λ (req) (admin-archived-concept-maps-handler config req))] + [("api" "admin" "cmaps" "archived" (string-arg) "restore") #:method "post" + (λ (req slug) (admin-restore-concept-map-handler config req slug))] [("api" "admin" "mail-settings") #:method "get" (λ (req) (admin-mail-settings-handler config req))] [("api" "admin" "mail-settings") #:method "put" diff --git a/static/cmap/README.md b/static/cmap/README.md index 12d3f66..d8561e3 100644 --- a/static/cmap/README.md +++ b/static/cmap/README.md @@ -15,11 +15,19 @@ submap concepts. 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 -their independent display label, synopsis, background colour, typography and -optional image. Page concepts retain their linked `pageSlug`; double-click is +their shared name, aspects, people, description page, synopsis, image, external +web address and linked wiki/CMap target, plus placement-specific background +colour and typography. The editor has one compact tab for content, links and +images, and a second tab for detailed colours, typography and submap +presentation. Detailed settings can be saved as named, wiki-wide database +styles; selected styles are applied immediately and non-default styles can be +deleted. Its action bar remains visible while a tab scrolls. +Structural kinds such as a sub-CMap head remain placement roles. Page concepts retain their linked `pageSlug`; double-click is reserved for opening that wiki page. Concepts may alternatively retain a `cmapSlug` for another stored CMap or a `parentCmapLink` for navigation back -from a child map. Images are data URLs inside the persisted CMap JSON document. +from a child map. An optional `externalUrl` accepts only HTTP(S), opens in a new +browser tab and is shared by every placement of that concept. Images are data +URLs inside the persisted CMap JSON document. Font families are selected from a practical list and font sizes are stored in typographic points. The host can change the linked page or CMap independently of the visible label and provides background and text-colour pickers. @@ -39,6 +47,30 @@ selected inline sub-CMap or moves selected child items one level outward. formatting, positions, connectors, recursive submap membership and promoted 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` +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 +referenced attachments of linked wiki and explanation pages. Attachments retain +their MIME type and binary base64 content; import uploads them under the target +page and rewrites the Markdown to the newly allocated URL. The root CMap and every included CMap retain +their stable slug. Derived CMaps automatically include their source CMap. + +Presentation includes coordinates, dimensions, colours, font settings, +sub-CMap membership, linking phrases and connectors. Connector endpoints use +local item ids; concept identity never depends on those local ids. The formal +contract is +[`racket-wiki-cmap-bundle-v1.schema.json`](../schemas/racket-wiki-cmap-bundle-v1.schema.json). + +An external generator may use a temporary id such as `new:security-review` for +a new concept. Every concept must also occur in at least one non-phrase +`document.items[]` placement with coordinates. On import the server turns such +a temporary identity into a UUID through the central Racket UUID helper. UUIDs +already present in an export remain unchanged. Imported CMap slugs likewise +remain unchanged. + The editor keeps up to one hundred complete document states for Undo and Redo. One drag or resize gesture forms one history step. The public `undo`, `redo`, `canUndo`, `canRedo` and `resetHistory` methods are also used by the wiki host diff --git a/static/cmap/cmap-racket-wiki.js b/static/cmap/cmap-racket-wiki.js index e0c9064..1987699 100644 --- a/static/cmap/cmap-racket-wiki.js +++ b/static/cmap/cmap-racket-wiki.js @@ -7,7 +7,10 @@ (() => { "use strict"; - const debugPrefix = "[racket-wiki:cmap 0.2.99]"; + const debugPrefix = "[racket-wiki:cmap 0.2.122]"; + // The editor is rebuilt when navigating between stored CMaps. Clipboard + // entries therefore belong to the shared module, not to one editor instance. + let copiedConceptReferences = []; function debug(message, details) { if (details === undefined) { @@ -80,12 +83,35 @@ function newConceptId() { if (window.crypto && typeof window.crypto.randomUUID === "function") { try { - return `concept-${window.crypto.randomUUID()}`; + return window.crypto.randomUUID().toLowerCase(); } catch (_error) { // randomUUID can be exposed but forbidden in an insecure/file context. } } - return `concept-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; + const bytes = new Uint8Array(16); + if (window.crypto && typeof window.crypto.getRandomValues === "function") { + window.crypto.getRandomValues(bytes); + } else { + for (let index = 0; index < bytes.length; index += 1) { + bytes[index] = Math.floor(Math.random() * 256); + } + } + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")); + return `${hex.slice(0, 4).join("")}-${hex.slice(4, 6).join("")}-${hex.slice(6, 8).join("")}-${hex.slice(8, 10).join("")}-${hex.slice(10).join("")}`; + } + + function normalizeConceptTags(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; + return { + type: String(tag.type || "label").trim() || "label", + value: String(tag.value || tag.name || "").trim() + }; + }).filter((tag) => tag && tag.value); } ////////////////////////////////////////////////////////////////////////////// @@ -110,6 +136,7 @@ this.onSubMapPromoted = options.onSubMapPromoted || null; this.onMapChange = options.onMapChange || null; this.onOpenCmap = options.onOpenCmap || null; + this.onOpenExternalUrl = options.onOpenExternalUrl || null; this.onOpenStoredSubMap = options.onOpenStoredSubMap || null; this.onOpenBoundaryReference = options.onOpenBoundaryReference || null; this.onVisibilityChange = options.onVisibilityChange || null; @@ -118,6 +145,7 @@ this.onCreateConnectedItem = options.onCreateConnectedItem || null; this.onSelectionChange = options.onSelectionChange || null; this.onHistoryChange = options.onHistoryChange || null; + this.onAutomaticLayoutChange = options.onAutomaticLayoutChange || null; this.labels = { createRelation: options.createRelationLabel || "Create relation", editConcept: options.editConceptLabel || "Edit concept", @@ -131,12 +159,12 @@ this.connectors = []; this.unresolvedConnectors = []; this.conceptMaps = new Map(); + this.documentMetadata = { tags: [], summary: "", explanationPageSlug: "" }; this.activeMapRoot = null; this.mapHistory = []; this.selectedItem = null; this.selectedItems = new Set(); this.selectedConnector = null; - this.copiedConceptIds = []; this.destroyed = false; this.nextId = 1; this.nextConnectorId = 1; @@ -239,6 +267,7 @@ this.connectors = []; this.unresolvedConnectors = []; this.conceptMaps = new Map(); + this.documentMetadata = { tags: [], summary: "", explanationPageSlug: "" }; this.activeMapRoot = null; this.mapHistory = []; this.nextId = 1; @@ -322,10 +351,12 @@ label: options.label || "Concept", synopsis: options.synopsis || "", aspects: Array.isArray(options.aspects) ? options.aspects.map(String) : [], + tags: normalizeConceptTags(options.tags), descriptionPageSlug: kind === "phrase" ? null : (options.descriptionPageSlug || conceptDescriptionReference(options.label || "Concept", id)), pageSlug: options.pageSlug || null, cmapSlug: options.cmapSlug || null, + externalUrl: options.externalUrl || null, parentCmapLink: Boolean(options.parentCmapLink), groupId: options.groupId || null, childMap: options.childMap || null, @@ -388,9 +419,9 @@ fitContentPending: autoWidth || autoHeight, node: null }; - record.usageCount = record.conceptId && kind !== "phrase" && kind !== "submap" ? + record.usageCount = record.conceptId && kind !== "phrase" ? this.items.filter((item) => item.conceptId === record.conceptId && - item.kind !== "phrase" && item.kind !== "submap").length + 1 : null; + item.kind !== "phrase").length + 1 : null; const node = this.map.node({ content: this.itemHtml(record), @@ -424,7 +455,7 @@ .map((item) => item.conceptId).filter(Boolean)); for (const conceptId of ids) { const peers = this.items.filter((item) => item.conceptId === conceptId && - item.kind !== "phrase" && item.kind !== "submap"); + item.kind !== "phrase"); for (const peer of peers) { peer.usageCount = peers.length; if (!peer.node) continue; @@ -811,6 +842,10 @@ return true; } + canStepBackWithinMap() { + return this.mapHistory.length > 0; + } + openParentMap() { if (!this.activeMapRoot) return false; this.clearSelection(); @@ -983,6 +1018,7 @@ descriptionPageSlug: headDocument.descriptionPageSlug, pageSlug: null, cmapSlug: targetSlug, + externalUrl: headDocument.externalUrl || null, parentCmapLink: false, imageSource: headDocument.imageSource || "" }); @@ -995,6 +1031,7 @@ descriptionPageSlug: childHead.descriptionPageSlug, pageSlug: null, cmapSlug: null, + externalUrl: childHead.externalUrl || null, parentCmapLink: false, imageSource: childHead.imageSource || "" }); @@ -1084,16 +1121,18 @@ // first, so that automatic sizing cannot turn the edited state into the // history baseline before it has been committed as its own Undo step. this.scheduleHistoryCommit(); + if (changes.tags !== undefined) changes.tags = normalizeConceptTags(changes.tags); for (const [key, value] of Object.entries(changes)) { if (value !== undefined) record[key] = value; } - const identityKeys = ["kind", "label", "synopsis", "aspects", "descriptionPageSlug", - "pageSlug", "cmapSlug", - "parentCmapLink", "imageSource"]; - if (record.conceptId && record.kind !== "submap") { + const identityKeys = ["label", "synopsis", "aspects", "tags", + "descriptionPageSlug", + "pageSlug", "cmapSlug", "externalUrl", + "imageSource"]; + if (record.conceptId && record.kind !== "phrase") { for (const peer of this.items) { - if (peer === record || peer.conceptId !== record.conceptId || peer.kind === "submap") continue; + if (peer === record || peer.conceptId !== record.conceptId || peer.kind === "phrase") continue; for (const key of identityKeys) { if (changes[key] !== undefined) peer[key] = changes[key]; } @@ -1225,11 +1264,55 @@ this.nextConnectorId = Math.max(this.nextConnectorId, record.id + 1); this.connectors.push(record); link.onRendered((_renderedLink, element) => this.decorateConnector(record, element)); + link.onConnectionChange((_changedLink, type, node) => + this.handleConnectorConnectionChange(record, type, node)); link.visible(this.isItemVisible(source) && this.isItemVisible(target)); this.scheduleHistoryCommit(); return record; } + /** + * Keep a dragged ionstage/cmap link endpoint and the persisted wiki + * connector model in lockstep. The drawing library owns the endpoint + * handles; the wiki model owns sourceId/targetId and submap projection. + */ + handleConnectorConnectionChange(connector, type, node) { + if (!connector || (type !== "source" && type !== "target")) return false; + const endpoint = this.items.find((item) => item.node === node) || null; + const otherType = type === "source" ? "target" : "source"; + + // A connector is only a storable relation while both ends are attached + // to different items. Restore the previous logical projection when an + // endpoint is dropped on empty space or on its opposite endpoint. + if (!endpoint || endpoint === connector[otherType]) { + connector[`visual${type === "source" ? "Source" : "Target"}`] = null; + this.applyConnectorVisualEndpoints(connector, + this.connectorEndpoint(connector.source), + this.connectorEndpoint(connector.target)); + return false; + } + + if (connector[type] === endpoint) { + connector[`visual${type === "source" ? "Source" : "Target"}`] = endpoint; + return false; + } + + const previous = connector[type]; + connector[type] = endpoint; + connector[`visual${type === "source" ? "Source" : "Target"}`] = endpoint; + this.reconcilePhraseMembership(); + this.refreshSubmapVisibility(); + this.refreshConnectorGeometry(); + debug("connector endpoint changed", { + connectorId: connector.id, + type, + previousItemId: previous ? previous.id : null, + itemId: endpoint.id + }); + this.scheduleHistoryCommit(); + return true; + } + /** * goal : Select a concept/linking phrase, optionally beside the current selection. * pre : record belongs to this editor. @@ -1280,6 +1363,8 @@ const selected = this.selectedItems.has(item); if (element) { element.classList.toggle("rw-cmap-selected", selected); + element.classList.toggle( + "rw-cmap-selected-primary", selected && item === this.selectedItem); if (selected) { element.setAttribute("aria-selected", "true"); item.node.toFront(); @@ -1316,10 +1401,12 @@ for (const item of this.selectedItems) { if (item.submapFrameElement) { item.submapFrameElement.classList.remove("rw-cmap-submap-frame-selected"); + item.submapFrameElement.classList.remove("rw-cmap-submap-frame-selected-primary"); } const element = item.node.element(); if (element) { element.classList.remove("rw-cmap-selected"); + element.classList.remove("rw-cmap-selected-primary"); element.removeAttribute("aria-selected"); this.removeHandles(element); } @@ -1346,42 +1433,95 @@ return Array.from(this.selectedItems); } + storeConceptReferences(records) { + if (!records.length) return 0; + const referencesById = new Map(records.map((source) => + [source.conceptId, { + conceptId: source.conceptId, + kind: source.kind === "submap" ? "concept" : source.kind, + label: source.label, + synopsis: source.synopsis, + aspects: Array.isArray(source.aspects) ? [...source.aspects] : [], + tags: normalizeConceptTags(source.tags).map((tag) => ({ ...tag })), + descriptionPageSlug: source.descriptionPageSlug, + pageSlug: source.pageSlug, + cmapSlug: source.cmapSlug, + externalUrl: source.externalUrl, + imageSource: source.imageSource, + width: Number(source.node.attr("width")), + height: Number(source.node.attr("height")), + backgroundColor: source.backgroundColor, + borderColor: source.borderColor, + textColor: source.textColor, + fontFamily: source.fontFamily, + fontSize: source.fontSize, + fontWeight: source.fontWeight, + fontStyle: source.fontStyle, + synopsisTextColor: source.synopsisTextColor, + synopsisFontFamily: source.synopsisFontFamily, + synopsisFontSize: source.synopsisFontSize, + synopsisFontWeight: source.synopsisFontWeight, + synopsisFontStyle: source.synopsisFontStyle + }])); + copiedConceptReferences = Array.from(referencesById.values()); + return copiedConceptReferences.length; + } + copySelectionReferences() { - this.copiedConceptIds = Array.from(new Set(this.selectedAll() - .filter((item) => item.conceptId && item.kind !== "phrase" && item.kind !== "submap") - .map((item) => item.conceptId))); + const selected = this.selectedAll() + .filter((item) => item.conceptId && item.kind !== "phrase"); + const copied = this.storeConceptReferences(selected); + if (!copied) return 0; this.notifySelection(); - return this.copiedConceptIds.length; + return copied; + } + + canCutSelectionReferences() { + return this.selectedAll().some((item) => + item !== this.activeMapRoot && item.conceptId && item.kind !== "phrase"); + } + + cutSelectionReferences() { + const cuttable = this.selectedAll().filter((item) => + item !== this.activeMapRoot && item.conceptId && item.kind !== "phrase"); + if (!cuttable.length) return 0; + const copied = this.storeConceptReferences(cuttable); + if (!copied) return 0; + this.clearSelection(false); + for (const record of cuttable) this.selectedItems.add(record); + this.selectedItem = cuttable.at(-1) || null; + this.deleteSelection(); + return copied; } canPasteConceptReferences() { - return this.copiedConceptIds.length > 0; + return copiedConceptReferences.length > 0; } pasteConceptReferences() { - const sources = this.copiedConceptIds - .map((conceptId) => this.items.find((item) => item.conceptId === conceptId)) - .filter(Boolean); + const sources = copiedConceptReferences; if (!sources.length) return []; this.clearSelection(false); const parentSubmap = this.activeMapRoot || null; const pasted = sources.map((source, index) => this.addItem({ conceptId: source.conceptId, - kind: source.kind, + kind: source.kind === "submap" ? "concept" : source.kind, label: source.label, synopsis: source.synopsis, aspects: source.aspects, + tags: source.tags, descriptionPageSlug: source.descriptionPageSlug, pageSlug: source.pageSlug, cmapSlug: source.cmapSlug, - parentCmapLink: source.parentCmapLink, + externalUrl: source.externalUrl, + parentCmapLink: false, imageSource: source.imageSource, parentSubmap, submapDepth: parentSubmap ? parentSubmap.submapDepth + 1 : 0, x: 120 + (index * 36), y: 120 + (index * 36), - width: Number(source.node.attr("width")), - height: Number(source.node.attr("height")), + width: source.width, + height: source.height, backgroundColor: source.backgroundColor, borderColor: source.borderColor, textColor: source.textColor, @@ -1413,6 +1553,119 @@ return this.selectedAll(); } + layoutSelectionRecords() { + return this.selectedAll().filter((item) => this.isEffectiveItemVisible(item)); + } + + canLayoutSelection(command) { + const minimum = ["distribute-horizontal", "distribute-vertical"].includes(command) ? 3 : 2; + return this.layoutSelectionRecords().length >= minimum; + } + + applySelectionLayout(command) { + const records = this.layoutSelectionRecords(); + if (!this.canLayoutSelection(command)) return false; + const boxes = records.map((record) => ({ + record, + x: Number(record.node.attr("x")), + y: Number(record.node.attr("y")), + width: Number(record.node.attr("width")), + height: Number(record.node.attr("height")) + })); + const reference = boxes.find((box) => box.record === this.selectedItem) || boxes.at(-1); + const updates = new Map(boxes.map(({ record }) => [record, {}])); + const referenceRight = reference.x + reference.width; + const referenceCenter = reference.x + (reference.width / 2); + const referenceBottom = reference.y + reference.height; + const referenceMiddle = reference.y + (reference.height / 2); + + if (["same-width", "same-size"].includes(command)) { + for (const box of boxes) updates.get(box.record).width = reference.width; + } + if (["same-height", "same-size"].includes(command)) { + for (const box of boxes) updates.get(box.record).height = reference.height; + } + if (command === "align-left") { + for (const box of boxes) updates.get(box.record).x = reference.x; + } + if (command === "align-right") { + for (const box of boxes) updates.get(box.record).x = referenceRight - box.width; + } + if (command === "align-center") { + for (const box of boxes) updates.get(box.record).x = referenceCenter - (box.width / 2); + } + if (command === "align-top") { + for (const box of boxes) updates.get(box.record).y = reference.y; + } + if (command === "align-bottom") { + for (const box of boxes) updates.get(box.record).y = referenceBottom - box.height; + } + if (command === "align-middle") { + for (const box of boxes) updates.get(box.record).y = referenceMiddle - (box.height / 2); + } + if (command === "distribute-horizontal") { + const ordered = [...boxes].sort((a, b) => + (a.x + (a.width / 2)) - (b.x + (b.width / 2)) || + String(a.record.id).localeCompare(String(b.record.id))); + const distributionLeft = ordered[0].x; + const distributionRight = ordered.at(-1).x + ordered.at(-1).width; + const occupiedWidth = ordered.reduce((sum, box) => sum + box.width, 0); + const gap = (distributionRight - distributionLeft - occupiedWidth) / (ordered.length - 1); + let cursor = distributionLeft; + for (const box of ordered) { + updates.get(box.record).x = cursor; + cursor += box.width + gap; + } + } + if (command === "distribute-vertical") { + const ordered = [...boxes].sort((a, b) => + (a.y + (a.height / 2)) - (b.y + (b.height / 2)) || + String(a.record.id).localeCompare(String(b.record.id))); + const distributionTop = ordered[0].y; + const distributionBottom = ordered.at(-1).y + ordered.at(-1).height; + const occupiedHeight = ordered.reduce((sum, box) => sum + box.height, 0); + const gap = (distributionBottom - distributionTop - occupiedHeight) / (ordered.length - 1); + let cursor = distributionTop; + for (const box of ordered) { + updates.get(box.record).y = cursor; + cursor += box.height + gap; + } + } + + if (!["same-width", "same-height", "same-size", "align-left", "align-right", + "align-center", "align-top", "align-bottom", "align-middle", + "distribute-horizontal", "distribute-vertical"].includes(command)) return false; + + this.scheduleHistoryCommit(); + for (const record of records) { + const attributes = updates.get(record); + if (attributes.width !== undefined) { + record.width = attributes.width; + record.autoWidth = false; + } + if (attributes.height !== undefined) { + record.height = attributes.height; + record.autoHeight = false; + } + record.node.attr(attributes); + record.node.redraw(); + } + this.saveCurrentContextLayout(); + this.refreshConnectorGeometry(); + for (const submap of this.items + .filter((item) => item.kind === "submap") + .sort((a, b) => b.submapDepth - a.submapDepth)) { + this.updateSubmapFrame(submap); + } + this.refreshSelectionDecoration(); + debug("selection layout applied", { + command, + referenceItemId: reference.record.id, + itemIds: records.map((record) => record.id) + }); + return true; + } + canGroupSelection() { const selected = this.selectedAll(); return selected.length >= 2 && @@ -1513,36 +1766,54 @@ return true; } + getDocumentMetadata() { + return { + tags: [...this.documentMetadata.tags], + summary: this.documentMetadata.summary, + explanationPageSlug: this.documentMetadata.explanationPageSlug + }; + } + + setDocumentMetadata(metadata = {}) { + this.scheduleHistoryCommit(); + 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() + }; + return this.getDocumentMetadata(); + } + toDocument() { this.saveCurrentContextLayout(); this.refreshConceptMapReferences(); const concepts = Array.from(new Map(this.items - .filter((record) => record.conceptId && record.kind !== "phrase" && record.kind !== "submap") + .filter((record) => record.conceptId && record.kind !== "phrase") .map((record) => [record.conceptId, { id: record.conceptId, - kind: record.kind, 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, - parentCmapLink: record.parentCmapLink, + 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, - label: record.label, - synopsis: record.synopsis, - aspects: record.aspects, - descriptionPageSlug: record.descriptionPageSlug, - pageSlug: record.pageSlug, - cmapSlug: record.cmapSlug, + ...(record.kind === "phrase" ? { + label: record.label, + synopsis: record.synopsis + } : {}), parentCmapLink: record.parentCmapLink, groupId: record.groupId, childMap: record.childMap, @@ -1554,7 +1825,6 @@ mapReference: record.mapReference, hiddenContexts: Array.from(record.hiddenContexts), layouts: record.layouts, - imageSource: record.imageSource, backgroundColor: record.backgroundColor, borderColor: record.borderColor, submapBackgroundColor: record.submapBackgroundColor, @@ -1598,10 +1868,25 @@ .filter((concept) => concept && concept.id) .map((concept) => [concept.id, concept])); 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 record = this.addItem({ ...itemDocument, ...concept, parentSubmap: null }); + const record = this.addItem({ + ...itemDocument, + ...concept, + id: itemDocument.id, + conceptId: itemDocument.conceptId, + kind: itemDocument.kind || concept.kind || "concept", + parentSubmap: null + }); record.autoWidth = Boolean(itemDocument.autoWidth); record.autoHeight = Boolean(itemDocument.autoHeight); record.fitContentPending = false; @@ -1756,7 +2041,10 @@ element.style.overflow = "visible"; this.applyItemTypography(record, element); - if (record.fitContentPending) { + // Concept content is never presentation-optional. A context may retain a + // different width or a larger manual height, but it may not keep a + // height that clips part of the shared content. + if (record.fitContentPending || record.kind !== "phrase") { this.fitItemToContent(record, element); } @@ -1764,7 +2052,7 @@ if (image && image.dataset.rwCmapFitBound !== "1") { image.dataset.rwCmapFitBound = "1"; image.addEventListener("load", () => { - if (!record.autoWidth && !record.autoHeight) return; + if (record.kind === "phrase" && !record.autoWidth && !record.autoHeight) return; record.fitContentPending = true; this.fitItemToContent(record, element); }, { once: true }); @@ -1806,6 +2094,20 @@ }); } + const externalButton = element.querySelector(".rw-cmap-open-external"); + if (externalButton && externalButton.dataset.rwCmapBound !== "1") { + externalButton.dataset.rwCmapBound = "1"; + externalButton.addEventListener("pointerdown", (event) => { + event.preventDefault(); + event.stopPropagation(); + }); + externalButton.addEventListener("click", (event) => { + event.preventDefault(); + event.stopPropagation(); + if (record.externalUrl && this.onOpenExternalUrl) this.onOpenExternalUrl(record); + }); + } + if (element.dataset.rwCmapBound !== "1") { element.dataset.rwCmapBound = "1"; debug("item pointer handlers attached", { @@ -1818,6 +2120,7 @@ if (this.selectedItems.has(record)) { element.classList.add("rw-cmap-selected"); + element.classList.toggle("rw-cmap-selected-primary", this.selectedItem === record); element.setAttribute("aria-selected", "true"); if (this.selectedItem === record) this.ensureHandles(record, element); } @@ -1847,14 +2150,16 @@ } /** - * goal : Give a new, not manually sized item a compact content-based size. + * goal : Size automatic items and prevent fixed concept cards clipping content. * pre : element has been rendered and contains the current item HTML. * post : Automatic dimensions closely surround the text, with long text * wrapping at a practical maximum width. */ fitItemToContent(record, element) { record.fitContentPending = false; - if (!record.autoWidth && !record.autoHeight) return; + if (record.kind === "phrase" && !record.autoWidth && !record.autoHeight) return; + + const fixedWidth = !record.autoWidth && record.kind !== "phrase"; const probe = document.createElement("div"); probe.className = element.className; @@ -1863,9 +2168,9 @@ position: "fixed", left: "-10000px", top: "0", - width: "max-content", + width: fixedWidth ? `${record.width}px` : "max-content", height: "auto", - maxWidth: record.kind === "phrase" ? "280px" : "380px", + maxWidth: fixedWidth ? "none" : (record.kind === "phrase" ? "280px" : "380px"), boxSizing: "border-box", fontFamily: record.fontFamily, fontSize: record.fontSize, @@ -1883,9 +2188,9 @@ const content = probe.firstElementChild; if (content) { Object.assign(content.style, { - width: "max-content", + width: fixedWidth ? "100%" : "max-content", height: "auto", - maxWidth: record.kind === "phrase" ? "276px" : "376px", + maxWidth: fixedWidth ? "none" : (record.kind === "phrase" ? "276px" : "376px"), overflow: "visible", whiteSpace: "normal" }); @@ -1900,9 +2205,14 @@ const measuredWidth = Math.ceil(bounds.width) + 4; const measuredHeight = Math.ceil(bounds.height) + 4; const nextWidth = record.autoWidth ? Math.max(minimumWidth, measuredWidth) : record.width; - const nextHeight = record.autoHeight ? Math.max(minimumHeight, measuredHeight) : record.height; + const nextHeight = record.autoHeight ? Math.max(minimumHeight, measuredHeight) : + (record.kind === "phrase" ? record.height : Math.max(record.height, measuredHeight)); if (nextWidth === record.width && nextHeight === record.height) return; + // Rendering may finish after the host has recorded its saved baseline. + // Give the host both exact states so it can advance that baseline only + // when no user edit occurred in between. + const beforeAutomaticLayout = this.onAutomaticLayoutChange ? this.historySnapshot() : null; const previousWidth = record.width; const previousHeight = record.height; const attributes = { width: nextWidth, height: nextHeight }; @@ -1922,6 +2232,16 @@ height: nextHeight }); this.refreshHistorySnapshot(); + if (this.onAutomaticLayoutChange) { + const afterAutomaticLayout = this.historySnapshot(); + if (beforeAutomaticLayout !== afterAutomaticLayout) { + this.onAutomaticLayoutChange({ + beforeSnapshot: beforeAutomaticLayout, + afterSnapshot: afterAutomaticLayout, + itemId: record.id + }); + } + } } decorateConnector(record, renderedElement = null) { @@ -2293,6 +2613,9 @@ "--rw-cmap-submap-border", record.submapBorderColor || "#57834a"); record.submapFrameElement.classList.toggle( "rw-cmap-submap-frame-selected", this.selectedItems.has(record)); + record.submapFrameElement.classList.toggle( + "rw-cmap-submap-frame-selected-primary", + this.selectedItems.has(record) && this.selectedItem === record); record.submapFrameElement.setAttribute("aria-label", record.label); this.updateSubmapAnchorLine(record, bounds, surface); this.ensureCanvasExtent(right, bottom); @@ -2942,7 +3265,7 @@ let lastEditor = null; window.RacketWikiCmap = { - version: "0.2.99", + version: "0.2.122", createEditor(canvas, options) { lastEditor = new CmapEditor(canvas, options); return lastEditor; diff --git a/static/cmap/cmap.css b/static/cmap/cmap.css index 52430d3..c6ba5c0 100644 --- a/static/cmap/cmap.css +++ b/static/cmap/cmap.css @@ -280,16 +280,86 @@ body.cmap-mode #main { .cmap-workspace { position: relative; z-index: 0; + display: flex; + align-items: flex-start; isolation: isolate; width: 100%; } +.cmap-selection-toolbar { + position: sticky; + z-index: 120; + top: 82px; + left: 0; + display: grid; + flex: 0 0 82px; + grid-template-columns: 1fr; + gap: 5px; + box-sizing: border-box; + width: 82px; + max-height: calc(100vh - 96px); + margin: 5px 8px 0 0; + padding: 7px; + overflow: auto; + border: 1px solid #c6ccd3; + border-radius: 7px; + background: #f2f4f6; + box-shadow: 0 3px 12px rgb(35 45 55 / 14%); +} + +.cmap-selection-tool-group { + display: grid; + grid-template-columns: repeat(2, 30px); + gap: 5px; +} + +.cmap-selection-tool-group button { + display: grid; + box-sizing: border-box; + width: 30px; + height: 30px; + padding: 5px; + place-items: center; + border: 1px solid #aeb7c1; + border-radius: 4px; + background: #fff; + color: #344454; + cursor: pointer; +} + +.cmap-selection-tool-group button:hover:not(:disabled), +.cmap-selection-tool-group button:focus-visible { + border-color: #64788c; + background: #e4ebf2; + color: #172b3e; +} + +.cmap-selection-tool-group button:disabled { + border-color: #d6dade; + background: #f7f8f9; + color: #aeb5bc; + cursor: default; +} + +.cmap-selection-tool-group button svg, +.cmap-selection-tool-group button i { + width: 17px; + height: 17px; +} + +.cmap-selection-tool-separator { + height: 1px; + margin: 1px 2px; + background: #cbd1d7; +} + .cmap-canvas { --cmap-a4-width: 1123px; --cmap-a4-height: 794px; position: relative; + flex: 1 0 auto; width: max-content; - min-width: max(760px, 100%); + min-width: max(760px, calc(100% - 90px)); min-height: 794px; overflow: visible; border: 0; @@ -518,7 +588,8 @@ body.cmap-mode #main { .cmap-description-tooltip .markdown-body > :first-child { margin-top: 0; } .cmap-description-tooltip .markdown-body > :last-child { margin-bottom: 0; } -.rw-cmap-open-linked { +.rw-cmap-open-linked, +.rw-cmap-open-external { position: absolute; z-index: 46; top: 3px; @@ -536,15 +607,35 @@ body.cmap-mode #main { font: 700 12px/1 Arial, Helvetica, sans-serif; } +.rw-cmap-open-external { + position: absolute; + right: 3px; + border-color: #4479a1; + color: #245f8c; +} + +.rw-cmap-open-linked + .rw-cmap-open-external { + right: 27px; +} + +.cmap-card-submap .rw-cmap-open-external { + right: 52px; +} + .rw-cmap-view-description + .cmap-card-image, .rw-cmap-view-description ~ .cmap-card-title { padding-left: 16px; } -.rw-cmap-open-linked ~ .cmap-card-title { +.rw-cmap-open-linked ~ .cmap-card-title, +.rw-cmap-open-external ~ .cmap-card-title { padding-right: 16px; } +.rw-cmap-open-linked + .rw-cmap-open-external ~ .cmap-card-title { + padding-right: 40px; +} + .cmap-card-title { margin-bottom: .3rem; font-size: 1em; @@ -567,6 +658,24 @@ body.cmap-mode #main { margin: 0 0 .35rem; } +.cmap-card-people { + display: inline-flex; + max-width: 100%; + align-items: center; + gap: 3px; + margin-top: 4px; + padding: 2px 6px; + overflow: hidden; + border: 1px solid #8b6aa8; + border-radius: 999px; + background: #f1e9f7; + color: #4e3266; + font-size: 0.76em; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + .cmap-card-aspect { padding: 1px 5px; border: 1px solid color-mix(in srgb, currentColor 35%, transparent); @@ -682,6 +791,11 @@ body.cmap-mode #main { box-shadow: 0 0 0 5px rgb(132 126 255 / 28%); } +.rw-cmap-submap-frame-selected-primary { + outline-color: #2473c7; + box-shadow: 0 0 0 5px rgb(36 115 199 / 32%); +} + .rw-cmap-submap-frame-toggle { position: absolute; top: 50%; @@ -737,13 +851,19 @@ body.cmap-mode #main { } .rw-cmap-selected { - z-index: 20 !important; + z-index: 300000 !important; outline: 3px solid #9994ff !important; outline-offset: 2px; box-shadow: 0 0 0 1px rgb(255 255 255 / 95%), 0 0 0 5px rgb(132 126 255 / 38%) !important; } +.rw-cmap-selected-primary { + outline-color: #2473c7 !important; + box-shadow: 0 0 0 1px rgb(255 255 255 / 95%), + 0 0 0 5px rgb(36 115 199 / 42%) !important; +} + .rw-cmap-marquee { position: absolute; z-index: 10000; @@ -887,7 +1007,8 @@ body.cmap-mode #main { } #cmap-concept-dialog { - width: min(780px, calc(100vw - 32px)); + width: min(860px, calc(100vw - 32px)); + height: min(760px, calc(100vh - 32px)); max-height: calc(100vh - 32px); } @@ -947,20 +1068,88 @@ body.cmap-mode #main { } #cmap-concept-form { - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 11px 14px; - max-height: calc(100vh - 34px); - overflow: auto; - padding: 18px; + grid-template-rows: auto auto minmax(0, 1fr) auto; + gap: 0; + height: 100%; + max-height: none; + overflow: hidden; + padding: 0; } -#cmap-concept-form > h2, -#cmap-concept-form > .cmap-dialog-wide, -#cmap-concept-form > .cmap-submap-style-fields, -#cmap-concept-form > .cmap-concept-dialog-actions { +#cmap-concept-form > h2 { + padding: 18px 20px 10px; +} + +.cmap-concept-tabs { + display: flex; + gap: 4px; + padding: 0 20px; + border-bottom: 1px solid #cbd2da; +} + +.cmap-concept-tabs button { + margin-bottom: -1px; + padding: 9px 14px; + border: 1px solid transparent; + border-bottom-color: #cbd2da; + border-radius: 5px 5px 0 0; + background: transparent; + cursor: pointer; +} + +.cmap-concept-tabs button[aria-selected="true"] { + border-color: #cbd2da; + border-bottom-color: #fff; + background: #fff; + color: #173b57; + font-weight: 700; +} + +.cmap-concept-dialog-body { + min-height: 0; + overflow: auto; + padding: 16px 20px; +} + +.cmap-concept-panel { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 13px 16px; +} + +.cmap-concept-panel.hidden { + display: none; +} + +.cmap-concept-panel > .cmap-dialog-wide, +.cmap-concept-panel > .cmap-submap-style-fields { grid-column: 1 / -1; } +.cmap-style-manager { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px 12px; + align-items: end; + padding: 11px 12px; + border: 1px solid #b9c7d3; + border-radius: 5px; + background: #f3f7fa; +} + +.cmap-style-manager-actions { + display: flex; + gap: 7px; +} + +.cmap-style-manager small { + grid-column: 1 / -1; +} + +.cmap-quick-style-field { + align-content: start; +} + .cmap-concept-dialog h2 { margin: 0 0 2px; } @@ -973,6 +1162,7 @@ body.cmap-mode #main { .cmap-concept-dialog input[type="text"], .cmap-concept-dialog input[type="search"], +.cmap-concept-dialog input[type="url"], .cmap-concept-dialog input[type="number"], .cmap-concept-dialog select, .cmap-concept-dialog textarea { @@ -1105,6 +1295,89 @@ body.cmap-mode #main { .cmap-check-label input { margin: 0; } +#cmap-export-form .cmap-check-label { + display: flex; + grid-template-columns: none; + flex-direction: row; + align-items: center; + gap: 8px; +} + +.cmap-person-tags-field small { + font-weight: 400; +} + +.cmap-concept-panel small { + font-weight: 400; +} + +.cmap-person-picker { + position: relative; + font-weight: 400; +} + +.cmap-person-picker > summary { + padding: 8px 10px; + border: 1px solid #aab2bd; + border-radius: 4px; + background: #fff; + cursor: pointer; +} + +.cmap-person-picker[open] { + padding: 0 9px 9px; + border: 1px solid #aab2bd; + border-radius: 4px; + background: #fff; +} + +.cmap-person-picker[open] > summary { + margin: 0 -9px 8px; + border: 0; + border-bottom: 1px solid #d3d7dc; + border-radius: 0; +} + +.cmap-person-tag-options, +.cmap-people-list { + display: grid; + gap: 6px; + max-height: 240px; + overflow: auto; +} + +.cmap-person-tag-option { + display: flex !important; + grid-template-columns: none !important; + flex-direction: row; + align-items: center; + gap: 7px !important; + font-weight: 400 !important; +} + +.cmap-person-add-row, +.cmap-person-admin-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: center; + margin-top: 9px; +} + +.cmap-person-admin-row { + grid-template-columns: minmax(180px, 1fr) auto auto; + margin: 0; +} + +.cmap-person-admin-row label { + display: flex; + grid-template-columns: none; + flex-direction: row; + align-items: center; + gap: 6px; + font-weight: 400; +} + .cmap-submap-style-fields legend { grid-column: 1 / -1; } @@ -1160,21 +1433,32 @@ body.cmap-mode #main { display: flex; justify-content: flex-end; gap: 8px; - margin-top: 4px; + margin: 0; + padding: 12px 20px; + border-top: 1px solid #cbd2da; + background: #fff; } @media (max-width: 720px) { - #cmap-concept-form, + .cmap-concept-panel, .cmap-appearance-fields, .cmap-typography-fields { grid-template-columns: 1fr; } - #cmap-concept-form > *, + .cmap-concept-panel > *, .cmap-appearance-fields > *, .cmap-typography-fields > * { grid-column: 1; } + + .cmap-style-manager { + grid-template-columns: 1fr; + } + + .cmap-style-manager small { + grid-column: 1; + } } @media (max-width: 1100px) { diff --git a/static/cmap/cmap.js b/static/cmap/cmap.js index 1fd4513..2cfde8c 100644 --- a/static/cmap/cmap.js +++ b/static/cmap/cmap.js @@ -1,5 +1,5 @@ /** - * racket-wiki cmap component 0.2.95 + * racket-wiki cmap component 0.2.122 * Based on cmap v0.1.3. * (c) 2015 iOnStage * Released under the MIT License. @@ -596,6 +596,7 @@ this.parentElement = this.prop(null); this.cache = this.prop({}); this.relations = this.prop([]); + this.connectionChangeHandler = null; }, Component); Link.prototype.straighten = function(sx, sy, tx, ty) { @@ -1241,25 +1242,30 @@ ComponentList.prototype.fromPoint = function(ctor, x, y) { var data = this.data; - var closeComponent = null; + // The visual stack is connector controls, concepts and finally relations. + // Use that same priority for coordinate hit testing so a relation that is + // hidden behind a concept can never steal the concept's click. + var types = (ctor === Component) ? [Connector, Node, Link] : [ctor]; - for (var i = data.length - 1; i >= 0; i--) { - var component = data[i]; + for (var toleranceIndex = 0; toleranceIndex < 2; toleranceIndex++) { + var tolerance = toleranceIndex === 0 ? 0 : 8; + for (var typeIndex = 0; typeIndex < types.length; typeIndex++) { + for (var i = data.length - 1; i >= 0; i--) { + var component = data[i]; - if (!(component instanceof ctor)) - continue; + if (!(component instanceof types[typeIndex])) + continue; - if (component.visible === false) - continue; + if (component.visible === false) + continue; - if (component.contains(x, y, 0)) - return component; - - if (!closeComponent && component.contains(x, y, 8)) - closeComponent = component; + if (component.contains(x, y, tolerance)) + return component; + } + } } - return closeComponent; + return null; }; var DisabledConnectorList = helper.inherits(function() { @@ -1308,6 +1314,10 @@ this.markDirty(); }, Component); + Cmap.LINK_Z_INDEX_BASE = 100; + Cmap.NODE_Z_INDEX_BASE = 100000; + Cmap.CONNECTOR_Z_INDEX_BASE = 200000; + Cmap.prototype.add = function(component) { component.parentElement(this.element()); this.componentList().add(component); @@ -1331,12 +1341,17 @@ }; Cmap.prototype.updateZIndex = function() { - this.componentList().toArray().forEach(function(component, index) { + var linkIndex = 0; + var nodeIndex = 0; + this.componentList().toArray().forEach(function(component) { if (component instanceof Connector) return; - // update z-index of node/link - var zIndex = index * 10; + // Relations always occupy a lower band than concept nodes. Reordering a + // selected component therefore only changes its order inside that band. + var zIndex = component instanceof Link ? + Cmap.LINK_Z_INDEX_BASE + linkIndex++ : + Cmap.NODE_Z_INDEX_BASE + nodeIndex++; component.zIndex(zIndex); if (!(component instanceof Link)) @@ -1344,7 +1359,7 @@ // update connector z-index of link helper.eachInstance(component.relations(), LinkConnectorRelation, function(relation, index) { - relation.connector().zIndex(zIndex + index + 1); + relation.connector().zIndex(Cmap.CONNECTOR_Z_INDEX_BASE + index); }); }); }; @@ -1756,6 +1771,15 @@ if (component instanceof Node && typeof component.moveEndHandler === 'function') component.moveEndHandler(component.x(), component.y(), event); + if (component instanceof Connector) { + var link = context.link; + var triple = helper.firstInstance(link.relations(), Triple); + var connectedNode = triple ? triple[context.type + 'Node']() : null; + + if (typeof link.connectionChangeHandler === 'function') + link.connectionChangeHandler(context.type, connectedNode, event); + } + this.unfixScrollSize(); }; @@ -2037,6 +2061,18 @@ return LinkModule.connectNode(this, Cmap.CONNECTION_TYPE_TARGET, node); }; + LinkModule.prototype.onConnectionChange = function(handler) { + var module = this; + + if (handler !== null && typeof handler !== 'function') + throw TypeError('Invalid connection-change handler'); + + this.component.connectionChangeHandler = handler ? function(type, node, event) { + var nodeModule = node ? module.cmap.nodeModuleList.fromComponent(node) : null; + handler(module.wrapper, type, nodeModule ? nodeModule.wrapper : null, event); + } : null; + }; + LinkModule.prototype.straighten = function() { var link = this.component; var triple = helper.firstInstance(link.relations(), Triple); diff --git a/static/css/wiki.css b/static/css/wiki.css index 1ccd75b..9eff06f 100644 --- a/static/css/wiki.css +++ b/static/css/wiki.css @@ -532,6 +532,32 @@ body { font-size: .94em; } +.rw-mermaid { + margin: 1.25em 0; + overflow-x: auto; + text-align: center; +} + +.rw-mermaid svg { + display: inline-block; + max-width: 100%; + height: auto; +} + +pre.rw-mermaid-error { + border-color: #b42318; + background: #fff4f2; +} + +pre.rw-mermaid-error::before { + display: block; + margin-bottom: .6em; + color: #b42318; + content: "Mermaid diagram could not be rendered"; + font-family: system-ui, sans-serif; + font-weight: 600; +} + .markdown-body blockquote, .EasyMDEContainer .editor-preview blockquote, .EasyMDEContainer .editor-preview-side blockquote { @@ -1342,6 +1368,29 @@ body.editor-mode .editor-metadata-row { gap: 12px; } +.archived-cmaps-list { + display: grid; + gap: 12px; +} + +.archived-cmap-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 4px 16px; + align-items: center; + padding: 12px 0; + border-bottom: 1px solid var(--wiki-border); +} + +.archived-cmap-row .muted { + grid-column: 1; +} + +.archived-cmap-row button { + grid-column: 2; + grid-row: 1 / span 2; +} + .orphaned-upload-row { padding: 12px 0; border-bottom: 1px solid var(--wiki-border); diff --git a/static/index.html b/static/index.html index a5cf1fe..398afe6 100644 --- a/static/index.html +++ b/static/index.html @@ -202,7 +202,7 @@
Ctrl/Cmd/Shift + click
Add to or remove from the selection
Ctrl/Cmd+A
Select all
-
Ctrl/Cmd+C/V
Copy/paste linked concepts
+
Ctrl/Cmd+X/C/V
Cut/copy/paste linked concepts
Ctrl/Cmd+G
Group selection as sub-CMap
Ctrl/Cmd+Shift+G
Detach from sub-CMap
Ctrl/Cmd+Z
Undo
@@ -217,14 +217,45 @@
+
@@ -300,15 +334,26 @@

Admin

Software version:

+

Database schema version:

+ + + +
@@ -550,6 +582,68 @@ + +
+

Manage people

+

Inactive people remain on existing concepts but are hidden from the selection list for new tags.

+
+
+ + +
+
+ +
+
+
+ + +
+

CMap details

+ + + +

These details are included in Markdown exports. The explanation page is always included when it exists.

+
+ + +
+
+
+ + +
+

Export CMap…

+ + +

Markdown is a readable report. JSON is a complete importable bundle containing diagram layout, concepts and linked wiki pages. Level 0 exports only this CMap.

+

+
+ + + + +
+
+
+ + +

Unsaved concept map

@@ -574,6 +668,7 @@
+ @@ -582,6 +677,9 @@ + + + diff --git a/static/js/cmap-export.js b/static/js/cmap-export.js new file mode 100644 index 0000000..8084e62 --- /dev/null +++ b/static/js/cmap-export.js @@ -0,0 +1,333 @@ +/* Build a self-contained Markdown report from one stored CMap and its links. */ +((root, factory) => { + const api = factory(); + if (typeof module === "object" && module.exports) module.exports = api; + if (root) root.RacketWikiCmapExport = api; +})(typeof window !== "undefined" ? window : globalThis, () => { + "use strict"; + + const labels = { + en: { + exportTitle: "CMap export", generated: "Generated", root: "Root CMap", + depth: "Linked CMap depth", pagesIncluded: "Linked wiki pages included", + yes: "yes", no: "no", cmap: "CMap", level: "level", address: "Address", + tags: "Tags", none: "none", summary: "Summary", noSummary: "No summary supplied.", + explanation: "CMap explanation", concepts: "Concepts", relations: "Relations", + linkedMaps: "Linked CMaps", noConcepts: "No concepts.", noRelations: "No relations.", + synopsis: "Summary", aspects: "Aspects", wikiPage: "Wiki page", + conceptExplanation: "Concept explanation", linkedCmap: "Linked CMap", webPage: "Web page", + people: "People (responsibility/action)", + placements: "Placements", sourceView: "Derived view of", sourceMissing: "Source CMap unavailable", + missingPage: "Page unavailable", linkedPages: "Linked wiki pages", page: "Page" + }, + nl: { + exportTitle: "CMap-export", generated: "Gegenereerd", root: "Start-CMap", + depth: "Diepte gekoppelde CMaps", pagesIncluded: "Gekoppelde wikipagina's opgenomen", + yes: "ja", no: "nee", cmap: "CMap", level: "niveau", address: "Adres", + tags: "Tags", none: "geen", summary: "Samenvatting", noSummary: "Geen samenvatting opgegeven.", + explanation: "CMap-uitleg", concepts: "Concepten", relations: "Relaties", + linkedMaps: "Gekoppelde CMaps", noConcepts: "Geen concepten.", noRelations: "Geen relaties.", + synopsis: "Samenvatting", aspects: "Aspecten", wikiPage: "Wikipagina", + conceptExplanation: "Conceptuitleg", linkedCmap: "Gekoppelde CMap", webPage: "Webpagina", + people: "Personen (verantwoordelijkheid/actie)", + placements: "Plaatsingen", sourceView: "Afgeleide weergave van", sourceMissing: "Bron-CMap niet beschikbaar", + missingPage: "Pagina niet beschikbaar", linkedPages: "Gekoppelde wikipagina's", page: "Pagina" + } + }; + + function decodedDocument(value) { + let documentValue = value; + for (let attempt = 0; attempt < 2 && typeof documentValue === "string"; attempt += 1) { + documentValue = JSON.parse(documentValue); + } + return documentValue && typeof documentValue === "object" && !Array.isArray(documentValue) ? + documentValue : {}; + } + + function cleanMetadata(documentValue) { + const metadata = documentValue.metadata && typeof documentValue.metadata === "object" ? + documentValue.metadata : {}; + return { + 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() + }; + } + + function itemRecords(documentValue) { + const concepts = new Map((Array.isArray(documentValue.concepts) ? documentValue.concepts : []) + .filter((concept) => concept && concept.id) + .map((concept) => [String(concept.id), concept])); + return (Array.isArray(documentValue.items) ? documentValue.items : []) + .filter((item) => item && item.id !== undefined) + .map((item) => ({ + ...item, + ...(concepts.get(String(item.conceptId)) || {}), + id: item.id, + conceptId: item.conceptId + })); + } + + function derivedDocument(sourceDocument, derivedDocumentValue) { + const pointer = derivedDocumentValue.derivedView || {}; + const rootId = Number(pointer.rootItemId); + const allItems = Array.isArray(sourceDocument.items) ? sourceDocument.items : []; + const byId = new Map(allItems.map((item) => [Number(item.id), item])); + const belongsToRoot = (item) => { + if (Number(item.id) === rootId) return true; + let parentId = Number(item.parentSubmapId); + const seen = new Set(); + while (Number.isInteger(parentId) && parentId > 0 && !seen.has(parentId)) { + if (parentId === rootId) return true; + seen.add(parentId); + parentId = Number(byId.get(parentId)?.parentSubmapId); + } + return false; + }; + const items = allItems.filter(belongsToRoot); + const itemIds = new Set(items.map((item) => Number(item.id))); + const conceptIds = new Set(items.map((item) => item.conceptId).filter(Boolean).map(String)); + const rootItem = itemRecords(sourceDocument).find((item) => Number(item.id) === rootId) || + byId.get(rootId) || {}; + const ownMetadata = cleanMetadata(derivedDocumentValue); + const metadata = { + tags: ownMetadata.tags.length ? ownMetadata.tags : + (Array.isArray(rootItem.aspects) ? rootItem.aspects.map(String) : []), + summary: ownMetadata.summary || String(rootItem.synopsis || "").trim(), + explanationPageSlug: ownMetadata.explanationPageSlug || String(rootItem.descriptionPageSlug || "").trim() + }; + return { + ...sourceDocument, + metadata, + items, + concepts: (Array.isArray(sourceDocument.concepts) ? sourceDocument.concepts : []) + .filter((concept) => conceptIds.has(String(concept.id))), + connectors: (Array.isArray(sourceDocument.connectors) ? sourceDocument.connectors : []) + .filter((connector) => itemIds.has(Number(connector.sourceId)) && itemIds.has(Number(connector.targetId))) + }; + } + + async function resolveMapDocument(map, loadConceptMap) { + const ownDocument = decodedDocument(map.document); + const pointer = ownDocument.derivedView; + if (!pointer || !pointer.sourceCmapSlug || !Number.isInteger(Number(pointer.rootItemId))) { + return { document: ownDocument, sourceSlug: "" }; + } + try { + const sourceMap = await loadConceptMap(pointer.sourceCmapSlug); + return { + document: derivedDocument(decodedDocument(sourceMap.document), ownDocument), + sourceSlug: pointer.sourceCmapSlug + }; + } catch (_error) { + return { document: ownDocument, sourceSlug: pointer.sourceCmapSlug, sourceMissing: true }; + } + } + + function linkedMapSlugs(documentValue) { + return [...new Set(itemRecords(documentValue) + .map((item) => String(item.cmapSlug || "").trim()) + .filter(Boolean))]; + } + + function headingText(value) { + return String(value || "").replace(/[\r\n]+/g, " ").replace(/#+/g, "").trim(); + } + + function inlineText(value) { + return String(value || "").replace(/[\r\n]+/g, " ").replace(/([\\`*_[\]])/g, "\\$1").trim(); + } + + function shiftHeadings(markdown, amount) { + let fenced = false; + return String(markdown || "").split("\n").map((line) => { + if (/^\s*(```|~~~)/.test(line)) { + fenced = !fenced; + return line; + } + if (fenced) return line; + return line.replace(/^(#{1,6})\s+/, (match, hashes) => + `${"#".repeat(Math.min(6, hashes.length + amount))} `); + }).join("\n"); + } + + function relationLines(documentValue) { + const items = itemRecords(documentValue); + const byId = new Map(items.map((item) => [Number(item.id), item])); + const connectors = (Array.isArray(documentValue.connectors) ? documentValue.connectors : []) + .filter((connector) => connector && connector.sourceId !== undefined && connector.targetId !== undefined); + const used = new Set(); + const result = []; + const itemLabel = (id) => headingText(byId.get(Number(id))?.label || `[${id}]`); + + for (const phrase of items.filter((item) => item.kind === "phrase")) { + const incoming = connectors.filter((connector) => Number(connector.targetId) === Number(phrase.id)); + const outgoing = connectors.filter((connector) => Number(connector.sourceId) === Number(phrase.id)); + for (const before of incoming) { + for (const after of outgoing) { + used.add(before); + used.add(after); + result.push(`${itemLabel(before.sourceId)} — **${inlineText(phrase.label || "")}** → ${itemLabel(after.targetId)}`); + } + } + } + for (const connector of connectors) { + if (used.has(connector)) continue; + result.push(`${itemLabel(connector.sourceId)} ${connector.hasArrow === false ? "—" : "→"} ${itemLabel(connector.targetId)}`); + } + return result; + } + + function conceptEntries(documentValue) { + const entries = new Map(); + for (const item of itemRecords(documentValue)) { + if (item.kind === "phrase") continue; + const key = item.conceptId ? `concept:${item.conceptId}` : `item:${item.id}`; + if (!entries.has(key)) entries.set(key, { ...item, placementCount: 0 }); + entries.get(key).placementCount += 1; + } + return [...entries.values()]; + } + + async function generateMarkdown(options) { + const rootMap = options.rootMap; + if (!rootMap || !rootMap.slug) throw new Error("A root CMap is required."); + if (typeof options.loadConceptMap !== "function") throw new Error("loadConceptMap is required."); + const maximumDepth = Math.max(0, Math.min(10, Number(options.maxDepth) || 0)); + const includeWikiPages = Boolean(options.includeWikiPages); + const locale = String(options.language || "nl").toLowerCase().startsWith("nl") ? "nl" : "en"; + const t = labels[locale]; + const maps = []; + const visited = new Set(); + + async function collectMap(map, depth) { + if (!map?.slug || visited.has(map.slug)) return; + visited.add(map.slug); + const resolved = await resolveMapDocument(map, options.loadConceptMap); + maps.push({ map, depth, ...resolved }); + if (depth >= maximumDepth) return; + for (const slug of linkedMapSlugs(resolved.document)) { + if (visited.has(slug)) continue; + try { + await collectMap(await options.loadConceptMap(slug), depth + 1); + } catch (error) { + maps.push({ map: { slug, title: slug }, depth: depth + 1, document: {}, loadError: error }); + visited.add(slug); + } + } + } + await collectMap(rootMap, 0); + + const pageCache = new Map(); + const explanationReferences = new Set(); + async function loadPage(reference) { + if (!reference || typeof options.loadWikiPage !== "function") return null; + if (!pageCache.has(reference)) { + pageCache.set(reference, Promise.resolve().then(() => options.loadWikiPage(reference)) + .catch((error) => ({ slug: reference, title: reference, loadError: error }))); + } + return pageCache.get(reference); + } + + for (const entry of maps) { + const metadata = cleanMetadata(entry.document); + if (metadata.explanationPageSlug) { + explanationReferences.add(metadata.explanationPageSlug); + await loadPage(metadata.explanationPageSlug); + } + if (includeWikiPages) { + for (const concept of conceptEntries(entry.document)) { + await loadPage(concept.pageSlug); + await loadPage(concept.descriptionPageSlug); + } + } + } + + const lines = [ + `# ${t.exportTitle}: ${headingText(rootMap.title || rootMap.slug)}`, + "", + `- **${t.generated}:** ${new Date().toISOString()}`, + `- **${t.root}:** \`cmap:${rootMap.slug}\``, + `- **${t.depth}:** ${maximumDepth}`, + `- **${t.pagesIncluded}:** ${includeWikiPages ? t.yes : t.no}`, + "" + ]; + + for (const entry of maps) { + const metadata = cleanMetadata(entry.document); + lines.push(`## ${t.cmap}: ${headingText(entry.map.title || entry.map.slug)} (${t.level} ${entry.depth})`, ""); + lines.push(`- **${t.address}:** \`cmap:${entry.map.slug}\``); + lines.push(`- **${t.tags}:** ${metadata.tags.length ? metadata.tags.map((tag) => `\`${inlineText(tag)}\``).join(", ") : t.none}`); + if (entry.sourceSlug) lines.push(`- **${t.sourceView}:** \`cmap:${entry.sourceSlug}\``); + if (entry.sourceMissing) lines.push(`- **${t.sourceMissing}:** \`cmap:${entry.sourceSlug}\``); + if (entry.loadError) lines.push(`- **Fout:** ${inlineText(entry.loadError.message || entry.loadError)}`); + lines.push("", `### ${t.summary}`, "", metadata.summary || t.noSummary, ""); + + if (metadata.explanationPageSlug) { + const page = await loadPage(metadata.explanationPageSlug); + lines.push(`### ${t.explanation}`, "", `**${t.page}:** \`${metadata.explanationPageSlug}\``, ""); + if (page?.loadError) lines.push(`_${t.missingPage}: ${inlineText(page.loadError.message || page.loadError)}_`, ""); + else if (page) { + if (Array.isArray(page.tags) && page.tags.length) { + lines.push(`**${t.tags}:** ${page.tags.map((tag) => `\`${inlineText(tag)}\``).join(", ")}`, ""); + } + lines.push(shiftHeadings(page.markdown || "", 3).trim() || t.noSummary, ""); + } + } + + lines.push(`### ${t.concepts}`, ""); + const concepts = conceptEntries(entry.document); + if (!concepts.length) lines.push(`_${t.noConcepts}_`, ""); + for (const concept of concepts) { + lines.push(`#### ${headingText(concept.label || concept.id || "Concept")}`, ""); + if (concept.synopsis) lines.push(`- **${t.synopsis}:** ${inlineText(concept.synopsis)}`); + if (Array.isArray(concept.aspects) && concept.aspects.length) { + lines.push(`- **${t.aspects}:** ${concept.aspects.map(inlineText).join(", ")}`); + } + const people = (Array.isArray(concept.tags) ? concept.tags : []) + .filter((tag) => tag && typeof tag === "object" && tag.type === "person" && tag.value) + .map((tag) => tag.value); + if (people.length) { + lines.push(`- **${t.people}:** ${people.map(inlineText).join(", ")}`); + } + if (concept.pageSlug) lines.push(`- **${t.wikiPage}:** \`${inlineText(concept.pageSlug)}\``); + if (concept.descriptionPageSlug) lines.push(`- **${t.conceptExplanation}:** \`${inlineText(concept.descriptionPageSlug)}\``); + if (concept.cmapSlug) lines.push(`- **${t.linkedCmap}:** \`cmap:${inlineText(concept.cmapSlug)}\``); + if (concept.externalUrl) lines.push(`- **${t.webPage}:** ${inlineText(concept.externalUrl)}`); + if (concept.placementCount > 1) lines.push(`- **${t.placements}:** ${concept.placementCount}`); + lines.push(""); + } + + lines.push(`### ${t.relations}`, ""); + const relations = relationLines(entry.document); + if (!relations.length) lines.push(`_${t.noRelations}_`, ""); + else lines.push(...relations.map((relation) => `- ${relation}`), ""); + const linked = linkedMapSlugs(entry.document); + if (linked.length) { + lines.push(`### ${t.linkedMaps}`, "", ...linked.map((slug) => `- \`cmap:${inlineText(slug)}\``), ""); + } + } + + if (includeWikiPages) { + const pages = []; + for (const [reference, promise] of pageCache) { + if (explanationReferences.has(reference)) continue; + pages.push(await promise); + } + if (pages.length) lines.push(`## ${t.linkedPages}`, ""); + for (const page of pages) { + lines.push(`### ${headingText(page.title || page.slug)}`, "", `- **${t.address}:** \`${inlineText(page.slug)}\``); + if (Array.isArray(page.tags) && page.tags.length) { + lines.push(`- **${t.tags}:** ${page.tags.map((tag) => `\`${inlineText(tag)}\``).join(", ")}`); + } + lines.push(""); + if (page.loadError) lines.push(`_${t.missingPage}: ${inlineText(page.loadError.message || page.loadError)}_`, ""); + else lines.push(shiftHeadings(page.markdown || "", 2).trim() || t.noSummary, ""); + } + } + + return `${lines.join("\n").replace(/\n{3,}/g, "\n\n").trim()}\n`; + } + + return { generateMarkdown, decodedDocument, derivedDocument, relationLines }; +}); diff --git a/static/js/cmap-interchange.js b/static/js/cmap-interchange.js new file mode 100644 index 0000000..ef1e209 --- /dev/null +++ b/static/js/cmap-interchange.js @@ -0,0 +1,436 @@ +/* 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; + const SCHEMA = "/schemas/racket-wiki-cmap-bundle-v1.schema.json"; + const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + const TEMPORARY_ID = /^new:[A-Za-z0-9][A-Za-z0-9._-]{0,119}$/; + const SLUG = /^[\p{L}\p{N}][\p{L}\p{N}._-]{0,119}$/u; + const CONCEPT_KEYS = [ + "id", "label", "synopsis", "aspects", "tags", "descriptionPageSlug", + "pageSlug", "cmapSlug", "externalUrl", "imageSource" + ]; + const PLACEMENT_CONTENT_KEYS = new Set(CONCEPT_KEYS.filter((key) => key !== "id")); + + function clone(value) { + return value === undefined ? undefined : JSON.parse(JSON.stringify(value)); + } + + function validPageReference(value) { + if (typeof value !== "string") return false; + const separator = value.indexOf(":"); + if (separator < 0) return SLUG.test(value); + const namespace = value.slice(0, separator); + const slug = value.slice(separator + 1); + return namespace.length <= 80 && SLUG.test(namespace) && SLUG.test(slug); + } + + function validExternalUrl(value) { + try { + const url = new URL(String(value)); + return url.protocol === "http:" || url.protocol === "https:"; + } catch (_error) { + return false; + } + } + + function decodedDocument(value) { + let documentValue = value; + for (let attempt = 0; attempt < 2 && typeof documentValue === "string"; attempt += 1) { + documentValue = JSON.parse(documentValue); + } + return documentValue && typeof documentValue === "object" && !Array.isArray(documentValue) ? + documentValue : {}; + } + + function conceptContent(value) { + const result = {}; + for (const key of CONCEPT_KEYS) { + if (Object.prototype.hasOwnProperty.call(value || {}, key)) result[key] = clone(value[key]); + } + return result; + } + + function conceptsById(documentValue) { + return new Map((Array.isArray(documentValue.concepts) ? documentValue.concepts : []) + .filter((concept) => concept && typeof concept.id === "string" && concept.id) + .map((concept) => [concept.id, concept])); + } + + function itemConceptIds(documentValue) { + return [...new Set((Array.isArray(documentValue.items) ? documentValue.items : []) + .filter((item) => item && item.kind !== "phrase" && typeof item.conceptId === "string" && item.conceptId) + .map((item) => item.conceptId))]; + } + + function linkedMapSlugs(documentValue) { + const concepts = conceptsById(documentValue); + return [...new Set(itemConceptIds(documentValue) + .map((id) => String(concepts.get(id)?.cmapSlug || "").trim()) + .filter(Boolean))]; + } + + function linkedPageReferences(documentValue) { + const references = new Set(); + const metadata = documentValue.metadata && typeof documentValue.metadata === "object" ? + documentValue.metadata : {}; + if (typeof metadata.explanationPageSlug === "string" && metadata.explanationPageSlug.trim()) { + references.add(metadata.explanationPageSlug.trim()); + } + const concepts = conceptsById(documentValue); + for (const id of itemConceptIds(documentValue)) { + const concept = concepts.get(id) || {}; + for (const key of ["pageSlug", "descriptionPageSlug"]) { + if (typeof concept[key] === "string" && concept[key].trim()) references.add(concept[key].trim()); + } + } + return references; + } + + function attachmentUrls(markdown) { + const source = String(markdown || ""); + const urls = new Set(); + const add = (value) => { + const url = String(value || "").trim(); + if (url.startsWith("/uploads/") && url.split("/").length >= 4) urls.add(url); + }; + for (const match of source.matchAll(/!?\[[^\]]*\]\((\/uploads\/[^)]*)\)/g)) add(match[1]); + for (const match of source.matchAll(/(?:src|href)\s*=\s*["'](\/uploads\/[^"']+)["']/gi)) add(match[1]); + for (const match of source.matchAll(/\/uploads\/[^\s"'<>\\)]+/g)) add(match[0]); + const collected = [...urls]; + return collected.filter((url) => !collected.some((other) => + other !== url && other.startsWith(`${url} `))); + } + + function attachmentName(url) { + const encoded = String(url || "").split("/").at(-1) || "attachment.bin"; + try { + return decodeURIComponent(encoded) || "attachment.bin"; + } catch (_error) { + return encoded || "attachment.bin"; + } + } + + function replaceAttachmentUrls(markdown, replacements) { + let result = String(markdown || ""); + const entries = replacements instanceof Map ? [...replacements.entries()] : + Object.entries(replacements || {}); + entries.sort(([left], [right]) => right.length - left.length); + for (const [source, target] of entries) { + if (!source || source === target) continue; + result = result.split(source).join(String(target)); + } + return result; + } + + function placementDocument(documentValue) { + const documentCopy = clone(decodedDocument(documentValue)); + const ids = itemConceptIds(documentCopy); + for (const concept of (Array.isArray(documentCopy.concepts) ? documentCopy.concepts : [])) { + if (concept && typeof concept.id === "string" && !ids.includes(concept.id)) ids.push(concept.id); + } + documentCopy.items = (Array.isArray(documentCopy.items) ? documentCopy.items : []).map((item) => { + if (!item || item.kind === "phrase") return item; + return Object.fromEntries(Object.entries(item) + .filter(([key]) => !PLACEMENT_CONTENT_KEYS.has(key))); + }); + documentCopy.concepts = ids.map((id) => ({ id })); + if (!Array.isArray(documentCopy.connectors)) documentCopy.connectors = []; + return documentCopy; + } + + async function pageRecord(page, requestedReference, loadAttachment) { + const markdown = String(page.markdown || ""); + const attachments = []; + for (const url of attachmentUrls(markdown)) { + if (typeof loadAttachment !== "function") { + throw new Error(`Attachment loader is required for ${url}.`); + } + const loaded = await loadAttachment(url, requestedReference); + if (!loaded || typeof loaded.contentBase64 !== "string") { + throw new Error(`Attachment ${url} did not provide base64 content.`); + } + attachments.push({ + url, + name: String(loaded.name || attachmentName(url)), + mimeType: String(loaded.mimeType || "application/octet-stream"), + contentBase64: loaded.contentBase64 + }); + } + return { + reference: String(requestedReference), + title: String(page.title || page.slug || requestedReference), + markdown, + tags: Array.isArray(page.tags) ? page.tags.map(String) : [], + attachments + }; + } + + async function buildBundle(options) { + if (!options?.rootMap?.slug) throw new Error("A root CMap is required."); + if (typeof options.loadConceptMap !== "function") throw new Error("loadConceptMap is required."); + if (typeof options.loadWikiPage !== "function") throw new Error("loadWikiPage is required."); + const maximumDepth = Math.max(0, Math.min(10, Number(options.maxDepth) || 0)); + const maps = []; + const concepts = new Map(); + const pageReferences = new Set(); + const visited = new Set(); + const missingMaps = new Set(); + + async function collectMap(map, depth) { + if (!map?.slug || visited.has(map.slug)) return; + visited.add(map.slug); + const documentValue = decodedDocument(map.document); + maps.push({ + slug: String(map.slug), + title: String(map.title || map.slug), + document: placementDocument(documentValue) + }); + for (const concept of conceptsById(documentValue).values()) { + if (!concepts.has(concept.id)) concepts.set(concept.id, conceptContent(concept)); + } + for (const reference of linkedPageReferences(documentValue)) pageReferences.add(reference); + + const sourceSlug = String(documentValue.derivedView?.sourceCmapSlug || "").trim(); + if (sourceSlug && !visited.has(sourceSlug)) { + try { + await collectMap(await options.loadConceptMap(sourceSlug), depth); + } catch (_error) { + missingMaps.add(sourceSlug); + } + } + if (depth >= maximumDepth) return; + for (const slug of linkedMapSlugs(documentValue)) { + if (visited.has(slug)) continue; + try { + await collectMap(await options.loadConceptMap(slug), depth + 1); + } catch (_error) { + missingMaps.add(slug); + } + } + } + + await collectMap(options.rootMap, 0); + const pages = []; + const missingPages = []; + for (const reference of [...pageReferences].sort()) { + try { + pages.push(await pageRecord( + await options.loadWikiPage(reference), reference, options.loadAttachment)); + } catch (_error) { + missingPages.push(reference); + } + } + + const bundle = { + $schema: SCHEMA, + format: FORMAT, + formatVersion: FORMAT_VERSION, + exportedAt: options.exportedAt || new Date().toISOString(), + generator: options.generator || "Racket Wiki", + rootCmapSlug: String(options.rootMap.slug), + cmaps: maps, + concepts: [...concepts.values()], + pages, + missing: { cmaps: [...missingMaps].sort(), pages: missingPages } + }; + validateBundle(bundle); + return bundle; + } + + function validateBundle(bundle) { + const errors = []; + const issue = (path, message) => errors.push(`${path}: ${message}`); + if (!bundle || typeof bundle !== "object" || Array.isArray(bundle)) { + throw new Error("The import must be a JSON object."); + } + if (bundle.format !== FORMAT) issue("format", `must be ${FORMAT}`); + if (bundle.formatVersion !== FORMAT_VERSION) issue("formatVersion", `must be ${FORMAT_VERSION}`); + if (!Array.isArray(bundle.cmaps) || !bundle.cmaps.length) issue("cmaps", "must contain at least one CMap"); + if (!Array.isArray(bundle.concepts)) issue("concepts", "must be an array"); + if (!Array.isArray(bundle.pages)) issue("pages", "must be an array"); + + const conceptIds = new Set(); + const conceptLabels = new Set(); + const usedConceptIds = new Set(); + const linkedPageReferences = new Set(); + for (const [index, concept] of (Array.isArray(bundle.concepts) ? bundle.concepts : []).entries()) { + const path = `concepts[${index}]`; + if (!concept || typeof concept !== "object" || Array.isArray(concept)) { + issue(path, "must be an object"); + continue; + } + if (typeof concept.id !== "string" || !(UUID.test(concept.id) || TEMPORARY_ID.test(concept.id))) { + issue(`${path}.id`, "must be a UUID or a new: temporary id"); + } else if (conceptIds.has(concept.id)) { + issue(`${path}.id`, "is duplicated"); + } else conceptIds.add(concept.id); + if (typeof concept.label !== "string" || !concept.label.trim()) issue(`${path}.label`, "is required"); + else { + const name = concept.label.trim().toLocaleLowerCase(); + if (conceptLabels.has(name)) issue(`${path}.label`, "duplicates another concept name"); + else conceptLabels.add(name); + } + for (const key of ["pageSlug", "descriptionPageSlug"]) { + if (typeof concept[key] === "string" && concept[key].trim()) { + linkedPageReferences.add(concept[key].trim()); + if (!validPageReference(concept[key].trim())) issue(`${path}.${key}`, "must be a valid wiki page reference"); + } + } + if (typeof concept.cmapSlug === "string" && concept.cmapSlug.trim() && !SLUG.test(concept.cmapSlug.trim())) { + issue(`${path}.cmapSlug`, "must be a valid CMap slug"); + } + if (concept.externalUrl !== undefined && concept.externalUrl !== null && + (typeof concept.externalUrl !== "string" || + !concept.externalUrl.trim() || !validExternalUrl(concept.externalUrl.trim()))) { + issue(`${path}.externalUrl`, "must be a complete http or https URL"); + } + } + + const mapSlugs = new Set(); + for (const [mapIndex, cmap] of (Array.isArray(bundle.cmaps) ? bundle.cmaps : []).entries()) { + const path = `cmaps[${mapIndex}]`; + if (!cmap || typeof cmap !== "object" || Array.isArray(cmap)) { + issue(path, "must be an object"); + continue; + } + if (typeof cmap.slug !== "string" || !SLUG.test(cmap.slug)) issue(`${path}.slug`, "must be a valid CMap slug"); + else if (mapSlugs.has(cmap.slug)) issue(`${path}.slug`, "is duplicated"); + else mapSlugs.add(cmap.slug); + if (typeof cmap.title !== "string" || !cmap.title.trim()) issue(`${path}.title`, "is required"); + const documentValue = cmap.document; + if (!documentValue || typeof documentValue !== "object" || Array.isArray(documentValue)) { + issue(`${path}.document`, "must be an object"); + continue; + } + const items = Array.isArray(documentValue.items) ? documentValue.items : []; + const metadataReference = documentValue.metadata?.explanationPageSlug; + if (typeof metadataReference === "string" && metadataReference.trim()) { + linkedPageReferences.add(metadataReference.trim()); + if (!validPageReference(metadataReference.trim())) { + issue(`${path}.document.metadata.explanationPageSlug`, "must be a valid wiki page reference"); + } + } + const documentConceptIds = new Set(); + for (const [referenceIndex, reference] of (Array.isArray(documentValue.concepts) ? + documentValue.concepts : []).entries()) { + const referencePath = `${path}.document.concepts[${referenceIndex}].id`; + if (!reference || typeof reference.id !== "string" || !conceptIds.has(reference.id)) { + issue(referencePath, "must reference a concept in concepts[]"); + } else if (documentConceptIds.has(reference.id)) issue(referencePath, "is duplicated within the CMap"); + else documentConceptIds.add(reference.id); + } + const itemIds = new Set(); + for (const [itemIndex, item] of items.entries()) { + const itemPath = `${path}.document.items[${itemIndex}]`; + if (!item || typeof item !== "object" || Array.isArray(item)) { + issue(itemPath, "must be an object"); + continue; + } + if (!Number.isInteger(Number(item.id))) issue(`${itemPath}.id`, "must be an integer"); + else if (itemIds.has(Number(item.id))) issue(`${itemPath}.id`, "is duplicated within the CMap"); + else itemIds.add(Number(item.id)); + if (!Number.isFinite(Number(item.x))) issue(`${itemPath}.x`, "must be a number"); + if (!Number.isFinite(Number(item.y))) issue(`${itemPath}.y`, "must be a number"); + if (item.kind !== "phrase") { + if (typeof item.conceptId !== "string" || !conceptIds.has(item.conceptId)) { + issue(`${itemPath}.conceptId`, "must reference a concept in concepts[]"); + } else usedConceptIds.add(item.conceptId); + } + } + for (const [connectorIndex, connector] of (Array.isArray(documentValue.connectors) ? + documentValue.connectors : []).entries()) { + const connectorPath = `${path}.document.connectors[${connectorIndex}]`; + if (!itemIds.has(Number(connector?.sourceId))) issue(`${connectorPath}.sourceId`, "references an unknown item"); + if (!itemIds.has(Number(connector?.targetId))) issue(`${connectorPath}.targetId`, "references an unknown item"); + } + } + if (typeof bundle.rootCmapSlug !== "string" || !mapSlugs.has(bundle.rootCmapSlug)) { + issue("rootCmapSlug", "must reference a CMap in cmaps[]"); + } + for (const id of conceptIds) { + if (!usedConceptIds.has(id)) issue(`concepts[id=${id}]`, "must occur as a diagram placement"); + } + + const pageReferences = new Set(); + for (const [index, page] of (Array.isArray(bundle.pages) ? bundle.pages : []).entries()) { + const path = `pages[${index}]`; + if (!page || typeof page !== "object" || Array.isArray(page)) { + issue(path, "must be an object"); + continue; + } + if (typeof page.reference !== "string" || !validPageReference(page.reference)) issue(`${path}.reference`, "must be a valid wiki page reference"); + else if (pageReferences.has(page.reference)) issue(`${path}.reference`, "is duplicated"); + else pageReferences.add(page.reference); + if (typeof page.title !== "string" || !page.title.trim()) issue(`${path}.title`, "is required"); + if (typeof page.markdown !== "string") issue(`${path}.markdown`, "must be a string"); + if (!Array.isArray(page.tags) || !page.tags.every((tag) => typeof tag === "string")) { + issue(`${path}.tags`, "must be an array of strings"); + } + if (page.attachments !== undefined && !Array.isArray(page.attachments)) { + issue(`${path}.attachments`, "must be an array"); + } + const attachmentReferences = new Set(); + for (const [attachmentIndex, attachment] of (Array.isArray(page.attachments) ? + page.attachments : []).entries()) { + const attachmentPath = `${path}.attachments[${attachmentIndex}]`; + if (!attachment || typeof attachment !== "object" || Array.isArray(attachment)) { + issue(attachmentPath, "must be an object"); + continue; + } + if (typeof attachment.url !== "string" || !attachment.url.startsWith("/uploads/")) { + issue(`${attachmentPath}.url`, "must be a local /uploads/ URL"); + } else if (attachmentReferences.has(attachment.url)) { + issue(`${attachmentPath}.url`, "is duplicated within the page"); + } else attachmentReferences.add(attachment.url); + if (typeof attachment.name !== "string" || !attachment.name.trim()) { + issue(`${attachmentPath}.name`, "is required"); + } + if (typeof attachment.mimeType !== "string" || !attachment.mimeType.trim()) { + issue(`${attachmentPath}.mimeType`, "is required"); + } + if (typeof attachment.contentBase64 !== "string" || + !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(attachment.contentBase64)) { + issue(`${attachmentPath}.contentBase64`, "must be valid base64"); + } + } + } + const explicitlyMissingPages = new Set(Array.isArray(bundle.missing?.pages) ? bundle.missing.pages : []); + for (const reference of linkedPageReferences) { + if (!pageReferences.has(reference) && !explicitlyMissingPages.has(reference)) { + issue(`pages[reference=${reference}]`, "is required by a linked concept or CMap explanation"); + } + } + + if (errors.length) { + const error = new Error(`Invalid CMap bundle:\n${errors.slice(0, 20).join("\n")}`); + error.validationErrors = errors; + throw error; + } + return bundle; + } + + function preparedMapDocument(bundle, cmap) { + validateBundle(bundle); + const byId = new Map(bundle.concepts.map((concept) => [concept.id, concept])); + const documentValue = clone(cmap.document); + const ids = itemConceptIds(documentValue); + for (const reference of (Array.isArray(documentValue.concepts) ? documentValue.concepts : [])) { + if (reference?.id && !ids.includes(reference.id)) ids.push(reference.id); + } + documentValue.concepts = ids.map((id) => clone(byId.get(id))); + return documentValue; + } + + return { + FORMAT, FORMAT_VERSION, SCHEMA, buildBundle, validateBundle, + preparedMapDocument, placementDocument, decodedDocument, + attachmentUrls, replaceAttachmentUrls + }; +}); diff --git a/static/js/mermaid-racket-wiki.js b/static/js/mermaid-racket-wiki.js new file mode 100644 index 0000000..5c97e78 --- /dev/null +++ b/static/js/mermaid-racket-wiki.js @@ -0,0 +1,67 @@ +(function (root, factory) { + "use strict"; + + const api = factory(root); + if (typeof module === "object" && module.exports) module.exports = api; + else root.RacketWikiMermaid = api; +})(typeof window === "object" ? window : globalThis, function (root) { + "use strict"; + + const selector = "pre > code.language-mermaid, pre > code.lang-mermaid"; + let initialized = false; + let renderSequence = 0; + let hydrationTimer = null; + + function initialize(mermaidApi = root.mermaid) { + if (initialized) return; + if (!mermaidApi || typeof mermaidApi.initialize !== "function") { + throw new Error("Mermaid is not installed. Open /setup to repair the frontend setup."); + } + mermaidApi.initialize({ + startOnLoad: false, + securityLevel: "strict", + suppressErrorRendering: true + }); + initialized = true; + } + + async function hydrate(container = root.document, mermaidApi = root.mermaid) { + initialize(mermaidApi); + const blocks = Array.from(container.querySelectorAll(selector)); + for (const code of blocks) { + const source = code.parentElement; + if (!source || source.dataset.mermaidState) continue; + source.dataset.mermaidState = "rendering"; + try { + const id = `racket-wiki-mermaid-${++renderSequence}`; + const rendered = await mermaidApi.render(id, code.textContent || ""); + const diagram = source.ownerDocument.createElement("div"); + diagram.className = "rw-mermaid"; + diagram.setAttribute("role", "img"); + diagram.setAttribute("aria-label", "Mermaid diagram"); + diagram.innerHTML = rendered.svg; + source.replaceWith(diagram); + if (typeof rendered.bindFunctions === "function") rendered.bindFunctions(diagram); + } catch (error) { + source.dataset.mermaidState = "error"; + source.classList.add("rw-mermaid-error"); + source.title = error && error.message ? error.message : "Mermaid diagram could not be rendered"; + if (root.console && typeof root.console.error === "function") { + root.console.error("Mermaid diagram could not be rendered", error); + } + } + } + } + + function queue(container = root.document) { + if (hydrationTimer !== null) root.clearTimeout(hydrationTimer); + hydrationTimer = root.setTimeout(() => { + hydrationTimer = null; + hydrate(container).catch((error) => { + if (root.console && typeof root.console.error === "function") root.console.error(error); + }); + }, 0); + } + + return { hydrate, initialize, queue, selector }; +}); diff --git a/static/js/wiki.js b/static/js/wiki.js index 5fee49c..e57ad03 100644 --- a/static/js/wiki.js +++ b/static/js/wiki.js @@ -29,7 +29,9 @@ contextDockPosition: null, contextDockData: null, conceptMaps: [], + people: [], cmapConceptUsage: new Map(), + cmapConceptIdsByName: new Map(), cmapPageConcepts: new Map(), currentConceptMap: null, currentConceptMapSource: null, @@ -65,7 +67,7 @@ const wikiCmapLinkCombobox = new window.RacketWikiComboBox($("wiki-cmap-link-combobox")); function show(viewId) { - for (const id of ["page-view", "not-found-view", "editor-view", "rename-view", "search-view", "recent-view", "bookmarks-view", "todo-view", "graph-view", "cmap-view", "history-view", "profile-view", "admin-view", "alias-admin-view", "user-admin-view", "mail-admin-view", "orphaned-uploads-view"]) { + for (const id of ["page-view", "not-found-view", "editor-view", "rename-view", "search-view", "recent-view", "bookmarks-view", "todo-view", "graph-view", "cmap-view", "history-view", "profile-view", "admin-view", "alias-admin-view", "user-admin-view", "mail-admin-view", "orphaned-uploads-view", "archived-cmaps-view"]) { $(id).classList.toggle("hidden", id !== viewId); } $("page-action-links").classList.toggle("hidden", viewId !== "page-view"); @@ -588,6 +590,7 @@ const withImages = applyImageWidthMarkup(withEmbeds); const safeHtml = DOMPurify.sanitize(withImages); if (extracted.embeds.length) queueCmapEmbedHydration(); + if (/\b(?:language|lang)-mermaid\b/.test(safeHtml)) window.RacketWikiMermaid.queue(document); return safeHtml; } @@ -625,7 +628,8 @@ embed.append(header, viewport); const editor = window.RacketWikiCmap.createEditor(canvas, { Cmap: window.Cmap, - renderItem: (record) => cmapNodeHtml(record) + renderItem: (record) => cmapNodeHtml(record), + onOpenExternalUrl: (record) => openCmapExternalUrl(record) }); editor.loadDocument(documentValue); const visible = editor.items.filter((item) => editor.isEffectiveItemVisible(item)); @@ -896,8 +900,12 @@ if (!window.DOMPurify) { throw new Error("DOMPurify is not installed. Open /setup to repair the frontend setup."); } + if (!window.RacketWikiMermaid) { + throw new Error("The Mermaid wiki integration is not installed."); + } initializeHighlighting(); + window.RacketWikiMermaid.initialize(); easyMDE = new EasyMDE({ element: $("markdown-editor"), @@ -2045,6 +2053,11 @@ show("admin-view"); const info = await api("/api/admin/info"); $("admin-software-version").textContent = info.softwareVersion || "?"; + const schemaVersion = info.databaseSchemaVersion ?? "?"; + const requiredSchemaVersion = info.requiredDatabaseSchemaVersion ?? "?"; + $("admin-database-schema-version").textContent = + schemaVersion === requiredSchemaVersion ? String(schemaVersion) : + `${schemaVersion} (${tr("required", "required")}: ${requiredSchemaVersion})`; } function formatFileSize(size) { @@ -2242,6 +2255,72 @@ } } + /** + * goal : List archived CMaps and allow an administrator to restore them. + * pre : Current user has the admin role. + * post : Restored maps reappear in normal CMap navigation without data loss. + */ + async function loadArchivedCmapsAdmin() { + renderBreadcrumbs([ + { label: state.siteTitle, href: "/" }, + { label: tr("admin", "Admin"), href: "#admin" }, + { label: tr("archived-concept-maps", "Archived CMaps") } + ]); + show("archived-cmaps-view"); + $("archived-cmaps-status").textContent = ""; + const result = await api("/api/admin/cmaps/archived"); + const list = $("archived-cmaps-list"); + list.replaceChildren(); + const conceptMaps = Array.isArray(result.conceptMaps) ? result.conceptMaps : []; + + if (conceptMaps.length === 0) { + const empty = document.createElement("p"); + empty.className = "muted"; + empty.textContent = tr("no-archived-concept-maps", "No archived CMaps."); + list.append(empty); + return; + } + + for (const conceptMap of conceptMaps) { + const row = document.createElement("article"); + row.className = "archived-cmap-row"; + const title = document.createElement("strong"); + title.textContent = conceptMap.title; + const meta = document.createElement("div"); + meta.className = "muted"; + const archivedAt = conceptMap.archivedAt ? pageDisplayDate(conceptMap.archivedAt) : ""; + meta.textContent = [ + `cmap:${conceptMap.slug}`, + archivedAt ? `${tr("archived", "Archived")} ${archivedAt}` : tr("archived", "Archived"), + conceptMap.archivedBy ? `${tr("by", "by")} ${conceptMap.archivedBy}` : "" + ].filter(Boolean).join(" · "); + const restore = document.createElement("button"); + restore.type = "button"; + restore.textContent = tr("restore-concept-map", "Restore CMap"); + restore.addEventListener("click", async () => { + const question = tr( + "restore-concept-map-confirm", + "Restore concept map \"{title}\"?") + .replace("{title}", conceptMap.title); + if (!window.confirm(question)) return; + restore.disabled = true; + try { + await api(`/api/admin/cmaps/archived/${encodeURIComponent(conceptMap.slug)}/restore`, { + method: "POST" + }); + await loadConceptMaps(); + await loadArchivedCmapsAdmin(); + $("archived-cmaps-status").textContent = tr("concept-map-restored", "CMap restored."); + } catch (error) { + restore.disabled = false; + $("archived-cmaps-status").textContent = error.message; + } + }); + row.append(title, meta, restore); + list.append(row); + } + } + /** * goal : Render user administration from the current server-side user list. * pre : Current user has the admin role. @@ -2491,6 +2570,11 @@ return; } + if (location.hash === "#admin/archived-cmaps" && can("admin")) { + await loadArchivedCmapsAdmin(); + return; + } + const match = location.hash.match(/^#\/([^/]+)$/); if (match) { const slug = decodeURIComponent(match[1]); @@ -2680,7 +2764,7 @@ } /** - * goal : Show Todo items grouped by the namespace of their source page. + * goal : Show page markers and TODO-aspect concepts in one Todo view. * pre : Todo index is available through /api/todos. * post : Optional target Todo is scrolled into view and highlighted. */ @@ -2702,28 +2786,47 @@ list.append(empty); return; } - let todoNamespace = null; + let todoGroup = null; for (const item of result.items) { - const namespace = item.namespace || ""; - if (namespace !== todoNamespace) { + const conceptTodo = item.type === "concept"; + const group = conceptTodo ? "__concepts__" : (item.namespace || ""); + if (group !== todoGroup) { const heading = document.createElement("h2"); heading.className = "namespace-heading"; - heading.textContent = namespaceLabel(namespace); + heading.textContent = conceptTodo ? + tr("concept-todos", "Concepts") : namespaceLabel(item.namespace || ""); list.append(heading); - todoNamespace = namespace; + todoGroup = group; } const row = document.createElement("article"); - row.className = "todo-item"; - row.id = `todo-${item.slug}-${item.number}`; + row.className = `todo-item${conceptTodo ? " todo-item-concept" : ""}`; + row.id = conceptTodo ? `todo-concept-${item.conceptId}` : + `todo-${item.slug}-${item.number}`; const link = document.createElement("a"); - link.href = pageRoute(item.slug); + if (conceptTodo) { + const pageTarget = item.descriptionPageSlug || item.pageSlug; + const cmapTarget = item.cmapSlug || item.placementCmapSlug; + const externalTarget = normalizedCmapExternalUrl(item.externalUrl); + link.href = pageTarget ? pageRoute(pageTarget) : + (item.cmapSlug ? cmapRoute(item.cmapSlug) : + (externalTarget || (cmapTarget ? cmapRoute(cmapTarget) : "#cmaps"))); + if (externalTarget && link.href === externalTarget) { + link.target = "_blank"; + link.rel = "noopener noreferrer"; + } + } else { + link.href = pageRoute(item.slug); + } link.textContent = item.title; const text = document.createElement("div"); text.className = "todo-item-text"; - text.textContent = item.text; + text.textContent = item.text || (conceptTodo ? + tr("concept-marked-todo", "Concept marked with the TODO aspect.") : ""); const location = document.createElement("span"); location.className = "muted"; - location.textContent = `${tr("line", "line")} ${item.line}`; + location.textContent = conceptTodo ? + `${tr("concept-map", "Concept map")}: ${item.placementCmapTitle}` : + `${tr("line", "line")} ${item.line}`; row.append(link, text, location); list.append(row); } @@ -2770,7 +2873,14 @@ .map((aspect) => `${escapeHtml(aspect)}`) .join(""); const aspectList = aspects ? `
${aspects}
` : ""; - const usageCount = record.conceptId && record.kind !== "phrase" && record.kind !== "submap" ? + const personNames = (Array.isArray(record.tags) ? record.tags : []) + .filter((tag) => tag && typeof tag === "object" && tag.type === "person" && tag.value) + .map((tag) => String(tag.value)); + const peopleLabel = personNames.length ? + tr("person-tags-title", "Responsibility/action: {people}").replace("{people}", personNames.join(", ")) : ""; + const personTags = personNames.length ? + `
${personNames.map(escapeHtml).join(" · ")}
` : ""; + const usageCount = record.conceptId && record.kind !== "phrase" ? effectiveCmapConceptUsage(record) : null; const usageLabel = usageCount === null ? "" : tr("concept-usage-count", "{count} placements across all concept maps") @@ -2785,8 +2895,10 @@ const linkedTarget = record.pageSlug || record.cmapSlug || record.parentCmapLink; const linkedButton = linkedTarget && record.kind !== "submap" ? `` : ""; + const externalButton = record.externalUrl ? + `` : ""; const linkedCmapClass = record.cmapSlug || record.parentCmapLink ? " cmap-card-linked-cmap" : ""; - return `
${descriptionButton}${linkedButton}${image}
${safeLabel} ${usage}
${aspectList}${safeSynopsis ? `
${safeSynopsis}
` : ""}
`; + return `
${descriptionButton}${linkedButton}${externalButton}${image}
${safeLabel} ${usage}
${aspectList}${personTags}${safeSynopsis ? `
${safeSynopsis}
` : ""}
`; } function effectiveCmapConceptUsage(record) { @@ -2795,7 +2907,8 @@ const prototype = cmapPrototypeState(); const isActiveEditorRecord = Boolean(prototype.editor && prototype.editor.items.includes(record)); if (!isActiveEditorRecord) return Math.max(1, storedTotal || Number(record.usageCount) || 1); - const currentSlug = state.currentConceptMap ? state.currentConceptMap.slug : null; + const currentSlug = state.currentConceptMapSource?.slug || + state.currentConceptMap?.slug || null; const storedHere = currentSlug && byMap ? (byMap.get(currentSlug) || 0) : 0; return Math.max(1, storedTotal - storedHere + (Number(record.usageCount) || 1)); } @@ -2883,7 +2996,16 @@ function addCmapPrototypeNode(options = {}) { const prototype = cmapPrototypeState(); if (!prototype.editor) return null; - return prototype.editor.addItem(options); + const conceptId = options.conceptId || sharedCmapConceptId(options.label); + return prototype.editor.addItem(conceptId ? { ...options, conceptId } : options); + } + + function normalizeCmapConceptName(label) { + return String(label || "").trim().toLocaleLowerCase(); + } + + function sharedCmapConceptId(label) { + return state.cmapConceptIdsByName.get(normalizeCmapConceptName(label)) || null; } let cmapDialogRecord = null; @@ -2906,7 +3028,42 @@ } function displayCmapFontSize(value) { - return String(Math.round(value * 100) / 100); + return String(Math.round(value * 2) / 2); + } + + function normalizedCmapFontSize(value, fallback) { + const size = Number(value); + const usableSize = Number.isFinite(size) ? size : fallback; + return Math.max(6, Math.min(54, Math.round(usableSize * 2) / 2)); + } + + function normalizedCmapExternalUrl(value) { + const text = String(value || "").trim(); + if (!text) return ""; + try { + const url = new URL(text); + return ["http:", "https:"].includes(url.protocol) ? url.href : null; + } catch (_error) { + return null; + } + } + + function openCmapExternalUrl(record) { + const url = normalizedCmapExternalUrl(record && record.externalUrl); + if (!url) return false; + window.open(url, "_blank", "noopener,noreferrer"); + return true; + } + + function selectCmapConceptTab(name) { + for (const tab of $("cmap-concept-form").querySelectorAll("[data-cmap-concept-tab]")) { + const selected = tab.dataset.cmapConceptTab === name; + tab.setAttribute("aria-selected", selected ? "true" : "false"); + tab.tabIndex = selected ? 0 : -1; + const panel = $(tab.getAttribute("aria-controls")); + if (panel) panel.classList.toggle("hidden", !selected); + } + $("cmap-concept-form").querySelector(".cmap-concept-dialog-body").scrollTop = 0; } function selectCmapFont(fontFamily, selectId = "cmap-concept-font-family") { @@ -2974,6 +3131,125 @@ row.classList.remove("hidden"); } + async function loadPeople() { + const result = await api("/api/people"); + state.people = Array.isArray(result.people) ? result.people : []; + return state.people; + } + + function selectedCmapPersonNames() { + return Array.from($("cmap-person-tag-options").querySelectorAll("input[type='checkbox']:checked")) + .map((input) => input.dataset.personName).filter(Boolean); + } + + function renderCmapPersonTagOptions(selectedNames = []) { + const selected = new Set(selectedNames.map((name) => name.toLocaleLowerCase())); + const target = $("cmap-person-tag-options"); + target.replaceChildren(); + const visiblePeople = state.people.filter((person) => + person.active || selected.has(String(person.name).toLocaleLowerCase())); + for (const person of visiblePeople) { + const label = document.createElement("label"); + label.className = "cmap-person-tag-option"; + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.dataset.personName = person.name; + checkbox.checked = selected.has(String(person.name).toLocaleLowerCase()); + const text = document.createElement("span"); + text.textContent = person.active ? person.name : + `${person.name} (${tr("inactive", "inactive")})`; + label.append(checkbox, text); + target.append(label); + } + const knownNames = new Set(state.people.map((person) => String(person.name).toLocaleLowerCase())); + for (const selectedName of selectedNames) { + if (knownNames.has(String(selectedName).toLocaleLowerCase())) continue; + const label = document.createElement("label"); + label.className = "cmap-person-tag-option"; + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.dataset.personName = selectedName; + checkbox.checked = true; + const text = document.createElement("span"); + text.textContent = `${selectedName} (${tr("inactive", "inactive")})`; + label.append(checkbox, text); + target.append(label); + } + if (!target.childElementCount) { + const empty = document.createElement("span"); + empty.className = "muted"; + empty.textContent = tr("no-active-people", "No active people yet."); + target.append(empty); + } + const summary = $("cmap-person-tags-picker").querySelector("summary"); + const selectedCount = selected.size; + summary.textContent = selectedCount ? + tr("people-selected", "{count} people selected").replace("{count}", String(selectedCount)) : + tr("select-people", "Select people"); + } + + async function createPersonFromInput(inputId, selectInConceptDialog = false) { + const input = $(inputId); + const name = input.value.trim(); + if (!name) { + input.focus(); + return null; + } + const selected = selectInConceptDialog ? selectedCmapPersonNames() : []; + const person = await api("/api/people", { + method: "POST", + body: JSON.stringify({ name }) + }); + input.value = ""; + await loadPeople(); + if (selectInConceptDialog) renderCmapPersonTagOptions([...selected, person.name]); + return person; + } + + function renderPeopleManagement() { + const target = $("cmap-people-list"); + target.replaceChildren(); + for (const person of state.people) { + const row = document.createElement("div"); + row.className = "cmap-person-admin-row"; + const name = document.createElement("strong"); + name.textContent = person.name; + const activeLabel = document.createElement("label"); + const active = document.createElement("input"); + active.type = "checkbox"; + active.checked = Boolean(person.active); + const activeText = document.createElement("span"); + activeText.textContent = tr("active", "Active"); + activeLabel.append(active, activeText); + const save = document.createElement("button"); + save.type = "button"; + save.textContent = tr("save", "Save"); + save.addEventListener("click", async () => { + save.disabled = true; + try { + await api(`/api/people/${person.id}`, { + method: "PUT", + body: JSON.stringify({ name: person.name, active: active.checked }) + }); + await loadPeople(); + renderPeopleManagement(); + } catch (error) { + window.alert(error.message); + save.disabled = false; + } + }); + row.append(name, activeLabel, save); + target.append(row); + } + } + + async function openPeopleManagement() { + await loadPeople(); + renderPeopleManagement(); + $("cmap-people-dialog").showModal(); + closeCmapContextMenu(); + } + function readCmapImage(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); @@ -2998,10 +3274,17 @@ $("cmap-concept-label").value = record.label || ""; $("cmap-concept-synopsis").value = record.synopsis || ""; $("cmap-concept-aspects").value = (record.aspects || []).join(", "); + const selectedPeople = (Array.isArray(record.tags) ? record.tags : []) + .filter((tag) => tag && typeof tag === "object" && tag.type === "person") + .map((tag) => tag.value); + renderCmapPersonTagOptions(selectedPeople); + $("cmap-person-tags-picker").open = false; $("cmap-concept-description-page").value = record.descriptionPageSlug || ""; $("cmap-concept-description-link").href = record.descriptionPageSlug ? pageRoute(record.descriptionPageSlug) : "#"; $("cmap-concept-description-link").classList.toggle("hidden", !record.descriptionPageSlug); + $("cmap-concept-external-url").value = record.externalUrl || ""; + $("cmap-concept-external-url").setCustomValidity(""); $("cmap-concept-background").value = cmapColorValue(record.backgroundColor); $("cmap-concept-background-label").textContent = record.kind === "submap" ? tr("main-concept-background-color", "Main concept background color") : @@ -3028,7 +3311,6 @@ record.synopsisFontWeight || record.fontWeight || "700") !== "400"; $("cmap-concept-synopsis-italic").checked = (record.synopsisFontStyle || record.fontStyle) === "italic"; - $("cmap-concept-style-preset").value = "default"; $("cmap-concept-image").value = ""; populateCmapPageOptions(record); populateCmapLinkOptions(record); @@ -3036,6 +3318,8 @@ for (const input of $("cmap-concept-form").querySelectorAll(".cmap-color-input")) { updateCmapColorControl(input); } + renderCmapStyleOptions(); + selectCmapConceptTab("content"); $("cmap-concept-dialog").showModal(); $("cmap-concept-label").focus(); $("cmap-concept-label").select(); @@ -3050,8 +3334,12 @@ $("cmap-concept-label").value = "New concept"; $("cmap-concept-synopsis").value = ""; $("cmap-concept-aspects").value = ""; + renderCmapPersonTagOptions([]); + $("cmap-person-tags-picker").open = false; $("cmap-concept-description-page").value = ""; $("cmap-concept-description-link").classList.add("hidden"); + $("cmap-concept-external-url").value = ""; + $("cmap-concept-external-url").setCustomValidity(""); $("cmap-concept-background").value = "#fff4cf"; $("cmap-concept-background-label").textContent = tr("background-color", "Background color"); $("cmap-submap-style-fields").classList.add("hidden"); @@ -3067,7 +3355,6 @@ $("cmap-concept-synopsis-font-size").value = "9"; $("cmap-concept-synopsis-bold").checked = false; $("cmap-concept-synopsis-italic").checked = false; - $("cmap-concept-style-preset").value = "default"; $("cmap-concept-image").value = ""; populateCmapPageOptions({ kind: "concept", pageSlug: null }); populateCmapLinkOptions({ kind: "concept", cmapSlug: null, parentCmapLink: false }); @@ -3075,18 +3362,145 @@ for (const input of $("cmap-concept-form").querySelectorAll(".cmap-color-input")) { updateCmapColorControl(input); } + renderCmapStyleOptions(); + selectCmapConceptTab("content"); $("cmap-concept-dialog").showModal(); $("cmap-concept-label").focus(); $("cmap-concept-label").select(); } - const cmapStylePresets = { - default: { background: "#fff4cf", text: "#222222", font: "Arial, Helvetica, sans-serif", size: 11, bold: true, italic: false }, - subtle: { background: "#f1f3f5", text: "#56616b", font: "system-ui, sans-serif", size: 10, bold: false, italic: false }, - emphasis: { background: "#e7f2fb", text: "#173b57", font: "Georgia, Times New Roman, serif", size: 12, bold: true, italic: false }, - warning: { background: "#fff0d5", text: "#713b00", font: "Arial, Helvetica, sans-serif", size: 11, bold: true, italic: true }, - success: { background: "#e6f4e2", text: "#285b27", font: "Arial, Helvetica, sans-serif", size: 11, bold: false, italic: true } - }; + const cmapStyleValueKeys = [ + "backgroundColor", "textColor", "fontFamily", "fontSize", "fontWeight", "fontStyle", + "synopsisTextColor", "synopsisFontFamily", "synopsisFontSize", "synopsisFontWeight", + "synopsisFontStyle", "submapBackgroundColor", "submapBorderColor" + ]; + + function seededCmapStyle(id, nameKey, options) { + const synopsisSize = Math.max(6, options.size - 2); + return { + id, + nameKey, + protected: id === "default", + values: { + backgroundColor: options.background, + textColor: options.text, + fontFamily: options.font, + fontSize: options.size, + fontWeight: options.bold ? "700" : "400", + fontStyle: options.italic ? "italic" : "normal", + synopsisTextColor: options.synopsisText || options.text, + synopsisFontFamily: options.font, + synopsisFontSize: synopsisSize, + synopsisFontWeight: "400", + synopsisFontStyle: options.italic ? "italic" : "normal", + submapBackgroundColor: "#edf7e8", + submapBorderColor: "#57834a" + } + }; + } + + function defaultCmapStyles() { + return [ + seededCmapStyle("default", "style-default", { background: "#fff4cf", text: "#222222", synopsisText: "#4d4d4d", font: "Arial, Helvetica, sans-serif", size: 11, bold: true, italic: false }), + seededCmapStyle("subtle", "style-subtle", { background: "#f1f3f5", text: "#56616b", font: "system-ui, sans-serif", size: 10, bold: false, italic: false }), + seededCmapStyle("emphasis", "style-emphasis", { background: "#e7f2fb", text: "#173b57", font: "Georgia, Times New Roman, serif", size: 12, bold: true, italic: false }), + seededCmapStyle("warning", "style-warning", { background: "#fff0d5", text: "#713b00", font: "Arial, Helvetica, sans-serif", size: 11, bold: true, italic: true }), + seededCmapStyle("success", "style-success", { background: "#e6f4e2", text: "#285b27", font: "Arial, Helvetica, sans-serif", size: 11, bold: false, italic: true }) + ]; + } + + function normalizedCmapStyleValues(values, fallbackValues) { + if (!values || typeof values !== "object") return null; + const fallback = fallbackValues || defaultCmapStyles()[0].values; + const fontSize = normalizedCmapFontSize(values.fontSize, fallback.fontSize); + return { + backgroundColor: cmapColorValue(values.backgroundColor, fallback.backgroundColor).toLowerCase(), + textColor: cmapColorValue(values.textColor, fallback.textColor).toLowerCase(), + fontFamily: typeof values.fontFamily === "string" && values.fontFamily.trim() ? values.fontFamily.trim() : fallback.fontFamily, + fontSize, + fontWeight: String(values.fontWeight) === "400" ? "400" : "700", + fontStyle: values.fontStyle === "italic" ? "italic" : "normal", + synopsisTextColor: cmapColorValue(values.synopsisTextColor, fallback.synopsisTextColor).toLowerCase(), + synopsisFontFamily: typeof values.synopsisFontFamily === "string" && values.synopsisFontFamily.trim() ? values.synopsisFontFamily.trim() : fallback.synopsisFontFamily, + synopsisFontSize: normalizedCmapFontSize(values.synopsisFontSize, Math.max(6, fontSize - 2)), + synopsisFontWeight: String(values.synopsisFontWeight) === "700" ? "700" : "400", + synopsisFontStyle: values.synopsisFontStyle === "italic" ? "italic" : "normal", + submapBackgroundColor: cmapColorValue(values.submapBackgroundColor, fallback.submapBackgroundColor).toLowerCase(), + submapBorderColor: cmapColorValue(values.submapBorderColor, fallback.submapBorderColor).toLowerCase() + }; + } + + function normalizeCmapStyles(styles) { + const seeds = defaultCmapStyles(); + if (!Array.isArray(styles)) return seeds; + const seenIds = new Set(["default"]); + const normalized = styles.flatMap((style) => { + const name = typeof style?.name === "string" ? style.name.trim() : ""; + const nameKey = typeof style?.nameKey === "string" ? style.nameKey.trim() : ""; + if (!style || typeof style.id !== "string" || !style.id || style.id === "default" || + seenIds.has(style.id) || (!name && !nameKey)) return []; + const values = normalizedCmapStyleValues(style.values, seeds[0].values); + if (!values) return []; + seenIds.add(style.id); + return [{ + id: style.id, + ...(nameKey ? { nameKey } : { name }), + protected: false, + values + }]; + }); + const storedDefault = styles.find((style) => style?.id === "default"); + const defaultValues = normalizedCmapStyleValues(storedDefault?.values, seeds[0].values); + return [{ ...seeds[0], values: defaultValues || seeds[0].values }, ...normalized]; + } + + async function loadCmapStyles() { + try { + const result = await api("/api/cmap-styles"); + cmapStyles = normalizeCmapStyles(result.styles); + const databaseSnapshot = JSON.stringify(cmapStyles); + const initialSnapshot = JSON.stringify(defaultCmapStyles()); + let legacyStyles = null; + try { + const legacy = JSON.parse(window.localStorage.getItem("racket-wiki:cmap-styles:v1") || "null"); + if (legacy?.version === 1 && Array.isArray(legacy.styles)) { + legacyStyles = normalizeCmapStyles(legacy.styles); + } + } catch (error) { + console.warn("Legacy browser-local CMap styles could not be read.", error); + } + if (legacyStyles && can("editor") && databaseSnapshot === initialSnapshot && + JSON.stringify(legacyStyles) !== initialSnapshot) { + cmapStyles = legacyStyles; + if (await storeCmapStyles()) window.localStorage.removeItem("racket-wiki:cmap-styles:v1"); + } else if (legacyStyles && (databaseSnapshot !== initialSnapshot || + JSON.stringify(legacyStyles) === initialSnapshot)) { + window.localStorage.removeItem("racket-wiki:cmap-styles:v1"); + } + renderCmapStyleOptions(); + } catch (error) { + console.warn("The CMap styles could not be read from the database.", error); + cmapStyles = defaultCmapStyles(); + } + return cmapStyles; + } + + async function storeCmapStyles() { + try { + const result = await api("/api/cmap-styles", { + method: "PUT", + body: JSON.stringify({ styles: cmapStyles }) + }); + cmapStyles = normalizeCmapStyles(result.styles); + return true; + } catch (error) { + console.warn("The CMap styles could not be stored in the database.", error); + window.alert(tr("style-storage-failed", "The style could not be stored in the wiki database.")); + return false; + } + } + + let cmapStyles = defaultCmapStyles(); const defaultCmapColorPalette = ["#ffffff", "#f1f3f5", "#e7f2fb", "#e6f4e2", "#fff4cf", "#fff0d5", "#f7dede", "#dcd8f7", "#222222", "#4479a1", "#57834a", "#a97c00"]; @@ -3204,23 +3618,163 @@ } } - function applySelectedCmapStyle() { - const preset = cmapStylePresets[$("cmap-concept-style-preset").value]; - if (!preset) return; - $("cmap-concept-background").value = preset.background; - $("cmap-concept-text-color").value = preset.text; - selectCmapFont(preset.font); - $("cmap-concept-font-size").value = String(preset.size); - $("cmap-concept-bold").checked = preset.bold; - $("cmap-concept-italic").checked = preset.italic; - $("cmap-concept-synopsis-text-color").value = preset.text; - selectCmapFont(preset.font, "cmap-concept-synopsis-font-family"); - $("cmap-concept-synopsis-font-size").value = String(Math.max(6, preset.size - 2)); - $("cmap-concept-synopsis-bold").checked = false; - $("cmap-concept-synopsis-italic").checked = preset.italic; - updateCmapColorControl($("cmap-concept-background")); - updateCmapColorControl($("cmap-concept-text-color")); - updateCmapColorControl($("cmap-concept-synopsis-text-color")); + function captureCmapAppearance() { + return normalizedCmapStyleValues({ + backgroundColor: $("cmap-concept-background").value, + textColor: $("cmap-concept-text-color").value, + fontFamily: $("cmap-concept-font-family").value, + fontSize: $("cmap-concept-font-size").value, + fontWeight: $("cmap-concept-bold").checked ? "700" : "400", + fontStyle: $("cmap-concept-italic").checked ? "italic" : "normal", + synopsisTextColor: $("cmap-concept-synopsis-text-color").value, + synopsisFontFamily: $("cmap-concept-synopsis-font-family").value, + synopsisFontSize: $("cmap-concept-synopsis-font-size").value, + synopsisFontWeight: $("cmap-concept-synopsis-bold").checked ? "700" : "400", + synopsisFontStyle: $("cmap-concept-synopsis-italic").checked ? "italic" : "normal", + submapBackgroundColor: $("cmap-submap-background").value, + submapBorderColor: $("cmap-submap-border").value + }); + } + + function applyCmapAppearance(values) { + const style = normalizedCmapStyleValues(values); + if (!style) return; + $("cmap-concept-background").value = style.backgroundColor; + $("cmap-concept-text-color").value = style.textColor; + selectCmapFont(style.fontFamily); + $("cmap-concept-font-size").value = displayCmapFontSize(style.fontSize); + $("cmap-concept-bold").checked = style.fontWeight === "700"; + $("cmap-concept-italic").checked = style.fontStyle === "italic"; + $("cmap-concept-synopsis-text-color").value = style.synopsisTextColor; + selectCmapFont(style.synopsisFontFamily, "cmap-concept-synopsis-font-family"); + $("cmap-concept-synopsis-font-size").value = displayCmapFontSize(style.synopsisFontSize); + $("cmap-concept-synopsis-bold").checked = style.synopsisFontWeight === "700"; + $("cmap-concept-synopsis-italic").checked = style.synopsisFontStyle === "italic"; + $("cmap-submap-background").value = style.submapBackgroundColor; + $("cmap-submap-border").value = style.submapBorderColor; + for (const input of $("cmap-concept-panel-appearance").querySelectorAll(".cmap-color-input")) { + updateCmapColorControl(input); + } + } + + function sameCmapAppearance(left, right) { + return Boolean(left && right) && cmapStyleValueKeys.every((key) => left[key] === right[key]); + } + + function cmapStyleName(style) { + return style.nameKey ? tr(style.nameKey, style.nameKey) : style.name; + } + + function matchingCmapStyleId() { + const current = captureCmapAppearance(); + return cmapStyles.find((style) => sameCmapAppearance(style.values, current))?.id || ""; + } + + function updateCmapStyleDeleteButton() { + const selected = cmapStyles.find((style) => style.id === $("cmap-concept-style-preset").value); + $("cmap-delete-style").disabled = !selected || selected.protected; + } + + function renderCmapStyleOptions(selectedId = matchingCmapStyleId()) { + const usableId = cmapStyles.some((style) => style.id === selectedId) ? selectedId : ""; + for (const select of [$("cmap-concept-quick-style"), $("cmap-concept-style-preset")]) { + select.replaceChildren(); + const custom = document.createElement("option"); + custom.value = ""; + custom.textContent = tr("custom-style", "Custom"); + select.append(custom); + for (const style of cmapStyles) { + const option = document.createElement("option"); + option.value = style.id; + option.textContent = cmapStyleName(style); + select.append(option); + } + select.value = usableId; + } + updateCmapStyleDeleteButton(); + } + + function syncCmapStyleSelection() { + const matchingId = matchingCmapStyleId(); + $("cmap-concept-quick-style").value = matchingId; + $("cmap-concept-style-preset").value = matchingId; + updateCmapStyleDeleteButton(); + } + + function applySelectedCmapStyle(event) { + const selectedId = event?.currentTarget?.value ?? $("cmap-concept-style-preset").value; + $("cmap-concept-quick-style").value = selectedId; + $("cmap-concept-style-preset").value = selectedId; + const style = cmapStyles.find((candidate) => candidate.id === selectedId); + if (!style) { + updateCmapStyleDeleteButton(); + return; + } + applyCmapAppearance(style.values); + updateCmapStyleDeleteButton(); + } + + function newCmapStyleId() { + try { + if (window.crypto && typeof window.crypto.randomUUID === "function") { + return `custom-${window.crypto.randomUUID()}`; + } + } catch (_error) { + // Fall back to a timestamp when randomUUID is unavailable in this context. + } + return `custom-${Date.now()}-${Math.random().toString(36).slice(2)}`; + } + + async function saveCurrentCmapStyle() { + const selected = cmapStyles.find((style) => style.id === $("cmap-concept-style-preset").value); + const proposedName = selected && !selected.nameKey ? selected.name : ""; + const name = window.prompt(tr("style-name-prompt", "Name for this style"), proposedName); + if (name === null) return; + const cleanName = name.trim(); + if (!cleanName) { + window.alert(tr("style-name-required", "Enter a style name.")); + return; + } + const existing = cmapStyles.find((style) => + cmapStyleName(style).toLocaleLowerCase() === cleanName.toLocaleLowerCase()); + if (existing?.protected) { + window.alert(tr("default-style-protected", "The default style cannot be changed or deleted.")); + return; + } + if (existing && !window.confirm(tr( + "replace-style-confirm", 'Replace the existing style "{name}"?').replace("{name}", cmapStyleName(existing)))) return; + const replacement = { + id: existing?.id || newCmapStyleId(), + name: cleanName, + protected: false, + values: captureCmapAppearance() + }; + const previousStyles = cmapStyles; + cmapStyles = existing ? cmapStyles.map((style) => style.id === existing.id ? replacement : style) : + [...cmapStyles, replacement]; + if (!await storeCmapStyles()) { + cmapStyles = previousStyles; + return; + } + renderCmapStyleOptions(replacement.id); + } + + async function deleteSelectedCmapStyle() { + const selected = cmapStyles.find((style) => style.id === $("cmap-concept-style-preset").value); + if (!selected) return; + if (selected.protected) { + window.alert(tr("default-style-protected", "The default style cannot be changed or deleted.")); + return; + } + if (!window.confirm(tr( + "delete-style-confirm", 'Delete style "{name}"?').replace("{name}", cmapStyleName(selected)))) return; + const previousStyles = cmapStyles; + cmapStyles = cmapStyles.filter((style) => style.id !== selected.id); + if (!await storeCmapStyles()) { + cmapStyles = previousStyles; + return; + } + renderCmapStyleOptions(); } function setCmapZoom(value) { @@ -3317,6 +3871,23 @@ $("cmap-tools-menu").setAttribute("aria-expanded", "false"); } + function updateCmapSelectionToolbar(editor, selectedRecords = [], connector = null) { + const toolbar = $("cmap-selection-toolbar"); + const selected = Array.isArray(selectedRecords) ? selectedRecords : []; + toolbar.dataset.selectionCount = String(selected.length); + for (const button of toolbar.querySelectorAll("[data-cmap-layout]")) { + button.disabled = !editor || !editor.canLayoutSelection(button.dataset.cmapLayout); + } + const actionButtons = new Map(Array.from( + toolbar.querySelectorAll("[data-cmap-selection-action]"), + (button) => [button.dataset.cmapSelectionAction, button] + )); + actionButtons.get("edit").disabled = !editor || selected.length !== 1 || Boolean(connector); + actionButtons.get("group").disabled = !editor || !editor.canGroupSelection(); + actionButtons.get("ungroup").disabled = !editor || !editor.canUngroupSelection(); + actionButtons.get("hide").disabled = !editor || !editor.canHideSelectionInCurrentContext(); + } + function openCmapContextMenu(clientX, clientY, createContext = {}) { const menu = $("cmap-context-menu"); cmapContextCreateContext = createContext; @@ -3388,6 +3959,10 @@ if (editor.copySelectionReferences()) event.preventDefault(); return; } + if (commandKey && !event.altKey && key === "x") { + if (editor.cutSelectionReferences()) event.preventDefault(); + return; + } if (commandKey && !event.altKey && key === "v") { if (editor.canPasteConceptReferences()) { event.preventDefault(); @@ -3456,6 +4031,7 @@ if (!label || !label.trim()) return false; return prototype.editor.groupSelection({ label: label.trim(), + conceptId: sharedCmapConceptId(label), childMap: label.trim(), synopsis: tr("grouped-submap-synopsis", "Grouped sub-concept map.") }); @@ -3507,8 +4083,9 @@ } canvas.replaceChildren(); state.cmapPrototype = null; + updateCmapSelectionToolbar(null, [], null); - console.info("[racket-wiki:cmap-host 0.2.99] resetCmapPrototype", { + console.info("[racket-wiki:cmap-host 0.2.122] resetCmapPrototype", { cmapAvailable: typeof window.Cmap === "function", interactionLayerAvailable: Boolean(window.RacketWikiCmap), interactionLayerVersion: window.RacketWikiCmap ? window.RacketWikiCmap.version : null, @@ -3539,6 +4116,7 @@ navigateToHash(cmapRoute(record.cmapSlug)).catch((error) => console.error(error)); } }, + onOpenExternalUrl: (record) => openCmapExternalUrl(record), onOpenStoredSubMap: (record) => { if (record.cmapSlug) { navigateToHash(cmapRoute(record.cmapSlug)).catch((error) => console.error(error)); @@ -3551,7 +4129,7 @@ } }, onOpenSubMap: (record) => { - console.info("[racket-wiki:cmap-host 0.2.99] submap state changed", { + console.info("[racket-wiki:cmap-host 0.2.122] submap state changed", { id: record.id, expanded: record.expanded, childMap: record.childMap @@ -3572,9 +4150,19 @@ $("cmap-ungroup-selected").disabled = !prototype.editor.canUngroupSelection(); $("cmap-hide-selected").disabled = !prototype.editor.canHideSelectionInCurrentContext(); $("cmap-delete-selected").disabled = selected.length === 0 && !connector; + $("cmap-cut-selected").disabled = !prototype.editor.canCutSelectionReferences(); $("cmap-copy-selected").disabled = !selected.some((item) => - item.conceptId && item.kind !== "phrase" && item.kind !== "submap"); + item.conceptId && item.kind !== "phrase"); $("cmap-paste-concepts").disabled = !prototype.editor.canPasteConceptReferences(); + updateCmapSelectionToolbar(prototype.editor, selected, connector); + }, + onAutomaticLayoutChange: ({ beforeSnapshot, afterSnapshot }) => { + // Asynchronous text/image measurement is renderer normalization, not + // an edit. Advance the baseline only when it still equals the exact + // pre-layout state, so a real intervening user change is never hidden. + if (state.cmapSavedSnapshot === beforeSnapshot) { + state.cmapSavedSnapshot = afterSnapshot; + } }, onHistoryChange: ({ canUndo, canRedo }) => { $("cmap-undo").disabled = !canUndo; @@ -3596,7 +4184,7 @@ relationLabel: tr("relation", "Relation") }); restoreCmapZoom(); - console.info("[racket-wiki:cmap-host 0.2.99] editor stored", { + console.info("[racket-wiki:cmap-host 0.2.122] editor stored", { editorAvailable: Boolean(prototype.editor), canvasChildCount: canvas.children.length }); @@ -3673,6 +4261,8 @@ delete referenceButton.dataset.markdown; } $("cmap-rename-map").disabled = !hasStoredMap; + $("cmap-edit-metadata").disabled = !hasStoredMap; + $("cmap-export-markdown").disabled = !hasStoredMap; $("cmap-set-start-map").disabled = !hasStoredMap; $("cmap-set-start-map").textContent = hasStoredMap && startCmapSlug() === state.currentConceptMap.slug ? @@ -3687,12 +4277,13 @@ const [result, usageResult] = await Promise.all([ api("/api/cmaps"), api("/api/cmaps/concept-usage").catch((error) => { - console.warn("[racket-wiki:cmap-host 0.2.99] concept usage is unavailable; CMaps continue without global counts", error); + console.warn("[racket-wiki:cmap-host 0.2.122] concept usage is unavailable; CMaps continue without global counts", error); return { placements: [] }; }) ]); state.conceptMaps = Array.isArray(result.conceptMaps) ? result.conceptMaps : []; state.cmapConceptUsage = new Map(); + state.cmapConceptIdsByName = new Map(); state.cmapPageConcepts = new Map(); const placements = Array.isArray(usageResult.placements) ? usageResult.placements : []; const pageByConcept = new Map(); @@ -3704,6 +4295,8 @@ } for (const placement of placements) { if (!placement || !placement.conceptId || !placement.cmapSlug) continue; + const nameKey = normalizeCmapConceptName(placement.label); + if (nameKey) state.cmapConceptIdsByName.set(nameKey, placement.conceptId); if (!state.cmapConceptUsage.has(placement.conceptId)) { state.cmapConceptUsage.set(placement.conceptId, new Map()); } @@ -3743,6 +4336,9 @@ function normalizeCmapConceptIdentities(documentValue, cmapSlug) { const legacyIds = new Map(); const normalize = (conceptId) => { + const prefixedUuid = typeof conceptId === "string" && conceptId.match( + /^concept-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i); + if (prefixedUuid) return prefixedUuid[1].toLowerCase(); if (!conceptId || !/^concept-\d+$/.test(conceptId) || !cmapSlug) return conceptId; if (!legacyIds.has(conceptId)) legacyIds.set(conceptId, `legacy:${cmapSlug}:${conceptId}`); return legacyIds.get(conceptId); @@ -3762,7 +4358,7 @@ documentValue = JSON.parse(documentValue); } if (!documentValue || typeof documentValue !== "object" || Array.isArray(documentValue)) { - console.error("[racket-wiki:cmap-host 0.2.99] invalid stored CMap document", { + console.error("[racket-wiki:cmap-host 0.2.122] invalid stored CMap document", { slug: conceptMap.slug, valueType: Array.isArray(documentValue) ? "array" : typeof documentValue, value: documentValue @@ -3877,7 +4473,7 @@ sourceMap.document = decodeStoredConceptMapDocument(sourceMap); editorDocument = sourceMap.document; } - console.info("[racket-wiki:cmap-host 0.2.99] stored CMap received", { + console.info("[racket-wiki:cmap-host 0.2.122] stored CMap received", { slug: conceptMap.slug, version: conceptMap.currentVersion, itemCount: Array.isArray(conceptMap.document.items) ? conceptMap.document.items.length : 0, @@ -3911,7 +4507,7 @@ } markCurrentCmapSaved(); const loadedEditor = cmapPrototypeState().editor; - console.info("[racket-wiki:cmap-host 0.2.99] stored CMap loaded", { + console.info("[racket-wiki:cmap-host 0.2.122] stored CMap loaded", { slug: conceptMap.slug, editorAvailable: Boolean(loadedEditor), itemCount: loadedEditor ? loadedEditor.items.length : 0, @@ -4029,7 +4625,11 @@ method: "POST", body: JSON.stringify({ title: title.trim(), - document: { schemaVersion: 1, items: [], connectors: [], conceptMaps: [] } + document: { + schemaVersion: 2, + metadata: { tags: [], summary: "", explanationPageSlug: "" }, + concepts: [], items: [], connectors: [], conceptMaps: [] + } }) }); await loadConceptMaps(); @@ -4068,6 +4668,11 @@ title: title.trim(), document: { schemaVersion: 2, + metadata: { + tags: Array.isArray(record.aspects) ? [...record.aspects] : [], + summary: record.synopsis || "", + explanationPageSlug: record.descriptionPageSlug || "" + }, derivedView: { sourceCmapSlug: sourceMap.slug, rootItemId: record.id @@ -4127,25 +4732,400 @@ } } + function activeCmapMetadata() { + const currentDocument = state.currentConceptMap?.document; + if (currentDocument?.derivedView) { + const metadata = currentDocument.metadata || {}; + return { + tags: Array.isArray(metadata.tags) ? metadata.tags : [], + summary: metadata.summary || "", + explanationPageSlug: metadata.explanationPageSlug || "" + }; + } + const editor = cmapPrototypeState().editor; + return editor?.getDocumentMetadata ? editor.getDocumentMetadata() : + { tags: [], summary: "", explanationPageSlug: "" }; + } + + function openCmapMetadataDialog() { + if (!state.currentConceptMap) return; + const metadata = activeCmapMetadata(); + $("cmap-metadata-summary").value = metadata.summary || ""; + $("cmap-metadata-tags").value = (metadata.tags || []).join(", "); + $("cmap-metadata-explanation-page").value = metadata.explanationPageSlug || ""; + $("cmap-metadata-explanation-page").setCustomValidity(""); + $("cmap-metadata-dialog").showModal(); + $("cmap-metadata-summary").focus(); + closeCmapContextMenu(); + } + + async function saveCmapMetadata() { + const conceptMap = state.currentConceptMap; + const editor = cmapPrototypeState().editor; + if (!conceptMap || !editor) return false; + const explanationInput = $("cmap-metadata-explanation-page").value.trim(); + const explanationPageSlug = explanationInput ? newPageReference(explanationInput) : ""; + if (explanationInput && !explanationPageSlug) { + const input = $("cmap-metadata-explanation-page"); + input.setCustomValidity(tr("invalid-description-page", "Enter a valid description page address.")); + input.reportValidity(); + return false; + } + const metadata = { + tags: $("cmap-metadata-tags").value.split(",") + .map((tag) => tag.trim()).filter(Boolean), + summary: $("cmap-metadata-summary").value.trim(), + explanationPageSlug + }; + + if (conceptMap.document?.derivedView) { + const updated = await api(`/api/cmaps/${encodeURIComponent(conceptMap.slug)}`, { + method: "PUT", + body: JSON.stringify({ + title: conceptMap.title, + baseVersion: conceptMap.currentVersion, + summary: tr("updated-concept-map-details", "Updated CMap details"), + saveKind: "manual", + document: { ...conceptMap.document, metadata } + }) + }); + updated.document = decodeStoredConceptMapDocument(updated); + state.currentConceptMap = updated; + updateStoredConceptMapSummary(updated); + } else { + editor.setDocumentMetadata(metadata); + if (!await saveStoredConceptMap({ + force: true, + summary: tr("updated-concept-map-details", "Updated CMap details") + })) return false; + } + $("cmap-metadata-dialog").close(); + showCmapStatus(tr("concept-map-details-saved", "CMap details saved"), true); + return true; + } + + function openCmapExportDialog() { + if (!state.currentConceptMap) return; + $("cmap-export-status").textContent = ""; + $("cmap-export-dialog").showModal(); + $("cmap-export-depth").focus(); + closeCmapContextMenu(); + } + + async function buildCurrentCmapMarkdownExport() { + if (!state.currentConceptMap || !window.RacketWikiCmapExport) { + throw new Error(tr("cmap-export-unavailable", "CMap export is unavailable.")); + } + if (cmapHasUnsavedChanges() && can("editor")) { + const saved = await saveStoredConceptMap({ automatic: true, force: true, historyMode: "autosave" }); + if (!saved) throw new Error(tr("save-before-export-failed", "The CMap could not be saved before export.")); + } + const rootMap = await api(`/api/cmaps/${encodeURIComponent(state.currentConceptMap.slug)}`); + rootMap.document = decodeStoredConceptMapDocument(rootMap); + const depth = Math.max(0, Math.min(10, Number($("cmap-export-depth").value) || 0)); + return window.RacketWikiCmapExport.generateMarkdown({ + rootMap, + maxDepth: depth, + includeWikiPages: $("cmap-export-pages").checked, + language: state.language, + loadConceptMap: async (slug) => { + const map = await api(`/api/cmaps/${encodeURIComponent(slug)}`); + map.document = decodeStoredConceptMapDocument(map); + return map; + }, + loadWikiPage: (reference) => api(`/api/pages/${encodeURIComponent(reference)}`) + }); + } + + async function buildCurrentCmapJsonExport() { + if (!state.currentConceptMap || !window.RacketWikiCmapInterchange) { + throw new Error(tr("cmap-json-unavailable", "CMap JSON export is unavailable.")); + } + if (cmapHasUnsavedChanges() && can("editor")) { + const saved = await saveStoredConceptMap({ automatic: true, force: true, historyMode: "autosave" }); + if (!saved) throw new Error(tr("save-before-export-failed", "The CMap could not be saved before export.")); + } + const rootMap = await api(`/api/cmaps/${encodeURIComponent(state.currentConceptMap.slug)}`); + rootMap.document = decodeStoredConceptMapDocument(rootMap); + const depth = Math.max(0, Math.min(10, Number($("cmap-export-depth").value) || 0)); + return window.RacketWikiCmapInterchange.buildBundle({ + rootMap, + maxDepth: depth, + generator: "Racket Wiki 0.2.122", + loadConceptMap: async (slug) => { + const map = await api(`/api/cmaps/${encodeURIComponent(slug)}`); + map.document = decodeStoredConceptMapDocument(map); + return map; + }, + loadWikiPage: (reference) => api(`/api/pages/${encodeURIComponent(reference)}`), + loadAttachment: async (url) => { + const response = await fetch(url, { credentials: "same-origin" }); + if (!response.ok) throw new Error(`Attachment could not be exported: ${url}`); + const content = await response.arrayBuffer(); + return { + mimeType: response.headers.get("content-type") || "application/octet-stream", + contentBase64: arrayBufferToBase64(content) + }; + } + }); + } + + function arrayBufferToBase64(buffer) { + const bytes = new Uint8Array(buffer); + let binary = ""; + const chunkSize = 0x8000; + for (let offset = 0; offset < bytes.length; offset += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)); + } + return window.btoa(binary); + } + + function base64AttachmentBlob(attachment) { + const binary = window.atob(String(attachment.contentBase64 || "")); + if (binary.length > 50 * 1024 * 1024) { + throw new Error(`Attachment exceeds 50 MiB: ${attachment.name}`); + } + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return new Blob([bytes], { + type: attachment.mimeType || "application/octet-stream" + }); + } + + async function importPageAttachments(page) { + const replacements = new Map(); + for (const attachment of (Array.isArray(page.attachments) ? page.attachments : [])) { + const uploaded = await api(`/api/pages/${encodeURIComponent(page.reference)}/upload`, { + method: "POST", + headers: { "X-File-Name": attachment.name }, + body: base64AttachmentBlob(attachment) + }); + replacements.set(attachment.url, uploaded.url); + } + return window.RacketWikiCmapInterchange.replaceAttachmentUrls(page.markdown, replacements); + } + + function downloadTextFile(text, filename, contentType) { + const blob = new Blob([text], { type: contentType }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.append(link); + link.click(); + link.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 0); + } + + async function exportCurrentCmapJson() { + const status = $("cmap-export-status"); + status.textContent = tr("preparing-cmap-json", "Preparing complete CMap JSON…"); + try { + const bundle = await buildCurrentCmapJsonExport(); + downloadTextFile(`${JSON.stringify(bundle, null, 2)}\n`, + `${bundle.rootCmapSlug}-cmap.json`, "application/json;charset=utf-8"); + status.textContent = tr("cmap-json-downloaded", "CMap JSON downloaded."); + return true; + } catch (error) { + status.textContent = error.message; + return false; + } + } + + async function importCmapBundleFile(file) { + if (!window.RacketWikiCmapInterchange) { + throw new Error(tr("cmap-json-unavailable", "CMap JSON import is unavailable.")); + } + if (file.size > 256 * 1024 * 1024) { + throw new Error(tr("cmap-json-too-large", "The CMap JSON file exceeds 256 MiB.")); + } + const bundle = JSON.parse(await file.text()); + window.RacketWikiCmapInterchange.validateBundle(bundle); + if (cmapHasUnsavedChanges()) { + const saved = await saveStoredConceptMap({ automatic: true, force: true, historyMode: "autosave" }); + if (!saved) throw new Error(tr("save-before-import-failed", "The current CMap could not be saved before import.")); + } + + const existingPages = new Map(state.pages.map((page) => [page.slug, page])); + const existingMaps = new Map(state.conceptMaps.map((cmap) => [cmap.slug, cmap])); + const pageConflicts = bundle.pages.filter((page) => existingPages.has(page.reference)); + const mapConflicts = bundle.cmaps.filter((cmap) => existingMaps.has(cmap.slug)); + let replaceExisting = false; + if (pageConflicts.length || mapConflicts.length) { + replaceExisting = window.confirm(tr( + "cmap-import-conflicts", + "The import contains {maps} existing CMaps and {pages} existing pages. Choose OK to replace them with the imported content, or Cancel to keep them and import only new records.") + .replace("{maps}", mapConflicts.length) + .replace("{pages}", pageConflicts.length)); + } + + const result = { mapsCreated: 0, mapsUpdated: 0, mapsSkipped: 0, + pagesCreated: 0, pagesUpdated: 0, pagesSkipped: 0, attachmentsImported: 0 }; + for (const page of bundle.pages) { + const existing = existingPages.get(page.reference); + if (existing && !replaceExisting) { + result.pagesSkipped += 1; + continue; + } + const attachments = Array.isArray(page.attachments) ? page.attachments : []; + const body = { + slug: page.reference, + title: page.title, + markdown: page.markdown, + tags: page.tags, + summary: tr("imported-from-cmap-json", "Imported from CMap JSON") + }; + if (existing) { + body.markdown = await importPageAttachments(page); + body.baseVersion = existing.currentVersion; + await api(`/api/pages/${encodeURIComponent(page.reference)}`, { + method: "PUT", body: JSON.stringify(body) + }); + result.pagesUpdated += 1; + } else { + const created = await api("/api/pages", { method: "POST", body: JSON.stringify(body) }); + if (attachments.length) { + body.markdown = await importPageAttachments(page); + body.baseVersion = created.currentVersion; + await api(`/api/pages/${encodeURIComponent(page.reference)}`, { + method: "PUT", body: JSON.stringify(body) + }); + } + result.pagesCreated += 1; + } + result.attachmentsImported += attachments.length; + } + + for (const cmap of bundle.cmaps) { + const existing = existingMaps.get(cmap.slug); + if (existing && !replaceExisting) { + result.mapsSkipped += 1; + continue; + } + const documentValue = window.RacketWikiCmapInterchange.preparedMapDocument(bundle, cmap); + if (existing) { + await api(`/api/cmaps/${encodeURIComponent(cmap.slug)}`, { + method: "PUT", + body: JSON.stringify({ + title: cmap.title, + document: documentValue, + baseVersion: existing.currentVersion, + saveKind: "manual", + summary: tr("imported-from-cmap-json", "Imported from CMap JSON") + }) + }); + result.mapsUpdated += 1; + } else { + await api("/api/cmaps", { + method: "POST", + body: JSON.stringify({ slug: cmap.slug, title: cmap.title, document: documentValue }) + }); + result.mapsCreated += 1; + } + } + await loadPages(); + await loadConceptMaps(); + await navigateToHash(cmapRoute(bundle.rootCmapSlug)); + return result; + } + + async function handleCmapImportFile(file) { + showCmapStatus(tr("importing-cmap-json", "Importing CMap JSON…")); + try { + const result = await importCmapBundleFile(file); + showCmapStatus(tr( + "cmap-json-imported", + "Import complete: {maps} CMaps, {pages} pages and {attachments} attachments imported; {skipped} existing records kept.") + .replace("{maps}", result.mapsCreated + result.mapsUpdated) + .replace("{pages}", result.pagesCreated + result.pagesUpdated) + .replace("{attachments}", result.attachmentsImported) + .replace("{skipped}", result.mapsSkipped + result.pagesSkipped), true); + } catch (error) { + showCmapStatus(error.message); + window.alert(error.message); + } + } + + async function copyText(text) { + try { + await navigator.clipboard.writeText(text); + } catch (_error) { + const input = document.createElement("textarea"); + input.value = text; + input.style.position = "fixed"; + input.style.left = "-10000px"; + document.body.append(input); + input.select(); + document.execCommand("copy"); + input.remove(); + } + } + + async function exportCurrentCmap(download) { + const status = $("cmap-export-status"); + status.textContent = tr("preparing-markdown-export", "Preparing Markdown export…"); + try { + const markdown = await buildCurrentCmapMarkdownExport(); + if (download) { + const blob = new Blob([markdown], { type: "text/markdown;charset=utf-8" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `${state.currentConceptMap.slug}-report.md`; + document.body.append(link); + link.click(); + link.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 0); + status.textContent = tr("markdown-export-downloaded", "Markdown export downloaded."); + } else { + await copyText(markdown); + status.textContent = tr("markdown-export-copied", "Markdown export copied."); + } + return true; + } catch (error) { + status.textContent = error.message; + return false; + } + } + async function deleteStoredConceptMap() { cancelCmapAutosave(); if (cmapSavePromise) await cmapSavePromise; const conceptMap = state.currentConceptMap; if (!conceptMap) return false; const question = tr( - "delete-concept-map-confirm", - "Delete concept map \"{title}\"? Links to it will remain but will no longer open the map.") + "archive-concept-map-confirm", + "Archive the entire concept map \"{title}\"? It will disappear from normal navigation, but an administrator can restore it.") .replace("{title}", conceptMap.title); if (!window.confirm(question)) return false; + const typedTitle = window.prompt( + tr( + "archive-concept-map-type-title", + "Type the complete CMap name to confirm: {title}") + .replace("{title}", conceptMap.title), + ""); + if (typedTitle === null) return false; + if (typedTitle !== conceptMap.title) { + showCmapStatus(tr("archive-concept-map-title-mismatch", "The CMap name did not match; nothing was archived.")); + return false; + } try { - await api(`/api/cmaps/${encodeURIComponent(conceptMap.slug)}`, { method: "DELETE" }); + await api(`/api/cmaps/${encodeURIComponent(conceptMap.slug)}`, { + method: "DELETE", + body: JSON.stringify({ + confirmTitle: typedTitle, + baseVersion: conceptMap.currentVersion + }) + }); markCurrentCmapSaved(); state.currentConceptMap = null; state.currentConceptMapSource = null; await loadConceptMaps(); const target = state.conceptMaps.length ? cmapRoute(state.conceptMaps[0].slug) : "#cmaps"; await navigateToHash(target); - showCmapStatus(tr("concept-map-deleted", "CMap deleted"), true); + showCmapStatus(tr("concept-map-archived", "CMap archived"), true); return true; } catch (error) { showCmapStatus(error.message); @@ -4321,10 +5301,13 @@ const pages = new Set(); const conceptMaps = new Set(); const items = documentValue && Array.isArray(documentValue.items) ? documentValue.items : []; + const concepts = new Map((documentValue && Array.isArray(documentValue.concepts) ? + documentValue.concepts : []).map((concept) => [concept.id, concept])); for (const item of items) { - if (item && typeof item.pageSlug === "string" && item.pageSlug) pages.add(item.pageSlug); - if (item && typeof item.cmapSlug === "string" && item.cmapSlug) conceptMaps.add(item.cmapSlug); + const concept = item && concepts.get(item.conceptId) || item; + if (concept && typeof concept.pageSlug === "string" && concept.pageSlug) pages.add(concept.pageSlug); + if (concept && typeof concept.cmapSlug === "string" && concept.cmapSlug) conceptMaps.add(concept.cmapSlug); } return { pages, conceptMaps }; @@ -4740,6 +5723,7 @@ state.translationTemplate = translationData.template || ""; state.translations = translationData.translations || {}; applyTranslations(); + await loadCmapStyles(); restoreCmapPageGuides(); initializeEditor(); $("account-name").textContent = state.session.user.displayName; @@ -4747,6 +5731,7 @@ updateRoleUi(); await loadPages(); await loadConceptMaps(); + await loadPeople(); loadBreadcrumbTrail(); await loadBookmarks(); await route(); @@ -4872,6 +5857,16 @@ }); $("cmap-save-map").addEventListener("click", () => saveStoredConceptMap()); $("cmap-rename-map").addEventListener("click", () => renameStoredConceptMap()); + $("cmap-edit-metadata").addEventListener("click", openCmapMetadataDialog); + $("cmap-export-markdown").addEventListener("click", openCmapExportDialog); + $("cmap-import-json").addEventListener("click", () => { + closeCmapContextMenu(); + $("cmap-import-file").value = ""; + $("cmap-import-file").click(); + }); + $("cmap-manage-people").addEventListener("click", () => { + openPeopleManagement().catch((error) => showCmapStatus(error.message)); + }); $("cmap-delete-map").addEventListener("click", () => deleteStoredConceptMap()); $("cmap-create-snapshot").addEventListener("click", () => { createConceptMapSnapshot().catch((error) => showCmapStatus(error.message)); @@ -4898,6 +5893,10 @@ }); }); $("cmap-edit-selected").addEventListener("click", () => editSelectedCmapNode()); + $("cmap-cut-selected").addEventListener("click", () => { + const prototype = cmapPrototypeState(); + if (prototype.editor) prototype.editor.cutSelectionReferences(); + }); $("cmap-copy-selected").addEventListener("click", () => { const prototype = cmapPrototypeState(); if (prototype.editor) prototype.editor.copySelectionReferences(); @@ -4933,6 +5932,34 @@ const prototype = cmapPrototypeState(); if (prototype.editor) prototype.editor.deleteSelection(); }); + $("cmap-selection-toolbar").addEventListener("click", (event) => { + const button = event.target.closest("button"); + if (!button || button.disabled) return; + const prototype = cmapPrototypeState(); + const editor = prototype.editor; + if (!editor) return; + const layoutCommand = button.dataset.cmapLayout; + if (layoutCommand) { + editor.applySelectionLayout(layoutCommand); + return; + } + switch (button.dataset.cmapSelectionAction) { + case "edit": + editSelectedCmapNode(); + break; + case "group": + groupSelectedCmapItems(); + break; + case "ungroup": + editor.ungroupSelection(); + break; + case "hide": + editor.hideSelectionInCurrentContext(); + break; + default: + break; + } + }); $("cmap-toggle-page-guides").addEventListener("click", () => { const visible = $("cmap-toggle-page-guides").getAttribute("aria-checked") !== "true"; setCmapPageGuides(visible); @@ -4947,12 +5974,16 @@ } }); $("cmap-map-back").addEventListener("click", () => { + const prototype = cmapPrototypeState(); + if (prototype.editor && prototype.editor.canStepBackWithinMap()) { + prototype.editor.openParentMap(); + return; + } if (state.currentConceptMapSource) { navigateToHash(cmapRoute(state.currentConceptMapSource.slug)) .catch((error) => console.error(error)); return; } - const prototype = cmapPrototypeState(); if (prototype.editor) prototype.editor.openParentMap(); }); $("cmap-canvas").addEventListener("contextmenu", (event) => { @@ -5024,7 +6055,15 @@ $("cmap-concept-description-link").addEventListener("click", () => $("cmap-concept-dialog").close()); installCmapColorPickers(); - $("cmap-apply-style").addEventListener("click", applySelectedCmapStyle); + $("cmap-concept-quick-style").addEventListener("change", applySelectedCmapStyle); + $("cmap-concept-style-preset").addEventListener("change", applySelectedCmapStyle); + $("cmap-save-style").addEventListener("click", saveCurrentCmapStyle); + $("cmap-delete-style").addEventListener("click", deleteSelectedCmapStyle); + for (const eventName of ["input", "change"]) { + $("cmap-concept-panel-appearance").addEventListener(eventName, (event) => { + if (!event.target.closest(".cmap-style-manager")) syncCmapStyleSelection(); + }); + } $("cmap-concept-image").addEventListener("change", (event) => { const file = event.target.files && event.target.files[0]; if (!file) return; @@ -5046,6 +6085,23 @@ $("cmap-concept-image").value = ""; updateCmapImagePreview(); }); + $("cmap-concept-form").querySelector(".cmap-concept-tabs").addEventListener("click", (event) => { + const tab = event.target.closest("[data-cmap-concept-tab]"); + if (tab) selectCmapConceptTab(tab.dataset.cmapConceptTab); + }); + $("cmap-concept-form").querySelector(".cmap-concept-tabs").addEventListener("keydown", (event) => { + if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return; + const tabs = Array.from(event.currentTarget.querySelectorAll("[data-cmap-concept-tab]")); + const current = tabs.indexOf(event.target.closest("[data-cmap-concept-tab]")); + if (current < 0) return; + event.preventDefault(); + const next = event.key === "Home" ? 0 : event.key === "End" ? tabs.length - 1 : + (current + (event.key === "ArrowRight" ? 1 : -1) + tabs.length) % tabs.length; + selectCmapConceptTab(tabs[next].dataset.cmapConceptTab); + tabs[next].focus(); + }); + $("cmap-concept-external-url").addEventListener("input", (event) => + event.currentTarget.setCustomValidity("")); $("cmap-concept-cancel").addEventListener("click", () => $("cmap-concept-dialog").close()); $("cmap-concept-form").addEventListener("submit", async (event) => { event.preventDefault(); @@ -5056,32 +6112,47 @@ if ((!record && !createContext) || !prototype.editor) return; const label = $("cmap-concept-label").value.trim(); if (!label) { + selectCmapConceptTab("content"); $("cmap-concept-label").focus(); return; } - const fontSize = Math.max(6, Math.min(54, Number($("cmap-concept-font-size").value) || 11)); - const synopsisFontSize = Math.max(6, Math.min(54, - Number($("cmap-concept-synopsis-font-size").value) || 9)); + const fontSize = normalizedCmapFontSize($("cmap-concept-font-size").value, 11); + const synopsisFontSize = normalizedCmapFontSize( + $("cmap-concept-synopsis-font-size").value, 9); const pageInput = $("cmap-concept-page"); const cmapInput = $("cmap-concept-cmap"); const selectedPage = record && record.kind === "submap" ? "" : cmapPageCombobox.value(); const selectedCmap = record && record.kind === "submap" ? "" : cmapLinkCombobox.value(); const linkedPage = selectedPage === null ? newPageReference(pageInput.value) : selectedPage; if (selectedPage === null && !linkedPage) { + selectCmapConceptTab("content"); pageInput.setCustomValidity(tr("invalid-new-page", "Enter a page title or valid wiki address.")); pageInput.reportValidity(); return; } if (selectedCmap === null) { + selectCmapConceptTab("content"); cmapInput.setCustomValidity(tr("select-listed-concept-map", "Select a CMap from the list or clear the field.")); cmapInput.reportValidity(); return; } const linkedPageValue = linkedPage || null; + const externalUrlInput = $("cmap-concept-external-url"); + const externalUrl = normalizedCmapExternalUrl(externalUrlInput.value); + if (externalUrl === null) { + selectCmapConceptTab("content"); + externalUrlInput.setCustomValidity(tr( + "invalid-external-web-page", + "Enter a complete http or https web address.")); + externalUrlInput.reportValidity(); + externalUrlInput.focus(); + return; + } const descriptionInput = $("cmap-concept-description-page").value.trim(); const descriptionPage = descriptionInput ? newPageReference(descriptionInput) : pageReference("cmap", splitPageReference(newPageReference(label) || "concept").slug); if (!descriptionPage) { + selectCmapConceptTab("content"); $("cmap-concept-description-page").setCustomValidity( tr("invalid-description-page", "Enter a valid description page address.")); $("cmap-concept-description-page").reportValidity(); @@ -5090,15 +6161,21 @@ const linkedCmapValue = selectedCmap || ""; const parentCmapLink = linkedCmapValue === "__parent__"; const linkedCmap = linkedCmapValue && !parentCmapLink ? linkedCmapValue : null; + const existingNonPersonTags = record && Array.isArray(record.tags) ? + record.tags.filter((tag) => !(tag && typeof tag === "object" && tag.type === "person")) : []; + const personTags = selectedCmapPersonNames() + .map((name) => ({ type: "person", value: name })); const changes = { kind: record && record.kind === "submap" ? "submap" : (linkedPageValue ? "page" : "concept"), label, synopsis: $("cmap-concept-synopsis").value, aspects: $("cmap-concept-aspects").value.split(",") .map((aspect) => aspect.trim()).filter(Boolean), + tags: [...existingNonPersonTags, ...personTags], descriptionPageSlug: descriptionPage, pageSlug: linkedPageValue, cmapSlug: linkedCmap, + externalUrl: externalUrl || null, parentCmapLink, imageSource: cmapDialogImageSource, backgroundColor: cmapColorValue($("cmap-concept-background").value), @@ -5115,6 +6192,7 @@ synopsisFontWeight: $("cmap-concept-synopsis-bold").checked ? "700" : "400", synopsisFontStyle: $("cmap-concept-synopsis-italic").checked ? "italic" : "normal" }; + const existingConceptId = sharedCmapConceptId(label); if (!record || record.kind !== "submap") { changes.borderColor = linkedPageValue ? "#4479a1" : ((linkedCmap || parentCmapLink) ? "#57834a" : "#a97c00"); @@ -5123,9 +6201,16 @@ changes.submapBorderColor = cmapColorValue($("cmap-submap-border").value, "#57834a"); } if (record) { + if (existingConceptId && record.conceptId !== existingConceptId) { + const previousConceptId = record.conceptId; + record.conceptId = existingConceptId; + prototype.editor.refreshConceptUsageIndicators( + [previousConceptId, existingConceptId].filter(Boolean)); + } prototype.editor.updateItem(record, changes); prototype.editor.selectItem(record); } else { + if (existingConceptId) changes.conceptId = existingConceptId; if (createContext.point) { changes.x = Math.max(0, createContext.point.x - 70); changes.y = Math.max(0, createContext.point.y - 30); @@ -5141,7 +6226,11 @@ prototype.editor.selectItem(newRecord); } } + prototype.editor.commitHistory(); $("cmap-concept-dialog").close(); + if (currentCmapStorageMap()) { + await saveStoredConceptMap({ automatic: true }); + } }); $("cmap-concept-dialog").addEventListener("close", () => { cmapDialogRecord = null; @@ -5149,6 +6238,49 @@ cmapDialogImageSource = ""; cmapDialogImageRead = Promise.resolve(); }); + $("cmap-person-tag-options").addEventListener("change", () => + renderCmapPersonTagOptions(selectedCmapPersonNames())); + $("cmap-person-add").addEventListener("click", () => { + createPersonFromInput("cmap-person-new-name", true) + .catch((error) => window.alert(error.message)); + }); + $("cmap-person-new-name").addEventListener("keydown", (event) => { + if (event.key !== "Enter") return; + event.preventDefault(); + createPersonFromInput("cmap-person-new-name", true) + .catch((error) => window.alert(error.message)); + }); + $("cmap-people-add").addEventListener("click", () => { + createPersonFromInput("cmap-people-new-name") + .then(() => renderPeopleManagement()) + .catch((error) => window.alert(error.message)); + }); + $("cmap-people-new-name").addEventListener("keydown", (event) => { + if (event.key !== "Enter") return; + event.preventDefault(); + createPersonFromInput("cmap-people-new-name") + .then(() => renderPeopleManagement()) + .catch((error) => window.alert(error.message)); + }); + $("cmap-people-close").addEventListener("click", () => $("cmap-people-dialog").close()); + $("cmap-metadata-cancel").addEventListener("click", () => $("cmap-metadata-dialog").close()); + $("cmap-metadata-form").addEventListener("submit", (event) => { + event.preventDefault(); + saveCmapMetadata().catch((error) => showCmapStatus(error.message)); + }); + $("cmap-metadata-explanation-page").addEventListener("input", (event) => + event.currentTarget.setCustomValidity("")); + $("cmap-export-cancel").addEventListener("click", () => $("cmap-export-dialog").close()); + $("cmap-export-copy").addEventListener("click", () => exportCurrentCmap(false)); + $("cmap-export-json").addEventListener("click", () => exportCurrentCmapJson()); + $("cmap-export-form").addEventListener("submit", (event) => { + event.preventDefault(); + exportCurrentCmap(true); + }); + $("cmap-import-file").addEventListener("change", (event) => { + const file = event.currentTarget.files && event.currentTarget.files[0]; + if (file) handleCmapImportFile(file); + }); $("wiki-cmap-link-cancel").addEventListener("click", () => $("wiki-cmap-link-dialog").close()); $("wiki-cmap-link-form").addEventListener("submit", (event) => { event.preventDefault(); @@ -5249,6 +6381,10 @@ event.preventDefault(); navigateToHash("#admin/orphaned-uploads").catch(console.error); }); + $("admin-archived-cmaps-link").addEventListener("click", (event) => { + event.preventDefault(); + navigateToHash("#admin/archived-cmaps").catch(console.error); + }); $("admin-translations-link").addEventListener("click", (event) => { event.preventDefault(); const page = state.pages.find((item) => item.slug === state.translationPage); @@ -5267,6 +6403,10 @@ event.preventDefault(); navigateToHash("#admin").catch(console.error); }); + $("close-archived-cmaps").addEventListener("click", (event) => { + event.preventDefault(); + navigateToHash("#admin").catch(console.error); + }); $("close-user-admin").addEventListener("click", (event) => { event.preventDefault(); navigateToHash("#admin").catch(console.error); @@ -5405,7 +6545,18 @@ main.replaceChildren(); const message = document.createElement("div"); message.className = "card error"; - message.textContent = error.message; + const text = String(error && error.message || error); + const setupPath = "/setup"; + const setupIndex = text.indexOf(setupPath); + if (setupIndex === -1) { + message.textContent = text; + } else { + message.append(document.createTextNode(text.slice(0, setupIndex))); + const setupLink = document.createElement("a"); + setupLink.href = setupPath; + setupLink.textContent = setupPath; + message.append(setupLink, document.createTextNode(text.slice(setupIndex + setupPath.length))); + } main.append(message); }); })(); diff --git a/static/schemas/racket-wiki-cmap-bundle-v1.schema.json b/static/schemas/racket-wiki-cmap-bundle-v1.schema.json new file mode 100644 index 0000000..3587d05 --- /dev/null +++ b/static/schemas/racket-wiki-cmap-bundle-v1.schema.json @@ -0,0 +1,182 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://racket-wiki.local/schemas/racket-wiki-cmap-bundle-v1.schema.json", + "title": "Racket Wiki CMap bundle version 1", + "type": "object", + "required": ["format", "formatVersion", "rootCmapSlug", "cmaps", "concepts", "pages"], + "properties": { + "$schema": { "type": "string" }, + "format": { "const": "racket-wiki-cmap-bundle" }, + "formatVersion": { "const": 1 }, + "exportedAt": { "type": "string", "format": "date-time" }, + "generator": { "type": "string" }, + "rootCmapSlug": { "$ref": "#/$defs/slug" }, + "cmaps": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#/$defs/cmap" } + }, + "concepts": { + "type": "array", + "items": { "$ref": "#/$defs/concept" } + }, + "pages": { + "type": "array", + "items": { "$ref": "#/$defs/page" } + }, + "missing": { + "type": "object", + "properties": { + "cmaps": { "type": "array", "items": { "$ref": "#/$defs/slug" } }, + "pages": { "type": "array", "items": { "type": "string", "minLength": 1 } } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "$defs": { + "slug": { + "type": "string", + "pattern": "^[\\p{L}\\p{N}][\\p{L}\\p{N}._-]{0,119}$" + }, + "conceptId": { + "type": "string", + "anyOf": [ + { "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$" }, + { "pattern": "^new:[A-Za-z0-9][A-Za-z0-9._-]{0,119}$" } + ] + }, + "concept": { + "type": "object", + "required": ["id", "label"], + "properties": { + "id": { "$ref": "#/$defs/conceptId" }, + "label": { "type": "string", "minLength": 1 }, + "synopsis": { "type": "string" }, + "aspects": { "type": "array", "items": { "type": "string" } }, + "tags": { "type": "array", "items": {} }, + "descriptionPageSlug": { "type": ["string", "null"] }, + "pageSlug": { "type": ["string", "null"] }, + "cmapSlug": { "type": ["string", "null"] }, + "externalUrl": { "type": ["string", "null"], "pattern": "^[hH][tT][tT][pP][sS]?://" }, + "imageSource": { "type": ["string", "null"] } + }, + "additionalProperties": false + }, + "conceptReference": { + "type": "object", + "required": ["id"], + "properties": { "id": { "$ref": "#/$defs/conceptId" } }, + "additionalProperties": false + }, + "item": { + "type": "object", + "required": ["id", "x", "y"], + "properties": { + "id": { "type": "integer" }, + "conceptId": { "$ref": "#/$defs/conceptId" }, + "kind": { "enum": ["concept", "page", "submap", "phrase"] }, + "label": { "type": "string" }, + "x": { "type": "number" }, + "y": { "type": "number" }, + "width": { "type": "number" }, + "height": { "type": "number" }, + "parentSubmapId": { "type": ["integer", "null"] }, + "hidden": { "type": "boolean" }, + "backgroundColor": { "type": "string" }, + "borderColor": { "type": "string" }, + "textColor": { "type": "string" }, + "fontFamily": { "type": "string" }, + "fontSize": { "type": "number" }, + "fontWeight": { "type": "string" }, + "fontStyle": { "type": "string" }, + "synopsisFontSize": { "type": "number" }, + "synopsisFontWeight": { "type": "string" }, + "synopsisFontStyle": { "type": "string" }, + "submapBackgroundColor": { "type": "string" }, + "submapBorderColor": { "type": "string" } + }, + "allOf": [ + { + "if": { "required": ["kind"], "properties": { "kind": { "const": "phrase" } } }, + "then": { "required": ["label"] }, + "else": { "required": ["conceptId"] } + } + ], + "additionalProperties": true + }, + "connector": { + "type": "object", + "required": ["sourceId", "targetId"], + "properties": { + "id": { "type": "integer" }, + "sourceId": { "type": "integer" }, + "targetId": { "type": "integer" }, + "hasArrow": { "type": "boolean" } + }, + "additionalProperties": true + }, + "document": { + "type": "object", + "required": ["concepts", "items", "connectors"], + "properties": { + "schemaVersion": { "type": "integer" }, + "metadata": { "type": "object", "additionalProperties": true }, + "derivedView": { + "type": "object", + "required": ["sourceCmapSlug", "rootItemId"], + "properties": { + "sourceCmapSlug": { "$ref": "#/$defs/slug" }, + "rootItemId": { "type": "integer" } + }, + "additionalProperties": false + }, + "concepts": { "type": "array", "items": { "$ref": "#/$defs/conceptReference" } }, + "items": { "type": "array", "items": { "$ref": "#/$defs/item" } }, + "connectors": { "type": "array", "items": { "$ref": "#/$defs/connector" } }, + "conceptMaps": { "type": "array" }, + "viewport": { "type": "object" } + }, + "additionalProperties": true + }, + "cmap": { + "type": "object", + "required": ["slug", "title", "document"], + "properties": { + "slug": { "$ref": "#/$defs/slug" }, + "title": { "type": "string", "minLength": 1 }, + "document": { "$ref": "#/$defs/document" } + }, + "additionalProperties": false + }, + "page": { + "type": "object", + "required": ["reference", "title", "markdown", "tags"], + "properties": { + "reference": { "type": "string", "minLength": 1 }, + "title": { "type": "string", "minLength": 1 }, + "markdown": { "type": "string" }, + "tags": { "type": "array", "items": { "type": "string" } }, + "attachments": { + "type": "array", + "items": { "$ref": "#/$defs/attachment" } + } + }, + "additionalProperties": false + }, + "attachment": { + "type": "object", + "required": ["url", "name", "mimeType", "contentBase64"], + "properties": { + "url": { "type": "string", "pattern": "^/uploads/" }, + "name": { "type": "string", "minLength": 1 }, + "mimeType": { "type": "string", "minLength": 1 }, + "contentBase64": { + "type": "string", + "pattern": "^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$" + } + }, + "additionalProperties": false + } + } +} diff --git a/static/vendor/README.md b/static/vendor/README.md index 3eb42fd..bc3c42e 100644 --- a/static/vendor/README.md +++ b/static/vendor/README.md @@ -8,6 +8,7 @@ Pinned sources: - EasyMDE 2.21.0: `https://cdn.jsdelivr.net/npm/easymde@2.21.0/dist/easymde.min.js` - EasyMDE 2.21.0 CSS: `https://cdn.jsdelivr.net/npm/easymde@2.21.0/dist/easymde.min.css` - DOMPurify 3.4.13: `https://cdn.jsdelivr.net/npm/dompurify@3.4.13/dist/purify.min.js` +- Mermaid 11.17.1: `https://cdn.jsdelivr.net/npm/mermaid@11.17.1/dist/mermaid.min.js` - highlight.js 11.12.0: `https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.12.0/build/highlight.min.js` - highlight.js Scheme grammar 11.12.0: `https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.12.0/build/languages/scheme.min.js` - highlight.js GitHub style 11.12.0: `https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.12.0/build/styles/github.min.css` diff --git a/test/cmap-connector-rewire.test.js b/test/cmap-connector-rewire.test.js new file mode 100644 index 0000000..df0b3b1 --- /dev/null +++ b/test/cmap-connector-rewire.test.js @@ -0,0 +1,175 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); + +function fakeElement() { + return { + className: "rw-cmap-item", + style: {}, + dataset: {}, + querySelector: () => null, + querySelectorAll: () => [], + remove() {}, + addEventListener() {}, + classList: { add() {}, remove() {}, toggle() {} }, + setAttribute() {}, + removeAttribute() {} + }; +} + +function fakeNode(attributes) { + const state = { ...attributes }; + return { + attr(value) { + if (typeof value === "string") return state[value]; + Object.assign(state, value); + return this; + }, + onRendered() {}, + onMove() {}, + onMoveEnd() {}, + visible() {}, + redraw() {}, + toFront() {}, + remove() {}, + element: () => null + }; +} + +function fakeLink(attributes) { + const state = { ...attributes }; + let source = null; + let target = null; + let connectionChangeHandler = null; + return { + attr(value) { + if (typeof value === "string") return state[value]; + Object.assign(state, value); + return this; + }, + sourceNode(value) { + if (arguments.length === 0) return source; + source = value; + return this; + }, + targetNode(value) { + if (arguments.length === 0) return target; + target = value; + return this; + }, + onConnectionChange(handler) { connectionChangeHandler = handler; }, + triggerConnectionChange(type, node) { + if (type === "source") source = node; + if (type === "target") target = node; + connectionChangeHandler(this, type, node); + }, + onRendered() {}, + visible() {}, + straighten() {}, + draggable() {}, + redraw() {}, + remove() {}, + element: () => null + }; +} + +function loadEditorFactory() { + global.Element = class Element {}; + global.document = { + currentScript: null, + styleSheets: [], + body: { append() {} }, + createElement: () => fakeElement() + }; + global.window = { + Cmap: null, + setTimeout, + clearTimeout, + requestAnimationFrame: (callback) => callback() + }; + require("../static/cmap/cmap-racket-wiki.js"); + return global.window.RacketWikiCmap; +} + +test("a dragged connector endpoint survives collapsing and expanding its submap", () => { + const factory = loadEditorFactory(); + const links = []; + const map = { + onSelection() {}, + onActivation() {}, + node: (attributes) => fakeNode(attributes), + link(attributes) { + const link = fakeLink(attributes); + links.push(link); + return link; + } + }; + const canvas = { + addEventListener() {}, + querySelector: () => null, + clientWidth: 1200, + clientHeight: 800, + scrollLeft: 0, + scrollTop: 0, + isConnected: true + }; + const editor = factory.createEditor(canvas, { Cmap: () => map }); + const submap = editor.addItem({ + id: 1, + kind: "submap", + label: "Problemen en Symptomen LTS Model", + x: 300, + y: 200, + width: 320, + height: 70 + }); + submap.expanded = true; + submap.submapInitialized = true; + const problems = editor.addItem({ + id: 2, + kind: "phrase", + label: "problemen", + parentSubmap: submap, + submapDepth: 1, + x: 400, + y: 300 + }); + const symptoms = editor.addItem({ + id: 3, + kind: "phrase", + label: "Symptomen", + parentSubmap: submap, + submapDepth: 1, + x: 400, + y: 500 + }); + const concept = editor.addItem({ + id: 4, + kind: "concept", + label: "Rob maakt veel fouten", + parentSubmap: submap, + submapDepth: 1, + x: 800, + y: 400 + }); + const connector = editor.addConnector(problems, concept, true, { id: 1 }); + + links[0].triggerConnectionChange("source", symptoms.node); + assert.equal(connector.source, symptoms, + "the editor model follows the endpoint dragged by the drawing library"); + + editor.toggleSubmap(submap, false); + editor.toggleSubmap(submap, true); + + assert.equal(connector.source, symptoms); + assert.equal(connector.link.sourceNode(), symptoms.node); + assert.deepEqual(editor.toDocument().connectors, [{ + id: 1, + sourceId: symptoms.id, + targetId: concept.id, + hasArrow: true, + lineColor: "#333", + lineWidth: 2 + }]); +}); diff --git a/test/cmap-dirty-layout.test.js b/test/cmap-dirty-layout.test.js new file mode 100644 index 0000000..7f1e665 --- /dev/null +++ b/test/cmap-dirty-layout.test.js @@ -0,0 +1,131 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); + +function fakeElement({ width = 320, height = 120 } = {}) { + return { + className: "rw-cmap-item", + style: {}, + dataset: {}, + firstElementChild: { style: {} }, + innerHTML: "", + querySelector: () => null, + querySelectorAll: () => [], + getBoundingClientRect: () => ({ width, height }), + remove() {}, + addEventListener() {}, + classList: { add() {}, remove() {}, toggle() {} }, + setAttribute() {}, + removeAttribute() {} + }; +} + +function loadEditorFactory() { + global.Element = class Element {}; + global.document = { + currentScript: null, + styleSheets: [], + body: { append() {} }, + createElement: () => fakeElement() + }; + global.window = { Cmap: null }; + require("../static/cmap/cmap-racket-wiki.js"); + return global.window.RacketWikiCmap; +} + +test("automatic render sizing advances only an unchanged saved baseline", () => { + const factory = loadEditorFactory(); + const canvas = { + addEventListener() {}, + querySelector: () => null, + clientWidth: 1200, + clientHeight: 800, + scrollLeft: 0, + scrollTop: 0 + }; + const map = { onSelection() {}, onActivation() {} }; + let savedBaseline = null; + let notification = null; + const editor = factory.createEditor(canvas, { + Cmap: () => map, + onAutomaticLayoutChange: (change) => { + notification = change; + if (savedBaseline === change.beforeSnapshot) { + savedBaseline = change.afterSnapshot; + } + } + }); + + const attributes = { x: 10, y: 20, width: 220, height: 70 }; + const node = { + attr(value) { + if (typeof value === "string") return attributes[value]; + Object.assign(attributes, value); + return this; + }, + redraw() {}, + element: () => null + }; + const record = { + id: 1, + conceptId: "global-concept", + kind: "concept", + label: "Concept", + synopsis: "Long content", + aspects: [], + tags: [], + descriptionPageSlug: null, + pageSlug: null, + cmapSlug: null, + imageSource: "", + parentCmapLink: false, + groupId: null, + childMap: null, + parentSubmap: null, + submapDepth: 0, + expanded: false, + submapInitialized: false, + separateMap: false, + mapReference: null, + hiddenContexts: new Set(), + layouts: {}, + backgroundColor: "#fff", + borderColor: "#000", + submapBackgroundColor: "#fff", + submapBorderColor: "#000", + textColor: "#222", + fontFamily: "Arial", + fontSize: "11pt", + fontWeight: "normal", + fontStyle: "normal", + synopsisTextColor: "#222", + synopsisFontFamily: "Arial", + synopsisFontSize: "11pt", + synopsisFontWeight: "normal", + synopsisFontStyle: "normal", + width: 220, + height: 70, + autoWidth: false, + autoHeight: false, + fitContentPending: false, + node + }; + editor.items.push(record); + savedBaseline = editor.historySnapshot(); + + editor.fitItemToContent(record, fakeElement()); + + assert.ok(notification, "automatic sizing reports its exact before and after snapshots"); + assert.notEqual(notification.beforeSnapshot, notification.afterSnapshot); + assert.equal(savedBaseline, notification.afterSnapshot, + "an untouched baseline follows renderer-only normalization"); + + const editedBaseline = "user-edited-state"; + savedBaseline = editedBaseline; + record.height = 70; + attributes.height = 70; + editor.fitItemToContent(record, fakeElement({ height: 140 })); + assert.equal(savedBaseline, editedBaseline, + "automatic sizing never hides a different user-edited baseline"); +}); diff --git a/test/cmap-export.test.js b/test/cmap-export.test.js new file mode 100644 index 0000000..8940677 --- /dev/null +++ b/test/cmap-export.test.js @@ -0,0 +1,145 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { generateMarkdown, derivedDocument, relationLines } = require("../static/js/cmap-export.js"); + +function map(slug, title, document) { + return { slug, title, document }; +} + +const root = map("root", "Root map", { + schemaVersion: 2, + metadata: { + tags: ["architecture", "report"], + summary: "Summary of the root map.", + explanationPageSlug: "cmap:root-explanation" + }, + concepts: [ + { + id: "concept-a", label: "Decision", synopsis: "Choose the approach.", + aspects: ["governance"], pageSlug: "decisions:approach", + descriptionPageSlug: "cmap:decision", + cmapSlug: "child", + externalUrl: "https://example.com/decision", + tags: [{ type: "person", value: "Alex Morgan" }, { type: "person", value: "Sam de Vries" }] + }, + { id: "concept-b", label: "Delivery" } + ], + items: [ + { id: 1, conceptId: "concept-a", kind: "concept" }, + { id: 2, kind: "phrase", label: "enables" }, + { id: 3, conceptId: "concept-b", kind: "concept" } + ], + connectors: [ + { id: 1, sourceId: 1, targetId: 2, hasArrow: false }, + { id: 2, sourceId: 2, targetId: 3, hasArrow: true } + ] +}); + +const child = map("child", "Child map", { + schemaVersion: 2, + metadata: { tags: ["detail"], summary: "Child summary." }, + concepts: [{ id: "concept-c", label: "Child concept", cmapSlug: "root" }], + items: [{ id: 1, conceptId: "concept-c", kind: "concept" }], + connectors: [] +}); + +const pages = new Map([ + ["cmap:root-explanation", { slug: "cmap:root-explanation", title: "Root explanation", tags: ["explanation"], markdown: "# Why\n\nBecause." }], + ["decisions:approach", { slug: "decisions:approach", title: "Approach", tags: ["decision"], markdown: "# Decision\n\nUse the simple option." }], + ["cmap:decision", { slug: "cmap:decision", title: "Decision explanation", tags: [], markdown: "Details." }] +]); + +test("exports metadata, person tags, relations and linked maps to the selected depth", async () => { + const markdown = await generateMarkdown({ + rootMap: root, + maxDepth: 1, + includeWikiPages: false, + language: "nl", + loadConceptMap: async (slug) => ({ root, child }[slug]), + loadWikiPage: async (slug) => pages.get(slug) + }); + + assert.match(markdown, /CMap: Root map \(niveau 0\)/); + assert.match(markdown, /CMap: Child map \(niveau 1\)/); + assert.match(markdown, /`architecture`, `report`/); + assert.match(markdown, /Personen \(verantwoordelijkheid\/actie\):\*\* Alex Morgan, Sam de Vries/); + assert.match(markdown, /Decision — \*\*enables\*\* → Delivery/); + assert.match(markdown, /### CMap-uitleg/); + assert.match(markdown, /Webpagina:\*\* https:\/\/example\.com\/decision/); + assert.doesNotMatch(markdown, /## Gekoppelde wikipagina's/); + assert.equal((markdown.match(/## CMap: Root map/g) || []).length, 1, "cycles are exported once"); +}); + +test("depth zero exports only the selected map", async () => { + const markdown = await generateMarkdown({ + rootMap: root, + maxDepth: 0, + includeWikiPages: false, + loadConceptMap: async (slug) => ({ root, child }[slug]), + loadWikiPage: async (slug) => pages.get(slug) + }); + assert.match(markdown, /Root map/); + assert.doesNotMatch(markdown, /## CMap: Child map/); +}); + +test("optionally appends linked wiki pages and concept explanations", async () => { + const markdown = await generateMarkdown({ + rootMap: root, + maxDepth: 0, + includeWikiPages: true, + language: "en", + loadConceptMap: async (slug) => ({ root, child }[slug]), + loadWikiPage: async (slug) => pages.get(slug) + }); + assert.match(markdown, /## Linked wiki pages/); + assert.match(markdown, /### Approach/); + assert.match(markdown, /### Decision explanation/); + assert.equal((markdown.match(/Root explanation/g) || []).length, 0, + "the explanation page is embedded under the CMap rather than duplicated as an appendix heading"); + assert.match(markdown, /Because\./); +}); + +test("derived views contain their root subtree and inherit useful root metadata", () => { + const source = { + schemaVersion: 2, + concepts: [ + { id: "root-concept", label: "Team", synopsis: "Team scope", aspects: ["people"], descriptionPageSlug: "cmap:team" }, + { id: "child-concept", label: "Work" }, + { id: "outside-concept", label: "Outside" } + ], + items: [ + { id: 10, conceptId: "root-concept", kind: "submap" }, + { id: 11, conceptId: "child-concept", kind: "concept", parentSubmapId: 10 }, + { id: 12, conceptId: "outside-concept", kind: "concept" } + ], + connectors: [ + { id: 1, sourceId: 10, targetId: 11 }, + { id: 2, sourceId: 11, targetId: 12 } + ] + }; + const result = derivedDocument(source, { + derivedView: { sourceCmapSlug: "source", rootItemId: 10 } + }); + assert.deepEqual(result.items.map((item) => item.id), [10, 11]); + assert.deepEqual(result.connectors.map((connector) => connector.id), [1]); + assert.deepEqual(result.metadata.tags, ["people"]); + assert.equal(result.metadata.summary, "Team scope"); + assert.equal(result.metadata.explanationPageSlug, "cmap:team"); +}); + +test("direct and phrase relations receive readable Markdown", () => { + const documentValue = { + items: [ + { id: 1, label: "A" }, { id: 2, kind: "phrase", label: "supports" }, + { id: 3, label: "B" }, { id: 4, label: "C" } + ], + connectors: [ + { sourceId: 1, targetId: 2, hasArrow: false }, + { sourceId: 2, targetId: 3, hasArrow: true }, + { sourceId: 3, targetId: 4, hasArrow: true } + ] + }; + assert.deepEqual(relationLines(documentValue), ["A — **supports** → B", "B → C"]); +}); diff --git a/test/cmap-interchange.test.js b/test/cmap-interchange.test.js new file mode 100644 index 0000000..98c597c --- /dev/null +++ b/test/cmap-interchange.test.js @@ -0,0 +1,200 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const { + buildBundle, validateBundle, preparedMapDocument, attachmentUrls, replaceAttachmentUrls +} = require("../static/js/cmap-interchange.js"); + +const conceptId = "24d27086-9f8b-4b57-a7fb-47b513277555"; +const childId = "53ef1cf3-c936-48f7-889a-ad644fab9321"; +const styledItem = { + id: 10, conceptId, kind: "concept", x: 127.5, y: 88, width: 312, + height: 97, backgroundColor: "#ffe5a8", borderColor: "#a97c00", + textColor: "#172033", fontFamily: "Georgia", fontSize: 13.5, + fontWeight: "700", synopsisFontSize: 9.5 +}; +const root = { + slug: "architecture", + title: "Architecture", + document: { + schemaVersion: 2, + metadata: { explanationPageSlug: "cmap:architecture" }, + concepts: [{ + id: conceptId, label: "Process", synopsis: "Shared content", + aspects: ["TODO"], pageSlug: "process:details", cmapSlug: "child", + externalUrl: "https://example.com/process" + }], + items: [styledItem, { id: 11, kind: "phrase", label: "contains", x: 500, y: 90 }], + connectors: [{ id: 1, sourceId: 10, targetId: 11, hasArrow: true }] + } +}; +const child = { + slug: "child", title: "Child", document: { + schemaVersion: 2, + concepts: [{ id: childId, label: "Child concept" }], + items: [{ id: 1, conceptId: childId, kind: "concept", x: 20, y: 30 }], + connectors: [] + } +}; +const pages = new Map([ + ["cmap:architecture", { slug: "cmap:architecture", title: "Explanation", markdown: "# Explanation", tags: ["cmap"] }], + ["process:details", { + slug: "process:details", title: "Details", + markdown: "Full page\n\n![Diagram](/uploads/process:details/1700000-diagram.png)", tags: [] + }] +]); +const exportedAttachment = { + mimeType: "image/png", + contentBase64: "aW1hZ2U=" +}; +const loadAttachment = async (url) => { + assert.equal(url, "/uploads/process:details/1700000-diagram.png"); + return exportedAttachment; +}; + +test("bundle separates shared content while preserving complete diagram layout", async () => { + const bundle = await buildBundle({ + rootMap: root, + maxDepth: 1, + exportedAt: "2026-08-25T12:00:00.000Z", + generator: "test", + loadConceptMap: async (slug) => ({ child }[slug]), + loadWikiPage: async (reference) => pages.get(reference), + loadAttachment + }); + + assert.equal(bundle.format, "racket-wiki-cmap-bundle"); + assert.deepEqual(bundle.cmaps.map((cmap) => cmap.slug), ["architecture", "child"]); + assert.deepEqual(bundle.pages.map((page) => page.reference), ["cmap:architecture", "process:details"]); + assert.equal(bundle.concepts.find((concept) => concept.id === conceptId).synopsis, "Shared content"); + assert.equal(bundle.concepts.find((concept) => concept.id === conceptId).externalUrl, + "https://example.com/process"); + const exportedItem = bundle.cmaps[0].document.items[0]; + assert.equal(exportedItem.x, 127.5); + assert.equal(exportedItem.fontSize, 13.5); + assert.equal(exportedItem.backgroundColor, "#ffe5a8"); + assert.equal(exportedItem.synopsis, undefined, "shared content is not duplicated on a placement"); + assert.deepEqual(bundle.cmaps[0].document.connectors, root.document.connectors); + assert.deepEqual(bundle.pages.find((page) => page.reference === "process:details").attachments, [{ + url: "/uploads/process:details/1700000-diagram.png", + name: "1700000-diagram.png", + mimeType: "image/png", + contentBase64: "aW1hZ2U=" + }]); +}); + +test("prepared import document rejoins concepts and placements without changing layout", async () => { + const bundle = await buildBundle({ + rootMap: root, maxDepth: 0, + loadConceptMap: async () => { throw new Error("not loaded"); }, + loadWikiPage: async (reference) => pages.get(reference), + loadAttachment + }); + const documentValue = preparedMapDocument(bundle, bundle.cmaps[0]); + assert.equal(documentValue.concepts[0].synopsis, "Shared content"); + assert.equal(documentValue.concepts[0].externalUrl, "https://example.com/process"); + assert.equal(documentValue.items[0].width, 312); + assert.equal(documentValue.items[0].fontFamily, "Georgia"); + assert.deepEqual(documentValue.connectors, root.document.connectors); +}); + +test("a derived CMap always carries its source map as a layout dependency", async () => { + const derived = { + slug: "derived", title: "Derived", document: { + schemaVersion: 2, + derivedView: { sourceCmapSlug: "architecture", rootItemId: 10 }, + concepts: [], items: [], connectors: [] + } + }; + const bundle = await buildBundle({ + rootMap: derived, maxDepth: 0, + loadConceptMap: async (slug) => { + assert.equal(slug, "architecture"); + return root; + }, + loadWikiPage: async (reference) => pages.get(reference), + loadAttachment + }); + assert.deepEqual(bundle.cmaps.map((cmap) => cmap.slug), ["derived", "architecture"]); + assert.equal(bundle.rootCmapSlug, "derived"); + assert.equal(bundle.cmaps[1].document.items[0].x, 127.5); +}); + +test("temporary ids are accepted only when their concepts are placed", () => { + const bundle = { + format: "racket-wiki-cmap-bundle", formatVersion: 1, + rootCmapSlug: "generated", pages: [], missing: { cmaps: [], pages: [] }, + concepts: [{ id: "new:generated-concept", label: "Generated concept" }], + cmaps: [{ slug: "generated", title: "Generated", document: { + concepts: [{ id: "new:generated-concept" }], + items: [{ id: 1, conceptId: "new:generated-concept", kind: "concept", x: 10, y: 20 }], + connectors: [] + }}] + }; + assert.equal(validateBundle(bundle), bundle); + const orphan = structuredClone(bundle); + orphan.cmaps[0].document.items = []; + assert.throws(() => validateBundle(orphan), /must occur as a diagram placement/); +}); + +test("invalid connector endpoints and duplicate slugs are rejected", () => { + const bundle = { + format: "racket-wiki-cmap-bundle", formatVersion: 1, rootCmapSlug: "map", + concepts: [{ id: conceptId, label: "Concept" }], pages: [], + cmaps: [ + { slug: "map", title: "Map", document: { concepts: [{ id: conceptId }], items: [{ id: 1, conceptId, x: 0, y: 0 }], connectors: [{ sourceId: 1, targetId: 99 }] } }, + { slug: "map", title: "Duplicate", document: { concepts: [], items: [], connectors: [] } } + ] + }; + assert.throws(() => validateBundle(bundle), /unknown item[\s\S]*duplicated/); +}); + +test("external concept links only accept complete http or https URLs", () => { + const bundle = { + format: "racket-wiki-cmap-bundle", formatVersion: 1, rootCmapSlug: "map", + concepts: [{ id: conceptId, label: "Concept", externalUrl: "javascript:alert(1)" }], + pages: [], + cmaps: [{ slug: "map", title: "Map", document: { + concepts: [{ id: conceptId }], + items: [{ id: 1, conceptId, x: 0, y: 0 }], connectors: [] + }}] + }; + assert.throws(() => validateBundle(bundle), /externalUrl: must be a complete http or https URL/); + bundle.concepts[0].externalUrl = "https://example.com/path"; + assert.equal(validateBundle(bundle), bundle); +}); + +test("attachment URLs are detected once and can be rewritten after upload", () => { + const oldUrl = "/uploads/process:details/1700000-diagram.png"; + const markdown = `![Diagram](${oldUrl})\n`; + assert.deepEqual(attachmentUrls(markdown), [oldUrl]); + assert.equal( + replaceAttachmentUrls(markdown, new Map([[oldUrl, "/uploads/process:details/new-diagram.png"]])), + "![Diagram](/uploads/process:details/new-diagram.png)\n" + + "" + ); + assert.deepEqual( + attachmentUrls("![Diagram](/uploads/page/1700000-system overview.png)"), + ["/uploads/page/1700000-system overview.png"] + ); +}); + +test("invalid embedded attachment content is rejected", () => { + const bundle = { + format: "racket-wiki-cmap-bundle", formatVersion: 1, rootCmapSlug: "map", + concepts: [{ id: conceptId, label: "Concept" }], + cmaps: [{ slug: "map", title: "Map", document: { + concepts: [{ id: conceptId }], + items: [{ id: 1, conceptId, x: 0, y: 0 }], connectors: [] + }}], + pages: [{ + reference: "page", title: "Page", markdown: "", tags: [], + attachments: [{ + url: "/uploads/page/file.png", name: "file.png", + mimeType: "image/png", contentBase64: "not base64!" + }] + }] + }; + assert.throws(() => validateBundle(bundle), /contentBase64: must be valid base64/); +}); diff --git a/test/cmap-layering.test.js b/test/cmap-layering.test.js new file mode 100644 index 0000000..c8aae99 --- /dev/null +++ b/test/cmap-layering.test.js @@ -0,0 +1,120 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); + +class FakeEventTarget { + constructor() { + this.listeners = new Map(); + } + + addEventListener(type, listener) { + const listeners = this.listeners.get(type) || []; + listeners.push(listener); + this.listeners.set(type, listeners); + } + + removeEventListener(type, listener) { + this.listeners.set(type, (this.listeners.get(type) || []) + .filter((candidate) => candidate !== listener)); + } + + dispatchEvent(event) { + for (const listener of this.listeners.get(event.type) || []) listener(event); + } +} + +class FakeElement extends FakeEventTarget { + constructor(tagName) { + super(); + this.tagName = tagName.toUpperCase(); + this.style = {}; + this.attributes = {}; + this.childNodes = []; + this.parentNode = null; + this.scrollLeft = 0; + this.scrollTop = 0; + this.scrollWidth = 1000; + this.scrollHeight = 800; + this.clientWidth = 1000; + this.clientHeight = 800; + this.textContent = ""; + } + + appendChild(child) { + if (child.parentNode) child.parentNode.removeChild(child); + child.parentNode = this; + this.childNodes.push(child); + return child; + } + + removeChild(child) { + const index = this.childNodes.indexOf(child); + if (index >= 0) this.childNodes.splice(index, 1); + child.parentNode = null; + return child; + } + + setAttribute(name, value) { + this.attributes[name] = String(value); + } + + getBoundingClientRect() { + return { left: 0, top: 0, right: this.clientWidth, bottom: this.clientHeight }; + } + + set innerHTML(markup) { + this.childNodes = []; + if (!String(markup).includes("")) return; + const svg = new FakeElement("svg"); + svg.appendChild(new FakeElement("path")); + svg.appendChild(new FakeElement("path")); + this.appendChild(svg); + this.appendChild(new FakeElement("div")); + } +} + +function settleRendering() { + return new Promise((resolve) => setImmediate(() => setImmediate(resolve))); +} + +test("relations render and hit-test behind concept nodes", async () => { + const root = new FakeElement("div"); + const fakeDocument = new FakeEventTarget(); + fakeDocument.body = root; + fakeDocument.createElement = (tagName) => new FakeElement(tagName); + global.document = fakeDocument; + global.window = { + requestAnimationFrame: (callback) => setImmediate(callback) + }; + + const Cmap = require("../static/cmap/cmap.js"); + const map = Cmap(root); + const concept = map.node({ x: 40, y: 0, width: 120, height: 50 }); + const otherConcept = map.node({ x: 220, y: 0, width: 120, height: 50 }); + const relation = map.link({ + sourceX: 0, sourceY: 25, + cx: 150, cy: 25, + targetX: 360, targetY: 25 + }); + await settleRendering(); + + relation.toFront(); + await settleRendering(); + assert.ok(Number(relation.element().style.zIndex) < Number(concept.element().style.zIndex)); + assert.ok(Number(relation.element().style.zIndex) < Number(otherConcept.element().style.zIndex)); + + let selected = null; + map.onSelection((component) => { selected = component; }); + const surface = root.childNodes[0]; + surface.dispatchEvent({ + type: "mousedown", + pageX: 100, + pageY: 25, + clientX: 100, + clientY: 25, + preventDefault() {} + }); + fakeDocument.dispatchEvent({ type: "mouseup", pageX: 100, pageY: 25 }); + assert.equal(selected, concept, "the concept wins when a relation crosses behind it"); +}); diff --git a/test/cmap-selection-layout.test.js b/test/cmap-selection-layout.test.js new file mode 100644 index 0000000..a224b43 --- /dev/null +++ b/test/cmap-selection-layout.test.js @@ -0,0 +1,148 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); + +function fakeNode(attributes) { + const state = { ...attributes }; + return { + attr(value) { + if (typeof value === "string") return state[value]; + Object.assign(state, value); + return this; + }, + onRendered() {}, onMove() {}, onMoveEnd() {}, visible() {}, redraw() {}, + toFront() {}, remove() {}, element: () => null + }; +} + +function loadEditorFactory() { + global.Element = class Element {}; + global.document = { + currentScript: null, + styleSheets: [], + body: { append() {} }, + createElement: () => ({ style: {}, remove() {} }) + }; + global.window = { + Cmap: null, + setTimeout, + clearTimeout, + requestAnimationFrame: (callback) => callback() + }; + require("../static/cmap/cmap-racket-wiki.js"); + return global.window.RacketWikiCmap; +} + +test("the last selected concept determines target size and alignment", async () => { + const factory = loadEditorFactory(); + const map = { + onSelection() {}, + onActivation() {}, + node: (attributes) => fakeNode(attributes) + }; + const canvas = { + addEventListener() {}, querySelector: () => null, + clientWidth: 1200, clientHeight: 800, scrollLeft: 0, scrollTop: 0, + isConnected: true + }; + const editor = factory.createEditor(canvas, { Cmap: () => map }); + const first = editor.addItem({ + id: 1, label: "First", externalUrl: "https://example.com/first", + x: 10, y: 10, width: 100, height: 40 + }); + const second = editor.addItem({ id: 2, label: "Second", x: 80, y: 100, width: 120, height: 45 }); + const third = editor.addItem({ id: 3, label: "Third", x: 250, y: 250, width: 200, height: 60 }); + const savedDocument = editor.toDocument(); + assert.equal( + savedDocument.concepts.find((concept) => concept.id === first.conceptId).externalUrl, + "https://example.com/first", + "external links are serialized as shared concept content" + ); + const reloaded = factory.createEditor(canvas, { Cmap: () => map }); + reloaded.loadDocument(savedDocument); + assert.equal( + reloaded.toDocument().concepts.find((concept) => concept.id === first.conceptId).externalUrl, + "https://example.com/first", + "external links survive an editor save and reload round-trip" + ); + editor.selectItem(first); + editor.selectItem(third, { additive: true }); + editor.selectItem(second, { additive: true }); + editor.resetHistory(); + + assert.equal(editor.selected(), second, "the last selected concept is the target object"); + assert.equal(editor.canLayoutSelection("distribute-horizontal"), true); + assert.equal(editor.canLayoutSelection("distribute-vertical"), true); + editor.applySelectionLayout("align-right"); + assert.deepEqual([first, second, third].map((item) => Number(item.node.attr("x"))), + [100, 80, 0]); + + first.node.attr({ x: 10, y: 10 }); + second.node.attr({ x: 80, y: 100 }); + third.node.attr({ x: 250, y: 250 }); + editor.applySelectionLayout("align-center"); + assert.deepEqual([first, second, third].map((item) => Number(item.node.attr("x"))), + [90, 80, 40]); + + first.node.attr({ x: 10, y: 10 }); + second.node.attr({ x: 80, y: 100 }); + third.node.attr({ x: 250, y: 250 }); + editor.applySelectionLayout("align-left"); + assert.deepEqual([first, second, third].map((item) => Number(item.node.attr("x"))), + [80, 80, 80]); + + first.node.attr({ x: 10, y: 10 }); + second.node.attr({ x: 80, y: 100 }); + third.node.attr({ x: 250, y: 250 }); + editor.applySelectionLayout("align-bottom"); + assert.deepEqual([first, second, third].map((item) => Number(item.node.attr("y"))), + [105, 100, 85]); + + first.node.attr({ x: 10, y: 10 }); + second.node.attr({ x: 80, y: 100 }); + third.node.attr({ x: 250, y: 250 }); + editor.applySelectionLayout("align-middle"); + assert.deepEqual([first, second, third].map((item) => Number(item.node.attr("y"))), + [102.5, 100, 92.5]); + + first.node.attr({ x: 10, y: 10 }); + second.node.attr({ x: 80, y: 100 }); + third.node.attr({ x: 250, y: 250 }); + editor.applySelectionLayout("align-top"); + assert.deepEqual([first, second, third].map((item) => Number(item.node.attr("y"))), + [100, 100, 100]); + + first.node.attr({ x: 10, y: 10 }); + second.node.attr({ x: 80, y: 100 }); + third.node.attr({ x: 250, y: 250 }); + editor.applySelectionLayout("distribute-horizontal"); + assert.deepEqual([first, second, third].map((item) => Number(item.node.attr("x"))), + [10, 120, 250], "outer items stay fixed and horizontal gaps become equal"); + + first.node.attr({ x: 10, y: 10 }); + second.node.attr({ x: 80, y: 100 }); + third.node.attr({ x: 250, y: 250 }); + editor.applySelectionLayout("distribute-vertical"); + assert.deepEqual([first, second, third].map((item) => Number(item.node.attr("y"))), + [10, 127.5, 250], "outer items stay fixed and the middle item receives an equal gap"); + + editor.applySelectionLayout("same-width"); + assert.deepEqual([first, second, third].map((item) => Number(item.node.attr("width"))), + [120, 120, 120]); + editor.applySelectionLayout("same-height"); + assert.deepEqual([first, second, third].map((item) => Number(item.node.attr("height"))), + [45, 45, 45]); + + first.node.attr({ width: 90, height: 35 }); + third.node.attr({ width: 180, height: 70 }); + editor.applySelectionLayout("same-size"); + assert.deepEqual([first, second, third].map((item) => + [Number(item.node.attr("width")), Number(item.node.attr("height"))]), + [[120, 45], [120, 45], [120, 45]]); + + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(editor.canUndo(), true, "layout commands participate in undo and autosave history"); + assert.deepEqual(editor.toDocument().items.map((item) => [item.width, item.height]), + [[120, 45], [120, 45], [120, 45]]); +}); diff --git a/test/cmap-submap-navigation.test.js b/test/cmap-submap-navigation.test.js new file mode 100644 index 0000000..8b3c394 --- /dev/null +++ b/test/cmap-submap-navigation.test.js @@ -0,0 +1,74 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); + +function fakeNode(attributes) { + const state = { ...attributes }; + return { + attr(value) { + if (typeof value === "string") return state[value]; + Object.assign(state, value); + return this; + }, + onRendered() {}, onMove() {}, onMoveEnd() {}, visible() {}, redraw() {}, + toFront() {}, remove() {}, element: () => null + }; +} + +function loadEditorFactory() { + global.Element = class Element {}; + global.document = { + currentScript: null, + styleSheets: [], + body: { append() {} }, + createElement: () => ({ style: {}, remove() {} }) + }; + global.window = { + Cmap: null, + setTimeout, + clearTimeout, + requestAnimationFrame: (callback) => callback() + }; + require("../static/cmap/cmap-racket-wiki.js"); + return global.window.RacketWikiCmap; +} + +test("back from a second nested submap returns exactly one map level", () => { + const factory = loadEditorFactory(); + const map = { + onSelection() {}, + onActivation() {}, + node: (attributes) => fakeNode(attributes) + }; + const canvas = { + addEventListener() {}, querySelector: () => null, + clientWidth: 1200, clientHeight: 800, scrollLeft: 0, scrollTop: 0, + isConnected: true + }; + const editor = factory.createEditor(canvas, { Cmap: () => map }); + const first = editor.addItem({ + id: 1, kind: "submap", label: "First level", separateMap: true, + mapReference: { id: "first", title: "First level" }, + x: 100, y: 100, width: 220, height: 70 + }); + first.submapInitialized = true; + const second = editor.addItem({ + id: 2, kind: "submap", label: "Second level", separateMap: true, + mapReference: { id: "second", title: "Second level" }, + parentSubmap: first, submapDepth: 1, + x: 400, y: 200, width: 220, height: 70 + }); + second.submapInitialized = true; + + editor.openSubmapMap(first); + editor.openSubmapMap(second); + assert.equal(editor.activeMapRoot, second); + assert.equal(editor.canStepBackWithinMap(), true, + "the host must use local map history before navigating to a source CMap route"); + + editor.openParentMap(); + + assert.equal(editor.activeMapRoot, first); + assert.equal(editor.canStepBackWithinMap(), false); +}); diff --git a/test/mermaid-racket-wiki.test.js b/test/mermaid-racket-wiki.test.js new file mode 100644 index 0000000..ffd0ec2 --- /dev/null +++ b/test/mermaid-racket-wiki.test.js @@ -0,0 +1,68 @@ +"use strict"; + +const test = require("node:test"); +const assert = require("node:assert/strict"); +const integration = require("../static/js/mermaid-racket-wiki.js"); + +function fixture(sourceText) { + let replacement = null; + const document = { + createElement() { + return { + attributes: {}, + className: "", + innerHTML: "", + setAttribute(name, value) { this.attributes[name] = value; } + }; + } + }; + const source = { + classList: { values: [], add(value) { this.values.push(value); } }, + dataset: {}, + ownerDocument: document, + replaceWith(value) { replacement = value; }, + title: "" + }; + const code = { parentElement: source, textContent: sourceText }; + const container = { querySelectorAll: () => [code] }; + return { container, replacement: () => replacement, source }; +} + +test("initializes Mermaid with automatic rendering disabled and strict security", () => { + let config = null; + integration.initialize({ initialize(value) { config = value; } }); + assert.deepEqual(config, { + startOnLoad: false, + securityLevel: "strict", + suppressErrorRendering: true + }); +}); + +test("replaces a Mermaid code block with the rendered SVG", async () => { + const value = fixture("flowchart LR\n A --> B"); + const mermaid = { + initialize() {}, + async render(id, source) { + assert.match(id, /^racket-wiki-mermaid-/); + assert.equal(source, "flowchart LR\n A --> B"); + return { svg: "diagram" }; + } + }; + await integration.hydrate(value.container, mermaid); + assert.equal(value.replacement().className, "rw-mermaid"); + assert.equal(value.replacement().innerHTML, "diagram"); + assert.equal(value.replacement().attributes.role, "img"); +}); + +test("keeps invalid Mermaid source visible and marks it as an error", async () => { + const value = fixture("not a diagram"); + const mermaid = { + initialize() {}, + async render() { throw new Error("Parse error"); } + }; + await integration.hydrate(value.container, mermaid); + assert.equal(value.replacement(), null); + assert.equal(value.source.dataset.mermaidState, "error"); + assert.deepEqual(value.source.classList.values, ["rw-mermaid-error"]); + assert.equal(value.source.title, "Parse error"); +}); diff --git a/translate.rkt b/translate.rkt index bebb566..34007d9 100644 --- a/translate.rkt +++ b/translate.rkt @@ -24,20 +24,33 @@ (define english (hash - 'users "Users" 'page-aliases "Page aliases" 'no-page-aliases "No page aliases." 'old-address "Old address" 'current-address "Current address" 'referenced-by "Referenced by" 'current-references "Current references" 'historical-references "Historical references" 'cleanup-alias "Clean up references" 'cleanup-alias-confirm "Replace all current references to this old address with the current address? Historical versions will not be changed." 'cleanup-complete "Cleanup complete" 'delete-alias "Delete alias" 'delete-alias-confirm "Delete this alias?" 'delete-alias-history-confirm "Delete this alias? Historical page versions still contain this old address and those links may stop resolving." 'software-version "Software version" 'translations "Translations" 'orphaned-uploads "Orphaned uploads" 'no-orphaned-uploads "No orphaned uploads." 'uploaded-by "uploaded by" 'last-used "Last used" 'never-referenced "Never referenced by a saved page." 'delete-orphaned-upload-confirm "Delete this orphaned upload?" 'recent "Recent" 'recent-changes "Recent changes" 'bookmarks "Bookmarks" 'bookmark "Bookmark" 'bookmarked "Bookmarked" 'bookmark-section "Bookmark section" 'move "Move" 'remove "Remove" 'no-bookmarks "No bookmarks yet." 'no-recent-pages "No recent pages." 'no-recent-changes "No recent changes." 'changed-by "changed by" 'todo "Todo" 'todo-list "Todo list" 'graph "Graph" 'cmaps "CMaps" 'concept-maps "Concept maps" 'concept-map-storage-note "Concept maps are stored in the wiki database." 'new-concept-map "New CMap" 'concept-map-name "Concept map name" 'concept-map-saved "CMap saved" 'no-concept-maps "No saved CMaps" 'concept-map-prototype "Concept map prototype" 'concept-map-prototype-note "In-memory prototype; changes are not saved yet." 'concept-map-help "Select a concept to show its relation and resize handles. Drag the relation handle to another concept; edit the new relation name immediately. Double-click a page concept to open the wiki page." 'add-concept "Add concept" 'add-page-node "Add current page" 'add-submap "Add sub-CMap" 'connect-selected "Connect selected" 'edit-selected-concept "Edit selected" 'reset "Reset" 'relation "Relation" 'create-relation "Create relation" 'resize-concept "Resize concept" 'select-one-concept "Select one concept first." 'label "Label" 'synopsis "Synopsis" 'background-color "Background color" 'font-family "Font family" 'font-size "Font size" 'sub-concept-map "Sub concept map" 'wiki-graph "Wiki graph" 'graph-summary "{pages} pages, {links} links" 'context "context" 'context-graph "Context graph" 'context-graph-summary "{pages} related pages, {links} links" 'context-graph-direction-summary "{incoming} incoming, {outgoing} outgoing" 'popup "Popup" 'close "Close" 'dock-right "Dock right" 'dock-above "Dock above" 'open-full-graph "Open full graph" 'admin "Admin" 'role-reader "reader" 'role-editor "editor" 'role-admin "admin" + 'users "Users" 'page-aliases "Page aliases" 'no-page-aliases "No page aliases." 'old-address "Old address" 'current-address "Current address" 'referenced-by "Referenced by" 'current-references "Current references" 'historical-references "Historical references" 'cleanup-alias "Clean up references" 'cleanup-alias-confirm "Replace all current references to this old address with the current address? Historical versions will not be changed." 'cleanup-complete "Cleanup complete" 'delete-alias "Delete alias" 'delete-alias-confirm "Delete this alias?" 'delete-alias-history-confirm "Delete this alias? Historical page versions still contain this old address and those links may stop resolving." 'software-version "Software version" 'translations "Translations" 'orphaned-uploads "Orphaned uploads" 'no-orphaned-uploads "No orphaned uploads." 'archived-concept-maps "Archived CMaps" 'no-archived-concept-maps "No archived CMaps." 'restore-concept-map "Restore CMap" 'restore-concept-map-confirm "Restore concept map \"{title}\"?" 'concept-map-restored "CMap restored." 'archived "Archived" 'uploaded-by "uploaded by" 'last-used "Last used" 'never-referenced "Never referenced by a saved page." 'delete-orphaned-upload-confirm "Delete this orphaned upload?" 'recent "Recent" 'recent-changes "Recent changes" 'bookmarks "Bookmarks" 'bookmark "Bookmark" 'bookmarked "Bookmarked" 'bookmark-section "Bookmark section" 'move "Move" 'remove "Remove" 'no-bookmarks "No bookmarks yet." 'no-recent-pages "No recent pages." 'no-recent-changes "No recent changes." 'changed-by "changed by" 'todo "Todo" 'todo-list "Todo list" 'graph "Graph" 'cmaps "CMaps" 'concept-maps "Concept maps" 'concept-map-storage-note "Concept maps are stored in the wiki database." 'new-concept-map "New CMap" 'concept-map-name "Concept map name" 'concept-map-saved "CMap saved" 'no-concept-maps "No saved CMaps" 'concept-map-prototype "Concept map prototype" 'concept-map-prototype-note "In-memory prototype; changes are not saved yet." 'concept-map-help "Select a concept to show its relation and resize handles. Drag the relation handle to another concept; edit the new relation name immediately. Double-click a page concept to open the wiki page." 'add-concept "Add concept" 'add-page-node "Add current page" 'add-submap "Add sub-CMap" 'connect-selected "Connect selected" 'edit-selected-concept "Edit selected" 'reset "Reset" 'relation "Relation" 'create-relation "Create relation" 'resize-concept "Resize concept" 'select-one-concept "Select one concept first." 'label "Label" 'synopsis "Synopsis" 'background-color "Background color" 'font-family "Font family" 'font-size "Font size" 'sub-concept-map "Sub concept map" 'wiki-graph "Wiki graph" 'graph-summary "{pages} pages, {links} links" 'context "context" 'context-graph "Context graph" 'context-graph-summary "{pages} related pages, {links} links" 'context-graph-direction-summary "{incoming} incoming, {outgoing} outgoing" 'popup "Popup" 'close "Close" 'dock-right "Dock right" 'dock-above "Dock above" 'open-full-graph "Open full graph" 'admin "Admin" 'role-reader "reader" 'role-editor "editor" 'role-admin "admin" + 'database-schema-version "Database schema version" + 'concept-todos "Concepts" 'concept-marked-todo "Concept marked with the TODO aspect." 'select-all "Select all" 'group-selected "Group selection as sub-CMap" 'ungroup-selected "Detach from sub-CMap" 'delete-selected "Delete selection" 'group-submap-name "Name of the main concept for the new sub-CMap" 'grouped-submap-synopsis "Grouped sub-concept map." 'navigation-graph "Navigation graph" 'click "click" 'cmap-help-add-selection "Add to or remove from the selection" - 'copy-paste-linked-concepts "Copy/paste linked concepts" - 'copy-linked-concepts "Copy concept reference" 'paste-linked-concepts "Paste linked concept" + 'copy-paste-linked-concepts "Cut/copy/paste linked concepts" + 'cut-linked-concepts "Cut concept reference" 'copy-linked-concepts "Copy concept reference" 'paste-linked-concepts "Paste linked concept" 'concept-usage-count "{count} placements across all concept maps" 'connected-cmap-concepts "Connected CMap concepts" 'concept-map-locations "Concept map locations" 'copy-wiki-link "Copy wiki link" 'wiki-link-copied "Wiki link copied" 'open-linked-item "Open linked page or CMap" + 'content-and-links "Content & links" 'specific-appearance "Specific appearance" + 'external-web-page "External web page" + 'external-web-page-help "Opens in a new browser tab. Only http and https addresses are accepted." + 'open-external-web-page "Open external web page" + 'invalid-external-web-page "Enter a complete http or https web address." 'appearance "Appearance" 'style "Style" 'apply-style "Apply" + 'styles "Styles" 'custom-style "Custom" 'save-as-style "Save as style…" 'delete-style "Delete style" + 'styles-database-storage-help "Styles are shared through this wiki's database." + 'style-name-prompt "Name for this style" 'style-name-required "Enter a style name." + 'replace-style-confirm "Replace the existing style \"{name}\"?" 'delete-style-confirm "Delete style \"{name}\"?" + 'default-style-protected "The default style cannot be changed or deleted." + 'style-storage-failed "The style could not be stored in the wiki database." 'concept-heading-appearance "Heading" 'concept-subtext-appearance "Subtext" 'change-palette-color "double-click to change" 'color-swatch-help "Click for the palette; double-click for a custom color" @@ -46,17 +59,17 @@ 'cmap-help-rename "Edit the selected concept" 'cmap-help-clear-selection "Clear selection" 'concept-map-help-expanded "Select a concept to show its handles. Drag a relation to a concept or empty space. Hold Alt while dropping it on a concept to create a direct line. Double-click a concept to edit it; double-click a sub-CMap to expand or collapse it." - 'concept-map-help-context "Select with Ctrl/Cmd/Shift-click or by dragging over empty space. Drag a selected element to move the complete selection. Hold Alt while drawing a relation to create a direct line without a linking phrase. Grouping creates a new sub-CMap around the selection. Double-click a concept to edit it or a sub-CMap to open it; right-click for more tools." + 'concept-map-help-context "Select with Ctrl/Cmd/Shift-click or by dragging over empty space. Drag a selected element to move the complete selection. The toolbar left of the diagram sizes, aligns and distributes selected concepts. Hold Alt while drawing a relation to create a direct line without a linking phrase. Grouping creates a new sub-CMap around the selection. Double-click a concept to edit it or a sub-CMap to open it; right-click for more tools." 'detach-submap-confirm "Place \"{concept}\" outside submap \"{submap}\"?" 'promote-submap "Make separate CMap" 'submap-name "Name of the new concept map" 'submap-derived "Standalone view created for sub-CMap" 'hidden-concepts "Hidden concepts" 'hide-in-this-view "Hide in this view" 'show-hidden-concept "Show this concept again" 'before-submap-extraction "Before creating shared sub-CMap view" 'submap-extracted "Sub-CMap moved to a separate CMap" 'empty-submap "This sub-CMap has no concepts to move." 'submap-created-parent-unsaved "The new CMap was created, but the parent CMap still needs to be saved." - 'rename-concept-map "Rename CMap" 'delete-concept-map "Delete CMap" 'redo-action "Redo" 'save-now "Save now" + 'rename-concept-map "Rename CMap" 'archive-entire-concept-map "Archive entire CMap…" 'redo-action "Redo" 'save-now "Save now" 'set-start-concept-map "Use as start CMap" 'start-concept-map "Start CMap" 'start-concept-map-set "Start CMap set" 'autosave-pending "Changes waiting to be saved" 'autosaving "Saving automatically…" 'concept-map-autosaved "CMap saved automatically" - 'concept-map-renamed "CMap renamed" 'concept-map-deleted "CMap deleted" + 'concept-map-renamed "CMap renamed" 'concept-map-archived "CMap archived" 'concept-map-history "CMap history" 'concept-map-history-help "Snapshots are kept indefinitely. Up to five explicit saves are kept; automatic saves are not added to history." 'create-concept-map-snapshot "Make snapshot…" 'snapshot-description "Snapshot description" 'snapshot "Snapshot" 'creating-snapshot "Creating snapshot…" 'snapshot-created "Snapshot created" @@ -65,10 +78,31 @@ 'delete-concept-map-history-confirm "Delete CMap history version {version}? The current CMap will not be changed." 'concept-map-history-deleted "History item deleted" 'automatic-save "Automatic save" 'manual-save "Manual save" 'concept-map-created-version "CMap created" 'concept-map-renamed-version "CMap renamed" 'concept-map-initial-version "Initial available version" - 'delete-concept-map-confirm "Delete concept map \"{title}\"? Links to it will remain but will no longer open the map." + 'archive-concept-map-confirm "Archive the entire concept map \"{title}\"? It will disappear from normal navigation, but an administrator can restore it." + 'archive-concept-map-type-title "Type the complete CMap name to confirm: {title}" + 'archive-concept-map-title-mismatch "The CMap name did not match; nothing was archived." 'reload-concept-map "Reload CMap" + 'concept-map-details "CMap details" 'concept-map-details-help "These details are included in Markdown exports. The explanation page is always included when it exists." + 'concept-map-details-saved "CMap details saved" 'updated-concept-map-details "Updated CMap details" + 'summary "Summary" 'concept-map-explanation-page "CMap explanation page" 'concept-map-explanation-placeholder "cmap:explanation" + 'person-tags "People (special tags)" 'person-tags-placeholder "Alex Morgan, Sam de Vries" 'person-tags-help "These person tags mark who owns the responsibility or action represented by this concept." 'person-tags-title "Responsibility/action: {people}" + 'manage-people "Manage people" 'manage-people-help "Inactive people remain on existing concepts but are hidden from the selection list for new tags." 'select-people "Select people" 'people-selected "{count} people selected" 'person-name "Person name" 'add-person "Add person" 'no-active-people "No active people yet." 'active "Active" 'inactive "inactive" + 'export-markdown "Export CMap…" 'linked-concept-map-depth "Depth of linked CMaps" 'include-linked-wiki-pages "Include linked wiki pages in Markdown (JSON always includes them)" + 'cmap-export-help "Markdown is a readable report. JSON is a complete importable bundle containing diagram layout, concepts, linked wiki pages and their referenced attachments. Level 0 exports only this CMap." + 'copy-markdown "Copy Markdown" 'download-markdown "Download Markdown" 'preparing-markdown-export "Preparing Markdown export…" + 'markdown-export-downloaded "Markdown export downloaded." 'markdown-export-copied "Markdown export copied." + 'cmap-export-unavailable "CMap export is unavailable." 'save-before-export-failed "The CMap could not be saved before export." + 'download-cmap-json "Download JSON" 'import-cmap-json "Import CMap JSON…" + 'preparing-cmap-json "Preparing complete CMap JSON…" 'cmap-json-downloaded "CMap JSON downloaded." + 'cmap-json-unavailable "CMap JSON export/import is unavailable." 'cmap-json-too-large "The CMap JSON file exceeds 256 MiB." + 'save-before-import-failed "The current CMap could not be saved before import." + 'cmap-import-conflicts "The import contains {maps} existing CMaps and {pages} existing pages. Choose OK to replace them with the imported content, or Cancel to keep them and import only new records." + 'imported-from-cmap-json "Imported from CMap JSON" 'importing-cmap-json "Importing CMap JSON…" + 'cmap-json-imported "Import complete: {maps} CMaps, {pages} pages and {attachments} attachments imported; {skipped} existing records kept." 'edit-concept "Edit concept" 'aspects "Aspects" 'aspects-placeholder "Security, performance" 'concept-description-page "Concept description page" 'concept-description-page-placeholder "cmap:concept-name" 'view-concept-description "View concept description" 'view-description-page "View description page" 'invalid-description-page "Enter a valid description page address." 'linked-page "Linked wiki page" 'no-linked-page "No linked page" 'filter-pages "Select or enter a new page" 'linked-concept-map "Linked concept map" 'no-linked-concept-map "No linked concept map" 'filter-concept-maps "Filter concept maps" 'parent-concept-map "Parent concept map" 'select-concept-map "Select a CMap" 'concept-map-loaded "CMap loaded" 'select-listed-page "Select a wiki page from the list or clear the field." 'invalid-new-page "Enter a page title or valid wiki address." 'select-listed-concept-map "Select a CMap from the list or clear the field." 'unsaved-concept-map "Unsaved concept map" 'save-concept-map-before-leaving "Save the changes to this concept map before leaving?" 'dont-save "Don't save" 'concept-image "Concept image" 'remove-image "Remove image" 'text-color "Text color" 'main-concept-background-color "Main concept background color" 'submap-appearance "Sub-CMap appearance" 'submap-background-color "Sub-CMap background color" 'submap-border-color "Sub-CMap border color" 'link-concept-map "Link to CMap" 'insert-concept-map-link "Insert CMap link" 'concept-map-link-help "Insert the selected CMap as a link or as a read-only view." 'concept-map-not-found "CMap not found" 'insert "Insert" 'insert-link "Insert link" 'embed-concept-map "Embed CMap" 'embedded-concept-map-help "Double-click to open this CMap." 'a4-page-boundaries "A4 page boundaries" + 'selection-layout-tools "Selection and layout tools" 'selection-tools "Selection tools" 'size-tools "Size tools" 'alignment-tools "Alignment tools" 'distribution-tools "Distribution tools" + 'make-same-width "Make same width" 'make-same-height "Make same height" 'make-same-size "Make same width and height" 'align-left "Align left" 'align-horizontal-centers "Align horizontal centers" 'align-right "Align right" 'align-top "Align top" 'align-vertical-centers "Align vertical centers" 'align-bottom "Align bottom" 'distribute-horizontally "Distribute horizontally" 'distribute-vertically "Distribute vertically" 'search-wiki "Search pages and concept maps" 'contents "Contents" 'pages "Pages" 'namespace "Namespace" 'namespace-placeholder "RWS" 'root-namespace "Root" 'wiki-page "Wiki page" 'concept-map "Concept map" 'no-matching-search-results "No matching pages or concept maps." 'edit "Edit" 'rename "Rename" 'rename-page "Rename page" 'slug "Slug" 'rename-summary "Renamed page" 'edit-section "Edit section" 'template "Template" 'no-template "No template" 'replace-with-template "Replace the current page content with template {template}?" 'history "History" 'delete "Delete" 'page-not-found "Page not found" 'cancel "Cancel" 'save "Save" 'page-title "Page title" 'tags "Tags (comma separated)" @@ -117,20 +151,33 @@ (define dutch (hash - 'users "Gebruikers" 'page-aliases "Pagina-aliassen" 'no-page-aliases "Geen pagina-aliassen." 'old-address "Oud adres" 'current-address "Huidig adres" 'referenced-by "Gerefereerd door" 'current-references "Actuele verwijzingen" 'historical-references "Historische verwijzingen" 'cleanup-alias "Verwijzingen opschonen" 'cleanup-alias-confirm "Alle actuele verwijzingen naar dit oude adres vervangen door het huidige adres? Historische versies worden niet gewijzigd." 'cleanup-complete "Opschonen voltooid" 'delete-alias "Alias verwijderen" 'delete-alias-confirm "Deze alias verwijderen?" 'delete-alias-history-confirm "Deze alias verwijderen? Historische paginaversies bevatten dit oude adres nog en die links werken daarna mogelijk niet meer." 'software-version "Softwareversie" 'translations "Vertalingen" 'orphaned-uploads "Verweeste uploads" 'no-orphaned-uploads "Geen verweeste uploads." 'uploaded-by "geüpload door" 'last-used "Laatst gebruikt" 'never-referenced "Nooit door een opgeslagen pagina gerefereerd." 'delete-orphaned-upload-confirm "Deze verweeste upload verwijderen?" 'recent "Recent" 'recent-changes "Recent gewijzigd" 'bookmarks "Bookmarks" 'bookmark "Bookmark" 'bookmarked "Gebookmarkt" 'bookmark-section "Bookmark-hoofdstuk" 'move "Verplaatsen" 'remove "Verwijderen" 'no-bookmarks "Nog geen bookmarks." 'no-recent-pages "Nog geen recent gewijzigde pagina's." 'no-recent-changes "Nog geen recente wijzigingen." 'changed-by "gewijzigd door" 'todo "Todo" 'todo-list "Todo-lijst" 'graph "Graph" 'cmaps "CMaps" 'concept-maps "Conceptmaps" 'concept-map-storage-note "Conceptmaps worden in de wikidatabase opgeslagen." 'new-concept-map "Nieuwe CMap" 'concept-map-name "Naam van de conceptmap" 'concept-map-saved "CMap opgeslagen" 'no-concept-maps "Geen opgeslagen CMaps" 'concept-map-prototype "Conceptmap-prototype" 'concept-map-prototype-note "Prototype in geheugen; wijzigingen worden nog niet opgeslagen." 'concept-map-help "Selecteer een concept om de relatie- en formaatgrepen te tonen. Sleep de relatiegreep naar een ander concept en bewerk daarna direct de relatienaam. Dubbelklik op een paginaconcept om de wikipagina te openen." 'add-concept "Concept toevoegen" 'add-page-node "Huidige pagina toevoegen" 'add-submap "Sub-CMap toevoegen" 'connect-selected "Selectie verbinden" 'edit-selected-concept "Selectie bewerken" 'reset "Opnieuw" 'relation "Relatie" 'create-relation "Relatie maken" 'resize-concept "Concept vergroten/verkleinen" 'select-one-concept "Selecteer eerst één concept." 'label "Naam" 'synopsis "Samenvatting" 'background-color "Achtergrondkleur" 'font-family "Lettertype" 'font-size "Lettergrootte" 'sub-concept-map "Sub-conceptmap" 'wiki-graph "Wiki-graaf" 'graph-summary "{pages} pagina's, {links} links" 'context "context" 'context-graph "Contextgraaf" 'context-graph-summary "{pages} gerelateerde pagina's, {links} links" 'context-graph-direction-summary "{incoming} inkomend, {outgoing} uitgaand" 'popup "Popup" 'close "Sluiten" 'dock-right "Rechts vastzetten" 'dock-above "Boven vastzetten" 'open-full-graph "Volledig openen" 'admin "Admin" 'role-reader "lezer" 'role-editor "redacteur" 'role-admin "admin" + 'users "Gebruikers" 'page-aliases "Pagina-aliassen" 'no-page-aliases "Geen pagina-aliassen." 'old-address "Oud adres" 'current-address "Huidig adres" 'referenced-by "Gerefereerd door" 'current-references "Actuele verwijzingen" 'historical-references "Historische verwijzingen" 'cleanup-alias "Verwijzingen opschonen" 'cleanup-alias-confirm "Alle actuele verwijzingen naar dit oude adres vervangen door het huidige adres? Historische versies worden niet gewijzigd." 'cleanup-complete "Opschonen voltooid" 'delete-alias "Alias verwijderen" 'delete-alias-confirm "Deze alias verwijderen?" 'delete-alias-history-confirm "Deze alias verwijderen? Historische paginaversies bevatten dit oude adres nog en die links werken daarna mogelijk niet meer." 'software-version "Softwareversie" 'translations "Vertalingen" 'orphaned-uploads "Verweeste uploads" 'no-orphaned-uploads "Geen verweeste uploads." 'archived-concept-maps "Gearchiveerde CMaps" 'no-archived-concept-maps "Geen gearchiveerde CMaps." 'restore-concept-map "CMap herstellen" 'restore-concept-map-confirm "Conceptmap \"{title}\" herstellen?" 'concept-map-restored "CMap hersteld." 'archived "Gearchiveerd" 'uploaded-by "geüpload door" 'last-used "Laatst gebruikt" 'never-referenced "Nooit door een opgeslagen pagina gerefereerd." 'delete-orphaned-upload-confirm "Deze verweeste upload verwijderen?" 'recent "Recent" 'recent-changes "Recent gewijzigd" 'bookmarks "Bookmarks" 'bookmark "Bookmark" 'bookmarked "Gebookmarkt" 'bookmark-section "Bookmark-hoofdstuk" 'move "Verplaatsen" 'remove "Verwijderen" 'no-bookmarks "Nog geen bookmarks." 'no-recent-pages "Nog geen recent gewijzigde pagina's." 'no-recent-changes "Nog geen recente wijzigingen." 'changed-by "gewijzigd door" 'todo "Todo" 'todo-list "Todo-lijst" 'graph "Graph" 'cmaps "CMaps" 'concept-maps "Conceptmaps" 'concept-map-storage-note "Conceptmaps worden in de wikidatabase opgeslagen." 'new-concept-map "Nieuwe CMap" 'concept-map-name "Naam van de conceptmap" 'concept-map-saved "CMap opgeslagen" 'no-concept-maps "Geen opgeslagen CMaps" 'concept-map-prototype "Conceptmap-prototype" 'concept-map-prototype-note "Prototype in geheugen; wijzigingen worden nog niet opgeslagen." 'concept-map-help "Selecteer een concept om de relatie- en formaatgrepen te tonen. Sleep de relatiegreep naar een ander concept en bewerk daarna direct de relatienaam. Dubbelklik op een paginaconcept om de wikipagina te openen." 'add-concept "Concept toevoegen" 'add-page-node "Huidige pagina toevoegen" 'add-submap "Sub-CMap toevoegen" 'connect-selected "Selectie verbinden" 'edit-selected-concept "Selectie bewerken" 'reset "Opnieuw" 'relation "Relatie" 'create-relation "Relatie maken" 'resize-concept "Concept vergroten/verkleinen" 'select-one-concept "Selecteer eerst één concept." 'label "Naam" 'synopsis "Samenvatting" 'background-color "Achtergrondkleur" 'font-family "Lettertype" 'font-size "Lettergrootte" 'sub-concept-map "Sub-conceptmap" 'wiki-graph "Wiki-graaf" 'graph-summary "{pages} pagina's, {links} links" 'context "context" 'context-graph "Contextgraaf" 'context-graph-summary "{pages} gerelateerde pagina's, {links} links" 'context-graph-direction-summary "{incoming} inkomend, {outgoing} uitgaand" 'popup "Popup" 'close "Sluiten" 'dock-right "Rechts vastzetten" 'dock-above "Boven vastzetten" 'open-full-graph "Volledig openen" 'admin "Admin" 'role-reader "lezer" 'role-editor "redacteur" 'role-admin "admin" + 'database-schema-version "Databaseschemaversie" + 'concept-todos "Concepten" 'concept-marked-todo "Concept met het aspect TODO." 'select-all "Alles selecteren" 'group-selected "Selectie groeperen als sub-CMap" 'ungroup-selected "Losmaken van sub-CMap" 'delete-selected "Selectie verwijderen" 'group-submap-name "Naam van het hoofdconcept van de nieuwe sub-CMap" 'grouped-submap-synopsis "Gegroepeerde sub-conceptmap." 'navigation-graph "Navigatiegraaf" 'click "klik" 'cmap-help-add-selection "Aan de selectie toevoegen of eruit verwijderen" - 'copy-paste-linked-concepts "Gekoppelde concepten kopiëren/plakken" - 'copy-linked-concepts "Conceptverwijzing kopiëren" 'paste-linked-concepts "Gekoppeld concept plakken" + 'copy-paste-linked-concepts "Gekoppelde concepten knippen/kopiëren/plakken" + 'cut-linked-concepts "Conceptverwijzing knippen" 'copy-linked-concepts "Conceptverwijzing kopiëren" 'paste-linked-concepts "Gekoppeld concept plakken" 'concept-usage-count "{count} plaatsingen in alle conceptmaps" 'connected-cmap-concepts "Verbonden CMap-concepten" 'concept-map-locations "Locaties in conceptmaps" 'copy-wiki-link "Wikilink kopiëren" 'wiki-link-copied "Wikilink gekopieerd" 'open-linked-item "Gekoppelde pagina of CMap openen" + 'content-and-links "Inhoud & koppelingen" 'specific-appearance "Specifieke opmaak" + 'external-web-page "Externe webpagina" + 'external-web-page-help "Opent in een nieuw browsertabblad. Alleen http- en https-adressen worden geaccepteerd." + 'open-external-web-page "Externe webpagina openen" + 'invalid-external-web-page "Voer een volledig http- of https-webadres in." 'appearance "Vormgeving" 'style "Stijl" 'apply-style "Toepassen" + 'styles "Stijlen" 'custom-style "Aangepast" 'save-as-style "Opslaan als stijl…" 'delete-style "Stijl verwijderen" + 'styles-database-storage-help "Stijlen worden via de database met deze wiki gedeeld." + 'style-name-prompt "Naam voor deze stijl" 'style-name-required "Voer een stijlnaam in." + 'replace-style-confirm "Bestaande stijl \"{name}\" vervangen?" 'delete-style-confirm "Stijl \"{name}\" verwijderen?" + 'default-style-protected "De standaardstijl kan niet worden gewijzigd of verwijderd." + 'style-storage-failed "De stijl kon niet in de wikidatabase worden opgeslagen." 'concept-heading-appearance "Kop" 'concept-subtext-appearance "Subtekst" 'change-palette-color "dubbelklik om te wijzigen" 'color-swatch-help "Klik voor het palet; dubbelklik voor een eigen kleur" @@ -139,17 +186,17 @@ 'cmap-help-rename "Het geselecteerde concept bewerken" 'cmap-help-clear-selection "Selectie opheffen" 'concept-map-help-expanded "Selecteer een concept om de grepen te tonen. Sleep een relatie naar een concept of naar lege ruimte. Houd Alt ingedrukt wanneer u hem op een concept loslaat om een directe lijn te maken. Dubbelklik op een concept om het te bewerken; dubbelklik op een sub-CMap om die uit of in te klappen." - 'concept-map-help-context "Selecteer met Ctrl/Cmd/Shift-klik of door over lege ruimte te slepen. Sleep een geselecteerd element om de volledige selectie te verplaatsen. Houd Alt ingedrukt tijdens het tekenen van een relatie om een directe lijn zonder relatietekst te maken. Groeperen maakt een nieuwe sub-CMap rond de selectie. Dubbelklik op een concept om het te bewerken of op een sub-CMap om die te openen; klik met rechts voor meer functies." + 'concept-map-help-context "Selecteer met Ctrl/Cmd/Shift-klik of door over lege ruimte te slepen. Sleep een geselecteerd element om de volledige selectie te verplaatsen. De knoppenbalk links van het diagram maakt geselecteerde concepten even groot, lijnt ze uit en verdeelt ze. Houd Alt ingedrukt tijdens het tekenen van een relatie om een directe lijn zonder relatietekst te maken. Groeperen maakt een nieuwe sub-CMap rond de selectie. Dubbelklik op een concept om het te bewerken of op een sub-CMap om die te openen; klik met rechts voor meer functies." 'detach-submap-confirm "\"{concept}\" buiten submap \"{submap}\" plaatsen?" 'promote-submap "Aparte CMap maken" 'submap-name "Naam van de nieuwe conceptmap" 'submap-derived "Zelfstandige weergave voor sub-CMap aangemaakt" 'hidden-concepts "Verborgen concepten" 'hide-in-this-view "In deze weergave verbergen" 'show-hidden-concept "Dit concept weer tonen" 'before-submap-extraction "Voor het maken van de gedeelde sub-CMap-weergave" 'submap-extracted "Sub-CMap verplaatst naar een aparte CMap" 'empty-submap "Deze sub-CMap bevat geen concepten om te verplaatsen." 'submap-created-parent-unsaved "De nieuwe CMap is aangemaakt, maar de bovenliggende CMap moet nog worden opgeslagen." - 'rename-concept-map "CMap hernoemen" 'delete-concept-map "CMap verwijderen" 'redo-action "Opnieuw uitvoeren" 'save-now "Nu opslaan" + 'rename-concept-map "CMap hernoemen" 'archive-entire-concept-map "Volledige CMap archiveren…" 'redo-action "Opnieuw uitvoeren" 'save-now "Nu opslaan" 'set-start-concept-map "Als start-CMap gebruiken" 'start-concept-map "Start-CMap" 'start-concept-map-set "Start-CMap ingesteld" 'autosave-pending "Wijzigingen wachten op opslaan" 'autosaving "Automatisch opslaan…" 'concept-map-autosaved "CMap automatisch opgeslagen" - 'concept-map-renamed "CMap hernoemd" 'concept-map-deleted "CMap verwijderd" + 'concept-map-renamed "CMap hernoemd" 'concept-map-archived "CMap gearchiveerd" 'concept-map-history "CMap-geschiedenis" 'concept-map-history-help "Snapshots blijven onbeperkt bewaard. Van expliciete opslag worden maximaal vijf versies bewaard; automatische opslag komt niet in de geschiedenis." 'create-concept-map-snapshot "Snapshot maken…" 'snapshot-description "Omschrijving van snapshot" 'snapshot "Snapshot" 'creating-snapshot "Snapshot maken…" 'snapshot-created "Snapshot gemaakt" @@ -158,10 +205,31 @@ 'delete-concept-map-history-confirm "CMap-geschiedenisversie {version} verwijderen? De actuele CMap verandert niet." 'concept-map-history-deleted "Geschiedenisitem verwijderd" 'automatic-save "Automatisch opgeslagen" 'manual-save "Handmatig opgeslagen" 'concept-map-created-version "CMap aangemaakt" 'concept-map-renamed-version "CMap hernoemd" 'concept-map-initial-version "Eerste beschikbare versie" - 'delete-concept-map-confirm "Conceptmap \"{title}\" verwijderen? Links ernaartoe blijven bestaan, maar kunnen de conceptmap niet meer openen." + 'archive-concept-map-confirm "De volledige conceptmap \"{title}\" archiveren? Hij verdwijnt uit de gewone navigatie, maar een beheerder kan hem herstellen." + 'archive-concept-map-type-title "Typ ter bevestiging de volledige CMap-naam: {title}" + 'archive-concept-map-title-mismatch "De CMap-naam kwam niet overeen; er is niets gearchiveerd." 'reload-concept-map "CMap opnieuw laden" + 'concept-map-details "CMap-gegevens" 'concept-map-details-help "Deze gegevens worden opgenomen in Markdown-exports. De uitlegpagina wordt altijd meegenomen wanneer hij bestaat." + 'concept-map-details-saved "CMap-gegevens opgeslagen" 'updated-concept-map-details "CMap-gegevens bijgewerkt" + 'summary "Samenvatting" 'concept-map-explanation-page "Uitlegpagina van CMap" 'concept-map-explanation-placeholder "cmap:uitleg" + 'person-tags "Personen (speciale tags)" 'person-tags-placeholder "Alex Jansen, Sam de Vries" 'person-tags-help "Deze persoonstags geven aan bij wie de verantwoordelijkheid of actie van dit concept ligt." 'person-tags-title "Verantwoordelijkheid/actie: {people}" + 'manage-people "Personen beheren" 'manage-people-help "Inactieve personen blijven bij bestaande concepten staan, maar worden niet aangeboden voor nieuwe tags." 'select-people "Personen selecteren" 'people-selected "{count} personen geselecteerd" 'person-name "Naam van persoon" 'add-person "Persoon toevoegen" 'no-active-people "Nog geen actieve personen." 'active "Actief" 'inactive "inactief" + 'export-markdown "CMap exporteren…" 'linked-concept-map-depth "Diepte van gekoppelde CMaps" 'include-linked-wiki-pages "Gekoppelde wikipagina's meenemen in Markdown (JSON bevat ze altijd)" + 'cmap-export-help "Markdown is een leesbaar rapport. JSON is een volledige importeerbare bundle met diagramopmaak, concepten, gekoppelde wikipagina's en hun gebruikte bijlagen. Niveau 0 exporteert alleen deze CMap." + 'copy-markdown "Markdown kopiëren" 'download-markdown "Markdown downloaden" 'preparing-markdown-export "Markdown-export voorbereiden…" + 'markdown-export-downloaded "Markdown-export gedownload." 'markdown-export-copied "Markdown-export gekopieerd." + 'cmap-export-unavailable "CMap-export is niet beschikbaar." 'save-before-export-failed "De CMap kon niet worden opgeslagen voor de export." + 'download-cmap-json "JSON downloaden" 'import-cmap-json "CMap-JSON importeren…" + 'preparing-cmap-json "Volledige CMap-JSON voorbereiden…" 'cmap-json-downloaded "CMap-JSON gedownload." + 'cmap-json-unavailable "CMap-JSON-export/import is niet beschikbaar." 'cmap-json-too-large "Het CMap-JSON-bestand is groter dan 256 MiB." + 'save-before-import-failed "De huidige CMap kon niet worden opgeslagen voor de import." + 'cmap-import-conflicts "De import bevat {maps} bestaande CMaps en {pages} bestaande pagina's. Kies OK om die met de geïmporteerde inhoud te vervangen, of Annuleren om ze te behouden en alleen nieuwe records te importeren." + 'imported-from-cmap-json "Geïmporteerd uit CMap-JSON" 'importing-cmap-json "CMap-JSON importeren…" + 'cmap-json-imported "Import voltooid: {maps} CMaps, {pages} pagina's en {attachments} bijlagen geïmporteerd; {skipped} bestaande records behouden." 'edit-concept "Concept bewerken" 'aspects "Aspecten" 'aspects-placeholder "Beveiliging, performance" 'concept-description-page "Beschrijvingspagina van concept" 'concept-description-page-placeholder "cmap:conceptnaam" 'view-concept-description "Conceptbeschrijving bekijken" 'view-description-page "Beschrijvingspagina bekijken" 'invalid-description-page "Voer een geldig adres voor de beschrijvingspagina in." 'linked-page "Gekoppelde wikipagina" 'no-linked-page "Geen gekoppelde pagina" 'filter-pages "Selecteer of typ een nieuwe pagina" 'linked-concept-map "Gekoppelde conceptmap" 'no-linked-concept-map "Geen gekoppelde conceptmap" 'filter-concept-maps "Filter conceptmaps" 'parent-concept-map "Bovenliggende conceptmap" 'select-concept-map "Selecteer een CMap" 'concept-map-loaded "CMap geladen" 'select-listed-page "Selecteer een wikipagina uit de lijst of maak het veld leeg." 'invalid-new-page "Voer een paginatitel of geldig wiki-adres in." 'select-listed-concept-map "Selecteer een CMap uit de lijst of maak het veld leeg." 'unsaved-concept-map "Niet-opgeslagen conceptmap" 'save-concept-map-before-leaving "De wijzigingen aan deze conceptmap opslaan voordat u hem verlaat?" 'dont-save "Niet opslaan" 'concept-image "Afbeelding bij concept" 'remove-image "Afbeelding verwijderen" 'text-color "Tekstkleur" 'main-concept-background-color "Achtergrondkleur hoofdconcept" 'submap-appearance "Vormgeving sub-CMap" 'submap-background-color "Achtergrondkleur sub-CMap" 'submap-border-color "Randkleur sub-CMap" 'link-concept-map "Link naar CMap" 'insert-concept-map-link "CMap invoegen" 'concept-map-link-help "Voeg de geselecteerde CMap in als link of als alleen-lezen weergave." 'concept-map-not-found "CMap niet gevonden" 'insert "Invoegen" 'insert-link "Link invoegen" 'embed-concept-map "CMap insluiten" 'embedded-concept-map-help "Dubbelklik om deze CMap te openen." 'a4-page-boundaries "A4-paginagrenzen" + 'selection-layout-tools "Selectie- en opmaakhulpmiddelen" 'selection-tools "Selectiehulpmiddelen" 'size-tools "Formaatknoppen" 'alignment-tools "Uitlijnknoppen" 'distribution-tools "Verdeelknoppen" + 'make-same-width "Dezelfde breedte maken" 'make-same-height "Dezelfde hoogte maken" 'make-same-size "Dezelfde breedte en hoogte maken" 'align-left "Links uitlijnen" 'align-horizontal-centers "Horizontaal centreren" 'align-right "Rechts uitlijnen" 'align-top "Boven uitlijnen" 'align-vertical-centers "Verticaal centreren" 'align-bottom "Onder uitlijnen" 'distribute-horizontally "Horizontaal verdelen" 'distribute-vertically "Verticaal verdelen" 'search-wiki "Pagina's en conceptmaps doorzoeken" 'contents "Inhoud" 'pages "Pagina's" 'namespace "Namespace" 'namespace-placeholder "RWS" 'root-namespace "Hoofdnamespace" 'wiki-page "Wikipagina" 'concept-map "Conceptmap" 'no-matching-search-results "Geen overeenkomende wikipagina's of conceptmaps." 'edit "Bewerken" 'rename "Hernoemen" 'rename-page "Pagina hernoemen" 'slug "Slug" 'rename-summary "Pagina hernoemd" 'edit-section "Sectie bewerken" 'template "Sjabloon" 'no-template "Geen sjabloon" 'replace-with-template "De huidige pagina-inhoud vervangen door sjabloon {template}?" 'history "Geschiedenis" 'delete "Verwijderen" 'page-not-found "Pagina niet gevonden" 'cancel "Annuleren" 'save "Opslaan" 'page-title "Paginatitel" 'tags "Tags (komma-gescheiden)"