#lang racket/base ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Repeatable import of the bundled racket-wiki architecture pages and CMaps. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (require file/sha1 json racket/cmdline racket/file racket/list racket/path racket/runtime-path racket/set racket/string "../private/cmap-storage.rkt" "../private/config.rkt" "../private/database.rkt" "../private/storage.rkt") (provide (struct-out architecture-import-result) validate-racket-wiki-architecture! import-racket-wiki-architecture-from-data-directory! import-racket-wiki-architecture!) (struct architecture-import-result (kind reference status message) #:transparent) (struct architecture-page (reference title markdown tags) #:transparent) (struct architecture-concept-map (slug title document) #:transparent) (define-runtime-path architecture-directory ".") (define page-source-marker-pattern #px"^\\r?\\n") (define concept-map-source-marker-id "racket-wiki-architecture-source") ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Content loading and validation ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (manifest-path) (build-path architecture-directory "manifest.rktd")) (define (read-manifest) (call-with-input-file (manifest-path) read)) (define (content-path relative-path) (build-path architecture-directory relative-path)) (define (canonical-datum value) (cond [(hash? value) (define keys (sort (hash-keys value) stringbytes/utf-8 (format "~s" (canonical-datum value)))) (bytes->hex-string (sha256-bytes source-bytes))) (define (read-page-source namespace specification) (define slug (hash-ref specification 'slug)) (architecture-page (page-reference namespace slug) (hash-ref specification 'title) (file->string (content-path (hash-ref specification 'file))) (hash-ref specification 'tags '()))) (define (read-concept-map-source specification) (architecture-concept-map (hash-ref specification 'slug) (hash-ref specification 'title) (call-with-input-file (content-path (hash-ref specification 'file)) read-json))) (define (load-architecture-content) (define manifest (read-manifest)) (unless (hash? manifest) (error 'load-architecture-content "manifest.rktd must contain a hash")) (define namespace (hash-ref manifest 'namespace)) (define pages (for/list ([specification (in-list (hash-ref manifest 'pages))]) (read-page-source namespace specification))) (define concept-maps (for/list ([specification (in-list (hash-ref manifest 'concept-maps))]) (read-concept-map-source specification))) (values namespace pages concept-maps)) (define (duplicate-values values) (define seen (mutable-set)) (define duplicates (mutable-set)) (for ([value (in-list values)]) (if (set-member? seen value) (set-add! duplicates value) (set-add! seen value))) (sort (set->list duplicates) stringstring item-ids))) (unless (null? duplicate-item-ids) (error 'validate-racket-wiki-architecture! "CMap ~a has duplicate item ids: ~a" (architecture-concept-map-slug concept-map) (string-join duplicate-item-ids ", "))) (define item-id-set (list->set item-ids)) (for ([connector (in-list connectors)]) (unless (hash? connector) (error 'validate-racket-wiki-architecture! "CMap ~a contains a non-object connector" (architecture-concept-map-slug concept-map))) (define source-id (hash-ref connector 'sourceId #f)) (define target-id (hash-ref connector 'targetId #f)) (unless (and (set-member? item-id-set source-id) (set-member? item-id-set target-id)) (error 'validate-racket-wiki-architecture! "CMap ~a contains a connector with an unknown endpoint" (architecture-concept-map-slug concept-map))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; goal : Validate every bundled page, link and CMap before importing. ; pre : The architecture files are present beside this module. ; post : No wiki or file state has been changed. ; result : Two values containing the validated pages and CMaps. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (validate-racket-wiki-architecture!) (define-values (namespace pages concept-maps) (load-architecture-content)) (unless (string=? namespace "racket-wiki") (error 'validate-racket-wiki-architecture! "the architecture namespace must be racket-wiki")) (define page-reference-list (map architecture-page-reference pages)) (define concept-map-slug-list (map architecture-concept-map-slug concept-maps)) (define duplicate-pages (duplicate-values page-reference-list)) (define duplicate-concept-maps (duplicate-values concept-map-slug-list)) (unless (null? duplicate-pages) (error 'validate-racket-wiki-architecture! "duplicate page references: ~a" (string-join duplicate-pages ", "))) (unless (null? duplicate-concept-maps) (error 'validate-racket-wiki-architecture! "duplicate CMap slugs: ~a" (string-join duplicate-concept-maps ", "))) (for ([reference (in-list page-reference-list)]) (unless (valid-page-reference? reference) (error 'validate-racket-wiki-architecture! "invalid page reference: ~a" reference))) (for ([slug (in-list concept-map-slug-list)]) (unless (valid-slug? slug) (error 'validate-racket-wiki-architecture! "invalid CMap slug: ~a" slug))) (define page-references (list->set page-reference-list)) (define concept-map-slugs (list->set concept-map-slug-list)) (for ([page (in-list pages)]) (validate-page-links! page page-references concept-map-slugs)) (for ([concept-map (in-list concept-maps)]) (validate-concept-map! concept-map page-references)) (values pages concept-maps)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Source ownership markers ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (page-with-source-marker title markdown tags) (string-append "\n" markdown)) (define (page-source-unmodified? title markdown tags) (define match (regexp-match page-source-marker-pattern markdown)) (and match (let ([body (regexp-replace page-source-marker-pattern markdown "")] [stored-hash (list-ref match 1)]) (string=? stored-hash (source-hash (list title body tags)))))) (define (concept-map-source-marker? reference) (and (hash? reference) (string=? (hash-ref reference 'id "") concept-map-source-marker-id))) (define (concept-map-without-source-marker document) (define references (hash-ref document 'conceptMaps '())) (hash-set document 'conceptMaps (filter (λ (reference) (not (concept-map-source-marker? reference))) references))) (define (concept-map-with-source-marker title document) (define clean-document (concept-map-without-source-marker document)) (define marker (hash 'id concept-map-source-marker-id 'kind "architecture-source" 'sourceHash (source-hash (list title clean-document)))) (hash-set clean-document 'conceptMaps (append (hash-ref clean-document 'conceptMaps '()) (list marker)))) (define (concept-map-source-unmodified? title document) (define marker (findf concept-map-source-marker? (hash-ref document 'conceptMaps '()))) (and marker (string=? (hash-ref marker 'sourceHash "") (source-hash (list title (concept-map-without-source-marker document)))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Import operations ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (result kind reference status message) (architecture-import-result kind reference status message)) (define (page-content-equal? current page marked-markdown) (and (string=? (hash-ref current 'title) (architecture-page-title page)) (string=? (hash-ref current 'markdown) marked-markdown) (equal? (hash-ref current 'tags '()) (architecture-page-tags page)))) (define (import-page! config page author dry-run? overwrite-modified?) (define reference (architecture-page-reference page)) (define marked-markdown (page-with-source-marker (architecture-page-title page) (architecture-page-markdown page) (architecture-page-tags page))) (define current (read-page config reference)) (cond [(not current) (if dry-run? (result 'page reference 'would-create "Page would be created") (begin (create-page! config reference (architecture-page-title page) marked-markdown author "Imported racket-wiki architecture documentation" (architecture-page-tags page)) (result 'page reference 'created "Page created")))] [(page-content-equal? current page marked-markdown) (result 'page reference 'unchanged "Page already matches the bundled source")] [(and (not overwrite-modified?) (not (page-source-unmodified? (hash-ref current 'title) (hash-ref current 'markdown) (hash-ref current 'tags '())))) (result 'page reference 'skipped-modified "Page was modified after import and was not overwritten")] [dry-run? (result 'page reference (if overwrite-modified? 'would-overwrite 'would-update) "Page would be updated")] [else (update-page! config reference (architecture-page-title page) marked-markdown author (hash-ref current 'currentVersion) "Updated racket-wiki architecture documentation" (architecture-page-tags page)) (result 'page reference 'updated "Page updated")])) (define (concept-map-content-equal? current concept-map marked-document) (and (string=? (hash-ref current 'title) (architecture-concept-map-title concept-map)) (equal? (hash-ref current 'document) marked-document))) (define (import-concept-map! config concept-map author dry-run? overwrite-modified?) (define slug (architecture-concept-map-slug concept-map)) (define marked-document (concept-map-with-source-marker (architecture-concept-map-title concept-map) (architecture-concept-map-document concept-map))) (define current (read-concept-map config slug)) (cond [(not current) (if dry-run? (result 'concept-map slug 'would-create "CMap would be created") (begin (create-concept-map! config slug (architecture-concept-map-title concept-map) marked-document author) (result 'concept-map slug 'created "CMap created")))] [(concept-map-content-equal? current concept-map marked-document) (result 'concept-map slug 'unchanged "CMap already matches the bundled source")] [(and (not overwrite-modified?) (not (concept-map-source-unmodified? (hash-ref current 'title) (hash-ref current 'document)))) (result 'concept-map slug 'skipped-modified "CMap was modified after import and was not overwritten")] [dry-run? (result 'concept-map slug (if overwrite-modified? 'would-overwrite 'would-update) "CMap would be updated")] [else (update-concept-map! config slug (architecture-concept-map-title concept-map) marked-document author (hash-ref current 'currentVersion) "Updated racket-wiki architecture CMap" "import") (result 'concept-map slug 'updated "CMap updated")])) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; goal : Import the bundled architecture set below namespace racket-wiki. ; pre : config points at an initialized or initializable PostgreSQL wiki. ; post : Missing managed pages/CMaps are created and unchanged managed ; sources can receive new versions; locally modified sources are ; skipped unless overwrite-modified? is true. ; result : A list of architecture-import-result values. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (import-racket-wiki-architecture! config #:author [author "racket-wiki architecture import"] #:dry-run? [dry-run? #f] #:overwrite-modified? [overwrite-modified? #f]) (define-values (pages concept-maps) (validate-racket-wiki-architecture!)) (ensure-wiki-data! config) (unless (database-settings-exist? config) (error 'import-racket-wiki-architecture! "PostgreSQL is not configured for data directory ~a" (wiki-config-data-dir config))) (initialize-database! config) (append (for/list ([page (in-list pages)]) (import-page! config page author dry-run? overwrite-modified?)) (for/list ([concept-map (in-list concept-maps)]) (import-concept-map! config concept-map author dry-run? overwrite-modified?)))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; goal : Import the architecture set using only a wiki data directory. ; pre : data-directory contains the wiki's database.rktd configuration. ; post : The same changes as import-racket-wiki-architecture! have occurred. ; result : A list of architecture-import-result values. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (import-racket-wiki-architecture-from-data-directory! data-directory #:author [author "racket-wiki architecture import"] #:dry-run? [dry-run? #f] #:overwrite-modified? [overwrite-modified? #f]) (define config (make-wiki-config #:data-dir data-directory)) (import-racket-wiki-architecture! config #:author author #:dry-run? dry-run? #:overwrite-modified? overwrite-modified?)) (define (display-import-results results) (for ([import-result (in-list results)]) (displayln (format "~a ~a ~a" (architecture-import-result-status import-result) (architecture-import-result-kind import-result) (architecture-import-result-reference import-result)))) (define skipped (count (λ (import-result) (eq? (architecture-import-result-status import-result) 'skipped-modified)) results)) (displayln (format "Processed ~a architecture items." (length results))) (when (> skipped 0) (displayln (format "~a locally modified item(s) were preserved. Review them before using --overwrite-modified." skipped)))) (module+ main (define config (default-wiki-config)) (define author "racket-wiki architecture import") (define dry-run? #f) (define overwrite-modified? #f) (command-line #:program "racket-wiki architecture import" #:once-each [("--data") directory "Wiki data directory" (set! config (struct-copy wiki-config config [data-dir (path->complete-path directory)]))] [("--author") name "Author recorded in imported page and CMap versions" (set! author name)] [("--dry-run") "Validate and report changes without changing wiki content" (set! dry-run? #t)] [("--overwrite-modified") "Replace architecture items that were edited after import" (set! overwrite-modified? #t)]) (display-import-results (import-racket-wiki-architecture! config #:author author #:dry-run? dry-run? #:overwrite-modified? overwrite-modified?))) (module+ test (require rackunit) (define-values (test-pages test-concept-maps) (validate-racket-wiki-architecture!)) (check-equal? (length test-pages) 12) (check-equal? (length test-concept-maps) 2) (define test-markdown "# Test\n\nBody.\n") (define marked-test-markdown (page-with-source-marker "Test" test-markdown '("test"))) (check-true (page-source-unmodified? "Test" marked-test-markdown '("test"))) (check-false (page-source-unmodified? "Test" (string-append marked-test-markdown "Local edit\n") '("test"))) (check-false (page-source-unmodified? "Changed title" marked-test-markdown '("test"))) (define test-document (architecture-concept-map-document (car test-concept-maps))) (define marked-test-document (concept-map-with-source-marker "Test CMap" test-document)) (check-true (concept-map-source-unmodified? "Test CMap" marked-test-document)) (check-false (concept-map-source-unmodified? "Test CMap" (hash-set marked-test-document 'schemaVersion 2))) (check-false (concept-map-source-unmodified? "Changed title" marked-test-document)))