From 38f255c1a4e6437ee25eaa61aecbc4bce0b44d18 Mon Sep 17 00:00:00 2001 From: Hans Dijkema Date: Wed, 2 Sep 2026 08:38:03 +0200 Subject: [PATCH] refactoring --- README.md | 5 + architecture/pages/analyseerbaarheid.md | 2 +- architecture/pages/architectuur.md | 2 +- architecture/pages/beveiliging.md | 2 +- architecture/pages/data-en-versies.md | 2 - architecture/pages/gedrag.md | 4 +- architecture/pages/modulariteit.md | 4 +- architecture/pages/performance.md | 28 +- architecture/pages/structuur-en-samenhang.md | 4 +- private/cmap-storage.rkt | 1051 ++-- static/cmap/README.md | 22 +- static/cmap/cmap-racket-wiki.js | 539 +- static/cmap/cmap-view.js | 37 + static/cmap/cmap.css | 9 + static/cmap/model/concept-map.js | 422 ++ static/cmap/model/concept-repository.js | 217 + static/cmap/model/package.json | 3 + static/cmap/package.json | 3 + static/css/wiki.css | 252 +- static/index.html | 76 +- static/js/wiki.js | 4686 +---------------- static/js/wiki/admin/alias-admin.js | 166 + static/js/wiki/admin/archived-cmaps-admin.js | 103 + static/js/wiki/admin/mail-admin.js | 130 + .../js/wiki/admin/orphaned-uploads-admin.js | 124 + static/js/wiki/admin/overview.js | 25 + static/js/wiki/admin/user-admin.js | 148 + static/js/wiki/breadcrumb-trail.js | 116 + .../cmap/interchange.js} | 36 +- static/js/wiki/cmap/workspace.js | 3140 +++++++++++ static/js/wiki/markdown.js | 321 ++ static/js/wiki/package.json | 3 + static/js/wiki/page-outline.js | 64 + static/js/wiki/reference.js | 102 + static/js/wiki/routes.js | 71 + test/cmap-connector-rewire.test.js | 8 +- test/cmap-dirty-layout.test.js | 8 +- test/cmap-interchange.test.js | 36 +- test/cmap-layering.test.js | 3 +- test/cmap-model.test.mjs | 274 + test/cmap-selection-layout.test.js | 14 +- test/cmap-submap-navigation.test.js | 8 +- test/wiki-breadcrumb-trail.test.mjs | 79 + test/wiki-markdown.test.mjs | 69 + test/wiki-page-outline.test.mjs | 30 + test/wiki-reference.test.mjs | 43 + test/wiki-routes.test.mjs | 60 + translate.rkt | 24 +- 48 files changed, 6998 insertions(+), 5577 deletions(-) create mode 100644 static/cmap/cmap-view.js create mode 100644 static/cmap/model/concept-map.js create mode 100644 static/cmap/model/concept-repository.js create mode 100644 static/cmap/model/package.json create mode 100644 static/cmap/package.json create mode 100644 static/js/wiki/admin/alias-admin.js create mode 100644 static/js/wiki/admin/archived-cmaps-admin.js create mode 100644 static/js/wiki/admin/mail-admin.js create mode 100644 static/js/wiki/admin/orphaned-uploads-admin.js create mode 100644 static/js/wiki/admin/overview.js create mode 100644 static/js/wiki/admin/user-admin.js create mode 100644 static/js/wiki/breadcrumb-trail.js rename static/js/{cmap-interchange.js => wiki/cmap/interchange.js} (96%) create mode 100644 static/js/wiki/cmap/workspace.js create mode 100644 static/js/wiki/markdown.js create mode 100644 static/js/wiki/package.json create mode 100644 static/js/wiki/page-outline.js create mode 100644 static/js/wiki/reference.js create mode 100644 static/js/wiki/routes.js create mode 100644 test/cmap-model.test.mjs create mode 100644 test/wiki-breadcrumb-trail.test.mjs create mode 100644 test/wiki-markdown.test.mjs create mode 100644 test/wiki-page-outline.test.mjs create mode 100644 test/wiki-reference.test.mjs create mode 100644 test/wiki-routes.test.mjs diff --git a/README.md b/README.md index c40ecf8..b3e6e89 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,11 @@ Version 0.2.31 adds page namespaces as database metadata and extends wiki refere Current development version: **0.2.100**. +The current development version removes the automatic navigation graph and the +page-local context graph. Both derived large diagrams obscured the ordinary +page and CMap navigation without adding enough practical value. Stored CMaps +and their editor remain unchanged. + A small self-hosted wiki with a Racket backend and an HTML5/CSS/JavaScript frontend. Version 0.2.9 uses PostgreSQL as the wiki's complete content store. Users, sessions, pages, immutable page versions, attachment metadata and attachment bytes live in PostgreSQL. PostgreSQL full-text search is built into the page table and exposed by the wiki search UI. diff --git a/architecture/pages/analyseerbaarheid.md b/architecture/pages/analyseerbaarheid.md index f85d342..c29a243 100644 --- a/architecture/pages/analyseerbaarheid.md +++ b/architecture/pages/analyseerbaarheid.md @@ -64,7 +64,7 @@ Voeg observability incrementeel toe: 1. eerst een request-id en gestandaardiseerde foutregel; 2. daarna tijdmetingen rond trage endpoints; -3. vervolgens database- en graphmetingen wanneer echte belasting dat vereist; +3. vervolgens database- en browsermetingen wanneer echte belasting dat vereist; 4. pas dan een externe metrics- of tracingstack wanneer lokaal loggen onvoldoende blijkt. ## Analyseerbare code diff --git a/architecture/pages/architectuur.md b/architecture/pages/architectuur.md index 5ef5172..5835654 100644 --- a/architecture/pages/architectuur.md +++ b/architecture/pages/architectuur.md @@ -41,7 +41,7 @@ De huidige vorm rust op een klein aantal bewuste keuzes: ## Kwaliteitsbeeld -De architectuur past goed bij een persoonlijke of teamwiki: weinig processen, een duidelijke databasebron en volledige historie. De sterkste punten zijn de transactionele inhoudsopslag, eenvoudige deployment en lokale frontend-assets. De voornaamste ontwikkelpunten zijn de omvang van `server.rkt` en `static/js/wiki.js`, het ontbreken van een volwaardige geautomatiseerde testsuite, een nieuwe databaseverbinding per opslagbewerking en de N+1-aanpak waarmee de volledige navigatiegraaf wordt opgebouwd. +De architectuur past goed bij een persoonlijke of teamwiki: weinig processen, een duidelijke databasebron en volledige historie. De sterkste punten zijn de transactionele inhoudsopslag, eenvoudige deployment en lokale frontend-assets. De voornaamste ontwikkelpunten zijn de omvang van `server.rkt` en `static/js/wiki.js`, het ontbreken van een volwaardige geautomatiseerde testsuite en een nieuwe databaseverbinding per opslagbewerking. Deze punten zijn geen reden voor een voorafgaande grote herbouw. De ontwikkelregel is: meet eerst, isoleer het concrete probleem en splits een module wanneer een wijziging daar aantoonbaar eenvoudiger of beter testbaar door wordt. diff --git a/architecture/pages/beveiliging.md b/architecture/pages/beveiliging.md index 2255d2c..e072cc5 100644 --- a/architecture/pages/beveiliging.md +++ b/architecture/pages/beveiliging.md @@ -42,7 +42,7 @@ De uitzonderingsoptie is uitsluitend bedoeld voor een bewust vertrouwde lokale m ## Beschikbaarheid en misbruik -Er gelden al document- en veldvalidaties, maar een algemeen uploadmaximum en request-rate limiting zijn toekomstige versterkingen. De CMap-opslag begrenst het JSON-document tot 10 MiB. Grote Markdown, bijlagen, graph-opbouw en dure zoekvragen kunnen anders geheugen of verwerkingstijd gebruiken. +Er gelden al document- en veldvalidaties, maar een algemeen uploadmaximum en request-rate limiting zijn toekomstige versterkingen. De CMap-opslag begrenst het JSON-document tot 10 MiB. Grote Markdown, bijlagen en dure zoekvragen kunnen anders geheugen of verwerkingstijd gebruiken. Voor een publiek bereikbare installatie horen reverse-proxylimits, databaseback-ups, logrotatie en monitoring bij het beveiligingsmodel. Beschikbaarheid is ook een beveiligingseigenschap. diff --git a/architecture/pages/data-en-versies.md b/architecture/pages/data-en-versies.md index aa78917..ce6edcf 100644 --- a/architecture/pages/data-en-versies.md +++ b/architecture/pages/data-en-versies.md @@ -53,8 +53,6 @@ De prijs is lineaire databasegroei met het aantal versies maal de documentgroott `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. diff --git a/architecture/pages/gedrag.md b/architecture/pages/gedrag.md index 7f95f46..c3da69b 100644 --- a/architecture/pages/gedrag.md +++ b/architecture/pages/gedrag.md @@ -72,12 +72,10 @@ conceptkaarten. De hit-test gebruikt dezelfde laagprioriteit, zodat een achterli klik kan afvangen op de kaart die haar bedekt. Tijdelijke eindpunthendels blijven wel bovenop liggen zolang een relatie wordt versleept. -## Zoeken, Recent en navigatiegraaf +## Zoeken en Recent 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. -De gecombineerde navigatiegraaf leest verwijzingen uit alle huidige pagina's en CMaps. Hij wordt in de browser gecachet totdat een catalogus opnieuw wordt geladen. Dit levert rijke navigatie op, maar is het belangrijkste schaalrisico; zie [Verwachte performance](racket-wiki:performance). - ## Wachtwoordherstel en mail De publieke herstelactie geeft altijd dezelfde reactie, ook als een account niet bestaat. Een echte aanvraag maakt een gehashte, eenmalige code die één uur geldig is en past rate limiting per gebruiker toe. Na succesvol herstel worden bestaande sessies ingetrokken. diff --git a/architecture/pages/modulariteit.md b/architecture/pages/modulariteit.md index d9935e5..a5e3f40 100644 --- a/architecture/pages/modulariteit.md +++ b/architecture/pages/modulariteit.md @@ -6,7 +6,7 @@ De backend is functioneel opgesplitst. Configuratie, databaseverbinding, migrati De belangrijkste grens is die tussen `server.rkt` en de opslagmodules. `server.rkt` hoort HTTP te begrijpen; `storage.rkt`, `cmap-storage.rkt` en `auth.rkt` horen domeinbewerkingen en database-invarianten te begrijpen. Geen opslagprocedure mag een webrequest nodig hebben. -De frontend heeft een vergelijkbare grens. `cmap-racket-wiki.js` implementeert een editorcomponent met callbacks voor openen, selectie en wijzigingen. `wiki.js` koppelt die callbacks aan routes, API's en dialoogvensters. +De frontend heeft een vergelijkbare grens. `cmap-racket-wiki.js` implementeert een editorcomponent met callbacks voor openen, selectie en wijzigingen. `wiki.js` koppelt die callbacks aan routes, API's en dialoogvensters. Zuivere referentie-, route-, Markdown- en outlinebewerkingen staan in eigen modules; `BreadcrumbTrail` beheert uitsluitend de per-tab paginahistorie en kent de DOM niet. `routes.js` herkent hashes, terwijl `wiki.js` eigenaar blijft van permissiecontrole en het openen van views. De concrete controllers onder `static/js/wiki/admin/` beheren hun eigen formulier- of lijstinteractie. Zij ontvangen alleen de bestaande API- en vertaalfuncties en, wanneer de gewone catalogus werkelijk verandert, één gerichte herlaadfunctie. ## Sterke punten @@ -42,7 +42,7 @@ Deze concentraties zijn technische schuld, geen automatische opdracht tot een gr Wanneer `server.rkt` verder groeit, ligt opsplitsing per adaptergebied voor de hand: sessie/profiel, pagina's, CMaps en beheer. De centrale router kan dan dun blijven. Handlers ontvangen `config` expliciet en roepen dezelfde bestaande diensten aan. -Wanneer `wiki.js` verder groeit, zijn route/state, pagina-editor, CMap-host, graph-view en admin-views natuurlijke grenzen. Splits alleen met native ES-modules wanneer de setup- en cacheversies van alle scripts tegelijk beheerst worden. +Wanneer `wiki.js` verder groeit, zijn route/state, pagina-editor, CMap-host en admin-views natuurlijke grenzen. Splits alleen met native ES-modules wanneer de setup- en cacheversies van alle scripts tegelijk beheerst worden. Wanneer paginaopslag wordt aangepast, kunnen bookmarks, aliases en uploads later eigen modules krijgen. De pagina-schrijftransactie en afgeleide indices moeten daarbij als één consistente operatie behouden blijven; opsplitsing van bestanden mag geen opsplitsing van de transactie veroorzaken. diff --git a/architecture/pages/performance.md b/architecture/pages/performance.md index 92493db..04cff22 100644 --- a/architecture/pages/performance.md +++ b/architecture/pages/performance.md @@ -9,8 +9,8 @@ De onderstaande schaalinschatting is een architectuuranalyse, geen benchmark. We | Omvang | Verwachting met huidige architectuur | | --- | --- | | Honderden pagina's, tientallen CMaps | Normale lees-, schrijf- en zoekacties horen ruim voldoende te zijn op een gewone lokale server. | -| Enkele duizenden pagina's, honderden CMaps | Paginaweergave en geïndexeerd paginazoeken blijven waarschijnlijk goed; volledige graph-opbouw, CMap-zoeken, catalogusgrootte en verbindingsopbouw worden zichtbaar. | -| Tienduizenden pagina's of veel grote CMaps | Paginering, server-side graphindex, connectionpooling, geïndexeerd CMap-zoeken en versie-/bijlagebeleid worden waarschijnlijk noodzakelijk. | +| Enkele duizenden pagina's, honderden CMaps | Paginaweergave en geïndexeerd paginazoeken blijven waarschijnlijk goed; CMap-zoeken, catalogusgrootte en verbindingsopbouw worden zichtbaar. | +| Tienduizenden pagina's of veel grote CMaps | Paginering, connectionpooling, geïndexeerd CMap-zoeken en versie-/bijlagebeleid worden waarschijnlijk noodzakelijk. | Het ontwerp is dus passend voor een persoonlijke of teamwiki. Het is niet zonder aanvullende maatregelen ontworpen als internetbrede kennisbank met zeer veel gelijktijdige gebruikers. @@ -18,16 +18,10 @@ Het ontwerp is dus passend voor een persoonlijke of teamwiki. Het is niet zonder De actuele pagina staat direct in `pages`; voor normaal lezen hoeft geen versiegeschiedenis te worden opgebouwd. Titel en Markdown leveren een opgeslagen gewogen zoekvector met GIN-index. PostgreSQL verwerkt transacties en locking dicht bij de data. De browser ontvangt bij de paginacatalogus alleen metadata en haalt Markdown pas op bij gebruik. -Vendor-assets worden lokaal geserveerd en veranderen niet tijdens normaal gebruik. De browser cachet de opgebouwde navigatiegraaf totdat pagina- of CMapcatalogus opnieuw wordt geladen. +Vendor-assets worden lokaal geserveerd en veranderen niet tijdens normaal gebruik. ## Belangrijkste toekomstige knelpunten -### Volledige navigatiegraaf - -`loadGraphData()` haalt momenteel iedere pagina en iedere CMap sequentieel via een eigen API-request op en extraheert daarna links in de browser. Voor `P` pagina's en `C` CMaps zijn dat `P + C` inhoudsrequests naast de catalogi. Latency groeit daardoor lineair en netwerkvertraging telt herhaaldelijk op. - -De duurzame oplossing is een server-side relationele linkindex die in dezelfde schrijftransactie als pagina of CMap wordt bijgewerkt. Een graph-endpoint kan dan alle nodes en edges in één compacte response leveren. - ### CMap-zoeken Paginazoeken gebruikt een opgeslagen index. CMap-zoeken bouwt per query tekst uit JSONB-items en maakt daar op dat moment een `tsvector` van. Bij veel of grote kaarten wordt dit een volledige scan. Voeg dan een opgeslagen zoektekst/`tsvector` en GIN-index aan `concept_maps` toe, bijgewerkt tijdens CMap-opslag. @@ -50,7 +44,7 @@ Bijlagebytes staan in PostgreSQL en worden volledig in geheugen gelezen voor de ### Browserrendering -Grote CMaps tekenen veel DOM/SVG-objecten en connectors. Volledige maps en graphs hebben uiteindelijk layoutkosten die niet met een snellere database verdwijnen. Meet nodeaantal, rendertijd en interactieframes. Sub-CMaps, viewportculling of vereenvoudigde read-only rendering zijn dan gerichte opties. +Grote CMaps tekenen veel DOM/SVG-objecten en connectors. Volledige kaarten hebben uiteindelijk layoutkosten die niet met een snellere database verdwijnen. Meet nodeaantal, rendertijd en interactieframes. Sub-CMaps, viewportculling of vereenvoudigde read-only rendering zijn dan gerichte opties. ## Meetplan @@ -58,9 +52,8 @@ Voeg vóór optimalisatie minimaal deze metingen toe: - requestduur per endpoint en responsebytes; - connecttijd versus SQL-tijd; -- aantallen pagina's, CMaps, graph-edges en versies; +- aantallen pagina's, CMaps en versies; - gemiddelde en p95 Markdown-, JSONB- en bijlagegrootte; -- tijd voor volledige graph-opbouw; - tijd voor CMap-zoekquery's; - browserrendertijd bij representatieve CMaps. @@ -70,11 +63,10 @@ Gebruik datasets met echte linkdichtheid en documentgroottes. Duizend lege pagin De verwachte volgorde bij groei is: -1. graph-edges tijdens opslag indexeren en in één request leveren; -2. CMap-zoekvector opslaan en indexeren; -3. databaseconnectionpool toevoegen; -4. catalogi pagineren of per namespace laden; -5. expliciet versie- en uploadbeleid ontwerpen; -6. pas daarna horizontale of servicegerichte architectuur overwegen. +1. CMap-zoekvector opslaan en indexeren; +2. databaseconnectionpool toevoegen; +3. catalogi pagineren of per namespace laden; +4. expliciet versie- en uploadbeleid ontwerpen; +5. pas daarna horizontale of servicegerichte architectuur overwegen. Deze volgorde behoudt de eenvoud van [Structuur en samenhang](racket-wiki:structuur-en-samenhang) zolang die nog waardevol is. diff --git a/architecture/pages/structuur-en-samenhang.md b/architecture/pages/structuur-en-samenhang.md index 3a3103b..b018564 100644 --- a/architecture/pages/structuur-en-samenhang.md +++ b/architecture/pages/structuur-en-samenhang.md @@ -38,7 +38,7 @@ De map `private/` bevat de backendonderdelen: `translate.rkt` staat bewust aan de publieke rand: zowel backend als frontend gebruiken dezelfde effectieve vertaaltabel. -De map `static/` bevat de browserapplicatie. `static/index.html` definieert de views en dialogen. `static/js/wiki.js` beheert routing, API-aanroepen, editor- en paginatoestand en speciale views. `static/cmap/cmap.js` levert de grafische basis; `cmap-racket-wiki.js` voegt wiki-items, selectie, relaties, sub-CMaps, historie en documentserialisatie toe. `combobox.js` is een herbruikbaar klein UI-onderdeel. CSS is verdeeld tussen algemene wiki-opmaak en CMap-opmaak. +De map `static/` bevat de browserapplicatie. `static/index.html` definieert de views en dialogen. `static/js/wiki.js` beheert routedispatch, API-aanroepen, editor- en paginatoestand en speciale views. De modules onder `static/js/wiki/` bevatten afzonderlijk de referentiesyntaxis, routeherkenning, Markdowntransformaties, paginakoppen en breadcrumbhistorie. De controllers onder `static/js/wiki/admin/` beheren ieder één administratief formulier of overzicht; routes, permissiecontrole, breadcrumbs en algemene adminnavigatie blijven in `wiki.js`. `static/cmap/cmap.js` levert de grafische basis; `cmap-racket-wiki.js` voegt wiki-items, selectie, relaties, sub-CMaps, historie en documentserialisatie toe. `combobox.js` is een herbruikbaar klein UI-onderdeel. CSS is verdeeld tussen algemene wiki-opmaak en CMap-opmaak. ## Afhankelijkheidsrichting @@ -56,7 +56,7 @@ Deze richting houdt de belangrijkste domeinregels buiten de UI. Een rolcontrole Een pagina heeft een database-id, namespace, slug, actuele Markdown en een versieteller. `page_versions` verwijst naar dezelfde pagina-id. Todo's, bookmarks, aliases en bijlageverwijzingen sluiten via die id aan. -Een CMap heeft een stabiele slug, titel, JSONB-document en versieteller. `concept_map_versions` bewaart volledige JSONB-snapshots. CMap-items kunnen via `pageSlug` naar een wikipagina verwijzen en via `cmapSlug` naar een andere CMap. De browser gebruikt deze verwijzingen voor navigatie en de gecombineerde sitegraph. +Een CMap heeft een stabiele slug, titel, JSONB-document en versieteller. `concept_map_versions` bewaart volledige JSONB-snapshots. CMap-items kunnen via `pageSlug` naar een wikipagina verwijzen en via `cmapSlug` naar een andere CMap. De browser gebruikt deze verwijzingen om het gekoppelde doel rechtstreeks te openen. Zie [Data, transacties en versiebeheer](racket-wiki:data-en-versies) voor de invarianten en [Modulariteit en afhankelijkheden](racket-wiki:modulariteit) voor de gewenste grenzen bij uitbreiding. diff --git a/private/cmap-storage.rkt b/private/cmap-storage.rkt index a2ae302..12db116 100644 --- a/private/cmap-storage.rkt +++ b/private/cmap-storage.rkt @@ -34,32 +34,43 @@ (define concept-map-columns "slug, title, document::text, current_version, created_at, updated_at, created_by, updated_by") +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Concept-map documents and shared concept definitions +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +;;; Encodes a CMap document as size-limited JSON text for PostgreSQL. (define (document->text document) (unless (jsexpr? document) (raise-argument-error 'document->text "jsexpr?" document)) - (define text (jsexpr->string document)) - (when (> (bytes-length (string->bytes/utf-8 text)) maximum-document-size) - (error 'document->text "concept map document exceeds 10 MiB")) - text) + (let ((text (jsexpr->string document))) + (when (> (bytes-length (string->bytes/utf-8 text)) maximum-document-size) + (error 'document->text "concept map document exceeds 10 MiB")) + text)) +;;; Decodes a stored JSON object, including documents encoded twice by older versions. (define (text->document text) (with-handlers ((exn:fail? (λ (e) (error 'text->document "invalid stored concept map document: ~a" (exn-message e))))) - (define parsed (string->jsexpr text)) - (define document - (if (string? parsed) - (string->jsexpr parsed) - parsed)) - (unless (hash? document) - (error 'text->document "stored concept map document is not a JSON object")) - document)) + (let* ((parsed (string->jsexpr text)) + (document (if (string? parsed) + (string->jsexpr parsed) + parsed))) + (unless (hash? document) + (error 'text->document "stored concept map document is not a JSON object")) + document))) +;;; Returns the concepts array, treating a malformed or absent value as empty. (define (document-concepts document) - (define concepts (hash-ref document 'concepts '())) - (if (list? concepts) concepts '())) + (let ((concepts (hash-ref document 'concepts '()))) + (if (list? concepts) concepts '()))) + +;;; Returns the items array, treating a malformed or absent value as empty. +(define (document-items document) + (let ((items (hash-ref document 'items '()))) + (if (list? items) items '()))) (define concept-content-keys '(id label synopsis aspects tags descriptionPageSlug pageSlug cmapSlug externalUrl @@ -69,162 +80,221 @@ '(label synopsis aspects tags descriptionPageSlug pageSlug cmapSlug externalUrl imageSource)) +;;; Recognizes complete HTTP and HTTPS links accepted as shared concept content. (define (valid-external-url? value) (and (string? value) (regexp-match? #px"(?i:^https?://[^\\s]+$)" value))) +;;; Selects and sanitizes the part of a concept stored in concept_definitions. (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))))) + (let* ((has-external-url? (hash-has-key? concept 'externalUrl)) + (external-url (hash-ref concept 'externalUrl #f))) + (when (and has-external-url? + external-url + (not (eq? external-url 'null)) + (not (equal? external-url "")) + (not (valid-external-url? external-url))) + (error 'concept-content "externalUrl must be a complete http or https URL")) + (let copy-content ((keys concept-content-keys) + (content (hash))) + (cond + ((null? keys) content) + ((not (hash-has-key? concept (car keys))) + (copy-content (cdr keys) content)) + (else + (let* ((key (car keys)) + (value (hash-ref concept key)) + (tags? (eq? key 'tags)) + (stored-value + (cond + ((not tags?) value) + ((list? value) + (filter (λ (tag) + (and (hash? tag) + (equal? (hash-ref tag 'type #f) "person"))) + value)) + (else '())))) + (copy-content (cdr keys) (hash-set content key stored-value)))))))) +;;; Recognizes a non-phrase item that refers to a concept by string identifier. +(define (concept-placement? item) + (and (hash? item) + (string? (hash-ref item 'conceptId #f)) + (not (equal? (hash-ref item 'kind "concept") "phrase")))) + +;;; Returns every distinct concept identifier referenced by document placements. (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)))) + (map (λ (item) (hash-ref item 'conceptId)) + (filter concept-placement? (document-items document))))) +;;; Removes the supplied keys from an immutable hash. (define (remove-hash-keys value keys) - (for/fold ([result value]) ([key (in-list keys)]) - (hash-remove result key))) + (foldl (λ (key result) (hash-remove result key)) value keys)) ;; 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. +;;; Reduces an editor document to placement data and shared-concept references. (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)))) + (let* ((placement-items + (map (λ (item) + (if (and (hash? item) + (not (equal? (hash-ref item 'kind "concept") "phrase"))) + (remove-hash-keys item placement-content-keys) + item)) + (document-items document))) + (defined-concept-ids + (map (λ (concept) (hash-ref concept 'id)) + (filter (λ (concept) + (and (hash? concept) + (string? (hash-ref concept 'id #f)) + (not (string=? (hash-ref concept 'id) "")))) + (document-concepts document)))) + (concept-ids + (remove-duplicates + (append defined-concept-ids (document-item-concept-ids document))))) + (for-each + (λ (concept-id) + (unless (concept-id? concept-id) + (error 'concept-map-storage-document "invalid concept UUID: ~a" concept-id))) + concept-ids) + (hash-set (hash-set document 'items placement-items) + 'concepts + (map (λ (concept-id) (hash 'id concept-id)) concept-ids)))) +;;; Reads and sanitizes one shared concept definition by its canonical UUID. (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))))) + (let ((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))) + #f))) -(define (document-concept-id-map db document) - (define concepts-by-id - (for/hash ([concept (in-list (document-concepts document))] - #:when (and (hash? concept) (string? (hash-ref concept 'id #f)))) - (values (hash-ref concept 'id) concept))) - (define items (hash-ref document 'items '())) - (define sources - (append - (document-concepts document) - (for/list ([item (in-list (if (list? items) items '()))] - #:when (and (hash? item) (string? (hash-ref item 'conceptId #f)))) - (hash-set item 'id (hash-ref item 'conceptId))))) - (define by-name (make-hash)) - (for/fold ([by-id (hash)]) ([source (in-list sources)] - #:when (and (hash? source) - (string? (hash-ref source 'id #f)))) - (define concept-id (hash-ref source 'id)) - (define repository-concept (hash-ref concepts-by-id concept-id #f)) - (define label - (cond [(and repository-concept (string? (hash-ref repository-concept 'label #f))) - (hash-ref repository-concept 'label)] - [(string? (hash-ref source 'label #f)) (hash-ref source 'label)] - [else ""])) - (define name-key (string-downcase (string-trim label))) - (define stored-id - (and (not (string=? name-key "")) - (query-maybe-value - db - #<> 'label')) = $1 ORDER BY updated_at DESC, id DESC LIMIT 1 SQL - name-key))) - (define canonical-id - (or stored-id - (and (not (string=? name-key "")) (hash-ref by-name name-key #f)) - (hash-ref by-id concept-id #f) - (normalized-or-new-concept-id concept-id))) - (unless (string=? name-key "") (hash-set! by-name name-key canonical-id)) - (hash-set by-id concept-id canonical-id))) + name-key))) +;;; Maps document-local concept identifiers to canonical shared identifiers. +(define (document-concept-id-map db document) + (let* ((concepts + (filter (λ (concept) + (and (hash? concept) (string? (hash-ref concept 'id #f)))) + (document-concepts document))) + (concepts-by-id + (foldl (λ (concept result) + (hash-set result (hash-ref concept 'id) concept)) + (hash) + concepts)) + (item-sources + (map (λ (item) (hash-set item 'id (hash-ref item 'conceptId))) + (filter (λ (item) + (and (hash? item) + (string? (hash-ref item 'conceptId #f)))) + (document-items document)))) + (sources (append (document-concepts document) item-sources)) + (valid-sources + (filter (λ (source) + (and (hash? source) (string? (hash-ref source 'id #f)))) + sources))) + (let map-identifiers ((remaining valid-sources) + (by-id (hash)) + (by-name (hash))) + (if (null? remaining) + by-id + (let* ((source (car remaining)) + (concept-id (hash-ref source 'id)) + (repository-concept (hash-ref concepts-by-id concept-id #f)) + (label (concept-source-label source repository-concept)) + (name-key (string-downcase (string-trim label))) + (stored-id (concept-id-by-label db name-key)) + (known-name-id + (if (string=? name-key "") #f (hash-ref by-name name-key #f))) + (canonical-id + (or stored-id + known-name-id + (hash-ref by-id concept-id #f) + (normalized-or-new-concept-id concept-id))) + (next-by-name + (if (string=? name-key "") + by-name + (hash-set by-name name-key canonical-id)))) + (map-identifiers (cdr remaining) + (hash-set by-id concept-id canonical-id) + next-by-name)))))) + +;;; Rewrites repository concepts and placements to their canonical identifiers. (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)) + (let* ((id-map (document-concept-id-map db document)) + (concepts + (filter (λ (concept) + (and (hash? concept) (string? (hash-ref concept 'id #f)))) + (document-concepts document))) + (canonical-concepts + (foldl + (λ (concept by-id) + (let* ((original-id (hash-ref concept 'id)) + (canonical-id (hash-ref id-map original-id original-id)) + (stored-definition + (and (not (string=? canonical-id original-id)) + (concept-definition-by-id db canonical-id))) + (content (or stored-definition (concept-content concept)))) + (hash-set by-id canonical-id (hash-set content 'id canonical-id)))) + (hash) + concepts)) + (canonical-items + (map (λ (item) + (let ((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))) + (document-items document)))) + (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. +;;; Inserts or updates the canonical definition of every concept in a document. (define (sync-concept-definitions! db document author now) - (for ([concept (in-list (document-concepts document))] - #:when (and (hash? concept) - (string? (hash-ref concept 'id #f)) - (not (string=? (hash-ref concept 'id) "")))) - (unless (concept-id? (hash-ref concept 'id)) - (error 'sync-concept-definitions! "invalid concept UUID: ~a" - (hash-ref concept 'id))) - (query-exec - db - #<text (concept-content concept)) - now - author))) + concept-id + (document->text (concept-content concept)) + now + author))) + concepts))) +;;; Replaces stored references with canonical concept definitions for the editor. (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)) + (let* ((canonical-document (canonicalize-document-concepts db document)) + (local-concepts + (foldl + (λ (concept result) + (if (and (hash? concept) (string? (hash-ref concept 'id #f))) + (hash-set result + (hash-ref concept 'id) + (concept-content concept)) + result)) + (hash) + (document-concepts canonical-document))) + (concept-ids + (remove-duplicates + (append (hash-keys local-concepts) + (document-item-concept-ids canonical-document)))) + (hydrated + (map (λ (concept-id) + (let ((definition (concept-definition-by-id db concept-id))) + (or definition + (hash-ref local-concepts concept-id (hash 'id concept-id))))) + concept-ids))) + ;; 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))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Database rows and write support +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +;;; Converts a concept_maps result row to the hash returned by this module. (define (row->concept-map row [include-document? #t]) - (define result - (hash 'slug (vector-ref row 0) - 'title (vector-ref row 1) - 'currentVersion (vector-ref row 3) - 'createdAt (vector-ref row 4) - 'updatedAt (vector-ref row 5) - 'createdBy (vector-ref row 6) - 'updatedBy (vector-ref row 7))) - (if include-document? - (hash-set result 'document (text->document (vector-ref row 2))) - result)) + (let ((result + (hash 'slug (vector-ref row 0) + 'title (vector-ref row 1) + 'currentVersion (vector-ref row 3) + 'createdAt (vector-ref row 4) + 'updatedAt (vector-ref row 5) + 'createdBy (vector-ref row 6) + 'updatedBy (vector-ref row 7)))) + (if include-document? + (hash-set result 'document (text->document (vector-ref row 2))) + result))) +;;; Converts SQL NULL to JSON null and otherwise returns the selected row value. +(define (nullable-row-value row index) + (let ((value (vector-ref row index))) + (if (sql-null? value) 'null value))) + +;;; Extends concept-map metadata with the administrative archive audit fields. +(define (row->archived-concept-map row) + (hash-set (hash-set (row->concept-map row #f) + 'archivedAt (nullable-row-value row 8)) + 'archivedBy (let ((value (nullable-row-value row 9))) + (if (eq? value 'null) "" value)))) + +;;; Converts one grouped placement row to a concept-usage result. +(define (row->concept-usage row) + (hash 'conceptId (vector-ref row 0) + 'cmapSlug (vector-ref row 1) + 'cmapTitle (vector-ref row 2) + 'label (vector-ref row 3) + 'pageSlug (nullable-row-value row 4) + 'count (vector-ref row 5))) + +;;; Converts a shared TODO concept and its representative placement to JSON data. +(define (row->concept-todo row) + (hash 'type "concept" + 'conceptId (vector-ref row 0) + 'title (vector-ref row 1) + 'text (vector-ref row 2) + 'descriptionPageSlug (nullable-row-value row 3) + 'pageSlug (nullable-row-value row 4) + 'cmapSlug (nullable-row-value row 5) + 'externalUrl (nullable-row-value row 6) + 'placementCmapSlug (vector-ref row 7) + 'placementCmapTitle (vector-ref row 8))) + +;;; Converts one full-text query row to the result shape shared with wiki search. +(define (row->search-result row) + (hash 'slug (vector-ref row 0) + 'title (vector-ref row 1) + 'rank (vector-ref row 2) + 'snippet (vector-ref row 3) + 'type "cmap")) + +;;; Converts one user-visible version row to history metadata. +(define (row->history-entry row) + (hash 'version (vector-ref row 0) + 'title (vector-ref row 1) + 'author (vector-ref row 2) + 'action (vector-ref row 3) + 'summary (vector-ref row 4) + 'createdAt (vector-ref row 5))) + +;;; Converts one immutable version row to metadata plus its editor document. +(define (row->concept-map-version row) + (hash 'version (vector-ref row 0) + 'title (vector-ref row 1) + 'document (text->document (vector-ref row 2)) + 'author (vector-ref row 3) + 'action (vector-ref row 4) + 'summary (vector-ref row 5) + 'createdAt (vector-ref row 6))) + +;;; Validates the address, title, shape, JSON encoding, and size of editor input. (define (validate-concept-map-input who slug title document) (unless (valid-slug? slug) (error who "invalid concept map address: ~a" slug)) @@ -284,7 +426,13 @@ SQL (document->text document) (void)) -(define (insert-concept-map-version! db map-id version title document-text author action summary now) +;;; Converts a number or numeric string to a version number, otherwise #f. +(define (version-number value) + (if (number? value) value (string->number (format "~a" value)))) + +;;; Inserts one immutable user-visible version within the current transaction. +(define (insert-concept-map-version! db map-id version title document-text + author action summary now) (query-exec db #<concept-map with document conversion disabled. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (list-concept-maps config) (call-with-wiki-database config (λ (db) - (for/list ((row (in-list - (query-rows - db - (string-append - "SELECT " concept-map-columns - " FROM concept_maps WHERE archived = FALSE" - " ORDER BY lower(title), title"))))) - (row->concept-map row #f))))) + (map (λ (row) (row->concept-map row #f)) + (query-rows + db + (string-append + "SELECT " concept-map-columns + " FROM concept_maps WHERE archived = FALSE" + " ORDER BY lower(title), title")))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; 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. +; internals: Selects archived rows with their audit columns and converts them +; through row->archived-concept-map, including SQL NULL handling. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (list-archived-concept-maps config) (call-with-wiki-database config (λ (db) - (for/list ((row (in-list - (query-rows - db - #<archived-concept-map + (query-rows + db + #<concept-map row #f) - 'archivedAt (if (sql-null? (vector-ref row 8)) - 'null - (vector-ref row 8))) - 'archivedBy (or (and (not (sql-null? (vector-ref row 9))) - (vector-ref row 9)) - "")))))) + ))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; goal : Count current concept placements across every active CMap. @@ -366,15 +514,18 @@ SQL ; post : No database state is changed. ; result : Rows containing global concept identity, linked page, concept/map ; labels, CMap slug and placement count. +; internals: PostgreSQL expands each active map's items array, joins shared +; concept_definitions, groups placements and row->concept-usage +; converts the grouped rows. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (list-concept-usage config) (call-with-wiki-database config (λ (db) - (for/list ((row (in-list - (query-rows - db - #<concept-usage + (query-rows + db + #<concept-todo +; converts nullable links to JSON null. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (list-concept-todos config) (call-with-wiki-database config (λ (db) - (for/list ([row (in-list - (query-rows - db - #<concept-todo + (query-rows + db + #<> 'label', ''), 'Concept') AS label, coalesce(definition.document ->> 'synopsis', '') AS synopsis, @@ -476,47 +622,38 @@ 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. ; post : Concept-map rows have only been read. ; result : At most limit metadata hashes, newest first. +; internals: Orders active concept_maps by updated_at, applies the SQL limit +; and maps the resulting rows without loading their documents. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (list-recent-concept-maps config [limit 50]) (call-with-wiki-database config (λ (db) - (for/list ((row (in-list - (query-rows - db - (string-append - "SELECT " concept-map-columns - " FROM concept_maps WHERE archived = FALSE" - " ORDER BY updated_at DESC, lower(title), title" - " LIMIT $1") - limit)))) - (row->concept-map row #f))))) + (map (λ (row) (row->concept-map row #f)) + (query-rows + db + (string-append + "SELECT " concept-map-columns + " FROM concept_maps WHERE archived = FALSE" + " ORDER BY updated_at DESC, lower(title), title" + " LIMIT $1") + limit))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; goal : Search current concept maps by map title, address and concept text. ; pre : query-text is a string and database schema 9 is installed. ; post : Concept-map documents have only been read. ; result : Up to 50 relevance-sorted search result hashes without documents. +; internals: PostgreSQL builds weighted full-text vectors from map identity and +; canonical concept text; row->search-result returns the ranked +; headline and metadata used by combined wiki search. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (search-concept-maps config query-text) (if (string=? (string-trim query-text) "") @@ -524,10 +661,10 @@ SQL (call-with-wiki-database config (λ (db) - (for/list ((row (in-list - (query-rows - db - #<search-result + (query-rows + db + #<concept-map decodes its +; document and hydrate-concept-definitions replaces references with +; the canonical shared concept content required by the editor. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (read-concept-map config slug) (if (not (valid-slug? slug)) @@ -589,18 +724,18 @@ SQL (call-with-wiki-database config (λ (db) - (define row - (query-maybe-row - db - (string-append - "SELECT " concept-map-columns - " FROM concept_maps WHERE slug = $1 AND archived = FALSE") - slug)) - (if row - (hash-update (row->concept-map row) - 'document - (λ (document) (hydrate-concept-definitions db document))) - #f))))) + (let ((row + (query-maybe-row + db + (string-append + "SELECT " concept-map-columns + " FROM concept_maps WHERE slug = $1 AND archived = FALSE") + slug))) + (if row + (hash-update (row->concept-map row) + 'document + (λ (document) (hydrate-concept-definitions db document))) + #f)))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; goal : Create a persistent concept map. @@ -608,33 +743,39 @@ SQL ; post : One concept_maps row exists at version 1; history starts only when ; the user explicitly saves or creates a snapshot. ; result : The newly stored concept-map hash. +; internals: Inside one transaction, canonicalize-document-concepts resolves +; shared UUIDs, sync-concept-definitions! and sync-person-tags! +; update shared registries, and concept-map-storage-document strips +; duplicate content before insertion. read-concept-map hydrates the result. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (create-concept-map! config slug title document author) - (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 now (current-seconds)) - (call-with-wiki-database - config - (λ (db) - (call-with-transaction - db - (λ () - (define canonical-document (canonicalize-document-concepts db document)) - (define document-text - (document->text (concept-map-storage-document canonical-document))) - (sync-concept-definitions! db canonical-document author now) - (sync-person-tags! db canonical-document) - (query-value - db - #<text + (concept-map-storage-document canonical-document)))) + (sync-concept-definitions! db canonical-document author now) + (sync-person-tags! db canonical-document) + (query-value + db + #<number (format "~a" base-version)))) - (unless (and supplied-version (= supplied-version current-version)) - (error 'rename-concept-map! "version-conflict")) - (define next-version (+ current-version 1)) - (define now (current-seconds)) - (query-exec - db - #<number (format "~a" base-version)))) - (unless (and supplied-version (= supplied-version current-version)) - (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 - #<text + (concept-map-storage-document canonical-document)))) + (sync-concept-definitions! db canonical-document author now) + (query-exec + db + #<history-entry without decoding documents. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (concept-map-history config slug) (call-with-wiki-database config (λ (db) - (define map-id - (query-maybe-value - db - "SELECT id FROM concept_maps WHERE slug = $1 AND archived = FALSE" - slug)) - (unless map-id - (error 'concept-map-history "unknown concept map: ~a" slug)) - (for/list ((row (in-list - (query-rows db - #<history-entry + (query-rows db + #<concept-map-version decodes +; the selected immutable document. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (read-concept-map-version config slug version) - (define version-number - (if (number? version) version (string->number version))) - (and version-number - (call-with-wiki-database - config - (λ (db) - (define row - (query-maybe-row db - #<document (vector-ref row 2)) - 'author (vector-ref row 3) - 'action (vector-ref row 4) - 'summary (vector-ref row 5) - 'createdAt (vector-ref row 6))))))) + slug requested-version))) + (if row (row->concept-map-version row) #f)))) + #f))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; goal : Delete one user-facing concept-map history item. @@ -823,18 +970,18 @@ SQL ; post : Only the selected snapshot or manual-save row is removed; the ; current concept_maps document and version are unchanged. ; result : #t when an item was deleted, #f when it did not exist. +; internals: version-number validates the request and one joined DELETE limits +; removal to user-visible history belonging to an active map. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (delete-concept-map-version! config slug version) - (define version-number - (if (number? version) version (string->number version))) - (and version-number - (call-with-wiki-database - config - (λ (db) - (and - (query-maybe-value - db - #<number (format "~a" base-version)))) - (unless (and supplied-version (= supplied-version (vector-ref row 2))) - (error 'archive-concept-map! "version-conflict")) - (define now (current-seconds)) - (query-exec - db - #<document (jsexpr->string (jsexpr->string (hash 'value 7)))) + 'value) + 7) + (check-equal? (document-items (hash 'items "invalid")) '()) (check-equal? (hash-ref (concept-content (hash 'id concept-a 'label "Linked concept" 'externalUrl "https://example.com/path")) 'externalUrl) "https://example.com/path") + (check-equal? + (hash-ref + (concept-content + (hash 'id concept-a + 'tags (list (hash 'type "person" 'value "Alex") + (hash 'type "label" 'value "Architecture")))) + 'tags) + (list (hash 'type "person" 'value "Alex"))) (check-exn exn:fail? (λ () (concept-content diff --git a/static/cmap/README.md b/static/cmap/README.md index d8561e3..f04ad81 100644 --- a/static/cmap/README.md +++ b/static/cmap/README.md @@ -9,9 +9,16 @@ racket-wiki. Its public additions include `onSelection` on a map and same hit-test and drag lifecycle as selection, so it does not depend on a DOM `dblclick` event that may be suppressed by dragging. -`cmap-racket-wiki.js` contains the wiki-specific editor model, selection state, -content-based initial sizing, resize and relation controls, page concepts and -submap concepts. Automatic sizing remains active while text is edited and is +`model/concept-repository.js` owns shared concepts, semantic concept relations +and concept ownership. `model/concept-map.js` owns one map's concept +placements, linking phrases, connectors, metadata and presentation values. +Neither model module contains DOM or drawing-engine objects. + +`cmap-view.js` owns the canvas and the concrete `cmap.js` drawing instance. +`cmap-racket-wiki.js` contains the wiki-specific editor controller, selection +state, content-based initial sizing, resize and relation controls. Its view +records connect pure model ids to drawing nodes without making those nodes part +of the model. Automatic sizing remains active while text is edited and is disabled when the user resizes a concept manually. Selected concepts expose an edit handle. The host opens a modal editor for @@ -42,6 +49,13 @@ Multiple selected items can be moved or deleted together. Grouping is a structural operation: the editor creates an expanded sub-CMap with a named main concept and adopts the selection as its contents. Ungrouping dissolves a selected inline sub-CMap or moves selected child items one level outward. +**Make separate CMap** extracts the descendants into a complete stored CMap. +The former sub-CMap head remains in the parent as an ordinary concept whose +shared `cmapSlug` links to the new map. Ownership from that concept to the +extracted concepts is removed; internal child connectors move with the child, +while relations crossing the boundary terminate on the retained owner concept. +The same action converts an older `derivedView` in place: its slug, title and +metadata remain intact, but its document no longer depends on the parent CMap. `toDocument` and `loadDocument` round-trip the complete editor model: items, formatting, positions, connectors, recursive submap membership and promoted @@ -49,7 +63,7 @@ map references. The wiki host persists this JSON document through its CMap API. ## JSON interchange -`/js/cmap-interchange.js` implements the versioned `racket-wiki-cmap-bundle` +`/js/wiki/cmap/interchange.js` implements the versioned `racket-wiki-cmap-bundle` format. A bundle mirrors the normalized database model: `cmaps[]` contains the complete placement and presentation document, `concepts[]` contains shared content once per UUID, and `pages[]` contains the current Markdown, tags and diff --git a/static/cmap/cmap-racket-wiki.js b/static/cmap/cmap-racket-wiki.js index 1987699..cbdb89e 100644 --- a/static/cmap/cmap-racket-wiki.js +++ b/static/cmap/cmap-racket-wiki.js @@ -1,3 +1,13 @@ +import { + CmapModel, + ConceptMapConcept, + ConceptMapConnector, + ConceptMapPhrase, + PLACEMENT_FIELDS +} from "./model/concept-map.js"; +import { CONCEPT_FIELDS } from "./model/concept-repository.js"; +import { CmapView } from "./cmap-view.js"; + /* * Racket Wiki editor layer for the bundled racket-wiki CMap component. * @@ -152,14 +162,18 @@ resizeConcept: options.resizeConceptLabel || "Resize concept", relation: options.relationLabel || "Relation" }; - this.map = this.CmapFactory(canvas); - this.map.onSelection((component, event) => this.handleMapSelection(component, event)); - this.map.onActivation((component, event) => this.handleMapActivation(component, event)); + this.view = new CmapView( + canvas, + this.CmapFactory, + (component, event) => this.handleMapSelection(component, event), + (component, event) => this.handleMapActivation(component, event)); + this.map = this.view.map; this.items = []; this.connectors = []; this.unresolvedConnectors = []; - this.conceptMaps = new Map(); - this.documentMetadata = { tags: [], summary: "", explanationPageSlug: "" }; + this.model = CmapModel.fromDocument({}); + this.conceptMaps = this.model.conceptMap.conceptMapsById; + this.documentMetadata = this.model.conceptMap.metadata; this.activeMapRoot = null; this.mapHistory = []; this.selectedItem = null; @@ -190,6 +204,229 @@ }); } + /** Return the view record that renders one model item id. */ + itemRecord(id) { + return this.items.find((record) => Number(record.id) === Number(id)) || null; + } + + /** Return a copy of the records currently rendered by this editor. */ + itemRecords() { + return [...this.items]; + } + + /** Return rendered records that are visible in the active map context. */ + visibleItemRecords() { + return this.items.filter((record) => this.isEffectiveItemVisible(record)); + } + + containsItemRecord(record) { + return this.items.includes(record); + } + + descendantItemRecords(record) { + return this.items.filter((candidate) => this.isDescendantOf(candidate, record)); + } + + setConceptMapReference(reference) { + if (!reference?.id) throw new TypeError("A concept-map reference id is required"); + this.conceptMaps.set(reference.id, reference); + return reference; + } + + itemCount() { + return this.items.length; + } + + connectorCount() { + return this.connectors.length + this.unresolvedConnectors.length; + } + + conceptRepository() { + return this.model.repository; + } + + conceptMapModel() { + return this.model.conceptMap; + } + + /** + * Register the domain data behind a view record. + * The model receives only serializable concept and placement fields. + */ + registerModelItem(record) { + const existing = this.model.conceptMap.item(record.id); + if (existing) return existing; + const values = { kind: record.kind }; + for (const field of PLACEMENT_FIELDS) { + if (field === "parentSubmapId") { + values.parentSubmapId = record.parentSubmap ? record.parentSubmap.id : null; + } else if (field === "hiddenContexts") { + values.hiddenContexts = Array.from(record.hiddenContexts || []); + } else if (field === "x" || field === "y" || field === "width" || field === "height") { + values[field] = record.node ? Number(record.node.attr(field)) : Number(record[field]); + } else { + values[field] = record[field]; + } + } + let modelItem; + if (record.kind === "phrase") { + modelItem = new ConceptMapPhrase(record.id, { + ...values, + label: record.label, + synopsis: record.synopsis + }); + } else { + const conceptValues = {}; + for (const field of CONCEPT_FIELDS) conceptValues[field] = record[field]; + this.model.repository.ensure(record.conceptId, conceptValues); + modelItem = new ConceptMapConcept(record.id, record.conceptId, values); + } + this.model.conceptMap.addItem(modelItem); + return modelItem; + } + + /** + * Make an existing editor record a view onto its pure model item. + * DOM handles stay on the record; all serializable fields live in the model. + */ + bindRecordToModel(record, modelItem) { + if (record.modelItem === modelItem) return record; + Object.defineProperty(record, "modelItem", { value: modelItem, configurable: true }); + Object.defineProperty(record, "kind", { + configurable: true, + get: () => modelItem.kind, + set: (value) => { modelItem.kind = value; } + }); + Object.defineProperty(record, "conceptId", { + configurable: true, + get: () => modelItem.conceptId || null, + set: (conceptId) => { + if (modelItem instanceof ConceptMapPhrase || !conceptId) return; + const current = this.model.repository.concept(modelItem.conceptId); + const target = this.model.repository.concept(conceptId) || + this.model.repository.ensure(conceptId, current ? current.toDocument() : {}); + modelItem.conceptId = target.id; + } + }); + for (const field of ["label", "synopsis"]) { + Object.defineProperty(record, field, { + configurable: true, + get: () => modelItem instanceof ConceptMapPhrase ? modelItem[field] : + this.model.repository.requireConcept(modelItem.conceptId)[field], + set: (value) => { + if (modelItem instanceof ConceptMapPhrase) modelItem[field] = String(value || ""); + else this.model.repository.requireConcept(modelItem.conceptId).update({ [field]: value }); + } + }); + } + for (const field of CONCEPT_FIELDS.filter((name) => name !== "label" && name !== "synopsis")) { + Object.defineProperty(record, field, { + configurable: true, + get: () => modelItem instanceof ConceptMapPhrase ? null : + this.model.repository.requireConcept(modelItem.conceptId)[field], + set: (value) => { + if (!(modelItem instanceof ConceptMapPhrase)) { + this.model.repository.requireConcept(modelItem.conceptId).update({ [field]: value }); + } + } + }); + } + for (const field of PLACEMENT_FIELDS.filter((name) => + name !== "parentSubmapId" && name !== "x" && name !== "y")) { + Object.defineProperty(record, field, { + configurable: true, + get: () => modelItem[field], + set: (value) => { modelItem[field] = value; } + }); + } + Object.defineProperty(record, "parentSubmap", { + configurable: true, + get: () => modelItem.parentSubmapId === null ? null : + this.itemRecord(modelItem.parentSubmapId), + set: (value) => { modelItem.parentSubmapId = value ? Number(value.id) : null; } + }); + return record; + } + + /** Register and bind a record that was inserted through the editor view. */ + attachRecordToModel(record) { + return this.bindRecordToModel(record, this.registerModelItem(record)); + } + + /** Bind a rendered connector to its map-local connector model. */ + attachConnectorToModel(record) { + let modelConnector = this.model.conceptMap.connector(record.id); + if (!modelConnector) { + modelConnector = this.model.conceptMap.addConnector(new ConceptMapConnector( + record.id, record.source.id, record.target.id, record)); + } + Object.defineProperty(record, "modelConnector", { + value: modelConnector, + configurable: true + }); + for (const field of ["hasArrow", "lineColor", "lineWidth"]) { + Object.defineProperty(record, field, { + configurable: true, + get: () => modelConnector[field], + set: (value) => { modelConnector[field] = value; } + }); + } + for (const [field, idField] of [["source", "sourceId"], ["target", "targetId"]]) { + Object.defineProperty(record, field, { + configurable: true, + get: () => this.itemRecord(modelConnector[idField]), + set: (value) => { modelConnector[idField] = Number(value.id); } + }); + } + return record; + } + + /** Keep the pure model synchronized with geometry owned by the drawing engine. */ + synchronizeModel() { + const itemIds = new Set(this.items.map((record) => Number(record.id))); + for (const modelItem of this.model.conceptMap.items()) { + if (!itemIds.has(modelItem.id)) this.model.conceptMap.removeItem(modelItem.id); + } + for (const record of this.items) { + const modelItem = this.registerModelItem(record); + this.bindRecordToModel(record, modelItem); + if (record.node) { + modelItem.x = Number(record.node.attr("x")); + modelItem.y = Number(record.node.attr("y")); + modelItem.width = Number(record.node.attr("width")); + modelItem.height = Number(record.node.attr("height")); + } + } + const connectorIds = new Set(); + for (const connector of this.connectors) { + connectorIds.add(Number(connector.id)); + let modelConnector = this.model.conceptMap.connector(connector.id); + if (!modelConnector) { + modelConnector = this.model.conceptMap.addConnector(new ConceptMapConnector( + connector.id, connector.source.id, connector.target.id, connector)); + } + modelConnector.sourceId = Number(connector.source.id); + modelConnector.targetId = Number(connector.target.id); + modelConnector.hasArrow = connector.hasArrow; + modelConnector.lineColor = connector.lineColor; + modelConnector.lineWidth = connector.lineWidth; + } + for (const connector of this.unresolvedConnectors) { + connectorIds.add(Number(connector.id)); + if (!this.model.conceptMap.connector(connector.id)) { + this.model.conceptMap.addConnector(new ConceptMapConnector( + connector.id, connector.sourceId, connector.targetId, connector)); + } + } + for (const connector of this.model.conceptMap.connectors()) { + if (!connectorIds.has(connector.id)) this.model.conceptMap.removeConnector(connector.id); + } + this.model.conceptMap.metadata = this.documentMetadata; + this.model.conceptMap.setConceptMapReferences([...this.conceptMaps.values()]); + this.conceptMaps = this.model.conceptMap.conceptMapsById; + return this.model; + } + historySnapshot() { return JSON.stringify(this.toDocument()); } @@ -266,8 +503,9 @@ this.items = []; this.connectors = []; this.unresolvedConnectors = []; - this.conceptMaps = new Map(); - this.documentMetadata = { tags: [], summary: "", explanationPageSlug: "" }; + this.model = CmapModel.fromDocument({}); + this.conceptMaps = this.model.conceptMap.conceptMapsById; + this.documentMetadata = this.model.conceptMap.metadata; this.activeMapRoot = null; this.mapHistory = []; this.nextId = 1; @@ -423,7 +661,7 @@ this.items.filter((item) => item.conceptId === record.conceptId && item.kind !== "phrase").length + 1 : null; - const node = this.map.node({ + const node = this.view.createNode({ content: this.itemHtml(record), contentType: "html", x: numberOr(options.x, 80 + ((id * 37) % 420)), @@ -437,6 +675,7 @@ }); record.node = node; this.items.push(record); + this.attachRecordToModel(record); this.refreshConceptUsageIndicators(record.conceptId ? [record.conceptId] : []); this.refreshConceptMapReferences(); node.onRendered((_renderedNode, element) => this.decorateItem(record, element)); @@ -885,177 +1124,10 @@ return record.mapReference; } - prepareStoredSubmapExtraction(record, targetSlug) { - console.warn(`${debugPrefix} destructive submap extraction is disabled; use a shared derived view`, { - itemId: record ? record.id : null, - targetSlug - }); - return null; - /* istanbul ignore next -- retained only to read historical documents */ - if (!record || record.kind !== "submap" || !targetSlug) return null; + prepareStoredSubmapExtraction(record, targetSlug, childMetadata = null) { + if (!record || record.kind !== "submap") return null; this.ensureSubmapContents(record); - const sourceDocument = this.toDocument(); - const descendants = this.items.filter((item) => this.isDescendantOf(item, record)); - const descendantIds = new Set(descendants.map((item) => item.id)); - const allItemIds = new Set(this.items.map((item) => item.id)); - if (!descendantIds.size) return null; - - const descendantDocuments = sourceDocument.items.filter((item) => descendantIds.has(item.id)); - const headSource = sourceDocument.items.find((item) => Number(item.id) === Number(record.id)); - if (!headSource) return null; - const scopedDocuments = [headSource, ...descendantDocuments]; - const left = Math.min(...scopedDocuments.map((item) => Number(item.x) || 0)); - const top = Math.min(...scopedDocuments.map((item) => Number(item.y) || 0)); - const placeInChild = (item, parentSubmapId, submapDepth) => { - const x = (Number(item.x) || 0) - left + 80; - const y = (Number(item.y) || 0) - top + 80; - const rootLayout = item.layouts && item.layouts.root ? item.layouts.root : {}; - return { - ...item, - parentSubmapId, - submapDepth, - x, - y, - layouts: { - ...(item.layouts || {}), - root: { - ...rootLayout, - x, - y, - width: Number(item.width), - height: Number(item.height) - } - } - }; - }; - const childHead = { - ...placeInChild(headSource, null, 0), - kind: "concept", - childMap: null, - expanded: false, - submapInitialized: false, - separateMap: false, - mapReference: null, - pageSlug: null, - // The placement in the parent links to this map. The head on the - // child itself must not be a link back to the same map. - cmapSlug: null, - parentCmapLink: false - }; - const childItems = [childHead, ...descendantDocuments.map((item) => - placeInChild( - item, - Number(item.parentSubmapId) === Number(record.id) ? null : item.parentSubmapId, - Math.max(0, Number(item.submapDepth || 0) - record.submapDepth - 1) - ))]; - - const parentConnectors = []; - const childConnectors = []; - const parentConnectorKeys = new Set(); - for (const connector of sourceDocument.connectors) { - const sourceInside = descendantIds.has(Number(connector.sourceId)); - const targetInside = descendantIds.has(Number(connector.targetId)); - const sourceIsHead = Number(connector.sourceId) === Number(record.id); - const targetIsHead = Number(connector.targetId) === Number(record.id); - if ((sourceInside && targetInside) || - (sourceIsHead && targetInside) || - (sourceInside && targetIsHead)) { - childConnectors.push({ ...connector }); - continue; - } - if (sourceInside || targetInside) { - const outsideId = Number(sourceInside ? connector.targetId : connector.sourceId); - if (!allItemIds.has(outsideId)) { - childConnectors.push({ ...connector }); - continue; - } - const rewired = { - ...connector, - sourceId: sourceInside ? record.id : connector.sourceId, - targetId: targetInside ? record.id : connector.targetId - }; - if (Number(rewired.sourceId) === Number(rewired.targetId)) continue; - const key = `${rewired.sourceId}\u0000${rewired.targetId}\u0000${rewired.hasArrow !== false}`; - if (parentConnectorKeys.has(key)) continue; - parentConnectorKeys.add(key); - parentConnectors.push(rewired); - continue; - } - const key = `${connector.sourceId}\u0000${connector.targetId}\u0000${connector.hasArrow !== false}`; - if (!parentConnectorKeys.has(key)) { - parentConnectorKeys.add(key); - parentConnectors.push({ ...connector }); - } - } - - const childConceptIds = new Set(childItems.map((item) => item.conceptId).filter(Boolean)); - const parentItems = sourceDocument.items - .filter((item) => !descendantIds.has(item.id)) - .map((item) => item.id === record.id ? { - ...item, - kind: "concept", - childMap: null, - expanded: false, - submapInitialized: false, - separateMap: false, - mapReference: null, - cmapSlug: targetSlug, - parentCmapLink: false, - borderColor: "#57834a" - } : item); - const parentConceptIds = new Set(parentItems.map((item) => item.conceptId).filter(Boolean)); - const conceptById = new Map(sourceDocument.concepts.map((concept) => [concept.id, concept])); - const childConceptById = new Map(conceptById); - const parentConceptById = new Map(conceptById); - const headDocument = parentItems.find((item) => item.id === record.id); - if (headDocument && headDocument.conceptId) { - parentConceptById.set(headDocument.conceptId, { - id: headDocument.conceptId, - kind: "concept", - label: headDocument.label, - synopsis: headDocument.synopsis, - aspects: headDocument.aspects || [], - descriptionPageSlug: headDocument.descriptionPageSlug, - pageSlug: null, - cmapSlug: targetSlug, - externalUrl: headDocument.externalUrl || null, - parentCmapLink: false, - imageSource: headDocument.imageSource || "" - }); - childConceptById.set(headDocument.conceptId, { - id: headDocument.conceptId, - kind: "concept", - label: childHead.label, - synopsis: childHead.synopsis, - aspects: childHead.aspects || [], - descriptionPageSlug: childHead.descriptionPageSlug, - pageSlug: null, - cmapSlug: null, - externalUrl: childHead.externalUrl || null, - parentCmapLink: false, - imageSource: childHead.imageSource || "" - }); - } - - return { - childDocument: { - schemaVersion: 2, - concepts: Array.from(childConceptById.values()).filter((concept) => childConceptIds.has(concept.id)), - items: childItems, - connectors: childConnectors, - conceptMaps: sourceDocument.conceptMaps.filter((reference) => - descendantIds.has(Number(reference.rootItemId))) - }, - parentDocument: { - ...sourceDocument, - concepts: Array.from(parentConceptById.values()).filter((concept) => parentConceptIds.has(concept.id)), - items: parentItems, - connectors: parentConnectors, - conceptMaps: sourceDocument.conceptMaps.filter((reference) => - Number(reference.rootItemId) !== record.id && - !descendantIds.has(Number(reference.rootItemId))) - } - }; + return this.synchronizeModel().extractSubmap(record.id, targetSlug, childMetadata); } replaceDocument(document) { @@ -1084,7 +1156,7 @@ setZoom(percent) { const next = Math.max(25, Math.min(300, Number(percent) || 100)); this.zoomFactor = next / 100; - this.map.zoom(this.zoomFactor); + this.view.setZoom(this.zoomFactor); this.ensureCanvasExtent(0, 0); debug("zoom changed", { percent: next, factor: this.zoomFactor }); return next; @@ -1095,7 +1167,7 @@ } surfaceElement() { - return this.canvas.querySelector(":scope > .rw-cmap-surface"); + return this.view.surfaceElement(); } ensureCanvasExtent(x, y, padding = 180) { @@ -1228,7 +1300,7 @@ addConnector(source, target, hasArrow = true, options = {}) { const sourceCenter = this.itemCenter(source); const targetCenter = this.itemCenter(target); - const link = this.map.link({ + const link = this.view.createConnector({ content: "", width: 1, height: 1, @@ -1262,6 +1334,7 @@ lineWidth: numberOr(Number(options.lineWidth), 2) }; this.nextConnectorId = Math.max(this.nextConnectorId, record.id + 1); + this.attachConnectorToModel(record); this.connectors.push(record); link.onRendered((_renderedLink, element) => this.decorateConnector(record, element)); link.onConnectionChange((_changedLink, type, node) => @@ -1782,103 +1855,31 @@ summary: String(metadata.summary || "").trim(), explanationPageSlug: String(metadata.explanationPageSlug || "").trim() }; + this.model.conceptMap.metadata = this.documentMetadata; return this.getDocumentMetadata(); } + /** Serialize the pure repository and map model at the API boundary. */ toDocument() { this.saveCurrentContextLayout(); this.refreshConceptMapReferences(); - const concepts = Array.from(new Map(this.items - .filter((record) => record.conceptId && record.kind !== "phrase") - .map((record) => [record.conceptId, { - id: record.conceptId, - label: record.label, - synopsis: record.synopsis, - aspects: record.aspects, - tags: normalizeConceptTags(record.tags).filter((tag) => tag.type === "person"), - descriptionPageSlug: record.descriptionPageSlug, - pageSlug: record.pageSlug, - cmapSlug: record.cmapSlug, - externalUrl: record.externalUrl, - imageSource: record.imageSource - }])).values()); - return { - schemaVersion: 2, - metadata: this.getDocumentMetadata(), - concepts, - items: this.items.map((record) => ({ - id: record.id, - conceptId: record.conceptId, - kind: record.kind, - ...(record.kind === "phrase" ? { - label: record.label, - synopsis: record.synopsis - } : {}), - parentCmapLink: record.parentCmapLink, - groupId: record.groupId, - childMap: record.childMap, - parentSubmapId: record.parentSubmap ? record.parentSubmap.id : null, - submapDepth: record.submapDepth, - expanded: record.expanded, - submapInitialized: record.submapInitialized, - separateMap: record.separateMap, - mapReference: record.mapReference, - hiddenContexts: Array.from(record.hiddenContexts), - layouts: record.layouts, - backgroundColor: record.backgroundColor, - borderColor: record.borderColor, - submapBackgroundColor: record.submapBackgroundColor, - submapBorderColor: record.submapBorderColor, - textColor: record.textColor, - fontFamily: record.fontFamily, - fontSize: record.fontSize, - fontWeight: record.fontWeight, - fontStyle: record.fontStyle, - synopsisTextColor: record.synopsisTextColor, - synopsisFontFamily: record.synopsisFontFamily, - synopsisFontSize: record.synopsisFontSize, - synopsisFontWeight: record.synopsisFontWeight, - synopsisFontStyle: record.synopsisFontStyle, - width: Number(record.node.attr("width")), - height: Number(record.node.attr("height")), - autoWidth: record.autoWidth, - autoHeight: record.autoHeight, - x: Number(record.node.attr("x")), - y: Number(record.node.attr("y")) - })), - connectors: this.connectors.map((connector) => ({ - id: connector.id, - sourceId: connector.source.id, - targetId: connector.target.id, - hasArrow: connector.hasArrow, - lineColor: connector.lineColor, - lineWidth: connector.lineWidth - })).concat(this.unresolvedConnectors.map((connector) => ({ ...connector }))), - conceptMaps: Array.from(this.conceptMaps.values()) - }; + return this.synchronizeModel().toDocument(); } loadDocument(document = {}) { if (this.items.length || this.connectors.length || this.unresolvedConnectors.length) { throw new Error("A concept map document can only be loaded into an empty editor"); } - const itemDocuments = Array.isArray(document.items) ? document.items : []; - const connectorDocuments = Array.isArray(document.connectors) ? document.connectors : []; - const concepts = new Map((Array.isArray(document.concepts) ? document.concepts : []) - .filter((concept) => concept && concept.id) - .map((concept) => [concept.id, concept])); + this.model = CmapModel.fromDocument(document); + this.conceptMaps = this.model.conceptMap.conceptMapsById; + this.documentMetadata = this.model.conceptMap.metadata; + const itemDocuments = this.model.conceptMap.items().map((item) => item.toDocument()); + const connectorDocuments = this.model.conceptMap.connectors() + .map((connector) => connector.toDocument()); const records = new Map(); - const metadata = document.metadata && typeof document.metadata === "object" ? - document.metadata : {}; - this.documentMetadata = { - tags: Array.isArray(metadata.tags) ? metadata.tags.map(String) - .map((tag) => tag.trim()).filter(Boolean) : [], - summary: String(metadata.summary || "").trim(), - explanationPageSlug: String(metadata.explanationPageSlug || "").trim() - }; for (const itemDocument of itemDocuments) { - const concept = concepts.get(itemDocument.conceptId) || {}; + const concept = this.model.repository.concept(itemDocument.conceptId)?.toDocument() || {}; const record = this.addItem({ ...itemDocument, ...concept, @@ -1924,10 +1925,6 @@ } this.addConnector(source, target, connectorDocument.hasArrow !== false, connectorDocument); } - this.conceptMaps = new Map( - (Array.isArray(document.conceptMaps) ? document.conceptMaps : []) - .filter((reference) => reference && reference.id) - .map((reference) => [reference.id, reference])); this.reconcilePhraseMembership(); this.refreshConceptMapReferences(); this.applyCurrentContextLayout(); @@ -2812,7 +2809,7 @@ item.submapAnchorLineElement = null; } } - if (this.map && typeof this.map.destroy === "function") this.map.destroy(); + this.view.destroy(); debug("editor destroyed"); } @@ -2862,12 +2859,16 @@ if (!records.size && !connectors.size) return false; this.clearSelection(false); - for (const connector of connectors) connector.link.remove(); + for (const connector of connectors) { + connector.link.remove(); + this.model.conceptMap.removeConnector(connector.id); + } this.connectors = this.connectors.filter((connector) => !connectors.has(connector)); for (const record of records) { if (record.submapFrameElement) record.submapFrameElement.remove(); if (record.mapReference && record.mapReference.id) this.conceptMaps.delete(record.mapReference.id); record.node.remove(); + this.model.conceptMap.removeItem(record.id); } if (records.size && this.unresolvedConnectors.length) { const deletedIds = new Set(Array.from(records).map((record) => Number(record.id))); diff --git a/static/cmap/cmap-view.js b/static/cmap/cmap-view.js new file mode 100644 index 0000000..97d4459 --- /dev/null +++ b/static/cmap/cmap-view.js @@ -0,0 +1,37 @@ +/** + * Present a CMap through the bundled drawing engine. + * + * CmapView owns the canvas and concrete cmap.js instance. It creates and + * destroys drawing objects but contains no concept, placement or persistence + * rules; CmapEditor remains responsible for interpreting user interaction. + */ +export class CmapView { + constructor(canvas, CmapFactory, onSelection, onActivation) { + if (!(canvas instanceof Object)) throw new TypeError("A CMap canvas is required"); + if (typeof CmapFactory !== "function") throw new TypeError("A CMap factory is required"); + this.canvas = canvas; + this.map = CmapFactory(canvas); + this.map.onSelection(onSelection); + this.map.onActivation(onActivation); + } + + createNode(attributes) { + return this.map.node(attributes); + } + + createConnector(attributes) { + return this.map.link(attributes); + } + + setZoom(factor) { + this.map.zoom(factor); + } + + surfaceElement() { + return this.canvas.querySelector(":scope > .rw-cmap-surface"); + } + + destroy() { + if (typeof this.map.destroy === "function") this.map.destroy(); + } +} diff --git a/static/cmap/cmap.css b/static/cmap/cmap.css index c6ba5c0..4ce8538 100644 --- a/static/cmap/cmap.css +++ b/static/cmap/cmap.css @@ -52,6 +52,11 @@ body.cmap-mode #main { min-width: 16px; } +#cmap-extract-selected { + flex: 0 0 auto; + white-space: nowrap; +} + .cmap-hidden-items { position: relative; flex: 0 0 auto; @@ -466,6 +471,10 @@ body.cmap-mode #main { z-index: 100000; display: grid; min-width: 210px; + max-height: calc(100vh - 16px); + max-height: calc(100dvh - 16px); + overflow-y: auto; + overscroll-behavior: contain; padding: 5px; border: 1px solid #8c95a3; border-radius: 6px; diff --git a/static/cmap/model/concept-map.js b/static/cmap/model/concept-map.js new file mode 100644 index 0000000..72e7df7 --- /dev/null +++ b/static/cmap/model/concept-map.js @@ -0,0 +1,422 @@ +import { ConceptRepository } from "./concept-repository.js"; + +const PLACEMENT_FIELDS = [ + "parentCmapLink", "groupId", "childMap", "parentSubmapId", "submapDepth", + "expanded", "submapInitialized", "separateMap", "mapReference", + "hiddenContexts", "layouts", "backgroundColor", "borderColor", + "submapBackgroundColor", "submapBorderColor", "textColor", "fontFamily", + "fontSize", "fontWeight", "fontStyle", "synopsisTextColor", + "synopsisFontFamily", "synopsisFontSize", "synopsisFontWeight", + "synopsisFontStyle", "width", "height", "autoWidth", "autoHeight", "x", "y" +]; + +function copy(value) { + return value === undefined ? undefined : JSON.parse(JSON.stringify(value)); +} + +/** Join one repository concept to its placement and appearance on a CMap. */ +export class ConceptMapConcept { + constructor(id, conceptId, values = {}) { + if (!Number.isInteger(Number(id)) || !conceptId) { + throw new TypeError("A concept placement requires an integer id and concept id"); + } + this.id = Number(id); + this.conceptId = String(conceptId); + this.kind = values.kind || "concept"; + this.update(values); + } + + update(values = {}) { + for (const field of PLACEMENT_FIELDS) { + if (values[field] !== undefined) this[field] = copy(values[field]); + } + this.parentCmapLink = Boolean(this.parentCmapLink); + this.parentSubmapId = this.parentSubmapId === null || this.parentSubmapId === undefined ? + null : Number(this.parentSubmapId); + this.submapDepth = Number(this.submapDepth) || 0; + this.expanded = Boolean(this.expanded); + this.submapInitialized = Boolean(this.submapInitialized); + this.separateMap = Boolean(this.separateMap); + this.hiddenContexts = this.hiddenContexts instanceof Set ? this.hiddenContexts : + new Set(Array.isArray(this.hiddenContexts) ? this.hiddenContexts.map(String) : []); + this.layouts = this.layouts && typeof this.layouts === "object" ? this.layouts : {}; + this.autoWidth = Boolean(this.autoWidth); + this.autoHeight = Boolean(this.autoHeight); + return this; + } + + toDocument() { + const document = { id: this.id, conceptId: this.conceptId, kind: this.kind }; + for (const field of PLACEMENT_FIELDS) { + document[field] = field === "hiddenContexts" ? [...this.hiddenContexts] : copy(this[field]); + } + return document; + } +} + +/** Represent a map-local linking phrase without creating a repository concept. */ +export class ConceptMapPhrase { + constructor(id, values = {}) { + if (!Number.isInteger(Number(id))) throw new TypeError("A phrase requires an integer id"); + this.id = Number(id); + this.kind = "phrase"; + this.label = String(values.label || "?????"); + this.synopsis = String(values.synopsis || ""); + this.update(values); + } + + update(values = {}) { + if (values.label !== undefined) this.label = String(values.label); + if (values.synopsis !== undefined) this.synopsis = String(values.synopsis); + for (const field of PLACEMENT_FIELDS) { + if (values[field] !== undefined) this[field] = copy(values[field]); + } + this.parentSubmapId = this.parentSubmapId === null || this.parentSubmapId === undefined ? + null : Number(this.parentSubmapId); + this.hiddenContexts = this.hiddenContexts instanceof Set ? this.hiddenContexts : + new Set(Array.isArray(this.hiddenContexts) ? this.hiddenContexts.map(String) : []); + this.layouts = this.layouts && typeof this.layouts === "object" ? this.layouts : {}; + return this; + } + + toDocument() { + const document = { + id: this.id, + conceptId: null, + kind: "phrase", + label: this.label, + synopsis: this.synopsis + }; + for (const field of PLACEMENT_FIELDS) { + document[field] = field === "hiddenContexts" ? [...this.hiddenContexts] : copy(this[field]); + } + return document; + } +} + +/** Store one rendered connection between two map-local item placements. */ +export class ConceptMapConnector { + constructor(id, sourceId, targetId, values = {}) { + if (!Number.isInteger(Number(id)) || !Number.isInteger(Number(sourceId)) || + !Number.isInteger(Number(targetId))) { + throw new TypeError("A connector requires integer ids"); + } + this.id = Number(id); + this.sourceId = Number(sourceId); + this.targetId = Number(targetId); + this.relationId = values.relationId || null; + this.hasArrow = values.hasArrow !== false; + this.lineColor = values.lineColor || "#333"; + this.lineWidth = Number(values.lineWidth) || 2; + } + + toDocument() { + return { + id: this.id, + sourceId: this.sourceId, + targetId: this.targetId, + ...(this.relationId ? { relationId: this.relationId } : {}), + hasArrow: this.hasArrow, + lineColor: this.lineColor, + lineWidth: this.lineWidth + }; + } +} + +/** Own the placements, phrases, connectors and metadata of one CMap. */ +export class ConceptMap { + constructor(values = {}) { + this.schemaVersion = Number(values.schemaVersion) || 2; + this.metadata = { + tags: Array.isArray(values.metadata?.tags) ? values.metadata.tags.map(String) : [], + summary: String(values.metadata?.summary || ""), + explanationPageSlug: String(values.metadata?.explanationPageSlug || "") + }; + this.derivedView = values.derivedView ? copy(values.derivedView) : null; + this.itemsById = new Map(); + this.connectorsById = new Map(); + this.conceptMapsById = new Map(); + } + + addItem(value) { + const item = value instanceof ConceptMapConcept || value instanceof ConceptMapPhrase ? value : + (value.kind === "phrase" ? new ConceptMapPhrase(value.id, value) : + new ConceptMapConcept(value.id, value.conceptId, value)); + if (this.itemsById.has(item.id)) throw new Error(`Duplicate CMap item id: ${item.id}`); + this.itemsById.set(item.id, item); + return item; + } + + item(id) { + return this.itemsById.get(Number(id)) || null; + } + + items() { + return [...this.itemsById.values()]; + } + + removeItem(id) { + const itemId = Number(id); + this.itemsById.delete(itemId); + for (const [connectorId, connector] of this.connectorsById) { + if (connector.sourceId === itemId || connector.targetId === itemId) { + this.connectorsById.delete(connectorId); + } + } + } + + addConnector(value) { + const connector = value instanceof ConceptMapConnector ? value : new ConceptMapConnector( + value.id, value.sourceId, value.targetId, value); + if (this.connectorsById.has(connector.id)) { + throw new Error(`Duplicate CMap connector id: ${connector.id}`); + } + this.connectorsById.set(connector.id, connector); + return connector; + } + + connector(id) { + return this.connectorsById.get(Number(id)) || null; + } + + connectors() { + return [...this.connectorsById.values()]; + } + + removeConnector(id) { + this.connectorsById.delete(Number(id)); + } + + setConceptMapReferences(references = []) { + this.conceptMapsById = new Map(references + .filter((reference) => reference && reference.id) + .map((reference) => [reference.id, copy(reference)])); + } + + toDocument() { + const document = { + schemaVersion: this.schemaVersion, + metadata: copy(this.metadata), + items: this.items().map((item) => item.toDocument()), + connectors: this.connectors().map((connector) => connector.toDocument()), + conceptMaps: [...this.conceptMapsById.values()].map(copy) + }; + if (this.derivedView) document.derivedView = copy(this.derivedView); + return document; + } +} + +/** Combine a concept repository with one concrete concept-map document. */ +export class CmapModel { + constructor(repository = new ConceptRepository(), conceptMap = new ConceptMap()) { + this.repository = repository; + this.conceptMap = conceptMap; + } + + static fromDocument(document = {}) { + const repository = new ConceptRepository( + Array.isArray(document.concepts) ? document.concepts : [], + Array.isArray(document.conceptRelations) ? document.conceptRelations : [], + Array.isArray(document.conceptOwnerships) ? document.conceptOwnerships : []); + const conceptMap = new ConceptMap(document); + for (const item of (Array.isArray(document.items) ? document.items : [])) { + if (item.kind !== "phrase" && !repository.concept(item.conceptId)) { + repository.add({ id: item.conceptId, label: item.label || "Concept" }); + } + conceptMap.addItem(item); + } + for (const connector of (Array.isArray(document.connectors) ? document.connectors : [])) { + conceptMap.addConnector(connector); + } + conceptMap.setConceptMapReferences(document.conceptMaps); + return new CmapModel(repository, conceptMap); + } + + /** + * Split an embedded submap into a standalone map and its remaining parent. + * The owner stays in the parent as an ordinary concept and links to targetSlug. + * Descendant placements become the complete contents of the child map. + */ + extractSubmap(rootItemId, targetSlug = null, childMetadata = null) { + const sourceDocument = this.toDocument(); + const rootId = Number(rootItemId); + const rootItem = sourceDocument.items.find((item) => Number(item.id) === rootId); + if (!rootItem || rootItem.kind !== "submap") { + throw new TypeError("A submap item is required for extraction"); + } + + const descendantIds = new Set(); + let foundDescendant = true; + while (foundDescendant) { + foundDescendant = false; + for (const item of sourceDocument.items) { + const parentId = Number(item.parentSubmapId); + if (parentId !== rootId && !descendantIds.has(parentId)) continue; + if (descendantIds.has(Number(item.id))) continue; + descendantIds.add(Number(item.id)); + foundDescendant = true; + } + } + const descendantItems = sourceDocument.items + .filter((item) => descendantIds.has(Number(item.id))); + if (!descendantItems.some((item) => item.kind !== "phrase")) { + throw new Error("The submap has no concepts to extract"); + } + const left = Math.min(...descendantItems.map((item) => Number(item.x) || 0)); + const top = Math.min(...descendantItems.map((item) => Number(item.y) || 0)); + const childItems = descendantItems.map((item) => { + const x = (Number(item.x) || 0) - left + 80; + const y = (Number(item.y) || 0) - top + 80; + const parentSubmapId = Number(item.parentSubmapId) === rootId ? null : item.parentSubmapId; + const submapDepth = Math.max( + 0, Number(item.submapDepth || 0) - Number(rootItem.submapDepth || 0) - 1); + return { + ...item, + parentSubmapId, + submapDepth, + x, + y, + layouts: { + ...(item.layouts || {}), + root: { + ...(item.layouts?.root || {}), + x, + y, + width: Number(item.width), + height: Number(item.height) + } + } + }; + }); + + const parentItems = sourceDocument.items + .filter((item) => !descendantIds.has(Number(item.id))) + .map((item) => Number(item.id) === rootId ? { + ...item, + kind: "concept", + childMap: null, + expanded: false, + submapInitialized: false, + separateMap: false, + mapReference: null + } : item); + + const allItemIds = new Set(sourceDocument.items.map((item) => Number(item.id))); + const parentConnectors = []; + const parentConnectorKeys = new Set(); + const childConnectors = []; + for (const connector of sourceDocument.connectors) { + const sourceId = Number(connector.sourceId); + const targetId = Number(connector.targetId); + if (!allItemIds.has(sourceId) || !allItemIds.has(targetId)) continue; + const sourceInside = descendantIds.has(sourceId); + const targetInside = descendantIds.has(targetId); + if (sourceInside && targetInside) { + childConnectors.push({ ...connector }); + continue; + } + if (sourceInside || targetInside) { + const rewired = { + ...connector, + sourceId: sourceInside ? rootId : sourceId, + targetId: targetInside ? rootId : targetId + }; + if (rewired.sourceId === rewired.targetId) continue; + const key = `${rewired.sourceId}\u0000${rewired.targetId}\u0000${rewired.hasArrow !== false}`; + if (!parentConnectorKeys.has(key)) { + parentConnectorKeys.add(key); + parentConnectors.push(rewired); + } + continue; + } + const key = `${sourceId}\u0000${targetId}\u0000${connector.hasArrow !== false}`; + if (!parentConnectorKeys.has(key)) { + parentConnectorKeys.add(key); + parentConnectors.push({ ...connector }); + } + } + + const conceptIdsFor = (items) => new Set(items + .filter((item) => item.kind !== "phrase" && item.conceptId) + .map((item) => String(item.conceptId))); + const childConceptIds = conceptIdsFor(childItems); + const parentConceptIds = conceptIdsFor(parentItems); + const ownerConceptId = String(rootItem.conceptId); + const conceptsFor = (ids) => sourceDocument.concepts + .filter((concept) => ids.has(String(concept.id))) + .map((concept) => ({ ...concept })); + const parentConcepts = conceptsFor(parentConceptIds); + if (targetSlug) { + const ownerConcept = parentConcepts.find((concept) => concept.id === ownerConceptId); + if (ownerConcept) ownerConcept.cmapSlug = String(targetSlug); + } + + const relationsFor = (ids) => (sourceDocument.conceptRelations || []) + .filter((relation) => ids.has(String(relation.sourceConceptId)) && + ids.has(String(relation.targetConceptId))) + .map((relation) => ({ ...relation })); + const ownershipsFor = (ids) => (sourceDocument.conceptOwnerships || []) + .filter((ownership) => { + const parentId = String(ownership.parentConceptId); + const childId = String(ownership.childConceptId); + const extractedOwnership = parentId === ownerConceptId && childConceptIds.has(childId); + return !extractedOwnership && ids.has(parentId) && ids.has(childId); + }) + .map((ownership) => ({ ...ownership })); + + const ownerConcept = sourceDocument.concepts + .find((concept) => String(concept.id) === ownerConceptId) || {}; + const extractedMetadata = childMetadata ? copy(childMetadata) : { + tags: Array.isArray(ownerConcept.aspects) ? [...ownerConcept.aspects] : [], + summary: String(ownerConcept.synopsis || ""), + explanationPageSlug: String(ownerConcept.descriptionPageSlug || "") + }; + const childDocument = { + schemaVersion: sourceDocument.schemaVersion, + metadata: extractedMetadata, + concepts: conceptsFor(childConceptIds), + items: childItems, + connectors: childConnectors, + conceptMaps: sourceDocument.conceptMaps + .filter((reference) => descendantIds.has(Number(reference.rootItemId))) + }; + const childRelations = relationsFor(childConceptIds); + const childOwnerships = ownershipsFor(childConceptIds); + if (childRelations.length) childDocument.conceptRelations = childRelations; + if (childOwnerships.length) childDocument.conceptOwnerships = childOwnerships; + + const parentDocument = { + ...sourceDocument, + concepts: parentConcepts, + items: parentItems, + connectors: parentConnectors, + conceptMaps: sourceDocument.conceptMaps.filter((reference) => + Number(reference.rootItemId) !== rootId && + !descendantIds.has(Number(reference.rootItemId))) + }; + const parentRelations = relationsFor(parentConceptIds); + const parentOwnerships = ownershipsFor(parentConceptIds); + if (parentRelations.length) parentDocument.conceptRelations = parentRelations; + else delete parentDocument.conceptRelations; + if (parentOwnerships.length) parentDocument.conceptOwnerships = parentOwnerships; + else delete parentDocument.conceptOwnerships; + + return { parentDocument, childDocument }; + } + + toDocument() { + const document = this.conceptMap.toDocument(); + const usedConceptIds = new Set(this.conceptMap.items() + .filter((item) => item instanceof ConceptMapConcept) + .map((item) => item.conceptId)); + document.concepts = this.repository.toDocument() + .filter((concept) => usedConceptIds.has(concept.id)); + const relations = this.repository.relationDocuments(); + const ownerships = this.repository.ownershipDocuments(); + if (relations.length) document.conceptRelations = relations; + if (ownerships.length) document.conceptOwnerships = ownerships; + return document; + } +} + +export { PLACEMENT_FIELDS }; diff --git a/static/cmap/model/concept-repository.js b/static/cmap/model/concept-repository.js new file mode 100644 index 0000000..585460e --- /dev/null +++ b/static/cmap/model/concept-repository.js @@ -0,0 +1,217 @@ +const CONCEPT_FIELDS = [ + "label", "synopsis", "aspects", "tags", "descriptionPageSlug", + "pageSlug", "cmapSlug", "externalUrl", "imageSource" +]; + +function copy(value) { + return value === undefined ? undefined : JSON.parse(JSON.stringify(value)); +} + +function normalizeTags(tags) { + if (!Array.isArray(tags)) return []; + return tags.map((tag) => { + if (typeof tag === "string") return { type: "label", value: tag.trim() }; + if (!tag || typeof tag !== "object") return null; + const type = String(tag.type || "label").trim() || "label"; + const value = String(tag.value || tag.name || "").trim(); + return value ? { type, value } : null; + }).filter(Boolean); +} + +/** + * Store the shared content and external references of one concept. + * A Concept never contains coordinates, styling or browser objects. + */ +export class Concept { + constructor(id, values = {}) { + if (!id) throw new TypeError("A concept id is required"); + this.id = String(id); + this.label = String(values.label || "Concept"); + this.synopsis = String(values.synopsis || ""); + this.aspects = Array.isArray(values.aspects) ? values.aspects.map(String) : []; + this.tags = normalizeTags(values.tags); + this.descriptionPageSlug = values.descriptionPageSlug || null; + this.pageSlug = values.pageSlug || null; + this.cmapSlug = values.cmapSlug || null; + this.externalUrl = values.externalUrl || null; + this.imageSource = values.imageSource || ""; + } + + /** Change shared content without accepting placement or presentation fields. */ + update(changes = {}) { + for (const field of CONCEPT_FIELDS) { + if (changes[field] === undefined) continue; + if (field === "aspects") { + this.aspects = Array.isArray(changes.aspects) ? changes.aspects.map(String) : []; + } else if (field === "tags") { + this.tags = normalizeTags(changes.tags); + } else { + this[field] = changes[field]; + } + } + return this; + } + + /** Return the stable JSON representation used at the wiki API boundary. */ + toDocument() { + return { + id: this.id, + label: this.label, + synopsis: this.synopsis, + aspects: [...this.aspects], + tags: copy(this.tags), + descriptionPageSlug: this.descriptionPageSlug, + pageSlug: this.pageSlug, + cmapSlug: this.cmapSlug, + externalUrl: this.externalUrl, + imageSource: this.imageSource + }; + } +} + +/** Describe a semantic relation that exists independently of a diagram. */ +export class ConceptRelation { + constructor(id, sourceConceptId, targetConceptId, values = {}) { + if (!id || !sourceConceptId || !targetConceptId) { + throw new TypeError("A concept relation requires an id and two concepts"); + } + this.id = String(id); + this.sourceConceptId = String(sourceConceptId); + this.targetConceptId = String(targetConceptId); + this.label = String(values.label || ""); + this.tags = normalizeTags(values.tags); + } + + toDocument() { + return { + id: this.id, + sourceConceptId: this.sourceConceptId, + targetConceptId: this.targetConceptId, + label: this.label, + tags: copy(this.tags) + }; + } +} + +/** Describe semantic parent/child ownership between repository concepts. */ +export class ConceptOwnership { + constructor(parentConceptId, childConceptId) { + if (!parentConceptId || !childConceptId || parentConceptId === childConceptId) { + throw new TypeError("Concept ownership requires two different concepts"); + } + this.parentConceptId = String(parentConceptId); + this.childConceptId = String(childConceptId); + } + + toDocument() { + return { + parentConceptId: this.parentConceptId, + childConceptId: this.childConceptId + }; + } +} + +/** + * Own all shared concepts and their semantic relationships. + * Placements refer to this repository by concept id. + */ +export class ConceptRepository { + constructor(concepts = [], relations = [], ownerships = []) { + this.conceptsById = new Map(); + this.relationsById = new Map(); + this.ownershipsByChildId = new Map(); + for (const value of concepts) this.add(value); + for (const value of relations) this.addRelation(value); + for (const value of ownerships) this.addOwnership(value); + } + + add(value) { + const concept = value instanceof Concept ? value : new Concept(value.id, value); + this.conceptsById.set(concept.id, concept); + return concept; + } + + ensure(id, values = {}) { + const key = String(id); + const existing = this.conceptsById.get(key); + if (existing) { + existing.update(values); + return existing; + } + return this.add(new Concept(key, values)); + } + + concept(id) { + return this.conceptsById.get(String(id)) || null; + } + + concepts() { + return [...this.conceptsById.values()]; + } + + remove(id) { + const key = String(id); + this.conceptsById.delete(key); + for (const [relationId, relation] of this.relationsById) { + if (relation.sourceConceptId === key || relation.targetConceptId === key) { + this.relationsById.delete(relationId); + } + } + this.ownershipsByChildId.delete(key); + for (const [childId, ownership] of this.ownershipsByChildId) { + if (ownership.parentConceptId === key) this.ownershipsByChildId.delete(childId); + } + } + + addRelation(value) { + const relation = value instanceof ConceptRelation ? value : new ConceptRelation( + value.id, value.sourceConceptId, value.targetConceptId, value); + this.requireConcept(relation.sourceConceptId); + this.requireConcept(relation.targetConceptId); + this.relationsById.set(relation.id, relation); + return relation; + } + + addOwnership(value) { + const ownership = value instanceof ConceptOwnership ? value : new ConceptOwnership( + value.parentConceptId, value.childConceptId); + this.requireConcept(ownership.parentConceptId); + this.requireConcept(ownership.childConceptId); + if (this.isOwnedBy(ownership.parentConceptId, ownership.childConceptId)) { + throw new Error("Concept ownership would create a cycle"); + } + this.ownershipsByChildId.set(ownership.childConceptId, ownership); + return ownership; + } + + isOwnedBy(conceptId, possibleAncestorId) { + let current = this.ownershipsByChildId.get(String(conceptId)); + const seen = new Set(); + while (current && !seen.has(current.childConceptId)) { + if (current.parentConceptId === String(possibleAncestorId)) return true; + seen.add(current.childConceptId); + current = this.ownershipsByChildId.get(current.parentConceptId); + } + return false; + } + + requireConcept(id) { + const concept = this.concept(id); + if (!concept) throw new Error(`Unknown concept: ${id}`); + return concept; + } + + toDocument() { + return this.concepts().map((concept) => concept.toDocument()); + } + + relationDocuments() { + return [...this.relationsById.values()].map((relation) => relation.toDocument()); + } + + ownershipDocuments() { + return [...this.ownershipsByChildId.values()].map((ownership) => ownership.toDocument()); + } +} + +export { CONCEPT_FIELDS, normalizeTags }; diff --git a/static/cmap/model/package.json b/static/cmap/model/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/static/cmap/model/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/static/cmap/package.json b/static/cmap/package.json new file mode 100644 index 0000000..3dbc1ca --- /dev/null +++ b/static/cmap/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/static/css/wiki.css b/static/css/wiki.css index 9eff06f..4604832 100644 --- a/static/css/wiki.css +++ b/static/css/wiki.css @@ -1188,78 +1188,8 @@ body.editor-mode .editor-metadata-row { } -/* Wiki graph ------------------------------------------------------------- */ -#graph-view { - max-width: 1100px; -} - -.wiki-graph-shell { - margin-top: 18px; - border: 1px solid var(--wiki-border); - background: #fafafa; - overflow: auto; -} - -.wiki-graph { - display: block; - width: 100%; - min-width: 720px; - min-height: 520px; -} - -.wiki-graph-edges line { - stroke: #b8b8b8; - stroke-width: 1.4; -} - -.wiki-graph-node { - cursor: pointer; - outline: none; -} - -.wiki-graph-node circle { - fill: #46639b; - stroke: white; - stroke-width: 2; -} - -.wiki-graph-node text { - fill: var(--wiki-text); - font-family: Arial, Helvetica, sans-serif; - font-size: 14px; -} - -.wiki-graph-node:hover circle, -.wiki-graph-node:focus circle { - fill: #1e2f6d; - stroke: #1e2f6d; -} - -.wiki-graph-node-cmap circle { - fill: #57834a; -} - -.wiki-graph-node-cmap:hover circle, -.wiki-graph-node-cmap:focus circle { - fill: #315f2b; - stroke: #315f2b; -} - - -/* 0.2.15 context navigation and sticky-anchor correction ---------------- */ -.context-link { - color: var(--wiki-link); - font-size: .76rem; - font-weight: 400; - letter-spacing: normal; - text-transform: none; - text-decoration: none; -} - -.context-link:hover { - text-decoration: underline; -} +/* Sticky-anchor correction ---------------------------------------------- */ .markdown-body h1, .markdown-body h2, .markdown-body h3, @@ -1269,16 +1199,6 @@ body.editor-mode .editor-metadata-row { scroll-margin-top: 82px; } -.wiki-graph-node-focus circle { - r: 15px; - fill: #1e2f6d; - stroke: #1e2f6d; -} - -.wiki-graph-node-focus text { - font-weight: 700; -} - /* Recent changes and bookmark overview pages. */ .special-page-list, @@ -1444,173 +1364,3 @@ body.editor-mode .editor-metadata-row { .alias-history { color: #666; } .alias-actions { display: flex; gap: .5rem; margin-top: .6rem; } .alias-actions button { font: inherit; } - -/* 0.2.36 context graph overlay and docking ------------------------------ */ -.page-reading-layout { - min-width: 0; -} - -.page-reading-layout.context-dock-right { - display: grid; - grid-template-columns: minmax(0, 1fr) minmax(360px, 42%); - gap: 1rem; - align-items: start; -} - -.page-reading-layout.context-dock-top { - display: flex; - flex-direction: column; - gap: 1rem; -} - -.page-reading-layout.context-dock-top .context-dock { - order: -1; -} - -.context-dock { - border: 1px solid var(--wiki-border); - background: #fafafa; - padding: .75rem; - position: sticky; - top: 84px; - max-height: calc(100vh - 100px); - overflow: auto; -} - -.context-dock-header, -.context-overlay-header { - display: flex; - justify-content: space-between; - gap: 1rem; - align-items: flex-start; -} - -.context-graph-actions { - display: flex; - gap: .7rem; - flex-wrap: wrap; - font-size: .82rem; -} - -.context-graph-actions a { - color: var(--wiki-link); - text-decoration: none; -} - -.context-graph-actions a:hover { - text-decoration: underline; -} - -.context-graph-small { - min-width: 0; - min-height: 320px; - max-height: 520px; -} - -.context-overlay { - position: fixed; - inset: 0; - z-index: 500; - background: rgb(0 0 0 / 35%); - display: grid; - place-items: center; - padding: 2rem; -} - -.context-overlay-panel { - width: min(1180px, 96vw); - max-height: 92vh; - overflow: auto; - background: white; - border: 1px solid var(--wiki-border); - box-shadow: 0 14px 44px rgb(0 0 0 / 24%); - padding: 1rem; -} - -.context-overlay-header h2 { - margin: 0 0 .2rem; -} - -.context-overlay-graph-shell { - margin-top: .8rem; -} - -.wiki-graph-edges marker path { - fill: #9a9a9a; -} - -.wiki-graph-edge-incoming { - stroke: #7a5d9e !important; -} - -.wiki-graph-edge-outgoing { - stroke: #46639b !important; -} - -@media (max-width: 1100px) { - .page-reading-layout.context-dock-right { - display: flex; - flex-direction: column; - } - - .page-reading-layout.context-dock-right .context-dock { - order: -1; - width: 100%; - position: static; - max-height: none; - } -} - -@media (max-width: 700px) { - .context-overlay { - padding: .5rem; - } - - .context-overlay-panel { - width: 100%; - max-height: 96vh; - } - - .context-overlay-header { - display: block; - } - - .context-graph-actions { - margin-top: .5rem; - } -} - -/* Keep the established document width and use otherwise empty desktop - space for the docked context view. */ -#page-view:has(.context-dock-right) { - max-width: none; -} - -.page-reading-layout.context-dock-right { - grid-template-columns: minmax(0, 900px) minmax(420px, 520px); - justify-content: start; - gap: 1.5rem; -} - -.page-reading-layout.context-dock-right .markdown-body { - width: 100%; -} - -.page-reading-layout.context-dock-right .context-dock { - width: 100%; -} - -.context-dock .wiki-graph-node text { - font-size: 20px; - font-weight: 600; -} - -.context-dock .wiki-graph-node-focus text { - font-size: 22px; -} - -@media (max-width: 1450px) { - .page-reading-layout.context-dock-right { - grid-template-columns: minmax(0, 760px) minmax(360px, 440px); - } -} diff --git a/static/index.html b/static/index.html index 398afe6..dec8299 100644 --- a/static/index.html +++ b/static/index.html @@ -19,14 +19,13 @@ Bookmarks Todo list Recent - Navigation graph CMaps