#lang racket/base ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; PostgreSQL-backed concept-map storage. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (require db json racket/list racket/string "concept-id.rkt" "database.rkt" "people.rkt" "storage.rkt") (provide list-concept-maps list-archived-concept-maps list-concept-usage list-concept-todos list-recent-concept-maps search-concept-maps read-concept-map concept-map-history read-concept-map-version delete-concept-map-version! create-concept-map! rename-concept-map! update-concept-map! archive-concept-map! restore-concept-map!) (define maximum-document-size (* 10 1024 1024)) (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)) (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))))) (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) (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 imageSource)) (define placement-content-keys '(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) (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 (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) (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) (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) (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))) ;;; Chooses the best available label for matching a source to a shared concept. (define (concept-source-label source repository-concept) (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 ""))) ;;; Finds the most recently updated shared concept with the normalized label. (define (concept-id-by-label db name-key) (if (string=? name-key "") #f (query-maybe-value db #<> 'label')) = $1 ORDER BY updated_at DESC, id DESC LIMIT 1 SQL 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) (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) (let ((concepts (filter (λ (concept) (and (hash? concept) (string? (hash-ref concept 'id #f)) (not (string=? (hash-ref concept 'id) "")))) (document-concepts document)))) (for-each (λ (concept) (let ((concept-id (hash-ref concept 'id))) (unless (concept-id? concept-id) (error 'sync-concept-definitions! "invalid concept UUID: ~a" concept-id)) (query-exec db #<text (concept-content concept)) now author))) concepts))) ;;; Replaces stored references with canonical concept definitions for the editor. (define (hydrate-concept-definitions db document) (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]) (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))) ;;; Read the latest SVG render for one database CMap and version. (define (cmap-render-by-version db cmap-slug version) (let ((row (query-maybe-row db #<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)) (when (string=? (string-trim title) "") (error who "title is required")) (unless (hash? document) (raise-argument-error who "hash?" document)) (document->text document) (void)) ;;; 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) (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) (map row->archived-concept-map (query-rows db #<concept-usage ; converts the grouped rows. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (list-concept-usage config) (call-with-wiki-database config (λ (db) (map row->concept-usage (query-rows db #<> 'conceptId' AS concept_id, coalesce(nullif(definition.document ->> 'label', ''), nullif(repository.document ->> 'label', ''), nullif(item ->> 'label', ''), 'Concept') AS concept_label, coalesce(nullif(definition.document ->> 'pageSlug', ''), nullif(repository.document ->> 'pageSlug', ''), nullif(item ->> 'pageSlug', '')) AS page_slug FROM concept_maps cm CROSS JOIN LATERAL jsonb_array_elements( CASE WHEN jsonb_typeof(cm.document -> 'items') = 'array' THEN cm.document -> 'items' ELSE '[]'::jsonb END ) AS item LEFT JOIN LATERAL ( SELECT concept_value AS document FROM jsonb_array_elements( CASE WHEN jsonb_typeof(cm.document -> 'concepts') = 'array' THEN cm.document -> 'concepts' ELSE '[]'::jsonb END ) AS concept_entry(concept_value) WHERE concept_value ->> 'id' = item ->> 'conceptId' LIMIT 1 ) repository ON TRUE LEFT JOIN concept_definitions definition ON definition.id = item ->> 'conceptId' WHERE cm.archived = FALSE AND nullif(item ->> 'conceptId', '') IS NOT NULL AND coalesce(item ->> 'kind', 'concept') <> 'phrase' ) SELECT concept_id, slug, cmap_title, concept_label, page_slug, count(*) FROM placements GROUP BY concept_id, slug, cmap_title, concept_label, page_slug ORDER BY concept_id, lower(cmap_title), cmap_title SQL ))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; goal : List globally defined concepts carrying the TODO aspect. ; pre : Concept definitions and current CMap documents are available. ; post : No database state is changed and every concept occurs at most once. ; result : Concept todo hashes with shared content and a current map location. ; internals: PostgreSQL selects definitions whose aspects contain TODO and a ; lateral join chooses one active placement; row->concept-todo ; converts nullable links to JSON null. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (list-concept-todos config) (call-with-wiki-database config (λ (db) (map row->concept-todo (query-rows db #<> 'label', ''), 'Concept') AS label, coalesce(definition.document ->> 'synopsis', '') AS synopsis, nullif(definition.document ->> 'descriptionPageSlug', '') AS description_page_slug, nullif(definition.document ->> 'pageSlug', '') AS page_slug, nullif(definition.document ->> 'cmapSlug', '') AS linked_cmap_slug, nullif(definition.document ->> 'externalUrl', '') AS external_url, placement.cmap_slug, placement.cmap_title FROM concept_definitions definition JOIN LATERAL ( SELECT cm.slug AS cmap_slug, cm.title AS cmap_title FROM concept_maps cm CROSS JOIN LATERAL jsonb_array_elements( CASE WHEN jsonb_typeof(cm.document -> 'items') = 'array' THEN cm.document -> 'items' ELSE '[]'::jsonb END ) AS item WHERE cm.archived = FALSE AND item ->> 'conceptId' = definition.id AND coalesce(item ->> 'kind', 'concept') <> 'phrase' ORDER BY lower(cm.title), cm.title, cm.slug LIMIT 1 ) placement ON TRUE WHERE EXISTS ( SELECT 1 FROM jsonb_array_elements_text( CASE WHEN jsonb_typeof(definition.document -> 'aspects') = 'array' THEN definition.document -> 'aspects' ELSE '[]'::jsonb END ) AS aspect(value) WHERE lower(trim(aspect.value)) = 'todo' ) ORDER BY lower(coalesce(definition.document ->> 'label', '')), coalesce(definition.document ->> 'label', ''), definition.id SQL ))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; 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) (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) "") '() (call-with-wiki-database config (λ (db) (map row->search-result (query-rows db #<> 'label', coalesce(definition.document, concept) ->> 'synopsis'), ' ') FROM jsonb_array_elements( CASE WHEN jsonb_typeof(cm.document -> 'concepts') = 'array' THEN cm.document -> 'concepts' ELSE '[]'::jsonb END) AS concept LEFT JOIN concept_definitions definition ON definition.id = concept ->> 'id' ), '') AS concept_text FROM concept_maps cm WHERE cm.archived = FALSE ), ranked AS ( SELECT slug, title, concept_text, setweight(to_tsvector('simple', coalesce(title, '') || ' ' || coalesce(slug, '')), 'A') || setweight(to_tsvector('simple', concept_text), 'B') AS search_document FROM map_documents ) SELECT ranked.slug, ranked.title, ts_rank(ranked.search_document, q.query) AS rank, ts_headline( 'simple', concat_ws(' ', ranked.title, ranked.concept_text), q.query, 'StartSel=[[[, StopSel=]]], MaxWords=28, MinWords=8, ShortWord=2') AS snippet FROM ranked, q WHERE ranked.search_document @@ q.query ORDER BY rank DESC, lower(ranked.title), ranked.title LIMIT 50 SQL query-text)))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; goal : Read one current concept map including its editor document. ; pre : slug is a string. ; post : No database state is changed. ; result : Concept-map hash, or #f when no current map has that slug. ; internals: Reads the active concept_maps row, row->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)) #f (call-with-wiki-database config (λ (db) (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-set (hash-update (row->concept-map row) 'document (λ (document) (hydrate-concept-definitions db document))) 'renderedSvg (or (cmap-render-by-version db (vector-ref row 0) (vector-ref row 3)) "")) #f)))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; goal : Create a persistent concept map. ; pre : slug is unused, title is non-empty and document is a JSON object. ; 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 [rendered-svg ""]) (let* ((clean-slug (string-trim slug)) (clean-title (string-trim title)) (now (current-seconds))) (validate-concept-map-input 'create-concept-map! clean-slug clean-title document) (call-with-wiki-database config (λ (db) (call-with-transaction db (λ () (let* ((canonical-document (canonicalize-document-concepts db document)) (document-text (document->text (concept-map-storage-document canonical-document)))) (sync-concept-definitions! db canonical-document author now) (sync-person-tags! db canonical-document) (let ((cmap-id (query-value 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) (let ((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)) (map row->history-entry (query-rows db #<concept-map-version decodes ; the selected immutable document. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (read-concept-map-version config slug version) (let ((requested-version (version-number version))) (if requested-version (call-with-wiki-database config (λ (db) (let ((row (query-maybe-row db #<concept-map-version row) #f)))) #f))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; goal : Delete one user-facing concept-map history item. ; pre : slug identifies a current map and version is numeric. ; 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) (let ((requested-version (version-number version))) (if requested-version (call-with-wiki-database config (λ (db) (if (query-maybe-value 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 (hash 'id concept-a 'label "Unsafe concept" 'externalUrl "javascript:alert(1)")))) (check-exn exn:fail? (λ () (concept-map-storage-document (hash 'concepts (list (hash 'id "legacy:map:concept-1")) 'items '())))))