added mermaid and a lot of cmap changes
This commit is contained in:
+23
-28
@@ -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.
|
||||
|
||||
@@ -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-<uuid>`-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:<naam>` 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.
|
||||
|
||||
@@ -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:<naam>`-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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
+447
-24
@@ -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
|
||||
#<<SQL
|
||||
SELECT id
|
||||
FROM concept_definitions
|
||||
WHERE lower(trim(document ->> '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
|
||||
#<<SQL
|
||||
INSERT INTO concept_definitions(id, document, updated_at, updated_by)
|
||||
VALUES ($1, $2::text::jsonb, $3, $4)
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
SET document = EXCLUDED.document,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
updated_by = EXCLUDED.updated_by
|
||||
SQL
|
||||
(hash-ref concept 'id)
|
||||
(document->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
|
||||
#<<SQL
|
||||
SELECT slug, title, document::text, current_version,
|
||||
created_at, updated_at, created_by, updated_by,
|
||||
archived_at, archived_by
|
||||
FROM concept_maps
|
||||
WHERE archived = TRUE
|
||||
ORDER BY archived_at DESC, lower(title), title
|
||||
SQL
|
||||
))))
|
||||
(hash-set
|
||||
(hash-set (row->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
|
||||
#<<SQL
|
||||
SELECT definition.id,
|
||||
coalesce(nullif(definition.document ->> '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
|
||||
#<<SQL
|
||||
@@ -384,7 +700,6 @@ SQL
|
||||
(error 'update-concept-map! "invalid save action: ~a" action))
|
||||
(define clean-title (string-trim title))
|
||||
(validate-concept-map-input 'update-concept-map! slug clean-title document)
|
||||
(define document-text (document->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
|
||||
#<<SQL
|
||||
@@ -424,6 +743,7 @@ SQL
|
||||
now
|
||||
author
|
||||
(vector-ref row 0))
|
||||
(sync-person-tags! db canonical-document)
|
||||
(unless (string=? action "autosave")
|
||||
(insert-concept-map-version!
|
||||
db (vector-ref row 0) next-version clean-title document-text
|
||||
@@ -528,24 +848,127 @@ SQL
|
||||
#t)))))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
; goal : Soft-delete one concept map.
|
||||
; pre : slug identifies a current map.
|
||||
; post : The map is excluded from normal list/read operations.
|
||||
; goal : Archive one concept map after explicit identity confirmation.
|
||||
; pre : slug, exact title and base-version identify the current map.
|
||||
; post : The map is excluded from normal list/read operations atomically.
|
||||
; result : void.
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
(define (archive-concept-map! config slug author)
|
||||
(define (archive-concept-map! config slug expected-title author base-version)
|
||||
(call-with-wiki-database
|
||||
config
|
||||
(λ (db)
|
||||
(define changed
|
||||
(call-with-transaction
|
||||
db
|
||||
(λ ()
|
||||
(define row
|
||||
(query-maybe-row
|
||||
db
|
||||
"SELECT id, title, current_version FROM concept_maps WHERE slug = $1 AND archived = FALSE FOR UPDATE"
|
||||
slug))
|
||||
(unless row
|
||||
(error 'archive-concept-map! "unknown concept map: ~a" slug))
|
||||
(unless (and (string? expected-title)
|
||||
(string=? expected-title (vector-ref row 1)))
|
||||
(error 'archive-concept-map! "title-confirmation-mismatch"))
|
||||
(define supplied-version
|
||||
(if (number? base-version)
|
||||
base-version
|
||||
(string->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
|
||||
#<<SQL
|
||||
UPDATE concept_maps
|
||||
SET archived = TRUE, archived_at = $1, archived_by = $2,
|
||||
updated_at = $1, updated_by = $2
|
||||
WHERE slug = $3 AND archived = FALSE
|
||||
WHERE id = $3
|
||||
SQL
|
||||
now author (vector-ref row 0))))))
|
||||
(void))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
; goal : Restore one archived concept map.
|
||||
; pre : slug identifies an archived map.
|
||||
; post : Archive markers are cleared; content, version and history survive.
|
||||
; result : The restored current concept-map hash.
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
(define (restore-concept-map! config slug author)
|
||||
(call-with-wiki-database
|
||||
config
|
||||
(λ (db)
|
||||
(define restored-id
|
||||
(query-maybe-value
|
||||
db
|
||||
#<<SQL
|
||||
UPDATE concept_maps
|
||||
SET archived = FALSE, archived_at = NULL, archived_by = NULL,
|
||||
updated_at = $1, updated_by = $2
|
||||
WHERE slug = $3 AND archived = TRUE
|
||||
RETURNING id
|
||||
SQL
|
||||
(current-seconds) author slug))
|
||||
(void changed)))
|
||||
(void))
|
||||
(unless restored-id
|
||||
(error 'restore-concept-map! "unknown archived concept map: ~a" slug))))
|
||||
(read-concept-map config slug))
|
||||
|
||||
(module+ test
|
||||
(require rackunit)
|
||||
|
||||
(define concept-a "11111111-1111-4111-8111-111111111111")
|
||||
(define concept-b "22222222-2222-4222-8222-222222222222")
|
||||
|
||||
(define stored-document
|
||||
(concept-map-storage-document
|
||||
(hash 'metadata (hash 'summary "Map summary")
|
||||
'concepts (list (hash 'id concept-a
|
||||
'label "Shared concept"
|
||||
'synopsis "Central content"))
|
||||
'items (list (hash 'id 1
|
||||
'conceptId concept-a
|
||||
'kind "submap"
|
||||
'label "Stale item label"
|
||||
'synopsis "Stale item content"
|
||||
'externalUrl "https://example.com/shared"
|
||||
'x 40
|
||||
'backgroundColor "#ffffff")
|
||||
(hash 'id 2
|
||||
'conceptId concept-b
|
||||
'kind "concept"
|
||||
'label "Item-only concept")
|
||||
(hash 'id 3
|
||||
'kind "phrase"
|
||||
'label "relates to")))))
|
||||
|
||||
(check-equal? (hash-ref stored-document 'concepts)
|
||||
(list (hash 'id concept-a) (hash 'id concept-b)))
|
||||
(define stored-items (hash-ref stored-document 'items))
|
||||
(check-false (hash-has-key? (first stored-items) 'label))
|
||||
(check-false (hash-has-key? (first stored-items) 'synopsis))
|
||||
(check-false (hash-has-key? (first stored-items) 'externalUrl))
|
||||
(check-equal? (hash-ref (first stored-items) 'x) 40)
|
||||
(check-equal? (hash-ref (first stored-items) 'backgroundColor) "#ffffff")
|
||||
(check-equal? (hash-ref (third stored-items) 'label) "relates to")
|
||||
(check-equal? (hash-ref (hash-ref stored-document 'metadata) 'summary)
|
||||
"Map summary")
|
||||
(check-true (concept-id? concept-a))
|
||||
(check-true (concept-id? "A3C4F0D1-22E5-4C42-9A40-CC864993F785"))
|
||||
(check-false (concept-id? "concept-a"))
|
||||
(check-equal? (hash-ref (concept-content
|
||||
(hash 'id concept-a
|
||||
'label "Linked concept"
|
||||
'externalUrl "https://example.com/path"))
|
||||
'externalUrl)
|
||||
"https://example.com/path")
|
||||
(check-exn exn:fail?
|
||||
(lambda ()
|
||||
(concept-content
|
||||
(hash 'id concept-a
|
||||
'label "Unsafe concept"
|
||||
'externalUrl "javascript:alert(1)"))))
|
||||
(check-exn exn:fail?
|
||||
(lambda ()
|
||||
(concept-map-storage-document
|
||||
(hash 'concepts (list (hash 'id "legacy:map:concept-1"))
|
||||
'items '())))))
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
#lang racket/base
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
;; Wiki-wide CMap appearance styles stored in wiki_settings.
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
|
||||
(require db
|
||||
json
|
||||
racket/list
|
||||
racket/string
|
||||
"database.rkt")
|
||||
|
||||
(provide read-cmap-styles
|
||||
save-cmap-styles!)
|
||||
|
||||
(define setting-key "cmap.styles.v1")
|
||||
(define maximum-style-count 100)
|
||||
(define maximum-style-name-length 80)
|
||||
(define permitted-name-keys
|
||||
'(style-default style-subtle style-emphasis style-warning style-success))
|
||||
(define value-keys
|
||||
'(backgroundColor textColor fontFamily fontSize fontWeight fontStyle
|
||||
synopsisTextColor synopsisFontFamily synopsisFontSize synopsisFontWeight
|
||||
synopsisFontStyle submapBackgroundColor submapBorderColor))
|
||||
|
||||
(define (seeded-style id name-key background text font size bold? italic? [synopsis-text #f])
|
||||
(hash 'id id
|
||||
'nameKey name-key
|
||||
'protected (string=? id "default")
|
||||
'values
|
||||
(hash 'backgroundColor background
|
||||
'textColor text
|
||||
'fontFamily font
|
||||
'fontSize size
|
||||
'fontWeight (if bold? "700" "400")
|
||||
'fontStyle (if italic? "italic" "normal")
|
||||
'synopsisTextColor (or synopsis-text text)
|
||||
'synopsisFontFamily font
|
||||
'synopsisFontSize (max 6 (- size 2))
|
||||
'synopsisFontWeight "400"
|
||||
'synopsisFontStyle (if italic? "italic" "normal")
|
||||
'submapBackgroundColor "#edf7e8"
|
||||
'submapBorderColor "#57834a")))
|
||||
|
||||
(define initial-cmap-styles
|
||||
(list
|
||||
(seeded-style "default" "style-default" "#fff4cf" "#222222" "Arial, Helvetica, sans-serif" 11 #t #f "#4d4d4d")
|
||||
(seeded-style "subtle" "style-subtle" "#f1f3f5" "#56616b" "system-ui, sans-serif" 10 #f #f)
|
||||
(seeded-style "emphasis" "style-emphasis" "#e7f2fb" "#173b57" "Georgia, Times New Roman, serif" 12 #t #f)
|
||||
(seeded-style "warning" "style-warning" "#fff0d5" "#713b00" "Arial, Helvetica, sans-serif" 11 #t #t)
|
||||
(seeded-style "success" "style-success" "#e6f4e2" "#285b27" "Arial, Helvetica, sans-serif" 11 #f #t)))
|
||||
|
||||
(define (required-string who value description [maximum-length #f])
|
||||
(unless (and (string? value)
|
||||
(not (string=? (string-trim value) ""))
|
||||
(or (not maximum-length) (<= (string-length (string-trim value)) maximum-length)))
|
||||
(error who "invalid ~a" description))
|
||||
(string-trim value))
|
||||
|
||||
(define (style-color who value key)
|
||||
(unless (and (string? value) (regexp-match? #px"(?i:^#[0-9a-f]{6}$)" value))
|
||||
(error who "invalid colour for ~a" key))
|
||||
(string-downcase value))
|
||||
|
||||
(define (style-size who value key)
|
||||
(unless (and (real? value) (<= 6 value 54))
|
||||
(error who "invalid font size for ~a" key))
|
||||
value)
|
||||
|
||||
(define (normalize-style-values who values)
|
||||
(unless (hash? values) (error who "style values must be an object"))
|
||||
(for ([key (in-list value-keys)])
|
||||
(unless (hash-has-key? values key) (error who "missing style value: ~a" key)))
|
||||
(hash
|
||||
'backgroundColor (style-color who (hash-ref values 'backgroundColor) 'backgroundColor)
|
||||
'textColor (style-color who (hash-ref values 'textColor) 'textColor)
|
||||
'fontFamily (required-string who (hash-ref values 'fontFamily) "font family" 200)
|
||||
'fontSize (style-size who (hash-ref values 'fontSize) 'fontSize)
|
||||
'fontWeight (let ([value (hash-ref values 'fontWeight)])
|
||||
(unless (member value '("400" "700")) (error who "invalid font weight"))
|
||||
value)
|
||||
'fontStyle (let ([value (hash-ref values 'fontStyle)])
|
||||
(unless (member value '("normal" "italic")) (error who "invalid font style"))
|
||||
value)
|
||||
'synopsisTextColor (style-color who (hash-ref values 'synopsisTextColor) 'synopsisTextColor)
|
||||
'synopsisFontFamily (required-string who (hash-ref values 'synopsisFontFamily) "synopsis font family" 200)
|
||||
'synopsisFontSize (style-size who (hash-ref values 'synopsisFontSize) 'synopsisFontSize)
|
||||
'synopsisFontWeight (let ([value (hash-ref values 'synopsisFontWeight)])
|
||||
(unless (member value '("400" "700")) (error who "invalid synopsis font weight"))
|
||||
value)
|
||||
'synopsisFontStyle (let ([value (hash-ref values 'synopsisFontStyle)])
|
||||
(unless (member value '("normal" "italic")) (error who "invalid synopsis font style"))
|
||||
value)
|
||||
'submapBackgroundColor (style-color who (hash-ref values 'submapBackgroundColor) 'submapBackgroundColor)
|
||||
'submapBorderColor (style-color who (hash-ref values 'submapBorderColor) 'submapBorderColor)))
|
||||
|
||||
(define (normalize-cmap-styles styles [who 'cmap-styles])
|
||||
(unless (and (list? styles) (<= 1 (length styles) maximum-style-count))
|
||||
(error who "styles must contain between 1 and ~a entries" maximum-style-count))
|
||||
(define seen (make-hash))
|
||||
(define normalized
|
||||
(for/list ([style (in-list styles)])
|
||||
(unless (hash? style) (error who "each style must be an object"))
|
||||
(define id (required-string who (hash-ref style 'id #f) "style id" 120))
|
||||
(unless (regexp-match? #px"^[A-Za-z0-9_-]+$" id) (error who "invalid style id"))
|
||||
(when (hash-ref seen id #f) (error who "duplicate style id: ~a" id))
|
||||
(hash-set! seen id #t)
|
||||
(define name (and (string? (hash-ref style 'name #f))
|
||||
(required-string who (hash-ref style 'name) "style name" maximum-style-name-length)))
|
||||
(define name-key (and (string? (hash-ref style 'nameKey #f))
|
||||
(string->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))))))
|
||||
@@ -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))))
|
||||
+814
-1
@@ -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
|
||||
#<<SQL
|
||||
CREATE TABLE IF NOT EXISTS people (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL
|
||||
)
|
||||
SQL
|
||||
)
|
||||
(query-exec db "CREATE UNIQUE INDEX IF NOT EXISTS people_name_unique_idx ON people(lower(name))")
|
||||
(query-exec
|
||||
db
|
||||
#<<SQL
|
||||
WITH person_names AS (
|
||||
SELECT DISTINCT trim(tag ->> '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
|
||||
#<<SQL
|
||||
UPDATE concept_maps cm
|
||||
SET document = cm.document || jsonb_build_object(
|
||||
'concepts', CASE
|
||||
WHEN jsonb_typeof(cm.document -> '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
|
||||
#<<SQL
|
||||
CREATE TABLE IF NOT EXISTS concept_definitions (
|
||||
id TEXT PRIMARY KEY,
|
||||
document JSONB NOT NULL,
|
||||
updated_at BIGINT NOT NULL,
|
||||
updated_by TEXT NOT NULL
|
||||
)
|
||||
SQL
|
||||
)
|
||||
;; If a shared concept already has diverging copies, the copy from the most
|
||||
;; recently edited active map becomes the initial canonical definition.
|
||||
(query-exec
|
||||
db
|
||||
#<<SQL
|
||||
WITH candidates AS (
|
||||
SELECT concept ->> '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
|
||||
#<<SQL
|
||||
WITH candidates AS (
|
||||
SELECT item ->> '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
|
||||
#<<SQL
|
||||
CREATE TABLE IF NOT EXISTS concept_aliases (
|
||||
alias_id TEXT PRIMARY KEY,
|
||||
canonical_id TEXT NOT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
created_by TEXT NOT NULL,
|
||||
CHECK(alias_id <> canonical_id)
|
||||
)
|
||||
SQL
|
||||
)
|
||||
(query-exec db
|
||||
"CREATE INDEX IF NOT EXISTS concept_aliases_canonical_idx ON concept_aliases(canonical_id)")
|
||||
(query-exec
|
||||
db
|
||||
#<<SQL
|
||||
WITH derived_roots AS (
|
||||
SELECT target.slug AS target_slug,
|
||||
root_item ->> '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
|
||||
#<<SQL
|
||||
WITH resolved AS (
|
||||
SELECT coalesce(alias.canonical_id, definition.id) AS canonical_id,
|
||||
definition.document,
|
||||
definition.updated_at,
|
||||
definition.updated_by,
|
||||
row_number() OVER (
|
||||
PARTITION BY coalesce(alias.canonical_id, definition.id)
|
||||
ORDER BY definition.updated_at DESC, definition.id DESC
|
||||
) AS position
|
||||
FROM concept_definitions definition
|
||||
LEFT JOIN concept_aliases alias ON alias.alias_id = definition.id
|
||||
)
|
||||
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 resolved
|
||||
WHERE position = 1
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
SET document = EXCLUDED.document,
|
||||
updated_at = EXCLUDED.updated_at,
|
||||
updated_by = EXCLUDED.updated_by
|
||||
SQL
|
||||
)
|
||||
(record-schema-version! db 16))
|
||||
|
||||
(define (migrate-16->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
|
||||
#<<SQL
|
||||
WITH named AS (
|
||||
SELECT definition.id,
|
||||
definition.document,
|
||||
definition.updated_at,
|
||||
definition.updated_by,
|
||||
lower(trim(definition.document ->> '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
|
||||
#<<SQL
|
||||
UPDATE concept_maps cm
|
||||
SET document = cm.document || jsonb_build_object(
|
||||
'items', CASE
|
||||
WHEN jsonb_typeof(cm.document -> '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
|
||||
#<<SQL
|
||||
DELETE FROM concept_definitions definition
|
||||
USING concept_aliases alias
|
||||
WHERE definition.id = alias.alias_id
|
||||
SQL
|
||||
)
|
||||
(record-schema-version! db 17))
|
||||
|
||||
(define (migrate-17->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
|
||||
#<<SQL
|
||||
CREATE TEMP TABLE concept_id_merge (
|
||||
old_id TEXT PRIMARY KEY,
|
||||
canonical_id TEXT NOT NULL
|
||||
) ON COMMIT DROP
|
||||
SQL
|
||||
)
|
||||
(query-exec
|
||||
db
|
||||
#<<SQL
|
||||
WITH placements AS (
|
||||
SELECT item ->> '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
|
||||
#<<SQL
|
||||
UPDATE concept_maps cm
|
||||
SET document = cm.document || jsonb_build_object(
|
||||
'items', CASE
|
||||
WHEN jsonb_typeof(cm.document -> '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
|
||||
#<<SQL
|
||||
WITH candidates AS (
|
||||
SELECT concept ->> '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
|
||||
#<<SQL
|
||||
UPDATE concept_maps cm
|
||||
SET document = jsonb_set(
|
||||
cm.document,
|
||||
'{items}',
|
||||
(SELECT coalesce(jsonb_agg(
|
||||
CASE WHEN coalesce(item ->> '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
|
||||
#<<SQL
|
||||
UPDATE concept_maps cm
|
||||
SET document = cm.document || jsonb_build_object(
|
||||
'items',
|
||||
(SELECT coalesce(jsonb_agg(
|
||||
CASE WHEN coalesce(item ->> '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-<uuid> 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
|
||||
#<<SQL
|
||||
CREATE TEMP TABLE concept_uuid_rekey (
|
||||
old_id TEXT PRIMARY KEY,
|
||||
new_id TEXT NOT NULL
|
||||
) ON COMMIT DROP
|
||||
SQL
|
||||
)
|
||||
(define old-ids
|
||||
(query-list
|
||||
db
|
||||
#<<SQL
|
||||
WITH all_current_ids AS (
|
||||
SELECT id AS old_id FROM concept_definitions
|
||||
UNION
|
||||
SELECT concept ->> '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
|
||||
#<<SQL
|
||||
UPDATE concept_maps cm
|
||||
SET document = cm.document || jsonb_build_object(
|
||||
'items',
|
||||
(SELECT coalesce(jsonb_agg(
|
||||
CASE WHEN rekey.new_id IS NULL
|
||||
THEN item
|
||||
ELSE jsonb_set(item, '{conceptId}', to_jsonb(rekey.new_id))
|
||||
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)
|
||||
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
|
||||
#<<SQL
|
||||
CREATE TEMP TABLE rekeyed_concept_definitions ON COMMIT DROP AS
|
||||
SELECT DISTINCT ON (rekey.new_id)
|
||||
rekey.new_id AS id,
|
||||
jsonb_set(definition.document, '{id}', to_jsonb(rekey.new_id)) AS document,
|
||||
definition.updated_at,
|
||||
definition.updated_by
|
||||
FROM concept_definitions definition
|
||||
JOIN concept_uuid_rekey rekey ON rekey.old_id = definition.id
|
||||
ORDER BY rekey.new_id, definition.updated_at DESC, definition.id
|
||||
SQL
|
||||
)
|
||||
(query-exec db "DELETE FROM concept_definitions")
|
||||
(query-exec
|
||||
db
|
||||
#<<SQL
|
||||
INSERT INTO concept_definitions(id, document, updated_at, updated_by)
|
||||
SELECT id, document, updated_at, updated_by
|
||||
FROM rekeyed_concept_definitions
|
||||
SQL
|
||||
)
|
||||
(query-exec
|
||||
db
|
||||
#<<SQL
|
||||
ALTER TABLE concept_definitions
|
||||
ADD CONSTRAINT concept_definitions_uuid_id_check
|
||||
CHECK (id ~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$')
|
||||
SQL
|
||||
)
|
||||
(record-schema-version! db 21))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
; goal : Bring a racket-wiki PostgreSQL database to the current schema.
|
||||
; pre : db is a writable PostgreSQL connection and config identifies the
|
||||
@@ -468,6 +1254,33 @@ SQL
|
||||
(define after-concept-map-history (database-schema-version db))
|
||||
(when (= after-concept-map-history 11)
|
||||
(migrate-11->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!
|
||||
|
||||
@@ -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
|
||||
#<<SQL
|
||||
INSERT INTO people(name, active, created_at, updated_at)
|
||||
VALUES ($1, TRUE, $2, $2)
|
||||
ON CONFLICT (lower(name)) DO UPDATE
|
||||
SET name = excluded.name, active = TRUE, updated_at = excluded.updated_at
|
||||
RETURNING id, name, active
|
||||
SQL
|
||||
clean-name now)))))
|
||||
|
||||
(define (update-person! config id name active?)
|
||||
(define clean-name (clean-person-name 'update-person! name))
|
||||
(call-with-wiki-database
|
||||
config
|
||||
(lambda (db)
|
||||
(define row
|
||||
(query-maybe-row
|
||||
db
|
||||
#<<SQL
|
||||
UPDATE people
|
||||
SET name = $1, active = $2, updated_at = $3
|
||||
WHERE id = $4
|
||||
RETURNING id, name, active
|
||||
SQL
|
||||
clean-name (if active? #t #f) (current-seconds) id))
|
||||
(and row (row->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
|
||||
#<<SQL
|
||||
INSERT INTO people(name, active, created_at, updated_at)
|
||||
VALUES ($1, TRUE, $2, $2)
|
||||
ON CONFLICT (lower(name)) DO NOTHING
|
||||
SQL
|
||||
name now))
|
||||
(void))
|
||||
|
||||
(module+ test
|
||||
(require rackunit)
|
||||
(check-equal?
|
||||
(person-tag-names
|
||||
(hash 'concepts
|
||||
(list (hash 'tags
|
||||
(list (hash 'type "person" 'value " Alex Morgan ")
|
||||
(hash 'type "label" 'value "architecture")))
|
||||
(hash 'tags
|
||||
(list (hash 'type "person" 'value "alex morgan")
|
||||
(hash 'type "person" 'value "Sam de Vries"))))
|
||||
'items '()))
|
||||
'("Alex Morgan" "Sam de Vries")))
|
||||
@@ -25,6 +25,8 @@
|
||||
"https://cdn.jsdelivr.net/npm/easymde@2.21.0/dist/easymde.min.css")
|
||||
(cons "purify.min.js"
|
||||
"https://cdn.jsdelivr.net/npm/dompurify@3.4.13/dist/purify.min.js")
|
||||
(cons "mermaid.min.js"
|
||||
"https://cdn.jsdelivr.net/npm/mermaid@11.17.1/dist/mermaid.min.js")
|
||||
(cons "highlight.min.js"
|
||||
"https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11.12.0/build/highlight.min.js")
|
||||
(cons "highlight-scheme.min.js"
|
||||
|
||||
+21
-2
@@ -54,7 +54,7 @@ The setup page itself requires no JavaScript. On a fresh installation it asks fo
|
||||
PostgreSQL server, port, database, user, password and SSL mode. The database must
|
||||
already exist. After testing the connection, setup creates the racket-wiki schema,
|
||||
asks for the first administrator, and downloads the pinned EasyMDE, Lucide,
|
||||
DOMPurify, highlight.js, and diff2html browser files. PostgreSQL connection
|
||||
DOMPurify, Mermaid, highlight.js, and diff2html browser files. PostgreSQL connection
|
||||
settings are stored in @tt{database.rktd} below the configured data directory.
|
||||
After setup the browser is redirected to @tt{/login}; setup does not create an
|
||||
authenticated browser session.
|
||||
@@ -90,6 +90,10 @@ The application sidebar gives the current page's headings priority over the
|
||||
global page list. The contents list follows Markdown headings and is updated
|
||||
while a page is edited.
|
||||
|
||||
A fenced code block whose language is @tt{mermaid} is rendered as a diagram in
|
||||
the normal page, editor preview and page history. Mermaid is installed locally
|
||||
by the vendor setup and runs with its strict security level.
|
||||
|
||||
@section{Editor appearance}
|
||||
|
||||
EasyMDE and the rendered wiki page use matching body and heading sizes. The
|
||||
@@ -118,7 +122,22 @@ language-specific stemmer.
|
||||
|
||||
@section{Database schema migrations}
|
||||
|
||||
Racket Wiki records PostgreSQL schema migrations in @tt{wiki_schema}. The current page is stored in @tt{pages}; every saved historical revision is stored in @tt{page_versions}. Attachments, including their binary content, are stored in @tt{attachments}. Schema migrations run in order when the application starts. Schema 7 adds page namespaces and a unique namespace/slug index.
|
||||
Racket Wiki records PostgreSQL schema migrations in @tt{wiki_schema}. The current page is stored in @tt{pages}; every saved historical revision is stored in @tt{page_versions}. Attachments, including their binary content, are stored in @tt{attachments}. Schema migrations run in order when the application starts. Schema 7 adds page namespaces and a unique namespace/slug index. Schema 13 adds the active/inactive registry for person tags used by CMap concepts.
|
||||
|
||||
@section{CMap Markdown export and person tags}
|
||||
|
||||
Stored CMaps carry tags, a summary and an optional wiki-page reference for a
|
||||
longer explanation. The CMap tools menu exports a report-oriented Markdown
|
||||
document and follows linked stored CMaps to a user-selected depth. Linked wiki
|
||||
pages and concept-description pages are optional; the explicit CMap explanation
|
||||
is always included when available. Cycles are emitted once and a derived CMap
|
||||
exports its own subtree.
|
||||
|
||||
People attached to a concept are stored as typed tags with type @tt{person}.
|
||||
Several people can be selected for one concept, and linked placements with the
|
||||
same concept identity share the tags. The wiki-wide people registry supplies the
|
||||
multi-select list. Deactivated names remain attached to existing CMaps but are
|
||||
not offered for new tags.
|
||||
|
||||
@section{Todo items}
|
||||
|
||||
|
||||
+97
-4
@@ -16,9 +16,15 @@
|
||||
web-server/servlet-env
|
||||
"private/auth.rkt"
|
||||
"private/cmap-storage.rkt"
|
||||
"private/cmap-styles.rkt"
|
||||
"private/config.rkt"
|
||||
(only-in "private/database.rkt" call-with-wiki-database)
|
||||
"private/http-util.rkt"
|
||||
"private/mail.rkt"
|
||||
(only-in "private/migrations.rkt"
|
||||
current-schema-version
|
||||
database-schema-version)
|
||||
"private/people.rkt"
|
||||
"private/setup.rkt"
|
||||
"private/storage.rkt"
|
||||
"private/version.rkt"
|
||||
@@ -348,6 +354,32 @@ CSS
|
||||
(λ (_session)
|
||||
(json-response (hash 'placements (list-concept-usage config))))))
|
||||
|
||||
(define (people-list-handler config req)
|
||||
(require-role
|
||||
config req 'reader
|
||||
(lambda (_session)
|
||||
(json-response (hash 'people (list-people config #t))))))
|
||||
|
||||
(define (people-create-handler config req)
|
||||
(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 (create-person! config (hash-ref body 'name "")) #:code 201)))))
|
||||
|
||||
(define (people-update-handler config req id)
|
||||
(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))
|
||||
(define active (hash-ref body 'active #t))
|
||||
(unless (boolean? active)
|
||||
(raise-argument-error 'people-update-handler "boolean?" active))
|
||||
(define person (update-person! config id (hash-ref body 'name "") active))
|
||||
(if person (json-response person) (json-error 404 "Person not found"))))))
|
||||
|
||||
(define (request-concept-map-document body)
|
||||
(define document (hash-ref body 'document #f))
|
||||
(unless (hash? document)
|
||||
@@ -480,10 +512,19 @@ CSS
|
||||
(require-write-role
|
||||
config req 'editor
|
||||
(λ (session)
|
||||
(with-handlers ((exn:fail? (λ (e) (json-error 400 (exn-message e)))))
|
||||
(with-handlers ((exn:fail?
|
||||
(λ (e)
|
||||
(if (string=? (exn-message e) "archive-concept-map!: version-conflict")
|
||||
(json-error 409 "Concept map changed since it was opened")
|
||||
(json-error 400 (exn-message e))))))
|
||||
(define body (request-json req))
|
||||
(define expected-title (hash-ref body 'confirmTitle ""))
|
||||
(define base-version (hash-ref body 'baseVersion ""))
|
||||
(archive-concept-map! config
|
||||
slug
|
||||
(wiki-user-username (wiki-session-user session)))
|
||||
expected-title
|
||||
(wiki-user-username (wiki-session-user session))
|
||||
base-version)
|
||||
(json-response (hash 'ok #t))))))
|
||||
|
||||
(define (request-query-value req key [default ""])
|
||||
@@ -524,7 +565,9 @@ CSS
|
||||
(require-role
|
||||
config req 'reader
|
||||
(λ (_session)
|
||||
(json-response (hash 'items (list-todos config))))))
|
||||
(json-response
|
||||
(hash 'items (append (list-todos config)
|
||||
(list-concept-todos config)))))))
|
||||
|
||||
|
||||
(define (recent-list-handler config req)
|
||||
@@ -780,7 +823,43 @@ CSS
|
||||
(require-role
|
||||
config req 'admin
|
||||
(λ (_session)
|
||||
(json-response (hash 'softwareVersion racket-wiki-version)))))
|
||||
(define installed-schema-version
|
||||
(call-with-wiki-database config database-schema-version))
|
||||
(json-response
|
||||
(hash 'softwareVersion racket-wiki-version
|
||||
'databaseSchemaVersion installed-schema-version
|
||||
'requiredDatabaseSchemaVersion current-schema-version)))))
|
||||
|
||||
(define (admin-archived-concept-maps-handler config req)
|
||||
(require-role
|
||||
config req 'admin
|
||||
(λ (_session)
|
||||
(json-response
|
||||
(hash 'conceptMaps (list-archived-concept-maps config))))))
|
||||
|
||||
(define (cmap-styles-handler config req)
|
||||
(if (string-ci=? (bytes->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"
|
||||
|
||||
+35
-3
@@ -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
|
||||
|
||||
+367
-44
@@ -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,
|
||||
...(record.kind === "phrase" ? {
|
||||
label: record.label,
|
||||
synopsis: record.synopsis,
|
||||
aspects: record.aspects,
|
||||
descriptionPageSlug: record.descriptionPageSlug,
|
||||
pageSlug: record.pageSlug,
|
||||
cmapSlug: record.cmapSlug,
|
||||
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;
|
||||
|
||||
+301
-17
@@ -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) {
|
||||
|
||||
+48
-12
@@ -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 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))
|
||||
if (!(component instanceof types[typeIndex]))
|
||||
continue;
|
||||
|
||||
if (component.visible === false)
|
||||
continue;
|
||||
|
||||
if (component.contains(x, y, 0))
|
||||
if (component.contains(x, y, tolerance))
|
||||
return component;
|
||||
|
||||
if (!closeComponent && component.contains(x, y, 8))
|
||||
closeComponent = 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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
+195
-97
@@ -202,7 +202,7 @@
|
||||
<dl class="cmap-shortcut-list">
|
||||
<div><dt><kbd>Ctrl</kbd>/<kbd>Cmd</kbd>/<kbd>Shift</kbd> + <span data-tr="click">click</span></dt><dd data-tr="cmap-help-add-selection">Add to or remove from the selection</dd></div>
|
||||
<div><dt><kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>A</kbd></dt><dd data-tr="select-all">Select all</dd></div>
|
||||
<div><dt><kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>C</kbd>/<kbd>V</kbd></dt><dd data-tr="copy-paste-linked-concepts">Copy/paste linked concepts</dd></div>
|
||||
<div><dt><kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>X</kbd>/<kbd>C</kbd>/<kbd>V</kbd></dt><dd data-tr="copy-paste-linked-concepts">Cut/copy/paste linked concepts</dd></div>
|
||||
<div><dt><kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>G</kbd></dt><dd data-tr="group-selected">Group selection as sub-CMap</dd></div>
|
||||
<div><dt><kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>Shift</kbd>+<kbd>G</kbd></dt><dd data-tr="ungroup-selected">Detach from sub-CMap</dd></div>
|
||||
<div><dt><kbd>Ctrl</kbd>/<kbd>Cmd</kbd>+<kbd>Z</kbd></dt><dd data-tr="undo">Undo</dd></div>
|
||||
@@ -217,14 +217,45 @@
|
||||
</div>
|
||||
</header>
|
||||
<div class="cmap-workspace">
|
||||
<aside id="cmap-selection-toolbar" class="cmap-selection-toolbar editor-only" aria-label="Selection and layout tools" data-tr-aria-label="selection-layout-tools">
|
||||
<div class="cmap-selection-tool-group" role="group" aria-label="Selection tools" data-tr-aria-label="selection-tools">
|
||||
<button type="button" data-cmap-selection-action="edit" title="Edit selected" data-tr-title="edit-selected-concept" aria-label="Edit selected" data-tr-aria-label="edit-selected-concept" disabled><i data-lucide="pencil" aria-hidden="true"></i></button>
|
||||
<button type="button" data-cmap-selection-action="group" title="Group selection as sub-CMap" data-tr-title="group-selected" aria-label="Group selection as sub-CMap" data-tr-aria-label="group-selected" disabled><i data-lucide="group" aria-hidden="true"></i></button>
|
||||
<button type="button" data-cmap-selection-action="ungroup" title="Detach from sub-CMap" data-tr-title="ungroup-selected" aria-label="Detach from sub-CMap" data-tr-aria-label="ungroup-selected" disabled><i data-lucide="ungroup" aria-hidden="true"></i></button>
|
||||
<button type="button" data-cmap-selection-action="hide" title="Hide in this view" data-tr-title="hide-in-this-view" aria-label="Hide in this view" data-tr-aria-label="hide-in-this-view" disabled><i data-lucide="eye-off" aria-hidden="true"></i></button>
|
||||
</div>
|
||||
<div class="cmap-selection-tool-separator" aria-hidden="true"></div>
|
||||
<div class="cmap-selection-tool-group" role="group" aria-label="Size tools" data-tr-aria-label="size-tools">
|
||||
<button type="button" data-cmap-layout="same-width" title="Make same width" data-tr-title="make-same-width" aria-label="Make same width" data-tr-aria-label="make-same-width" disabled><i data-lucide="move-horizontal" aria-hidden="true"></i></button>
|
||||
<button type="button" data-cmap-layout="same-height" title="Make same height" data-tr-title="make-same-height" aria-label="Make same height" data-tr-aria-label="make-same-height" disabled><i data-lucide="move-vertical" aria-hidden="true"></i></button>
|
||||
<button type="button" data-cmap-layout="same-size" title="Make same width and height" data-tr-title="make-same-size" aria-label="Make same width and height" data-tr-aria-label="make-same-size" disabled><i data-lucide="maximize-2" aria-hidden="true"></i></button>
|
||||
</div>
|
||||
<div class="cmap-selection-tool-separator" aria-hidden="true"></div>
|
||||
<div class="cmap-selection-tool-group" role="group" aria-label="Alignment tools" data-tr-aria-label="alignment-tools">
|
||||
<button type="button" data-cmap-layout="align-left" title="Align left" data-tr-title="align-left" aria-label="Align left" data-tr-aria-label="align-left" disabled><i data-lucide="align-horizontal-justify-start" aria-hidden="true"></i></button>
|
||||
<button type="button" data-cmap-layout="align-center" title="Align horizontal centers" data-tr-title="align-horizontal-centers" aria-label="Align horizontal centers" data-tr-aria-label="align-horizontal-centers" disabled><i data-lucide="align-horizontal-justify-center" aria-hidden="true"></i></button>
|
||||
<button type="button" data-cmap-layout="align-right" title="Align right" data-tr-title="align-right" aria-label="Align right" data-tr-aria-label="align-right" disabled><i data-lucide="align-horizontal-justify-end" aria-hidden="true"></i></button>
|
||||
<button type="button" data-cmap-layout="align-top" title="Align top" data-tr-title="align-top" aria-label="Align top" data-tr-aria-label="align-top" disabled><i data-lucide="align-vertical-justify-start" aria-hidden="true"></i></button>
|
||||
<button type="button" data-cmap-layout="align-middle" title="Align vertical centers" data-tr-title="align-vertical-centers" aria-label="Align vertical centers" data-tr-aria-label="align-vertical-centers" disabled><i data-lucide="align-vertical-justify-center" aria-hidden="true"></i></button>
|
||||
<button type="button" data-cmap-layout="align-bottom" title="Align bottom" data-tr-title="align-bottom" aria-label="Align bottom" data-tr-aria-label="align-bottom" disabled><i data-lucide="align-vertical-justify-end" aria-hidden="true"></i></button>
|
||||
</div>
|
||||
<div class="cmap-selection-tool-separator" aria-hidden="true"></div>
|
||||
<div class="cmap-selection-tool-group" role="group" aria-label="Distribution tools" data-tr-aria-label="distribution-tools">
|
||||
<button type="button" data-cmap-layout="distribute-horizontal" title="Distribute horizontally" data-tr-title="distribute-horizontally" aria-label="Distribute horizontally" data-tr-aria-label="distribute-horizontally" disabled><i data-lucide="align-horizontal-space-around" aria-hidden="true"></i></button>
|
||||
<button type="button" data-cmap-layout="distribute-vertical" title="Distribute vertically" data-tr-title="distribute-vertically" aria-label="Distribute vertically" data-tr-aria-label="distribute-vertically" disabled><i data-lucide="align-vertical-space-around" aria-hidden="true"></i></button>
|
||||
</div>
|
||||
</aside>
|
||||
<div id="cmap-canvas" class="cmap-canvas" aria-label="Concept map"></div>
|
||||
<nav id="cmap-context-menu" class="cmap-context-menu hidden" role="menu" aria-label="Concept map tools">
|
||||
<button id="cmap-new-map" class="editor-only" type="button" role="menuitem" data-tr="new-concept-map">New CMap</button>
|
||||
<button id="cmap-save-map" class="editor-only" type="button" role="menuitem" data-tr="save-now">Save now</button>
|
||||
<div class="cmap-context-separator" role="separator"></div>
|
||||
<button id="cmap-rename-map" type="button" role="menuitem" data-tr="rename-concept-map" disabled>Rename CMap</button>
|
||||
<button id="cmap-edit-metadata" class="editor-only" type="button" role="menuitem" data-tr="concept-map-details" disabled>CMap details</button>
|
||||
<button id="cmap-export-markdown" type="button" role="menuitem" data-tr="export-markdown" disabled>Export CMap…</button>
|
||||
<button id="cmap-import-json" class="editor-only" type="button" role="menuitem" data-tr="import-cmap-json">Import CMap JSON…</button>
|
||||
<button id="cmap-manage-people" class="editor-only" type="button" role="menuitem" data-tr="manage-people">Manage people</button>
|
||||
<button id="cmap-set-start-map" type="button" role="menuitem" data-tr="set-start-concept-map" disabled>Use as start CMap</button>
|
||||
<button id="cmap-delete-map" class="danger" type="button" role="menuitem" data-tr="delete-concept-map" disabled>Delete CMap</button>
|
||||
<button id="cmap-create-snapshot" type="button" role="menuitem" data-tr="create-concept-map-snapshot" disabled>Make snapshot…</button>
|
||||
<button id="cmap-history" type="button" role="menuitem" data-tr="concept-map-history" disabled>CMap history</button>
|
||||
<div class="cmap-context-separator" role="separator"></div>
|
||||
@@ -233,6 +264,7 @@
|
||||
<button id="cmap-add-submap" type="button" role="menuitem" data-tr="add-submap">Add sub-CMap</button>
|
||||
<button id="cmap-promote-submap" type="button" role="menuitem" data-tr="promote-submap" disabled>Make separate CMap</button>
|
||||
<button id="cmap-edit-selected" type="button" role="menuitem" data-tr="edit-selected-concept">Edit selected</button>
|
||||
<button id="cmap-cut-selected" type="button" role="menuitem" data-tr="cut-linked-concepts" disabled>Cut concept reference</button>
|
||||
<button id="cmap-copy-selected" type="button" role="menuitem" data-tr="copy-linked-concepts">Copy concept reference</button>
|
||||
<button id="cmap-paste-concepts" type="button" role="menuitem" data-tr="paste-linked-concepts" disabled>Paste linked concept</button>
|
||||
<div class="cmap-context-separator" role="separator"></div>
|
||||
@@ -247,6 +279,8 @@
|
||||
<div class="cmap-context-separator" role="separator"></div>
|
||||
<button id="cmap-toggle-page-guides" type="button" role="menuitemcheckbox" aria-checked="true" data-tr="a4-page-boundaries">A4 page boundaries</button>
|
||||
<button id="cmap-reset" type="button" role="menuitem" data-tr="reload-concept-map">Reload CMap</button>
|
||||
<div class="cmap-context-separator" role="separator"></div>
|
||||
<button id="cmap-delete-map" class="danger" type="button" role="menuitem" data-tr="archive-entire-concept-map" disabled>Archive entire CMap…</button>
|
||||
</nav>
|
||||
</div>
|
||||
</section>
|
||||
@@ -300,15 +334,26 @@
|
||||
<h1 data-tr="admin">Admin</h1>
|
||||
</header>
|
||||
<p class="admin-version"><span data-tr="software-version">Software version</span>: <strong id="admin-software-version">—</strong></p>
|
||||
<p class="admin-version"><span data-tr="database-schema-version">Database schema version</span>: <strong id="admin-database-schema-version">—</strong></p>
|
||||
<nav class="admin-menu">
|
||||
<a id="admin-users-link" href="#admin/users" data-tr="users">Users</a>
|
||||
<a id="admin-mail-link" href="#admin/mail" data-tr="email-and-password-reset">Email and password reset</a>
|
||||
<a id="admin-aliases-link" href="#admin/aliases" data-tr="page-aliases">Page aliases</a>
|
||||
<a id="admin-translations-link" href="#" data-tr="translations">Translations</a>
|
||||
<a id="admin-orphaned-uploads-link" href="#admin/orphaned-uploads" data-tr="orphaned-uploads">Orphaned uploads</a>
|
||||
<a id="admin-archived-cmaps-link" href="#admin/archived-cmaps" data-tr="archived-concept-maps">Archived CMaps</a>
|
||||
</nav>
|
||||
</section>
|
||||
|
||||
<section id="archived-cmaps-view" class="hidden">
|
||||
<header class="page-header">
|
||||
<h1 data-tr="archived-concept-maps">Archived CMaps</h1>
|
||||
<a id="close-archived-cmaps" href="#admin" data-tr="back">Back</a>
|
||||
</header>
|
||||
<p id="archived-cmaps-status" class="muted" role="status" aria-live="polite"></p>
|
||||
<div id="archived-cmaps-list" class="archived-cmaps-list"></div>
|
||||
</section>
|
||||
|
||||
<section id="orphaned-uploads-view" class="hidden">
|
||||
<header class="page-header">
|
||||
<h1 data-tr="orphaned-uploads">Orphaned uploads</h1>
|
||||
@@ -394,8 +439,42 @@
|
||||
</div>
|
||||
|
||||
<dialog id="cmap-concept-dialog" class="cmap-concept-dialog" aria-labelledby="cmap-concept-dialog-title">
|
||||
<form id="cmap-concept-form">
|
||||
<form id="cmap-concept-form" novalidate>
|
||||
<h2 id="cmap-concept-dialog-title" data-tr="edit-concept">Edit concept</h2>
|
||||
<div class="cmap-concept-tabs" role="tablist" aria-label="Concept properties">
|
||||
<button id="cmap-concept-tab-content" type="button" role="tab" data-cmap-concept-tab="content" aria-controls="cmap-concept-panel-content" aria-selected="true" data-tr="content-and-links">Content & links</button>
|
||||
<button id="cmap-concept-tab-appearance" type="button" role="tab" data-cmap-concept-tab="appearance" aria-controls="cmap-concept-panel-appearance" aria-selected="false" data-tr="specific-appearance">Specific appearance</button>
|
||||
</div>
|
||||
<div class="cmap-concept-dialog-body">
|
||||
<section id="cmap-concept-panel-content" class="cmap-concept-panel" role="tabpanel" aria-labelledby="cmap-concept-tab-content">
|
||||
<label>
|
||||
<span data-tr="label">Label</span>
|
||||
<input id="cmap-concept-label" type="text" required>
|
||||
</label>
|
||||
<label>
|
||||
<span data-tr="aspects">Aspects</span>
|
||||
<input id="cmap-concept-aspects" type="text" data-tr-placeholder="aspects-placeholder" placeholder="Security, performance">
|
||||
</label>
|
||||
<label class="cmap-person-tags-field">
|
||||
<span data-tr="person-tags">People (special tags)</span>
|
||||
<details id="cmap-person-tags-picker" class="cmap-person-picker">
|
||||
<summary data-tr="select-people">Select people</summary>
|
||||
<div id="cmap-person-tag-options" class="cmap-person-tag-options"></div>
|
||||
<div class="cmap-person-add-row editor-only">
|
||||
<input id="cmap-person-new-name" type="text" data-tr-placeholder="person-name" placeholder="Person name">
|
||||
<button id="cmap-person-add" type="button" data-tr="add-person">Add person</button>
|
||||
</div>
|
||||
</details>
|
||||
<small class="muted" data-tr="person-tags-help">These person tags mark who owns the responsibility or action represented by this concept.</small>
|
||||
</label>
|
||||
<label class="cmap-quick-style-field">
|
||||
<span data-tr="style">Style</span>
|
||||
<select id="cmap-concept-quick-style"></select>
|
||||
</label>
|
||||
<label class="cmap-dialog-wide">
|
||||
<span data-tr="synopsis">Synopsis</span>
|
||||
<textarea id="cmap-concept-synopsis" rows="5"></textarea>
|
||||
</label>
|
||||
<label id="cmap-concept-page-row" class="cmap-concept-page-row">
|
||||
<span data-tr="linked-page">Linked wiki page</span>
|
||||
<div id="cmap-concept-page-combobox" class="wiki-combobox">
|
||||
@@ -414,106 +493,16 @@
|
||||
<div id="cmap-concept-cmap-options" class="wiki-combobox-list hidden" role="listbox"></div>
|
||||
</div>
|
||||
</label>
|
||||
<label>
|
||||
<span data-tr="label">Label</span>
|
||||
<input id="cmap-concept-label" type="text" required>
|
||||
</label>
|
||||
<label>
|
||||
<span data-tr="aspects">Aspects</span>
|
||||
<input id="cmap-concept-aspects" type="text" data-tr-placeholder="aspects-placeholder" placeholder="Security, performance">
|
||||
<label class="cmap-dialog-wide">
|
||||
<span data-tr="external-web-page">External web page</span>
|
||||
<input id="cmap-concept-external-url" type="url" inputmode="url" placeholder="https://example.com">
|
||||
<small class="muted" data-tr="external-web-page-help">Opens in a new browser tab. Only http and https addresses are accepted.</small>
|
||||
</label>
|
||||
<label class="cmap-dialog-wide">
|
||||
<span data-tr="concept-description-page">Concept description page</span>
|
||||
<input id="cmap-concept-description-page" type="text" data-tr-placeholder="concept-description-page-placeholder" placeholder="cmap:concept-name">
|
||||
<a id="cmap-concept-description-link" class="hidden" href="#" data-tr="view-description-page">View description page</a>
|
||||
</label>
|
||||
<label class="cmap-dialog-wide">
|
||||
<span data-tr="synopsis">Synopsis</span>
|
||||
<textarea id="cmap-concept-synopsis" rows="3"></textarea>
|
||||
</label>
|
||||
<fieldset class="cmap-appearance-fields cmap-dialog-wide">
|
||||
<legend data-tr="appearance">Appearance</legend>
|
||||
<label class="cmap-style-preset-row">
|
||||
<span data-tr="style">Style</span>
|
||||
<span class="cmap-inline-control">
|
||||
<select id="cmap-concept-style-preset">
|
||||
<option value="default" data-tr="style-default">Default</option>
|
||||
<option value="subtle" data-tr="style-subtle">Subtle</option>
|
||||
<option value="emphasis" data-tr="style-emphasis">Emphasis</option>
|
||||
<option value="warning" data-tr="style-warning">Warning</option>
|
||||
<option value="success" data-tr="style-success">Success</option>
|
||||
</select>
|
||||
<button id="cmap-apply-style" type="button" data-tr="apply-style">Apply</button>
|
||||
</span>
|
||||
</label>
|
||||
<label>
|
||||
<span id="cmap-concept-background-label" data-tr="background-color">Background color</span>
|
||||
<span class="cmap-color-control"><input id="cmap-concept-background" class="cmap-color-input" type="text" value="#f3f6f8"><button type="button" class="cmap-color-swatch" aria-label="Choose background color"></button></span>
|
||||
</label>
|
||||
<fieldset class="cmap-typography-fields">
|
||||
<legend data-tr="concept-heading-appearance">Heading</legend>
|
||||
<label>
|
||||
<span data-tr="text-color">Text color</span>
|
||||
<span class="cmap-color-control"><input id="cmap-concept-text-color" class="cmap-color-input" type="text" value="#222222"><button type="button" class="cmap-color-swatch" aria-label="Choose heading text color"></button></span>
|
||||
</label>
|
||||
<label>
|
||||
<span data-tr="font-family">Font family</span>
|
||||
<select id="cmap-concept-font-family">
|
||||
<option value="Arial, Helvetica, sans-serif">Arial</option>
|
||||
<option value="Verdana, Geneva, sans-serif">Verdana</option>
|
||||
<option value="Tahoma, Geneva, sans-serif">Tahoma</option>
|
||||
<option value="Trebuchet MS, Arial, sans-serif">Trebuchet MS</option>
|
||||
<option value="Georgia, Times New Roman, serif">Georgia</option>
|
||||
<option value="Times New Roman, Times, serif">Times New Roman</option>
|
||||
<option value="Courier New, Courier, monospace">Courier New</option>
|
||||
<option value="system-ui, sans-serif">System UI</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span data-tr="font-size">Font size</span>
|
||||
<span class="cmap-font-size-input"><input id="cmap-concept-font-size" type="number" min="6" max="54" step="0.01"><span>pt</span></span>
|
||||
</label>
|
||||
<label class="cmap-check-label"><input id="cmap-concept-bold" type="checkbox" checked><span data-tr="bold">Bold</span></label>
|
||||
<label class="cmap-check-label"><input id="cmap-concept-italic" type="checkbox"><span data-tr="italic">Italic</span></label>
|
||||
</fieldset>
|
||||
<fieldset class="cmap-typography-fields">
|
||||
<legend data-tr="concept-subtext-appearance">Subtext</legend>
|
||||
<label>
|
||||
<span data-tr="text-color">Text color</span>
|
||||
<span class="cmap-color-control"><input id="cmap-concept-synopsis-text-color" class="cmap-color-input" type="text" value="#222222"><button type="button" class="cmap-color-swatch" aria-label="Choose subtext color"></button></span>
|
||||
</label>
|
||||
<label>
|
||||
<span data-tr="font-family">Font family</span>
|
||||
<select id="cmap-concept-synopsis-font-family">
|
||||
<option value="Arial, Helvetica, sans-serif">Arial</option>
|
||||
<option value="Verdana, Geneva, sans-serif">Verdana</option>
|
||||
<option value="Tahoma, Geneva, sans-serif">Tahoma</option>
|
||||
<option value="Trebuchet MS, Arial, sans-serif">Trebuchet MS</option>
|
||||
<option value="Georgia, Times New Roman, serif">Georgia</option>
|
||||
<option value="Times New Roman, Times, serif">Times New Roman</option>
|
||||
<option value="Courier New, Courier, monospace">Courier New</option>
|
||||
<option value="system-ui, sans-serif">System UI</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span data-tr="font-size">Font size</span>
|
||||
<span class="cmap-font-size-input"><input id="cmap-concept-synopsis-font-size" type="number" min="6" max="54" step="0.01"><span>pt</span></span>
|
||||
</label>
|
||||
<label class="cmap-check-label"><input id="cmap-concept-synopsis-bold" type="checkbox"><span data-tr="bold">Bold</span></label>
|
||||
<label class="cmap-check-label"><input id="cmap-concept-synopsis-italic" type="checkbox"><span data-tr="italic">Italic</span></label>
|
||||
</fieldset>
|
||||
</fieldset>
|
||||
<fieldset id="cmap-submap-style-fields" class="cmap-submap-style-fields hidden">
|
||||
<legend data-tr="submap-appearance">Sub-CMap appearance</legend>
|
||||
<label>
|
||||
<span data-tr="submap-background-color">Sub-CMap background color</span>
|
||||
<span class="cmap-color-control"><input id="cmap-submap-background" class="cmap-color-input" type="text" value="#edf7e8"><button type="button" class="cmap-color-swatch" aria-label="Choose submap background color"></button></span>
|
||||
</label>
|
||||
<label>
|
||||
<span data-tr="submap-border-color">Sub-CMap border color</span>
|
||||
<span class="cmap-color-control"><input id="cmap-submap-border" class="cmap-color-input" type="text" value="#57834a"><button type="button" class="cmap-color-swatch" aria-label="Choose submap border color"></button></span>
|
||||
</label>
|
||||
</fieldset>
|
||||
<fieldset class="cmap-concept-image-fields cmap-dialog-wide">
|
||||
<legend data-tr="concept-image">Concept image</legend>
|
||||
<input id="cmap-concept-image" type="file" accept="image/*">
|
||||
@@ -522,6 +511,49 @@
|
||||
<button id="cmap-concept-image-remove" type="button" data-tr="remove-image">Remove image</button>
|
||||
</div>
|
||||
</fieldset>
|
||||
</section>
|
||||
<section id="cmap-concept-panel-appearance" class="cmap-concept-panel hidden" role="tabpanel" aria-labelledby="cmap-concept-tab-appearance">
|
||||
<div class="cmap-style-manager cmap-dialog-wide">
|
||||
<label>
|
||||
<span data-tr="styles">Styles</span>
|
||||
<select id="cmap-concept-style-preset" aria-describedby="cmap-style-storage-help"></select>
|
||||
</label>
|
||||
<div class="cmap-style-manager-actions">
|
||||
<button id="cmap-save-style" type="button" data-tr="save-as-style">Save as style…</button>
|
||||
<button id="cmap-delete-style" type="button" data-tr="delete-style">Delete style</button>
|
||||
</div>
|
||||
<small id="cmap-style-storage-help" class="muted" data-tr="styles-database-storage-help">Styles are shared through this wiki's database.</small>
|
||||
</div>
|
||||
<fieldset class="cmap-appearance-fields cmap-dialog-wide">
|
||||
<legend data-tr="appearance">Appearance</legend>
|
||||
<label>
|
||||
<span id="cmap-concept-background-label" data-tr="background-color">Background color</span>
|
||||
<span class="cmap-color-control"><input id="cmap-concept-background" class="cmap-color-input" type="text" value="#f3f6f8"><button type="button" class="cmap-color-swatch" aria-label="Choose background color"></button></span>
|
||||
</label>
|
||||
<fieldset class="cmap-typography-fields">
|
||||
<legend data-tr="concept-heading-appearance">Heading</legend>
|
||||
<label><span data-tr="text-color">Text color</span><span class="cmap-color-control"><input id="cmap-concept-text-color" class="cmap-color-input" type="text" value="#222222"><button type="button" class="cmap-color-swatch" aria-label="Choose heading text color"></button></span></label>
|
||||
<label><span data-tr="font-family">Font family</span><select id="cmap-concept-font-family"><option value="Arial, Helvetica, sans-serif">Arial</option><option value="Verdana, Geneva, sans-serif">Verdana</option><option value="Tahoma, Geneva, sans-serif">Tahoma</option><option value="Trebuchet MS, Arial, sans-serif">Trebuchet MS</option><option value="Georgia, Times New Roman, serif">Georgia</option><option value="Times New Roman, Times, serif">Times New Roman</option><option value="Courier New, Courier, monospace">Courier New</option><option value="system-ui, sans-serif">System UI</option></select></label>
|
||||
<label><span data-tr="font-size">Font size</span><span class="cmap-font-size-input"><input id="cmap-concept-font-size" type="number" min="6" max="54" step="0.5"><span>pt</span></span></label>
|
||||
<label class="cmap-check-label"><input id="cmap-concept-bold" type="checkbox" checked><span data-tr="bold">Bold</span></label>
|
||||
<label class="cmap-check-label"><input id="cmap-concept-italic" type="checkbox"><span data-tr="italic">Italic</span></label>
|
||||
</fieldset>
|
||||
<fieldset class="cmap-typography-fields">
|
||||
<legend data-tr="concept-subtext-appearance">Subtext</legend>
|
||||
<label><span data-tr="text-color">Text color</span><span class="cmap-color-control"><input id="cmap-concept-synopsis-text-color" class="cmap-color-input" type="text" value="#222222"><button type="button" class="cmap-color-swatch" aria-label="Choose subtext color"></button></span></label>
|
||||
<label><span data-tr="font-family">Font family</span><select id="cmap-concept-synopsis-font-family"><option value="Arial, Helvetica, sans-serif">Arial</option><option value="Verdana, Geneva, sans-serif">Verdana</option><option value="Tahoma, Geneva, sans-serif">Tahoma</option><option value="Trebuchet MS, Arial, sans-serif">Trebuchet MS</option><option value="Georgia, Times New Roman, serif">Georgia</option><option value="Times New Roman, Times, serif">Times New Roman</option><option value="Courier New, Courier, monospace">Courier New</option><option value="system-ui, sans-serif">System UI</option></select></label>
|
||||
<label><span data-tr="font-size">Font size</span><span class="cmap-font-size-input"><input id="cmap-concept-synopsis-font-size" type="number" min="6" max="54" step="0.5"><span>pt</span></span></label>
|
||||
<label class="cmap-check-label"><input id="cmap-concept-synopsis-bold" type="checkbox"><span data-tr="bold">Bold</span></label>
|
||||
<label class="cmap-check-label"><input id="cmap-concept-synopsis-italic" type="checkbox"><span data-tr="italic">Italic</span></label>
|
||||
</fieldset>
|
||||
</fieldset>
|
||||
<fieldset id="cmap-submap-style-fields" class="cmap-submap-style-fields hidden">
|
||||
<legend data-tr="submap-appearance">Sub-CMap appearance</legend>
|
||||
<label><span data-tr="submap-background-color">Sub-CMap background color</span><span class="cmap-color-control"><input id="cmap-submap-background" class="cmap-color-input" type="text" value="#edf7e8"><button type="button" class="cmap-color-swatch" aria-label="Choose submap background color"></button></span></label>
|
||||
<label><span data-tr="submap-border-color">Sub-CMap border color</span><span class="cmap-color-control"><input id="cmap-submap-border" class="cmap-color-input" type="text" value="#57834a"><button type="button" class="cmap-color-swatch" aria-label="Choose submap border color"></button></span></label>
|
||||
</fieldset>
|
||||
</section>
|
||||
</div>
|
||||
<div class="cmap-concept-dialog-actions">
|
||||
<button id="cmap-concept-cancel" type="button" data-tr="cancel">Cancel</button>
|
||||
<button type="submit" class="primary" data-tr="save">Save</button>
|
||||
@@ -550,6 +582,68 @@
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="cmap-people-dialog" class="cmap-concept-dialog" aria-labelledby="cmap-people-title">
|
||||
<form id="cmap-people-form">
|
||||
<h2 id="cmap-people-title" data-tr="manage-people">Manage people</h2>
|
||||
<p class="muted" data-tr="manage-people-help">Inactive people remain on existing concepts but are hidden from the selection list for new tags.</p>
|
||||
<div id="cmap-people-list" class="cmap-people-list"></div>
|
||||
<div class="cmap-person-add-row">
|
||||
<input id="cmap-people-new-name" type="text" data-tr-placeholder="person-name" placeholder="Person name">
|
||||
<button id="cmap-people-add" type="button" data-tr="add-person">Add person</button>
|
||||
</div>
|
||||
<div class="cmap-concept-dialog-actions">
|
||||
<button id="cmap-people-close" type="button" data-tr="close">Close</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="cmap-metadata-dialog" class="cmap-concept-dialog" aria-labelledby="cmap-metadata-title">
|
||||
<form id="cmap-metadata-form">
|
||||
<h2 id="cmap-metadata-title" data-tr="concept-map-details">CMap details</h2>
|
||||
<label>
|
||||
<span data-tr="summary">Summary</span>
|
||||
<textarea id="cmap-metadata-summary" rows="5"></textarea>
|
||||
</label>
|
||||
<label>
|
||||
<span data-tr="tags">Tags (comma separated)</span>
|
||||
<input id="cmap-metadata-tags" type="text" data-tr-placeholder="tags" placeholder="Tags (comma separated)">
|
||||
</label>
|
||||
<label>
|
||||
<span data-tr="concept-map-explanation-page">CMap explanation page</span>
|
||||
<input id="cmap-metadata-explanation-page" type="text" data-tr-placeholder="concept-map-explanation-placeholder" placeholder="cmap:explanation">
|
||||
</label>
|
||||
<p class="muted" data-tr="concept-map-details-help">These details are included in Markdown exports. The explanation page is always included when it exists.</p>
|
||||
<div class="cmap-concept-dialog-actions">
|
||||
<button id="cmap-metadata-cancel" type="button" data-tr="cancel">Cancel</button>
|
||||
<button type="submit" class="primary" data-tr="save">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<dialog id="cmap-export-dialog" class="cmap-concept-dialog" aria-labelledby="cmap-export-title">
|
||||
<form id="cmap-export-form">
|
||||
<h2 id="cmap-export-title" data-tr="export-markdown">Export CMap…</h2>
|
||||
<label>
|
||||
<span data-tr="linked-concept-map-depth">Depth of linked CMaps</span>
|
||||
<input id="cmap-export-depth" type="number" min="0" max="10" step="1" value="1" required>
|
||||
</label>
|
||||
<label class="cmap-check-label">
|
||||
<input id="cmap-export-pages" type="checkbox">
|
||||
<span data-tr="include-linked-wiki-pages">Include linked wiki pages in Markdown (JSON always includes them)</span>
|
||||
</label>
|
||||
<p class="muted" data-tr="cmap-export-help">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.</p>
|
||||
<p id="cmap-export-status" class="muted" role="status"></p>
|
||||
<div class="cmap-concept-dialog-actions">
|
||||
<button id="cmap-export-cancel" type="button" data-tr="cancel">Cancel</button>
|
||||
<button id="cmap-export-copy" type="button" data-tr="copy-markdown">Copy Markdown</button>
|
||||
<button id="cmap-export-json" type="button" data-tr="download-cmap-json">Download JSON</button>
|
||||
<button type="submit" class="primary" data-tr="download-markdown">Download Markdown</button>
|
||||
</div>
|
||||
</form>
|
||||
</dialog>
|
||||
|
||||
<input id="cmap-import-file" class="hidden" type="file" accept=".json,application/json">
|
||||
|
||||
<dialog id="cmap-unsaved-dialog" class="cmap-unsaved-dialog" aria-labelledby="cmap-unsaved-title">
|
||||
<form method="dialog">
|
||||
<h2 id="cmap-unsaved-title" data-tr="unsaved-concept-map">Unsaved concept map</h2>
|
||||
@@ -574,6 +668,7 @@
|
||||
</dialog>
|
||||
|
||||
<script src="/vendor/purify.min.js"></script>
|
||||
<script src="/vendor/mermaid.min.js"></script>
|
||||
<script src="/vendor/highlight.min.js"></script>
|
||||
<script src="/vendor/highlight-scheme.min.js"></script>
|
||||
<script src="/vendor/easymde.min.js"></script>
|
||||
@@ -582,6 +677,9 @@
|
||||
<script src="/cmap/cmap.js?id=__CMAP_CACHE_ID__"></script>
|
||||
<script src="/cmap/cmap-racket-wiki.js?id=__CMAP_CACHE_ID__"></script>
|
||||
<script src="/js/combobox.js?id=__CMAP_CACHE_ID__"></script>
|
||||
<script src="/js/mermaid-racket-wiki.js?id=__CMAP_CACHE_ID__"></script>
|
||||
<script src="/js/cmap-export.js?id=__CMAP_CACHE_ID__"></script>
|
||||
<script src="/js/cmap-interchange.js?id=__CMAP_CACHE_ID__"></script>
|
||||
<script src="/js/wiki.js?id=__CMAP_CACHE_ID__"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
@@ -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:<name> temporary id");
|
||||
} else if (conceptIds.has(concept.id)) {
|
||||
issue(`${path}.id`, "is duplicated");
|
||||
} else conceptIds.add(concept.id);
|
||||
if (typeof concept.label !== "string" || !concept.label.trim()) issue(`${path}.label`, "is required");
|
||||
else {
|
||||
const name = concept.label.trim().toLocaleLowerCase();
|
||||
if (conceptLabels.has(name)) issue(`${path}.label`, "duplicates another concept name");
|
||||
else conceptLabels.add(name);
|
||||
}
|
||||
for (const key of ["pageSlug", "descriptionPageSlug"]) {
|
||||
if (typeof concept[key] === "string" && concept[key].trim()) {
|
||||
linkedPageReferences.add(concept[key].trim());
|
||||
if (!validPageReference(concept[key].trim())) issue(`${path}.${key}`, "must be a valid wiki page reference");
|
||||
}
|
||||
}
|
||||
if (typeof concept.cmapSlug === "string" && concept.cmapSlug.trim() && !SLUG.test(concept.cmapSlug.trim())) {
|
||||
issue(`${path}.cmapSlug`, "must be a valid CMap slug");
|
||||
}
|
||||
if (concept.externalUrl !== undefined && concept.externalUrl !== null &&
|
||||
(typeof concept.externalUrl !== "string" ||
|
||||
!concept.externalUrl.trim() || !validExternalUrl(concept.externalUrl.trim()))) {
|
||||
issue(`${path}.externalUrl`, "must be a complete http or https URL");
|
||||
}
|
||||
}
|
||||
|
||||
const mapSlugs = new Set();
|
||||
for (const [mapIndex, cmap] of (Array.isArray(bundle.cmaps) ? bundle.cmaps : []).entries()) {
|
||||
const path = `cmaps[${mapIndex}]`;
|
||||
if (!cmap || typeof cmap !== "object" || Array.isArray(cmap)) {
|
||||
issue(path, "must be an object");
|
||||
continue;
|
||||
}
|
||||
if (typeof cmap.slug !== "string" || !SLUG.test(cmap.slug)) issue(`${path}.slug`, "must be a valid CMap slug");
|
||||
else if (mapSlugs.has(cmap.slug)) issue(`${path}.slug`, "is duplicated");
|
||||
else mapSlugs.add(cmap.slug);
|
||||
if (typeof cmap.title !== "string" || !cmap.title.trim()) issue(`${path}.title`, "is required");
|
||||
const documentValue = cmap.document;
|
||||
if (!documentValue || typeof documentValue !== "object" || Array.isArray(documentValue)) {
|
||||
issue(`${path}.document`, "must be an object");
|
||||
continue;
|
||||
}
|
||||
const items = Array.isArray(documentValue.items) ? documentValue.items : [];
|
||||
const metadataReference = documentValue.metadata?.explanationPageSlug;
|
||||
if (typeof metadataReference === "string" && metadataReference.trim()) {
|
||||
linkedPageReferences.add(metadataReference.trim());
|
||||
if (!validPageReference(metadataReference.trim())) {
|
||||
issue(`${path}.document.metadata.explanationPageSlug`, "must be a valid wiki page reference");
|
||||
}
|
||||
}
|
||||
const documentConceptIds = new Set();
|
||||
for (const [referenceIndex, reference] of (Array.isArray(documentValue.concepts) ?
|
||||
documentValue.concepts : []).entries()) {
|
||||
const referencePath = `${path}.document.concepts[${referenceIndex}].id`;
|
||||
if (!reference || typeof reference.id !== "string" || !conceptIds.has(reference.id)) {
|
||||
issue(referencePath, "must reference a concept in concepts[]");
|
||||
} else if (documentConceptIds.has(reference.id)) issue(referencePath, "is duplicated within the CMap");
|
||||
else documentConceptIds.add(reference.id);
|
||||
}
|
||||
const itemIds = new Set();
|
||||
for (const [itemIndex, item] of items.entries()) {
|
||||
const itemPath = `${path}.document.items[${itemIndex}]`;
|
||||
if (!item || typeof item !== "object" || Array.isArray(item)) {
|
||||
issue(itemPath, "must be an object");
|
||||
continue;
|
||||
}
|
||||
if (!Number.isInteger(Number(item.id))) issue(`${itemPath}.id`, "must be an integer");
|
||||
else if (itemIds.has(Number(item.id))) issue(`${itemPath}.id`, "is duplicated within the CMap");
|
||||
else itemIds.add(Number(item.id));
|
||||
if (!Number.isFinite(Number(item.x))) issue(`${itemPath}.x`, "must be a number");
|
||||
if (!Number.isFinite(Number(item.y))) issue(`${itemPath}.y`, "must be a number");
|
||||
if (item.kind !== "phrase") {
|
||||
if (typeof item.conceptId !== "string" || !conceptIds.has(item.conceptId)) {
|
||||
issue(`${itemPath}.conceptId`, "must reference a concept in concepts[]");
|
||||
} else usedConceptIds.add(item.conceptId);
|
||||
}
|
||||
}
|
||||
for (const [connectorIndex, connector] of (Array.isArray(documentValue.connectors) ?
|
||||
documentValue.connectors : []).entries()) {
|
||||
const connectorPath = `${path}.document.connectors[${connectorIndex}]`;
|
||||
if (!itemIds.has(Number(connector?.sourceId))) issue(`${connectorPath}.sourceId`, "references an unknown item");
|
||||
if (!itemIds.has(Number(connector?.targetId))) issue(`${connectorPath}.targetId`, "references an unknown item");
|
||||
}
|
||||
}
|
||||
if (typeof bundle.rootCmapSlug !== "string" || !mapSlugs.has(bundle.rootCmapSlug)) {
|
||||
issue("rootCmapSlug", "must reference a CMap in cmaps[]");
|
||||
}
|
||||
for (const id of conceptIds) {
|
||||
if (!usedConceptIds.has(id)) issue(`concepts[id=${id}]`, "must occur as a diagram placement");
|
||||
}
|
||||
|
||||
const pageReferences = new Set();
|
||||
for (const [index, page] of (Array.isArray(bundle.pages) ? bundle.pages : []).entries()) {
|
||||
const path = `pages[${index}]`;
|
||||
if (!page || typeof page !== "object" || Array.isArray(page)) {
|
||||
issue(path, "must be an object");
|
||||
continue;
|
||||
}
|
||||
if (typeof page.reference !== "string" || !validPageReference(page.reference)) issue(`${path}.reference`, "must be a valid wiki page reference");
|
||||
else if (pageReferences.has(page.reference)) issue(`${path}.reference`, "is duplicated");
|
||||
else pageReferences.add(page.reference);
|
||||
if (typeof page.title !== "string" || !page.title.trim()) issue(`${path}.title`, "is required");
|
||||
if (typeof page.markdown !== "string") issue(`${path}.markdown`, "must be a string");
|
||||
if (!Array.isArray(page.tags) || !page.tags.every((tag) => typeof tag === "string")) {
|
||||
issue(`${path}.tags`, "must be an array of strings");
|
||||
}
|
||||
if (page.attachments !== undefined && !Array.isArray(page.attachments)) {
|
||||
issue(`${path}.attachments`, "must be an array");
|
||||
}
|
||||
const attachmentReferences = new Set();
|
||||
for (const [attachmentIndex, attachment] of (Array.isArray(page.attachments) ?
|
||||
page.attachments : []).entries()) {
|
||||
const attachmentPath = `${path}.attachments[${attachmentIndex}]`;
|
||||
if (!attachment || typeof attachment !== "object" || Array.isArray(attachment)) {
|
||||
issue(attachmentPath, "must be an object");
|
||||
continue;
|
||||
}
|
||||
if (typeof attachment.url !== "string" || !attachment.url.startsWith("/uploads/")) {
|
||||
issue(`${attachmentPath}.url`, "must be a local /uploads/ URL");
|
||||
} else if (attachmentReferences.has(attachment.url)) {
|
||||
issue(`${attachmentPath}.url`, "is duplicated within the page");
|
||||
} else attachmentReferences.add(attachment.url);
|
||||
if (typeof attachment.name !== "string" || !attachment.name.trim()) {
|
||||
issue(`${attachmentPath}.name`, "is required");
|
||||
}
|
||||
if (typeof attachment.mimeType !== "string" || !attachment.mimeType.trim()) {
|
||||
issue(`${attachmentPath}.mimeType`, "is required");
|
||||
}
|
||||
if (typeof attachment.contentBase64 !== "string" ||
|
||||
!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(attachment.contentBase64)) {
|
||||
issue(`${attachmentPath}.contentBase64`, "must be valid base64");
|
||||
}
|
||||
}
|
||||
}
|
||||
const explicitlyMissingPages = new Set(Array.isArray(bundle.missing?.pages) ? bundle.missing.pages : []);
|
||||
for (const reference of linkedPageReferences) {
|
||||
if (!pageReferences.has(reference) && !explicitlyMissingPages.has(reference)) {
|
||||
issue(`pages[reference=${reference}]`, "is required by a linked concept or CMap explanation");
|
||||
}
|
||||
}
|
||||
|
||||
if (errors.length) {
|
||||
const error = new Error(`Invalid CMap bundle:\n${errors.slice(0, 20).join("\n")}`);
|
||||
error.validationErrors = errors;
|
||||
throw error;
|
||||
}
|
||||
return bundle;
|
||||
}
|
||||
|
||||
function preparedMapDocument(bundle, cmap) {
|
||||
validateBundle(bundle);
|
||||
const byId = new Map(bundle.concepts.map((concept) => [concept.id, concept]));
|
||||
const documentValue = clone(cmap.document);
|
||||
const ids = itemConceptIds(documentValue);
|
||||
for (const reference of (Array.isArray(documentValue.concepts) ? documentValue.concepts : [])) {
|
||||
if (reference?.id && !ids.includes(reference.id)) ids.push(reference.id);
|
||||
}
|
||||
documentValue.concepts = ids.map((id) => clone(byId.get(id)));
|
||||
return documentValue;
|
||||
}
|
||||
|
||||
return {
|
||||
FORMAT, FORMAT_VERSION, SCHEMA, buildBundle, validateBundle,
|
||||
preparedMapDocument, placementDocument, decodedDocument,
|
||||
attachmentUrls, replaceAttachmentUrls
|
||||
};
|
||||
});
|
||||
@@ -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 };
|
||||
});
|
||||
+1214
-63
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -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`
|
||||
|
||||
@@ -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
|
||||
}]);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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"]);
|
||||
});
|
||||
@@ -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", 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 = `\n<img src="${oldUrl}">`;
|
||||
assert.deepEqual(attachmentUrls(markdown), [oldUrl]);
|
||||
assert.equal(
|
||||
replaceAttachmentUrls(markdown, new Map([[oldUrl, "/uploads/process:details/new-diagram.png"]])),
|
||||
"\n" +
|
||||
"<img src=\"/uploads/process:details/new-diagram.png\">"
|
||||
);
|
||||
assert.deepEqual(
|
||||
attachmentUrls(""),
|
||||
["/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/);
|
||||
});
|
||||
@@ -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("<svg>")) 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");
|
||||
});
|
||||
@@ -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]]);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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: "<svg>diagram</svg>" };
|
||||
}
|
||||
};
|
||||
await integration.hydrate(value.container, mermaid);
|
||||
assert.equal(value.replacement().className, "rw-mermaid");
|
||||
assert.equal(value.replacement().innerHTML, "<svg>diagram</svg>");
|
||||
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");
|
||||
});
|
||||
+82
-14
@@ -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)"
|
||||
|
||||
Reference in New Issue
Block a user