Files
racket-wiki/private/cmap-appearance.rkt
T

225 lines
11 KiB
Racket

#lang racket/base
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; Wiki-wide CMap appearance stored in wiki_settings.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(require db
json
racket/list
racket/string
"database.rkt")
(provide read-cmap-appearance
save-cmap-appearance!)
(define styles-setting-key "cmap.styles.v1")
(define palette-setting-key "cmap.palette.v1")
(define maximum-style-count 100)
(define maximum-style-name-length 80)
(define permitted-name-keys
'(style-default style-subtle style-emphasis style-warning style-success))
(define value-keys
'(backgroundColor textColor fontFamily fontSize fontWeight fontStyle
synopsisTextColor synopsisFontFamily synopsisFontSize synopsisFontWeight
synopsisFontStyle submapBackgroundColor submapBorderColor))
(define (seeded-style id name-key background text font size bold? italic? [synopsis-text #f])
(hash 'id id
'nameKey name-key
'protected (string=? id "default")
'values
(hash 'backgroundColor background
'textColor text
'fontFamily font
'fontSize size
'fontWeight (if bold? "700" "400")
'fontStyle (if italic? "italic" "normal")
'synopsisTextColor (or synopsis-text text)
'synopsisFontFamily font
'synopsisFontSize (max 6 (- size 2))
'synopsisFontWeight "400"
'synopsisFontStyle (if italic? "italic" "normal")
'submapBackgroundColor "#edf7e8"
'submapBorderColor "#57834a")))
(define initial-cmap-styles
(list
(seeded-style "default" "style-default" "#fff4cf" "#222222" "Arial, Helvetica, sans-serif" 11 #t #f "#4d4d4d")
(seeded-style "subtle" "style-subtle" "#f1f3f5" "#56616b" "system-ui, sans-serif" 10 #f #f)
(seeded-style "emphasis" "style-emphasis" "#e7f2fb" "#173b57" "Georgia, Times New Roman, serif" 12 #t #f)
(seeded-style "warning" "style-warning" "#fff0d5" "#713b00" "Arial, Helvetica, sans-serif" 11 #t #t)
(seeded-style "success" "style-success" "#e6f4e2" "#285b27" "Arial, Helvetica, sans-serif" 11 #f #t)))
(define initial-cmap-palette
'("#ffffff" "#f1f3f5" "#e7f2fb" "#e6f4e2" "#fff4cf" "#fff0d5"
"#f7dede" "#dcd8f7" "#222222" "#4479a1" "#57834a" "#a97c00"))
(define (required-string who value description [maximum-length #f])
(unless (and (string? value)
(not (string=? (string-trim value) ""))
(or (not maximum-length) (<= (string-length (string-trim value)) maximum-length)))
(error who "invalid ~a" description))
(string-trim value))
(define (style-color who value key)
(unless (and (string? value) (regexp-match? #px"(?i:^#[0-9a-f]{6}$)" value))
(error who "invalid colour for ~a" key))
(string-downcase value))
(define (style-size who value key)
(unless (and (real? value) (<= 6 value 54))
(error who "invalid font size for ~a" key))
value)
(define (normalize-style-values who values)
(unless (hash? values) (error who "style values must be an object"))
(for ([key (in-list value-keys)])
(unless (hash-has-key? values key) (error who "missing style value: ~a" key)))
(hash
'backgroundColor (style-color who (hash-ref values 'backgroundColor) 'backgroundColor)
'textColor (style-color who (hash-ref values 'textColor) 'textColor)
'fontFamily (required-string who (hash-ref values 'fontFamily) "font family" 200)
'fontSize (style-size who (hash-ref values 'fontSize) 'fontSize)
'fontWeight (let ([value (hash-ref values 'fontWeight)])
(unless (member value '("400" "700")) (error who "invalid font weight"))
value)
'fontStyle (let ([value (hash-ref values 'fontStyle)])
(unless (member value '("normal" "italic")) (error who "invalid font style"))
value)
'synopsisTextColor (style-color who (hash-ref values 'synopsisTextColor) 'synopsisTextColor)
'synopsisFontFamily (required-string who (hash-ref values 'synopsisFontFamily) "synopsis font family" 200)
'synopsisFontSize (style-size who (hash-ref values 'synopsisFontSize) 'synopsisFontSize)
'synopsisFontWeight (let ([value (hash-ref values 'synopsisFontWeight)])
(unless (member value '("400" "700")) (error who "invalid synopsis font weight"))
value)
'synopsisFontStyle (let ([value (hash-ref values 'synopsisFontStyle)])
(unless (member value '("normal" "italic")) (error who "invalid synopsis font style"))
value)
'submapBackgroundColor (style-color who (hash-ref values 'submapBackgroundColor) 'submapBackgroundColor)
'submapBorderColor (style-color who (hash-ref values 'submapBorderColor) 'submapBorderColor)))
(define (normalize-cmap-styles styles [who 'cmap-styles])
(unless (and (list? styles) (<= 1 (length styles) maximum-style-count))
(error who "styles must contain between 1 and ~a entries" maximum-style-count))
(let* ((seen (make-hash))
(normalized
(for/list ([style (in-list styles)])
(unless (hash? style) (error who "each style must be an object"))
(let* ((id (required-string who (hash-ref style 'id #f) "style id" 120))
(name (and (string? (hash-ref style 'name #f))
(required-string who
(hash-ref style 'name)
"style name"
maximum-style-name-length)))
(name-key
(and (string? (hash-ref style 'nameKey #f))
(string->symbol
(required-string who
(hash-ref style 'nameKey)
"style name key"
40)))))
(unless (regexp-match? #px"^[A-Za-z0-9_-]+$" id)
(error who "invalid style id"))
(when (hash-ref seen id #f)
(error who "duplicate style id: ~a" id))
(hash-set! seen id #t)
(unless (or name (member name-key permitted-name-keys))
(error who "a style needs a name"))
(hash 'id id
(if (member name-key permitted-name-keys) 'nameKey 'name)
(if (member name-key permitted-name-keys) (symbol->string name-key) name)
'protected (string=? id "default")
'values (normalize-style-values who (hash-ref style 'values #f)))))))
(unless (hash-ref seen "default" #f)
(error who "the default style is required"))
normalized))
(define (normalize-cmap-palette palette [who 'cmap-palette])
(unless (and (list? palette) (= (length palette) (length initial-cmap-palette)))
(error who "the colour palette must contain ~a colours" (length initial-cmap-palette)))
(for/list ((color (in-list palette)))
(style-color who color 'palette)))
(define (read-setting db key fallback normalize who)
(define stored (query-maybe-value db "SELECT value FROM wiki_settings WHERE key = $1" key))
(if stored
(normalize (string->jsexpr stored) who)
(normalize fallback who)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Read the complete shared CMap appearance.
; pre : The wiki database is configured and its schema is initialized.
; post : The database is not changed; absent settings use model defaults.
; result : A hash containing validated styles and colour palette.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (read-cmap-appearance config)
(call-with-wiki-database
config
(λ (db)
(hash 'styles
(read-setting db
styles-setting-key
initial-cmap-styles
normalize-cmap-styles
'read-cmap-appearance)
'palette
(read-setting db
palette-setting-key
initial-cmap-palette
normalize-cmap-palette
'read-cmap-appearance)))))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
; goal : Validate and store the complete shared CMap appearance.
; pre : appearance contains styles and palette values accepted by this module.
; post : Both normalized settings are stored in one database transaction.
; result : The normalized appearance hash.
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(define (save-cmap-appearance! config appearance)
(unless (hash? appearance)
(error 'save-cmap-appearance! "appearance must be an object"))
(define styles
(normalize-cmap-styles (hash-ref appearance 'styles #f) 'save-cmap-appearance!))
(define palette
(normalize-cmap-palette (hash-ref appearance 'palette #f) 'save-cmap-appearance!))
(define encoded-styles (jsexpr->string styles))
(define encoded-palette (jsexpr->string palette))
(when (> (bytes-length (string->bytes/utf-8 encoded-styles)) (* 128 1024))
(error 'save-cmap-appearance! "style data is too large"))
(call-with-wiki-database
config
(λ (db)
(call-with-transaction
db
(λ ()
(query-exec
db
"INSERT INTO wiki_settings(key, value, updated_at) VALUES ($1, $2, $3) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at"
styles-setting-key encoded-styles (current-seconds))
(query-exec
db
"INSERT INTO wiki_settings(key, value, updated_at) VALUES ($1, $2, $3) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at"
palette-setting-key encoded-palette (current-seconds))))))
(hash 'styles styles 'palette palette))
(module+ test
(require rackunit)
(define values
(hash 'backgroundColor "#FFF4CF" 'textColor "#222222"
'fontFamily "Arial" 'fontSize 11 'fontWeight "700" 'fontStyle "normal"
'synopsisTextColor "#4d4d4d" 'synopsisFontFamily "Arial"
'synopsisFontSize 9 'synopsisFontWeight "400" 'synopsisFontStyle "normal"
'submapBackgroundColor "#edf7e8" 'submapBorderColor "#57834a"))
(define normalized
(normalize-cmap-styles
(list (hash 'id "default" 'nameKey "style-default" 'values values))))
(check-equal? (hash-ref (hash-ref (first normalized) 'values) 'backgroundColor) "#fff4cf")
(check-equal? (length (normalize-cmap-styles initial-cmap-styles)) 5)
(check-equal? (normalize-cmap-palette initial-cmap-palette) initial-cmap-palette)
(check-exn exn:fail? (λ () (normalize-cmap-styles '())))
(check-exn exn:fail?
(λ ()
(normalize-cmap-styles
(list (hash 'id "custom" 'name "Custom" 'values values))))))