#lang racket/base (require db json racket/file racket/list racket/path racket/string "config.rkt" "database.rkt" "todo.rkt") (provide ensure-wiki-data! valid-slug? title->slug list-pages read-page create-page! update-page! archive-page! page-history read-version search-pages list-todos save-upload! uploaded-file) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; goal : Ensure writable installation directories exist. ; pre : config is a wiki-config value and its data directory is writable. ; post : The data directory and writable static directory exist. ; result : void. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (ensure-wiki-data! config) (for ((directory (in-list (list (wiki-config-data-dir config) (data-static-directory config))))) (make-directory* directory))) (define (slug-alphanumeric? char) (or (char-alphabetic? char) (char-numeric? char))) (define (combining-mark? char) (if (member (char-general-category char) '(mn mc me)) #t #f)) (define (valid-slug-character? char) (or (slug-alphanumeric? char) (char=? char #\.) (char=? char #\_) (char=? char #\-))) (define (valid-slug? slug) (and (> (string-length slug) 0) (<= (string-length slug) 120) (slug-alphanumeric? (string-ref slug 0)) (for/and ((char (in-string slug))) (valid-slug-character? char)) (not (member slug '("." ".."))))) (define (title->slug title) (define normalized (string-downcase (string-normalize-nfkd (string-trim title)))) (define out (open-output-string)) (define separator-needed? #f) (define wrote-character? #f) (for ((char (in-string normalized))) (cond ((slug-alphanumeric? char) (when (and separator-needed? wrote-character?) (write-char #\- out)) (write-char char out) (set! separator-needed? #f) (set! wrote-character? #t)) ((combining-mark? char) (void)) (else (set! separator-needed? #t)))) (define slug (get-output-string out)) (define limited (if (> (string-length slug) 120) (substring slug 0 120) slug)) (regexp-replace #px"-+$" limited "")) (define (tags->text tags) (jsexpr->string tags)) (define (text->tags text) (with-handlers ((exn:fail? (λ (_e) '()))) (define value (string->jsexpr text)) (if (list? value) value '()))) (define (row->page row [include-markdown? #t]) (define result (hash 'slug (vector-ref row 0) 'title (vector-ref row 1) 'createdAt (vector-ref row 3) 'updatedAt (vector-ref row 4) 'createdBy (vector-ref row 5) 'updatedBy (vector-ref row 6) 'tags (text->tags (vector-ref row 7)) 'currentVersion (vector-ref row 8))) (if include-markdown? (hash-set result 'markdown (vector-ref row 2)) result)) (define page-columns "slug, title, markdown, created_at, updated_at, created_by, updated_by, tags, current_version") ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; goal : List current wiki page metadata. ; pre : The PostgreSQL schema is initialized. ; post : The pages table has only been read. ; result : A title-sorted list of page metadata hashes. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (list-pages config) (call-with-wiki-database config (λ (db) (for/list ((row (in-list (query-rows db (string-append "SELECT " page-columns " FROM pages WHERE archived = FALSE ORDER BY lower(title), title"))))) (row->page row #f))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; goal : Read the current form of one wiki page. ; pre : slug is a valid page slug. ; post : The pages table has only been read. ; result : Page metadata with Markdown, or #f when the page does not exist. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (read-page config slug) (if (not (valid-slug? slug)) #f (call-with-wiki-database config (λ (db) (define row (query-maybe-row db (string-append "SELECT " page-columns " FROM pages WHERE slug = $1 AND archived = FALSE") slug)) (if row (row->page row) #f))))) (define (replace-todos! db page-id markdown) (query-exec db "DELETE FROM todo_items WHERE page_id = $1" page-id) (for ((item (in-list (extract-todos markdown)))) (query-exec db "INSERT INTO todo_items(page_id, item_number, line_number, text) VALUES ($1, $2, $3, $4)" page-id (hash-ref item 'number) (hash-ref item 'line) (hash-ref item 'text)))) (define (insert-version! db page-id version title markdown author action summary now tags) (query-exec db #<text tags) author action summary now)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; goal : Create a wiki page and its first version. ; pre : slug is valid and unused. ; post : Current page state and version 1 are committed atomically. ; result : The new page metadata with Markdown. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (create-page! config slug title markdown author [summary "Created page"] [tags '()]) (call-with-wiki-database config (λ (db) (call-with-transaction db (λ () (define now (current-seconds)) (define page-id (query-value db #<text tags) now author)) (insert-version! db page-id 1 title markdown author "create" summary now tags) (replace-todos! db page-id markdown))))) (read-page config slug)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; goal : Save a new version of an existing wiki page. ; pre : The page exists and base-version equals its current version. ; post : Current page and version history are committed atomically. ; result : The updated page metadata with Markdown. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (update-page! config slug title markdown author base-version [summary "Edited page"] [tags #f]) (call-with-wiki-database config (λ (db) (call-with-transaction db (λ () (define row (query-maybe-row db "SELECT id, current_version, tags FROM pages WHERE slug = $1 AND archived = FALSE FOR UPDATE" slug)) (unless row (error 'update-page! "unknown page: ~a" slug)) (define current-version (vector-ref row 1)) (define supplied-version (if (number? base-version) base-version (string->number (format "~a" base-version)))) (unless (and supplied-version (= current-version supplied-version)) (error 'update-page! "version-conflict")) (define page-tags (if tags tags (text->tags (vector-ref row 2)))) (define next-version (+ current-version 1)) (define now (current-seconds)) (query-exec db #<text page-tags) next-version now author (vector-ref row 0)) (insert-version! db (vector-ref row 0) next-version title markdown author "edit" summary now page-tags) (replace-todos! db (vector-ref row 0) markdown))))) (read-page config slug)) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; goal : Archive an existing wiki page. ; pre : The page exists. ; post : The page is marked archived while its versions and attachments remain stored. ; result : void. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (archive-page! config slug author) (call-with-wiki-database config (λ (db) (define id (query-maybe-value db #<tags (vector-ref row 5)) 'createdAt (vector-ref row 6)))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; goal : Read one stored page version. ; pre : slug and version identify a possible stored version. ; post : Version rows have only been read. ; result : Version metadata with Markdown, or #f when the version is absent. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (read-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 #<tags (vector-ref row 6)) 'createdAt (vector-ref row 7))))))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ; goal : Search current wiki pages using PostgreSQL full-text search. ; pre : query-text is a string and the database schema is initialized. ; post : Page content has only been read. ; result : Up to 50 relevance-sorted search result hashes. ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (define (search-pages config query-text) (if (string=? (string-trim query-text) "") '() (call-with-wiki-database config (λ (db) (for/list ((row (in-list (query-rows db #<