namespaces added and documentation
This commit is contained in:
@@ -1,8 +1,8 @@
|
|||||||
# racket-wiki
|
# racket-wiki
|
||||||
|
|
||||||
Version 0.2.30 tightens implicit wiki links to classic WikiWords: only letter-only CamelCase words with at least two capitalized word segments are treated as implicit links. Version-like identifiers and words containing digits or punctuation are not WikiWords.
|
Version 0.2.31 adds page namespaces as database metadata and extends wiki references to forms such as `RWS:ModelTreeWalker` and `[roadmap](racket:roadmap)`. Todo items and bookmarks are grouped by namespace. The source has also been documented more thoroughly, especially `static/js/wiki.js`.
|
||||||
|
|
||||||
Current development version: **0.2.29**.
|
Current development version: **0.2.31**.
|
||||||
|
|
||||||
A small self-hosted wiki with a Racket backend and an HTML5/CSS/JavaScript frontend.
|
A small self-hosted wiki with a Racket backend and an HTML5/CSS/JavaScript frontend.
|
||||||
|
|
||||||
@@ -198,6 +198,33 @@ When the site is served through HTTPS, add `--secure-cookie` so the session cook
|
|||||||
|
|
||||||
The `--create-admin` command-line option remains available for recovery or scripted administration, but it is no longer part of the normal first-run procedure.
|
The `--create-admin` command-line option remains available for recovery or scripted administration, but it is no longer part of the normal first-run procedure.
|
||||||
|
|
||||||
|
|
||||||
|
## Page namespaces
|
||||||
|
|
||||||
|
Every page has a namespace and a page slug. Existing pages are migrated to the root namespace. The external page reference is compact:
|
||||||
|
|
||||||
|
```text
|
||||||
|
roadmap
|
||||||
|
racket:roadmap
|
||||||
|
RWS:model-tree-walker
|
||||||
|
```
|
||||||
|
|
||||||
|
The editor exposes the namespace as page metadata. The slug is still generated from the title when a page is first created and remains stable afterwards. The combination of namespace and slug is unique in PostgreSQL.
|
||||||
|
|
||||||
|
Explicit Markdown links may use the compact form directly:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
[roadmap](racket:roadmap)
|
||||||
|
```
|
||||||
|
|
||||||
|
Classic WikiWords may also be namespace-qualified:
|
||||||
|
|
||||||
|
```text
|
||||||
|
RWS:ModelTreeWalker
|
||||||
|
```
|
||||||
|
|
||||||
|
The WikiWord portion still follows the strict letter-only classic rule. Todo and Bookmark views group their entries by namespace; bookmark user sections remain available as a second grouping level.
|
||||||
|
|
||||||
## Editing
|
## Editing
|
||||||
|
|
||||||
EasyMDE supplies the Markdown editor, Markdown-aware styling while editing, line numbers, keyboard shortcuts, preview, side-by-side mode and fullscreen mode. On a normal desktop-sized window racket-wiki opens the editor in side-by-side mode by default. EasyMDE is configured with `sideBySideFullscreen: false`, so split editing remains inside the normal wiki page rather than taking over the complete browser window.
|
EasyMDE supplies the Markdown editor, Markdown-aware styling while editing, line numbers, keyboard shortcuts, preview, side-by-side mode and fullscreen mode. On a normal desktop-sized window racket-wiki opens the editor in side-by-side mode by default. EasyMDE is configured with `sideBySideFullscreen: false`, so split editing remains inside the normal wiki page rather than taking over the complete browser window.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#lang info
|
#lang info
|
||||||
|
|
||||||
(define pkg-authors '(hnmdijkema))
|
(define pkg-authors '(hnmdijkema))
|
||||||
(define version "0.2.30")
|
(define version "0.2.31")
|
||||||
(define license 'MIT)
|
(define license 'MIT)
|
||||||
(define collection "racket-wiki")
|
(define collection "racket-wiki")
|
||||||
(define pkg-desc
|
(define pkg-desc
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
#lang racket/base
|
#lang racket/base
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;; Command-line and programmatic entry points for racket-wiki.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
(require racket/cmdline
|
(require racket/cmdline
|
||||||
racket/path
|
racket/path
|
||||||
"private/auth.rkt"
|
"private/auth.rkt"
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
#lang racket/base
|
#lang racket/base
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;; Tracking current and historical references to uploaded files.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
(require db
|
(require db
|
||||||
racket/string)
|
racket/string)
|
||||||
|
|
||||||
@@ -14,7 +18,7 @@
|
|||||||
(define (attachment-rows db)
|
(define (attachment-rows db)
|
||||||
(query-rows db
|
(query-rows db
|
||||||
#<<SQL
|
#<<SQL
|
||||||
SELECT a.id, p.slug, a.stored_name
|
SELECT a.id, p.slug, a.stored_name, p.namespace
|
||||||
FROM attachments a
|
FROM attachments a
|
||||||
JOIN pages p ON p.id = a.page_id
|
JOIN pages p ON p.id = a.page_id
|
||||||
ORDER BY a.id
|
ORDER BY a.id
|
||||||
@@ -29,7 +33,13 @@ SQL
|
|||||||
(define attachment-id (vector-ref row 0))
|
(define attachment-id (vector-ref row 0))
|
||||||
(define slug (vector-ref row 1))
|
(define slug (vector-ref row 1))
|
||||||
(define stored-name (vector-ref row 2))
|
(define stored-name (vector-ref row 2))
|
||||||
(when (string-contains? markdown (attachment-url slug stored-name))
|
(define namespace (vector-ref row 3))
|
||||||
|
(define reference (if (string=? namespace "") slug (string-append namespace ":" slug)))
|
||||||
|
(define current-url (attachment-url reference stored-name))
|
||||||
|
(define legacy-url (attachment-url slug stored-name))
|
||||||
|
(when (or (string-contains? markdown current-url)
|
||||||
|
(and (not (string=? namespace ""))
|
||||||
|
(string-contains? markdown legacy-url)))
|
||||||
(query-exec db
|
(query-exec db
|
||||||
#<<SQL
|
#<<SQL
|
||||||
INSERT INTO attachment_references
|
INSERT INTO attachment_references
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
#lang racket/base
|
#lang racket/base
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;; Users, passwords, sessions, roles and CSRF authentication.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
(require crypto
|
(require crypto
|
||||||
crypto/libcrypto
|
crypto/libcrypto
|
||||||
db
|
db
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
#lang racket/base
|
#lang racket/base
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;; Small runtime configuration and data-path helpers.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
(require racket/path
|
(require racket/path
|
||||||
racket/runtime-path)
|
racket/runtime-path)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
#lang racket/base
|
#lang racket/base
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;; PostgreSQL connection settings and database initialization.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
(require db
|
(require db
|
||||||
racket/file
|
racket/file
|
||||||
racket/port
|
racket/port
|
||||||
@@ -18,6 +22,10 @@
|
|||||||
|
|
||||||
(struct database-settings (server port database user password ssl) #:transparent)
|
(struct database-settings (server port database user password ssl) #:transparent)
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;; Supporting functions
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
(define (database-settings-exist? config)
|
(define (database-settings-exist? config)
|
||||||
(file-exists? (database-config-path config)))
|
(file-exists? (database-config-path config)))
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
#lang racket/base
|
#lang racket/base
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;; Shared HTTP request/response helpers and security headers.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
(require json
|
(require json
|
||||||
racket/string
|
racket/string
|
||||||
web-server/http
|
web-server/http
|
||||||
|
|||||||
+24
-1
@@ -1,5 +1,9 @@
|
|||||||
#lang racket/base
|
#lang racket/base
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;; Ordered PostgreSQL schema migrations for existing wiki databases.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
(require db
|
(require db
|
||||||
racket/file
|
racket/file
|
||||||
racket/path
|
racket/path
|
||||||
@@ -12,7 +16,11 @@
|
|||||||
database-schema-version
|
database-schema-version
|
||||||
migrate-database!)
|
migrate-database!)
|
||||||
|
|
||||||
(define current-schema-version 6)
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;; Supporting functions
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
|
(define current-schema-version 7)
|
||||||
|
|
||||||
(define schema-1-statements
|
(define schema-1-statements
|
||||||
(list
|
(list
|
||||||
@@ -242,6 +250,9 @@ SQL
|
|||||||
|
|
||||||
|
|
||||||
(define (migrate-5->6! db)
|
(define (migrate-5->6! db)
|
||||||
|
;; Namespace is added here as well so a fresh migration chain can rebuild
|
||||||
|
;; attachment URLs before schema 7 creates the namespace indexes.
|
||||||
|
(query-exec db "ALTER TABLE pages ADD COLUMN IF NOT EXISTS namespace TEXT NOT NULL DEFAULT ''")
|
||||||
(query-exec db
|
(query-exec db
|
||||||
#<<SQL
|
#<<SQL
|
||||||
CREATE TABLE IF NOT EXISTS attachment_references (
|
CREATE TABLE IF NOT EXISTS attachment_references (
|
||||||
@@ -261,6 +272,15 @@ SQL
|
|||||||
(rebuild-attachment-references! db)
|
(rebuild-attachment-references! db)
|
||||||
(record-schema-version! db 6))
|
(record-schema-version! db 6))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
(define (migrate-6->7! db)
|
||||||
|
(query-exec db "ALTER TABLE pages ADD COLUMN IF NOT EXISTS namespace TEXT NOT NULL DEFAULT ''")
|
||||||
|
(query-exec db "ALTER TABLE pages DROP CONSTRAINT IF EXISTS pages_slug_key")
|
||||||
|
(query-exec db "CREATE UNIQUE INDEX IF NOT EXISTS pages_namespace_slug_idx ON pages(namespace, slug)")
|
||||||
|
(query-exec db "CREATE INDEX IF NOT EXISTS pages_namespace_idx ON pages(lower(namespace), lower(title), title)")
|
||||||
|
(record-schema-version! db 7))
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
; goal : Bring a racket-wiki PostgreSQL database to the current schema.
|
; goal : Bring a racket-wiki PostgreSQL database to the current schema.
|
||||||
; pre : db is a writable PostgreSQL connection and config identifies the
|
; pre : db is a writable PostgreSQL connection and config identifies the
|
||||||
@@ -290,6 +310,9 @@ SQL
|
|||||||
(define after-todo-reindex (database-schema-version db))
|
(define after-todo-reindex (database-schema-version db))
|
||||||
(when (= after-todo-reindex 5)
|
(when (= after-todo-reindex 5)
|
||||||
(migrate-5->6! db))
|
(migrate-5->6! db))
|
||||||
|
(define after-attachment-references (database-schema-version db))
|
||||||
|
(when (= after-attachment-references 6)
|
||||||
|
(migrate-6->7! db))
|
||||||
(define resulting-version (database-schema-version db))
|
(define resulting-version (database-schema-version db))
|
||||||
(when (> resulting-version current-schema-version)
|
(when (> resulting-version current-schema-version)
|
||||||
(error 'migrate-database!
|
(error 'migrate-database!
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
#lang racket/base
|
#lang racket/base
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;; First-run setup state and web setup processing.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
(require crypto
|
(require crypto
|
||||||
net/uri-codec
|
net/uri-codec
|
||||||
racket/path
|
racket/path
|
||||||
|
|||||||
+142
-64
@@ -1,5 +1,9 @@
|
|||||||
#lang racket/base
|
#lang racket/base
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;; PostgreSQL-backed page, history, search, bookmark and upload storage.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
(require db
|
(require db
|
||||||
json
|
json
|
||||||
racket/file
|
racket/file
|
||||||
@@ -13,6 +17,9 @@
|
|||||||
|
|
||||||
(provide ensure-wiki-data!
|
(provide ensure-wiki-data!
|
||||||
valid-slug?
|
valid-slug?
|
||||||
|
valid-page-reference?
|
||||||
|
page-reference
|
||||||
|
split-page-reference
|
||||||
title->slug
|
title->slug
|
||||||
list-pages
|
list-pages
|
||||||
read-page
|
read-page
|
||||||
@@ -38,6 +45,10 @@
|
|||||||
; post : The data directory and writable static directory exist.
|
; post : The data directory and writable static directory exist.
|
||||||
; result : void.
|
; result : void.
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;; Supporting functions
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
(define (ensure-wiki-data! config)
|
(define (ensure-wiki-data! config)
|
||||||
(for ((directory (in-list (list (wiki-config-data-dir config)
|
(for ((directory (in-list (list (wiki-config-data-dir config)
|
||||||
(data-static-directory config)))))
|
(data-static-directory config)))))
|
||||||
@@ -66,6 +77,42 @@
|
|||||||
(valid-slug-character? char))
|
(valid-slug-character? char))
|
||||||
(not (member slug '("." "..")))))
|
(not (member slug '("." "..")))))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Build the external wiki reference for a namespace and slug.
|
||||||
|
; pre : namespace and slug are strings; slug is a valid page slug.
|
||||||
|
; post : No state is changed.
|
||||||
|
; result : slug for the root namespace, otherwise namespace:slug.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (page-reference namespace slug)
|
||||||
|
(if (string=? (string-trim namespace) "")
|
||||||
|
slug
|
||||||
|
(string-append (string-trim namespace) ":" slug)))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Split an external page reference into namespace and slug.
|
||||||
|
; pre : reference is a string.
|
||||||
|
; post : No state is changed.
|
||||||
|
; result : Two values: namespace and slug. The namespace is empty for root pages.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (split-page-reference reference)
|
||||||
|
(define match (regexp-match #px"^([^:]+):(.*)$" reference))
|
||||||
|
(if match
|
||||||
|
(values (list-ref match 1) (list-ref match 2))
|
||||||
|
(values "" reference)))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Check whether a namespace-qualified page reference is valid.
|
||||||
|
; pre : reference is a string.
|
||||||
|
; post : No state is changed.
|
||||||
|
; result : #t for root slugs or namespace:slug references with letter/number namespaces.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (valid-page-reference? reference)
|
||||||
|
(define-values (namespace slug) (split-page-reference reference))
|
||||||
|
(and (valid-slug? slug)
|
||||||
|
(or (string=? namespace "")
|
||||||
|
(and (valid-slug? namespace)
|
||||||
|
(<= (string-length namespace) 80)))))
|
||||||
|
|
||||||
(define (title->slug title)
|
(define (title->slug title)
|
||||||
(define normalized
|
(define normalized
|
||||||
(string-downcase
|
(string-downcase
|
||||||
@@ -101,8 +148,12 @@
|
|||||||
(if (list? value) value '())))
|
(if (list? value) value '())))
|
||||||
|
|
||||||
(define (row->page row [include-markdown? #t])
|
(define (row->page row [include-markdown? #t])
|
||||||
|
(define namespace (vector-ref row 9))
|
||||||
|
(define slug (vector-ref row 0))
|
||||||
(define result
|
(define result
|
||||||
(hash 'slug (vector-ref row 0)
|
(hash 'slug (page-reference namespace slug)
|
||||||
|
'pageSlug slug
|
||||||
|
'namespace namespace
|
||||||
'title (vector-ref row 1)
|
'title (vector-ref row 1)
|
||||||
'createdAt (vector-ref row 3)
|
'createdAt (vector-ref row 3)
|
||||||
'updatedAt (vector-ref row 4)
|
'updatedAt (vector-ref row 4)
|
||||||
@@ -115,7 +166,7 @@
|
|||||||
result))
|
result))
|
||||||
|
|
||||||
(define page-columns
|
(define page-columns
|
||||||
"slug, title, markdown, created_at, updated_at, created_by, updated_by, tags, current_version")
|
"slug, title, markdown, created_at, updated_at, created_by, updated_by, tags, current_version, namespace")
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
; goal : List current wiki page metadata.
|
; goal : List current wiki page metadata.
|
||||||
@@ -130,7 +181,7 @@
|
|||||||
(for/list ((row (in-list
|
(for/list ((row (in-list
|
||||||
(query-rows db
|
(query-rows db
|
||||||
(string-append "SELECT " page-columns
|
(string-append "SELECT " page-columns
|
||||||
" FROM pages WHERE archived = FALSE ORDER BY lower(title), title")))))
|
" FROM pages WHERE archived = FALSE ORDER BY lower(namespace), namespace, lower(title), title")))))
|
||||||
(row->page row #f)))))
|
(row->page row #f)))))
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
@@ -139,18 +190,19 @@
|
|||||||
; post : The pages table has only been read.
|
; post : The pages table has only been read.
|
||||||
; result : Page metadata with Markdown, or #f when the page does not exist.
|
; result : Page metadata with Markdown, or #f when the page does not exist.
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
(define (read-page config slug)
|
(define (read-page config reference)
|
||||||
(if (not (valid-slug? slug))
|
(if (not (valid-page-reference? reference))
|
||||||
#f
|
#f
|
||||||
(call-with-wiki-database
|
(let-values (((namespace slug) (split-page-reference reference)))
|
||||||
config
|
(call-with-wiki-database
|
||||||
(λ (db)
|
config
|
||||||
(define row
|
(λ (db)
|
||||||
(query-maybe-row db
|
(define row
|
||||||
(string-append "SELECT " page-columns
|
(query-maybe-row db
|
||||||
" FROM pages WHERE slug = $1 AND archived = FALSE")
|
(string-append "SELECT " page-columns
|
||||||
slug))
|
" FROM pages WHERE namespace = $1 AND slug = $2 AND archived = FALSE")
|
||||||
(if row (row->page row) #f)))))
|
namespace slug))
|
||||||
|
(if row (row->page row) #f))))))
|
||||||
|
|
||||||
|
|
||||||
(define (replace-todos! db page-id markdown)
|
(define (replace-todos! db page-id markdown)
|
||||||
@@ -178,7 +230,8 @@ SQL
|
|||||||
; post : Current page state and version 1 are committed atomically.
|
; post : Current page state and version 1 are committed atomically.
|
||||||
; result : The new page metadata with Markdown.
|
; result : The new page metadata with Markdown.
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
(define (create-page! config slug title markdown author [summary "Created page"] [tags '()])
|
(define (create-page! config reference title markdown author [summary "Created page"] [tags '()])
|
||||||
|
(define-values (namespace slug) (split-page-reference reference))
|
||||||
(call-with-wiki-database
|
(call-with-wiki-database
|
||||||
config
|
config
|
||||||
(λ (db)
|
(λ (db)
|
||||||
@@ -189,20 +242,20 @@ SQL
|
|||||||
(define page-id
|
(define page-id
|
||||||
(query-value db
|
(query-value db
|
||||||
#<<SQL
|
#<<SQL
|
||||||
INSERT INTO pages(slug, title, markdown, tags, current_version,
|
INSERT INTO pages(namespace, slug, title, markdown, tags, current_version,
|
||||||
created_at, updated_at, created_by, updated_by, search_document)
|
created_at, updated_at, created_by, updated_by, search_document)
|
||||||
VALUES ($1, $2, $3, $4, 1, $5, $5, $6, $6,
|
VALUES ($1, $2, $3, $4, $5, 1, $6, $6, $7, $7,
|
||||||
setweight(to_tsvector('simple', coalesce($2, '')), 'A') ||
|
setweight(to_tsvector('simple', coalesce($3, '')), 'A') ||
|
||||||
setweight(to_tsvector('simple', coalesce($3, '')), 'B'))
|
setweight(to_tsvector('simple', coalesce($4, '')), 'B'))
|
||||||
RETURNING id
|
RETURNING id
|
||||||
SQL
|
SQL
|
||||||
slug title markdown (tags->text tags) now author))
|
namespace slug title markdown (tags->text tags) now author))
|
||||||
(define page-version-id
|
(define page-version-id
|
||||||
(insert-version! db page-id 1 title markdown author "create" summary now tags))
|
(insert-version! db page-id 1 title markdown author "create" summary now tags))
|
||||||
(replace-todos! db page-id markdown)
|
(replace-todos! db page-id markdown)
|
||||||
(replace-current-attachment-references! db page-id markdown now)
|
(replace-current-attachment-references! db page-id markdown now)
|
||||||
(record-version-attachment-references! db page-id page-version-id markdown now)))))
|
(record-version-attachment-references! db page-id page-version-id markdown now)))))
|
||||||
(read-page config slug))
|
(read-page config reference))
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
; goal : Save a new version of an existing wiki page.
|
; goal : Save a new version of an existing wiki page.
|
||||||
@@ -210,7 +263,8 @@ SQL
|
|||||||
; post : Current page and version history are committed atomically.
|
; post : Current page and version history are committed atomically.
|
||||||
; result : The updated page metadata with Markdown.
|
; result : The updated page metadata with Markdown.
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
(define (update-page! config slug title markdown author base-version [summary "Edited page"] [tags #f])
|
(define (update-page! config reference title markdown author base-version [summary "Edited page"] [tags #f] [new-namespace #f])
|
||||||
|
(define-values (namespace slug) (split-page-reference reference))
|
||||||
(call-with-wiki-database
|
(call-with-wiki-database
|
||||||
config
|
config
|
||||||
(λ (db)
|
(λ (db)
|
||||||
@@ -219,8 +273,8 @@ SQL
|
|||||||
(λ ()
|
(λ ()
|
||||||
(define row
|
(define row
|
||||||
(query-maybe-row db
|
(query-maybe-row db
|
||||||
"SELECT id, current_version, tags FROM pages WHERE slug = $1 AND archived = FALSE FOR UPDATE"
|
"SELECT id, current_version, tags, namespace FROM pages WHERE namespace = $1 AND slug = $2 AND archived = FALSE FOR UPDATE"
|
||||||
slug))
|
namespace slug))
|
||||||
(unless row
|
(unless row
|
||||||
(error 'update-page! "unknown page: ~a" slug))
|
(error 'update-page! "unknown page: ~a" slug))
|
||||||
(define current-version (vector-ref row 1))
|
(define current-version (vector-ref row 1))
|
||||||
@@ -232,25 +286,33 @@ SQL
|
|||||||
(error 'update-page! "version-conflict"))
|
(error 'update-page! "version-conflict"))
|
||||||
(define page-tags
|
(define page-tags
|
||||||
(if tags tags (text->tags (vector-ref row 2))))
|
(if tags tags (text->tags (vector-ref row 2))))
|
||||||
|
(define target-namespace
|
||||||
|
(if (eq? new-namespace #f)
|
||||||
|
(vector-ref row 3)
|
||||||
|
(string-trim new-namespace)))
|
||||||
|
(unless (or (string=? target-namespace "")
|
||||||
|
(and (valid-slug? target-namespace)
|
||||||
|
(<= (string-length target-namespace) 80)))
|
||||||
|
(error 'update-page! "invalid namespace: ~a" target-namespace))
|
||||||
(define next-version (+ current-version 1))
|
(define next-version (+ current-version 1))
|
||||||
(define now (current-seconds))
|
(define now (current-seconds))
|
||||||
(query-exec db
|
(query-exec db
|
||||||
#<<SQL
|
#<<SQL
|
||||||
UPDATE pages
|
UPDATE pages
|
||||||
SET title = $1, markdown = $2, tags = $3, current_version = $4,
|
SET title = $1, markdown = $2, tags = $3, current_version = $4,
|
||||||
updated_at = $5, updated_by = $6,
|
updated_at = $5, updated_by = $6, namespace = $7,
|
||||||
search_document = setweight(to_tsvector('simple', coalesce($1, '')), 'A') ||
|
search_document = setweight(to_tsvector('simple', coalesce($1, '')), 'A') ||
|
||||||
setweight(to_tsvector('simple', coalesce($2, '')), 'B')
|
setweight(to_tsvector('simple', coalesce($2, '')), 'B')
|
||||||
WHERE id = $7
|
WHERE id = $8
|
||||||
SQL
|
SQL
|
||||||
title markdown (tags->text page-tags) next-version now author (vector-ref row 0))
|
title markdown (tags->text page-tags) next-version now author target-namespace (vector-ref row 0))
|
||||||
(define page-id (vector-ref row 0))
|
(define page-id (vector-ref row 0))
|
||||||
(define page-version-id
|
(define page-version-id
|
||||||
(insert-version! db page-id next-version title markdown author "edit" summary now page-tags))
|
(insert-version! db page-id next-version title markdown author "edit" summary now page-tags))
|
||||||
(replace-todos! db page-id markdown)
|
(replace-todos! db page-id markdown)
|
||||||
(replace-current-attachment-references! db page-id markdown now)
|
(replace-current-attachment-references! db page-id markdown now)
|
||||||
(record-version-attachment-references! db page-id page-version-id markdown now)))))
|
(record-version-attachment-references! db page-id page-version-id markdown now)))))
|
||||||
(read-page config slug))
|
(read-page config (page-reference (if (eq? new-namespace #f) namespace (string-trim new-namespace)) slug)))
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
; goal : Archive an existing wiki page.
|
; goal : Archive an existing wiki page.
|
||||||
@@ -258,7 +320,8 @@ SQL
|
|||||||
; post : The page is marked archived while its versions and attachments remain stored.
|
; post : The page is marked archived while its versions and attachments remain stored.
|
||||||
; result : void.
|
; result : void.
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
(define (archive-page! config slug author)
|
(define (archive-page! config reference author)
|
||||||
|
(define-values (namespace slug) (split-page-reference reference))
|
||||||
(call-with-wiki-database
|
(call-with-wiki-database
|
||||||
config
|
config
|
||||||
(λ (db)
|
(λ (db)
|
||||||
@@ -267,10 +330,10 @@ SQL
|
|||||||
#<<SQL
|
#<<SQL
|
||||||
UPDATE pages
|
UPDATE pages
|
||||||
SET archived = TRUE, archived_at = $1, archived_by = $2
|
SET archived = TRUE, archived_at = $1, archived_by = $2
|
||||||
WHERE slug = $3 AND archived = FALSE
|
WHERE namespace = $3 AND slug = $4 AND archived = FALSE
|
||||||
RETURNING id
|
RETURNING id
|
||||||
SQL
|
SQL
|
||||||
(current-seconds) author slug))
|
(current-seconds) author namespace slug))
|
||||||
(unless id
|
(unless id
|
||||||
(error 'archive-page! "unknown page: ~a" slug))
|
(error 'archive-page! "unknown page: ~a" slug))
|
||||||
(query-exec db
|
(query-exec db
|
||||||
@@ -278,13 +341,14 @@ SQL
|
|||||||
id)))
|
id)))
|
||||||
(void))
|
(void))
|
||||||
|
|
||||||
(define (page-id config slug)
|
(define (page-id config reference)
|
||||||
|
(define-values (namespace slug) (split-page-reference reference))
|
||||||
(call-with-wiki-database
|
(call-with-wiki-database
|
||||||
config
|
config
|
||||||
(λ (db)
|
(λ (db)
|
||||||
(query-maybe-value db
|
(query-maybe-value db
|
||||||
"SELECT id FROM pages WHERE slug = $1 AND archived = FALSE"
|
"SELECT id FROM pages WHERE namespace = $1 AND slug = $2 AND archived = FALSE"
|
||||||
slug))))
|
namespace slug))))
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
; goal : Read the version history for a wiki page.
|
; goal : Read the version history for a wiki page.
|
||||||
@@ -292,12 +356,13 @@ SQL
|
|||||||
; post : Page version rows have only been read.
|
; post : Page version rows have only been read.
|
||||||
; result : A newest-first list of version metadata hashes.
|
; result : A newest-first list of version metadata hashes.
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
(define (page-history config slug)
|
(define (page-history config reference)
|
||||||
|
(define-values (namespace slug) (split-page-reference reference))
|
||||||
(call-with-wiki-database
|
(call-with-wiki-database
|
||||||
config
|
config
|
||||||
(λ (db)
|
(λ (db)
|
||||||
(define id
|
(define id
|
||||||
(query-maybe-value db "SELECT id FROM pages WHERE slug = $1 AND archived = FALSE" slug))
|
(query-maybe-value db "SELECT id FROM pages WHERE namespace = $1 AND slug = $2 AND archived = FALSE" namespace slug))
|
||||||
(unless id
|
(unless id
|
||||||
(error 'page-history "unknown page: ~a" slug))
|
(error 'page-history "unknown page: ~a" slug))
|
||||||
(for/list ((row (in-list
|
(for/list ((row (in-list
|
||||||
@@ -323,7 +388,8 @@ SQL
|
|||||||
; post : Version rows have only been read.
|
; post : Version rows have only been read.
|
||||||
; result : Version metadata with Markdown, or #f when the version is absent.
|
; result : Version metadata with Markdown, or #f when the version is absent.
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
(define (read-version config slug version)
|
(define (read-version config reference version)
|
||||||
|
(define-values (namespace slug) (split-page-reference reference))
|
||||||
(define version-number
|
(define version-number
|
||||||
(if (number? version) version (string->number version)))
|
(if (number? version) version (string->number version)))
|
||||||
(and version-number
|
(and version-number
|
||||||
@@ -336,9 +402,9 @@ SQL
|
|||||||
SELECT v.version, v.title, v.markdown, v.author, v.action, v.summary, v.tags, v.created_at
|
SELECT v.version, v.title, v.markdown, v.author, v.action, v.summary, v.tags, v.created_at
|
||||||
FROM page_versions v
|
FROM page_versions v
|
||||||
JOIN pages p ON p.id = v.page_id
|
JOIN pages p ON p.id = v.page_id
|
||||||
WHERE p.slug = $1 AND p.archived = FALSE AND v.version = $2
|
WHERE p.namespace = $1 AND p.slug = $2 AND p.archived = FALSE AND v.version = $3
|
||||||
SQL
|
SQL
|
||||||
slug version-number))
|
namespace slug version-number))
|
||||||
(and row
|
(and row
|
||||||
(hash 'version (vector-ref row 0)
|
(hash 'version (vector-ref row 0)
|
||||||
'title (vector-ref row 1)
|
'title (vector-ref row 1)
|
||||||
@@ -369,7 +435,8 @@ SELECT p.slug,
|
|||||||
p.title,
|
p.title,
|
||||||
ts_rank(p.search_document, q.query) AS rank,
|
ts_rank(p.search_document, q.query) AS rank,
|
||||||
ts_headline('simple', p.markdown, q.query,
|
ts_headline('simple', p.markdown, q.query,
|
||||||
'StartSel=[[[, StopSel=]]], MaxWords=28, MinWords=8, ShortWord=2') AS snippet
|
'StartSel=[[[, StopSel=]]], MaxWords=28, MinWords=8, ShortWord=2') AS snippet,
|
||||||
|
p.namespace
|
||||||
FROM pages p, q
|
FROM pages p, q
|
||||||
WHERE p.archived = FALSE
|
WHERE p.archived = FALSE
|
||||||
AND p.search_document @@ q.query
|
AND p.search_document @@ q.query
|
||||||
@@ -377,7 +444,8 @@ ORDER BY rank DESC, lower(p.title), p.title
|
|||||||
LIMIT 50
|
LIMIT 50
|
||||||
SQL
|
SQL
|
||||||
query-text))))
|
query-text))))
|
||||||
(hash 'slug (vector-ref row 0)
|
(hash 'slug (page-reference (vector-ref row 4) (vector-ref row 0))
|
||||||
|
'namespace (vector-ref row 4)
|
||||||
'title (vector-ref row 1)
|
'title (vector-ref row 1)
|
||||||
'rank (vector-ref row 2)
|
'rank (vector-ref row 2)
|
||||||
'snippet (vector-ref row 3)))))))
|
'snippet (vector-ref row 3)))))))
|
||||||
@@ -395,14 +463,15 @@ SQL
|
|||||||
(for/list ((row (in-list
|
(for/list ((row (in-list
|
||||||
(query-rows db
|
(query-rows db
|
||||||
#<<SQL
|
#<<SQL
|
||||||
SELECT p.slug, p.title, t.item_number, t.line_number, t.text
|
SELECT p.slug, p.title, t.item_number, t.line_number, t.text, p.namespace
|
||||||
FROM todo_items t
|
FROM todo_items t
|
||||||
JOIN pages p ON p.id = t.page_id
|
JOIN pages p ON p.id = t.page_id
|
||||||
WHERE p.archived = FALSE
|
WHERE p.archived = FALSE
|
||||||
ORDER BY lower(p.title), p.title, t.item_number
|
ORDER BY lower(p.namespace), p.namespace, lower(p.title), p.title, t.item_number
|
||||||
SQL
|
SQL
|
||||||
))))
|
))))
|
||||||
(hash 'slug (vector-ref row 0)
|
(hash 'slug (page-reference (vector-ref row 5) (vector-ref row 0))
|
||||||
|
'namespace (vector-ref row 5)
|
||||||
'title (vector-ref row 1)
|
'title (vector-ref row 1)
|
||||||
'number (vector-ref row 2)
|
'number (vector-ref row 2)
|
||||||
'line (vector-ref row 3)
|
'line (vector-ref row 3)
|
||||||
@@ -441,15 +510,16 @@ SQL
|
|||||||
(for/list ((row (in-list
|
(for/list ((row (in-list
|
||||||
(query-rows db
|
(query-rows db
|
||||||
#<<SQL
|
#<<SQL
|
||||||
SELECT p.slug, p.title, b.section, b.position, b.created_at, p.updated_at
|
SELECT p.slug, p.title, b.section, b.position, b.created_at, p.updated_at, p.namespace
|
||||||
FROM bookmarks b
|
FROM bookmarks b
|
||||||
JOIN pages p ON p.id = b.page_id
|
JOIN pages p ON p.id = b.page_id
|
||||||
WHERE b.user_id = $1
|
WHERE b.user_id = $1
|
||||||
AND p.archived = FALSE
|
AND p.archived = FALSE
|
||||||
ORDER BY lower(b.section), b.section, b.position, b.created_at, lower(p.title), p.title
|
ORDER BY lower(p.namespace), p.namespace, lower(b.section), b.section, b.position, b.created_at, lower(p.title), p.title
|
||||||
SQL
|
SQL
|
||||||
user-id))))
|
user-id))))
|
||||||
(hash 'slug (vector-ref row 0)
|
(hash 'slug (page-reference (vector-ref row 6) (vector-ref row 0))
|
||||||
|
'namespace (vector-ref row 6)
|
||||||
'title (vector-ref row 1)
|
'title (vector-ref row 1)
|
||||||
'section (vector-ref row 2)
|
'section (vector-ref row 2)
|
||||||
'position (vector-ref row 3)
|
'position (vector-ref row 3)
|
||||||
@@ -462,7 +532,8 @@ SQL
|
|||||||
; post : Exactly one bookmark exists for user-id and the page.
|
; post : Exactly one bookmark exists for user-id and the page.
|
||||||
; result : void.
|
; result : void.
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
(define (set-bookmark! config user-id slug section)
|
(define (set-bookmark! config user-id reference section)
|
||||||
|
(define-values (namespace slug) (split-page-reference reference))
|
||||||
(call-with-wiki-database
|
(call-with-wiki-database
|
||||||
config
|
config
|
||||||
(λ (db)
|
(λ (db)
|
||||||
@@ -471,8 +542,8 @@ SQL
|
|||||||
(λ ()
|
(λ ()
|
||||||
(define page-id
|
(define page-id
|
||||||
(query-maybe-value db
|
(query-maybe-value db
|
||||||
"SELECT id FROM pages WHERE slug = $1 AND archived = FALSE"
|
"SELECT id FROM pages WHERE namespace = $1 AND slug = $2 AND archived = FALSE"
|
||||||
slug))
|
namespace slug))
|
||||||
(unless page-id
|
(unless page-id
|
||||||
(error 'set-bookmark! "unknown page: ~a" slug))
|
(error 'set-bookmark! "unknown page: ~a" slug))
|
||||||
(define clean-section (string-trim section))
|
(define clean-section (string-trim section))
|
||||||
@@ -501,7 +572,8 @@ SQL
|
|||||||
; post : The bookmark no longer exists; other bookmarks are unchanged.
|
; post : The bookmark no longer exists; other bookmarks are unchanged.
|
||||||
; result : void.
|
; result : void.
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
(define (delete-bookmark! config user-id slug)
|
(define (delete-bookmark! config user-id reference)
|
||||||
|
(define-values (namespace slug) (split-page-reference reference))
|
||||||
(call-with-wiki-database
|
(call-with-wiki-database
|
||||||
config
|
config
|
||||||
(λ (db)
|
(λ (db)
|
||||||
@@ -509,9 +581,9 @@ SQL
|
|||||||
#<<SQL
|
#<<SQL
|
||||||
DELETE FROM bookmarks
|
DELETE FROM bookmarks
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
AND page_id = (SELECT id FROM pages WHERE slug = $2)
|
AND page_id = (SELECT id FROM pages WHERE namespace = $2 AND slug = $3)
|
||||||
SQL
|
SQL
|
||||||
user-id slug)))
|
user-id namespace slug)))
|
||||||
(void))
|
(void))
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
@@ -535,7 +607,8 @@ SELECT a.id,
|
|||||||
a.uploaded_at,
|
a.uploaded_at,
|
||||||
a.uploaded_by,
|
a.uploaded_by,
|
||||||
owner.slug,
|
owner.slug,
|
||||||
owner.title
|
owner.title,
|
||||||
|
owner.namespace
|
||||||
FROM attachments a
|
FROM attachments a
|
||||||
JOIN pages owner ON owner.id = a.page_id
|
JOIN pages owner ON owner.id = a.page_id
|
||||||
WHERE NOT EXISTS (
|
WHERE NOT EXISTS (
|
||||||
@@ -552,7 +625,7 @@ SQL
|
|||||||
(for/list ((use-row (in-list
|
(for/list ((use-row (in-list
|
||||||
(query-rows db
|
(query-rows db
|
||||||
#<<SQL
|
#<<SQL
|
||||||
SELECT p.slug, p.title, pv.version, ar.referenced_at
|
SELECT p.slug, p.title, pv.version, ar.referenced_at, p.namespace
|
||||||
FROM attachment_references ar
|
FROM attachment_references ar
|
||||||
JOIN pages p ON p.id = ar.page_id
|
JOIN pages p ON p.id = ar.page_id
|
||||||
LEFT JOIN page_versions pv ON pv.id = ar.page_version_id
|
LEFT JOIN page_versions pv ON pv.id = ar.page_version_id
|
||||||
@@ -562,7 +635,8 @@ ORDER BY ar.referenced_at DESC, ar.id DESC
|
|||||||
LIMIT 5
|
LIMIT 5
|
||||||
SQL
|
SQL
|
||||||
attachment-id))))
|
attachment-id))))
|
||||||
(hash 'slug (vector-ref use-row 0)
|
(hash 'slug (page-reference (vector-ref use-row 4) (vector-ref use-row 0))
|
||||||
|
'namespace (vector-ref use-row 4)
|
||||||
'title (vector-ref use-row 1)
|
'title (vector-ref use-row 1)
|
||||||
'version (if (sql-null? (vector-ref use-row 2)) #f (vector-ref use-row 2))
|
'version (if (sql-null? (vector-ref use-row 2)) #f (vector-ref use-row 2))
|
||||||
'referencedAt (vector-ref use-row 3))))
|
'referencedAt (vector-ref use-row 3))))
|
||||||
@@ -573,7 +647,8 @@ SQL
|
|||||||
'size (vector-ref row 4)
|
'size (vector-ref row 4)
|
||||||
'uploadedAt (vector-ref row 5)
|
'uploadedAt (vector-ref row 5)
|
||||||
'uploadedBy (vector-ref row 6)
|
'uploadedBy (vector-ref row 6)
|
||||||
'ownerSlug (vector-ref row 7)
|
'ownerSlug (page-reference (vector-ref row 9) (vector-ref row 7))
|
||||||
|
'ownerNamespace (vector-ref row 9)
|
||||||
'ownerTitle (vector-ref row 8)
|
'ownerTitle (vector-ref row 8)
|
||||||
'lastUses last-uses)))))
|
'lastUses last-uses)))))
|
||||||
|
|
||||||
@@ -619,10 +694,10 @@ SQL
|
|||||||
; post : Attachment metadata and bytes are stored in one PostgreSQL row.
|
; post : Attachment metadata and bytes are stored in one PostgreSQL row.
|
||||||
; result : A hash containing original name, stored name and page-local URL.
|
; result : A hash containing original name, stored name and page-local URL.
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
(define (save-upload! config slug original-name content author)
|
(define (save-upload! config reference original-name content author)
|
||||||
(define id (page-id config slug))
|
(define id (page-id config reference))
|
||||||
(unless id
|
(unless id
|
||||||
(error 'save-upload! "unknown page: ~a" slug))
|
(error 'save-upload! "unknown page: ~a" reference))
|
||||||
(define stored-name
|
(define stored-name
|
||||||
(format "~a-~a-~a" (current-seconds) (random 1000000) (safe-file-name original-name)))
|
(format "~a-~a-~a" (current-seconds) (random 1000000) (safe-file-name original-name)))
|
||||||
(define mime-type
|
(define mime-type
|
||||||
@@ -653,7 +728,7 @@ SQL
|
|||||||
author)))
|
author)))
|
||||||
(hash 'name original-name
|
(hash 'name original-name
|
||||||
'storedName stored-name
|
'storedName stored-name
|
||||||
'url (format "/uploads/~a/~a" slug stored-name)))
|
'url (format "/uploads/~a/~a" reference stored-name)))
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
; goal : Read one stored attachment from PostgreSQL.
|
; goal : Read one stored attachment from PostgreSQL.
|
||||||
@@ -661,8 +736,9 @@ SQL
|
|||||||
; post : PostgreSQL has only been read.
|
; post : PostgreSQL has only been read.
|
||||||
; result : A hash containing bytes, MIME type and names, or #f when absent.
|
; result : A hash containing bytes, MIME type and names, or #f when absent.
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
(define (uploaded-file config slug stored-name)
|
(define (uploaded-file config reference stored-name)
|
||||||
(and (valid-slug? slug)
|
(define-values (namespace slug) (split-page-reference reference))
|
||||||
|
(and (valid-page-reference? reference)
|
||||||
(not (regexp-match? #px"[/\\\\]" stored-name))
|
(not (regexp-match? #px"[/\\\\]" stored-name))
|
||||||
(call-with-wiki-database
|
(call-with-wiki-database
|
||||||
config
|
config
|
||||||
@@ -674,8 +750,10 @@ SELECT a.original_name, a.stored_name, a.mime_type, a.content, a.size
|
|||||||
FROM attachments a
|
FROM attachments a
|
||||||
JOIN pages p ON p.id = a.page_id
|
JOIN pages p ON p.id = a.page_id
|
||||||
WHERE p.slug = $1 AND p.archived = FALSE AND a.stored_name = $2
|
WHERE p.slug = $1 AND p.archived = FALSE AND a.stored_name = $2
|
||||||
|
ORDER BY CASE WHEN p.namespace = $3 THEN 0 ELSE 1 END, a.id
|
||||||
|
LIMIT 1
|
||||||
SQL
|
SQL
|
||||||
slug stored-name))
|
slug stored-name namespace))
|
||||||
(if row
|
(if row
|
||||||
(hash 'originalName (vector-ref row 0)
|
(hash 'originalName (vector-ref row 0)
|
||||||
'storedName (vector-ref row 1)
|
'storedName (vector-ref row 1)
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
#lang racket/base
|
#lang racket/base
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;; Extraction of Todo(...) markers from Markdown source.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
(require racket/list
|
(require racket/list
|
||||||
racket/string)
|
racket/string)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
#lang racket/base
|
#lang racket/base
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;; Pinned frontend dependency download and verification.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
(require net/url
|
(require net/url
|
||||||
net/url-connect
|
net/url-connect
|
||||||
racket/file
|
racket/file
|
||||||
|
|||||||
+13
-1
@@ -71,6 +71,18 @@ reader receives a normal not-found view. Unauthenticated browser requests are
|
|||||||
redirected to @tt{/login} before the wiki application is served.
|
redirected to @tt{/login} before the wiki application is served.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@section{Page namespaces}
|
||||||
|
|
||||||
|
Pages have an explicit namespace in addition to their stable slug. Existing pages
|
||||||
|
are migrated to the root namespace. A compact page reference uses
|
||||||
|
@tt{namespace:slug}, for example @tt{racket:roadmap}. Explicit Markdown links
|
||||||
|
may therefore use @tt{[Roadmap](racket:roadmap)}. A classic WikiWord can also be
|
||||||
|
qualified, for example @tt{RWS:ModelTreeWalker}.
|
||||||
|
|
||||||
|
The namespace and slug are stored separately in PostgreSQL and their combination
|
||||||
|
is unique. Todo items and bookmarks are grouped by namespace in the browser.
|
||||||
|
|
||||||
@section{Page contents}
|
@section{Page contents}
|
||||||
|
|
||||||
The application sidebar gives the current page's headings priority over the
|
The application sidebar gives the current page's headings priority over the
|
||||||
@@ -105,7 +117,7 @@ language-specific stemmer.
|
|||||||
|
|
||||||
@section{Database schema migrations}
|
@section{Database schema migrations}
|
||||||
|
|
||||||
Racket Wiki records PostgreSQL schema migrations in @tt{wiki_schema}. The current page is stored in @tt{pages}; every saved historical revision is stored in @tt{page_versions}. Attachments, including their binary content, are stored in @tt{attachments}. Schema migrations run in order when the application starts.
|
Racket Wiki records PostgreSQL schema migrations in @tt{wiki_schema}. The current page is stored in @tt{pages}; every saved historical revision is stored in @tt{page_versions}. Attachments, including their binary content, are stored in @tt{attachments}. Schema migrations run in order when the application starts. Schema 7 adds page namespaces and a unique namespace/slug index.
|
||||||
|
|
||||||
@section{Todo items}
|
@section{Todo items}
|
||||||
|
|
||||||
|
|||||||
+19
-7
@@ -1,5 +1,9 @@
|
|||||||
#lang racket/base
|
#lang racket/base
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;; HTTP routing and server-rendered setup/login handling.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
(require net/url
|
(require net/url
|
||||||
net/uri-codec
|
net/uri-codec
|
||||||
racket/file
|
racket/file
|
||||||
@@ -19,6 +23,10 @@
|
|||||||
|
|
||||||
(provide start-wiki-server)
|
(provide start-wiki-server)
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;; Supporting functions
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
(define (user->jsexpr user)
|
(define (user->jsexpr user)
|
||||||
(hash 'id (wiki-user-id user)
|
(hash 'id (wiki-user-id user)
|
||||||
'username (wiki-user-username user)
|
'username (wiki-user-username user)
|
||||||
@@ -244,7 +252,7 @@ CSS
|
|||||||
(require-role
|
(require-role
|
||||||
config req 'reader
|
config req 'reader
|
||||||
(λ (_session)
|
(λ (_session)
|
||||||
(define page (and (valid-slug? slug) (read-page config slug)))
|
(define page (and (valid-page-reference? slug) (read-page config slug)))
|
||||||
(if page
|
(if page
|
||||||
(json-response page)
|
(json-response page)
|
||||||
(json-error 404 "Page not found")))))
|
(json-error 404 "Page not found")))))
|
||||||
@@ -264,20 +272,22 @@ CSS
|
|||||||
(with-handlers ((exn:fail? (λ (e) (json-error 400 (exn-message e)))))
|
(with-handlers ((exn:fail? (λ (e) (json-error 400 (exn-message e)))))
|
||||||
(define body (request-json req))
|
(define body (request-json req))
|
||||||
(define requested-slug (hash-ref body 'slug #f))
|
(define requested-slug (hash-ref body 'slug #f))
|
||||||
|
(define namespace (string-trim (hash-ref body 'namespace "")))
|
||||||
(define title (hash-ref body 'title ""))
|
(define title (hash-ref body 'title ""))
|
||||||
(define markdown (hash-ref body 'markdown ""))
|
(define markdown (hash-ref body 'markdown ""))
|
||||||
(define tags (request-tags body))
|
(define tags (request-tags body))
|
||||||
(define summary (hash-ref body 'summary "Created page"))
|
(define summary (hash-ref body 'summary "Created page"))
|
||||||
(define slug
|
(define slug
|
||||||
(if requested-slug
|
(cond
|
||||||
requested-slug
|
(requested-slug requested-slug)
|
||||||
(title->slug title)))
|
((string=? namespace "") (title->slug title))
|
||||||
|
(else (page-reference namespace (title->slug title)))))
|
||||||
(cond
|
(cond
|
||||||
((string=? (string-trim title) "")
|
((string=? (string-trim title) "")
|
||||||
(json-error 400 "Title is required"))
|
(json-error 400 "Title is required"))
|
||||||
((string=? slug "")
|
((string=? slug "")
|
||||||
(json-error 400 "The title cannot be converted to a page slug"))
|
(json-error 400 "The title cannot be converted to a page slug"))
|
||||||
((not (valid-slug? slug))
|
((not (valid-page-reference? slug))
|
||||||
(json-error 400 "Invalid page address"))
|
(json-error 400 "Invalid page address"))
|
||||||
((read-page config slug)
|
((read-page config slug)
|
||||||
(json-error 409 "A page with this address already exists"))
|
(json-error 409 "A page with this address already exists"))
|
||||||
@@ -303,6 +313,7 @@ CSS
|
|||||||
(json-error 400 (exn-message e))))))
|
(json-error 400 (exn-message e))))))
|
||||||
(define body (request-json req))
|
(define body (request-json req))
|
||||||
(define title (hash-ref body 'title ""))
|
(define title (hash-ref body 'title ""))
|
||||||
|
(define namespace (hash-ref body 'namespace #f))
|
||||||
(define markdown (hash-ref body 'markdown ""))
|
(define markdown (hash-ref body 'markdown ""))
|
||||||
(define tags (request-tags body))
|
(define tags (request-tags body))
|
||||||
(define base-version (hash-ref body 'baseVersion ""))
|
(define base-version (hash-ref body 'baseVersion ""))
|
||||||
@@ -315,7 +326,8 @@ CSS
|
|||||||
(wiki-user-username (wiki-session-user session))
|
(wiki-user-username (wiki-session-user session))
|
||||||
base-version
|
base-version
|
||||||
summary
|
summary
|
||||||
tags))))))
|
tags
|
||||||
|
namespace))))))
|
||||||
|
|
||||||
(define (page-delete-handler config req slug)
|
(define (page-delete-handler config req slug)
|
||||||
(require-write-role
|
(require-write-role
|
||||||
@@ -594,7 +606,7 @@ CSS
|
|||||||
(cond
|
(cond
|
||||||
((not session)
|
((not session)
|
||||||
(redirect-response "/login"))
|
(redirect-response "/login"))
|
||||||
((and slug (valid-slug? slug))
|
((and slug (valid-page-reference? slug))
|
||||||
(let ((page (read-page config slug)))
|
(let ((page (read-page config slug)))
|
||||||
(cond
|
(cond
|
||||||
(page
|
(page
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
#lang racket/base
|
#lang racket/base
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;; Manual command for installing pinned frontend dependencies.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
(require racket/cmdline
|
(require racket/cmdline
|
||||||
racket/path
|
racket/path
|
||||||
"private/config.rkt"
|
"private/config.rkt"
|
||||||
|
|||||||
@@ -1201,3 +1201,20 @@ body.editor-mode .editor-metadata-row {
|
|||||||
margin: 4px 0 0 18px;
|
margin: 4px 0 0 18px;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Namespace is page metadata; keep it compact beside the title editor. */
|
||||||
|
.editor-namespace-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .6rem;
|
||||||
|
margin: .3rem 0;
|
||||||
|
font-size: .9rem;
|
||||||
|
}
|
||||||
|
.editor-namespace-row input {
|
||||||
|
max-width: 18rem;
|
||||||
|
}
|
||||||
|
.namespace-heading {
|
||||||
|
margin: 1rem 0 .35rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|||||||
@@ -78,6 +78,10 @@
|
|||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<div class="editor-heading">
|
<div class="editor-heading">
|
||||||
<input id="editor-title" class="title-input" placeholder="Page title" data-tr-placeholder="page-title">
|
<input id="editor-title" class="title-input" placeholder="Page title" data-tr-placeholder="page-title">
|
||||||
|
<div class="editor-namespace-row">
|
||||||
|
<label for="editor-namespace" data-tr="namespace">Namespace</label>
|
||||||
|
<input id="editor-namespace" type="text" placeholder="RWS" data-tr-placeholder="namespace-placeholder">
|
||||||
|
</div>
|
||||||
<div id="editor-slug-info" class="muted"></div>
|
<div id="editor-slug-info" class="muted"></div>
|
||||||
<div class="editor-template-row">
|
<div class="editor-template-row">
|
||||||
<label for="template-select" data-tr="template">Template</label>
|
<label for="template-select" data-tr="template">Template</label>
|
||||||
|
|||||||
+384
-77
@@ -1,6 +1,16 @@
|
|||||||
(() => {
|
(() => {
|
||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* racket-wiki browser application.
|
||||||
|
*
|
||||||
|
* The source is deliberately kept as one direct script instead of a client
|
||||||
|
* framework. Larger functions are documented with goal/pre/post/result-style
|
||||||
|
* comments, following the same readability rules as the Racket sources.
|
||||||
|
* Markdown source remains authoritative; Todo markup, WikiWords, namespace
|
||||||
|
* references and image options are expanded only for rendering.
|
||||||
|
*/
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
session: null,
|
session: null,
|
||||||
pages: [],
|
pages: [],
|
||||||
@@ -19,6 +29,10 @@
|
|||||||
|
|
||||||
let easyMDE = null;
|
let easyMDE = null;
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// General UI and HTTP support
|
||||||
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
function show(viewId) {
|
function show(viewId) {
|
||||||
@@ -53,6 +67,38 @@
|
|||||||
.replaceAll('"', """);
|
.replaceAll('"', """);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* goal : Split a wiki page reference into namespace and page slug.
|
||||||
|
* pre : reference is a string such as "roadmap" or "racket:roadmap".
|
||||||
|
* post : No application state is changed.
|
||||||
|
* result : An object with namespace and slug fields.
|
||||||
|
*/
|
||||||
|
function splitPageReference(reference) {
|
||||||
|
const value = String(reference || "");
|
||||||
|
const separator = value.indexOf(":");
|
||||||
|
if (separator < 0) return { namespace: "", slug: value };
|
||||||
|
return { namespace: value.slice(0, separator), slug: value.slice(separator + 1) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* goal : Build the compact external reference for a wiki page.
|
||||||
|
* pre : namespace and slug are already suitable wiki identifiers.
|
||||||
|
* post : No application state is changed.
|
||||||
|
* result : slug for root pages, otherwise namespace:slug.
|
||||||
|
*/
|
||||||
|
function pageReference(namespace, slug) {
|
||||||
|
const cleanNamespace = String(namespace || "").trim();
|
||||||
|
return cleanNamespace ? `${cleanNamespace}:${slug}` : slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageRoute(reference) {
|
||||||
|
return `#/${encodeURIComponent(reference)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function namespaceLabel(namespace) {
|
||||||
|
return namespace || tr("root-namespace", "Root");
|
||||||
|
}
|
||||||
|
|
||||||
function expandTodoMarkup(markdown, pageSlug = null) {
|
function expandTodoMarkup(markdown, pageSlug = null) {
|
||||||
let inFence = false;
|
let inFence = false;
|
||||||
let todoNumber = 0;
|
let todoNumber = 0;
|
||||||
@@ -92,6 +138,12 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* goal : Perform one authenticated API request and decode its response.
|
||||||
|
* pre : path identifies a racket-wiki API route.
|
||||||
|
* post : CSRF and JSON headers are applied when needed; 401 redirects to login.
|
||||||
|
* result : Decoded JSON/text response or a thrown Error.
|
||||||
|
*/
|
||||||
async function api(path, options = {}) {
|
async function api(path, options = {}) {
|
||||||
const headers = new Headers(options.headers || {});
|
const headers = new Headers(options.headers || {});
|
||||||
if (options.body && !(options.body instanceof Blob) && !(options.body instanceof ArrayBuffer)) {
|
if (options.body && !(options.body instanceof Blob) && !(options.body instanceof ArrayBuffer)) {
|
||||||
@@ -114,6 +166,10 @@
|
|||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Markdown rendering and wiki-link syntax
|
||||||
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
function applyImageWidthMarkup(html) {
|
function applyImageWidthMarkup(html) {
|
||||||
const imageOptionsSuffix = /(<img\b[^>]*>)\s*\{\s*width\s*=\s*([0-9]+(?:\.[0-9]+)?)(%|px)?(?:\s+(left|center|right))?(?:\s+(float))?(?:\s+float\s*=\s*(left|right))?\s*\}/gi;
|
const imageOptionsSuffix = /(<img\b[^>]*>)\s*\{\s*width\s*=\s*([0-9]+(?:\.[0-9]+)?)(%|px)?(?:\s+(left|center|right))?(?:\s+(float))?(?:\s+float\s*=\s*(left|right))?\s*\}/gi;
|
||||||
|
|
||||||
@@ -164,24 +220,33 @@
|
|||||||
return value.match(/\p{Lu}\p{Ll}+/gu) || [];
|
return value.match(/\p{Lu}\p{Ll}+/gu) || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function camelCaseTarget(text, aliases) {
|
/**
|
||||||
|
* goal : Resolve a classic WikiWord to an existing page or a new page target.
|
||||||
|
* pre : text contains only the candidate WikiWord and aliases is built from state.pages.
|
||||||
|
* post : No page is created; this only creates a render target.
|
||||||
|
* result : {slug,title} or null when text is not a valid WikiWord.
|
||||||
|
*/
|
||||||
|
function camelCaseTarget(text, aliases, namespace = "") {
|
||||||
const parts = wikiWordParts(text);
|
const parts = wikiWordParts(text);
|
||||||
if (parts.length === 0) return null;
|
if (parts.length === 0) return null;
|
||||||
|
|
||||||
const existing = aliases.get(normalizeMentionText(text));
|
const aliasKey = `${String(namespace || "").toLocaleLowerCase()}:${normalizeMentionText(text)}`;
|
||||||
|
const existing = aliases.get(aliasKey);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return { slug: existing.slug, title: existing.title };
|
return { slug: existing.slug, title: existing.title };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pageSlug = parts.map((part) => part.toLocaleLowerCase()).join("-");
|
||||||
return {
|
return {
|
||||||
slug: parts.map((part) => part.toLocaleLowerCase()).join("-"),
|
slug: pageReference(namespace, pageSlug),
|
||||||
title: parts.join(" ")
|
title: parts.join(" ")
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function mentionAliases(page) {
|
function mentionAliases(page) {
|
||||||
const aliases = new Set();
|
const aliases = new Set();
|
||||||
const values = [page.title, slugTitle(page.slug), page.slug.replaceAll("-", " ")];
|
const rawSlug = page.pageSlug || splitPageReference(page.slug).slug;
|
||||||
|
const values = [page.title, slugTitle(rawSlug), rawSlug.replaceAll("-", " ")];
|
||||||
for (const value of values) {
|
for (const value of values) {
|
||||||
if (!value) continue;
|
if (!value) continue;
|
||||||
aliases.add(value);
|
aliases.add(value);
|
||||||
@@ -194,15 +259,30 @@
|
|||||||
.filter((alias) => alias.key.length >= 5);
|
.filter((alias) => alias.key.length >= 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* goal : Build an ambiguity-aware lookup table for WikiWords.
|
||||||
|
* pre : state.pages contains the current page metadata.
|
||||||
|
* post : No state is changed.
|
||||||
|
* result : Map keys include the page namespace, so equal names may exist in different namespaces.
|
||||||
|
*/
|
||||||
function pageMentionMap(currentSlug = null) {
|
function pageMentionMap(currentSlug = null) {
|
||||||
const map = new Map();
|
const map = new Map();
|
||||||
for (const page of state.pages) {
|
for (const page of state.pages) {
|
||||||
if (page.slug === currentSlug) continue;
|
if (page.slug === currentSlug) continue;
|
||||||
|
const namespace = String(page.namespace || "").toLocaleLowerCase();
|
||||||
for (const alias of mentionAliases(page)) {
|
for (const alias of mentionAliases(page)) {
|
||||||
if (!map.has(alias.key)) {
|
const namespacedKey = `${namespace}:${alias.key}`;
|
||||||
map.set(alias.key, page);
|
if (!map.has(namespacedKey)) {
|
||||||
} else if (map.get(alias.key)?.slug !== page.slug) {
|
map.set(namespacedKey, page);
|
||||||
map.set(alias.key, null);
|
} else if (map.get(namespacedKey)?.slug !== page.slug) {
|
||||||
|
map.set(namespacedKey, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootKey = `:${alias.key}`;
|
||||||
|
if (!map.has(rootKey)) {
|
||||||
|
map.set(rootKey, page);
|
||||||
|
} else if (map.get(rootKey)?.slug !== page.slug) {
|
||||||
|
map.set(rootKey, null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -235,11 +315,48 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* goal : Rewrite explicit Markdown targets such as (racket:roadmap) to the internal hash route.
|
||||||
|
* pre : markdown is source text; fenced code must remain untouched.
|
||||||
|
* post : Only explicit wiki-looking link destinations are rewritten for rendering.
|
||||||
|
* result : Render-only Markdown; stored Markdown is never changed.
|
||||||
|
*/
|
||||||
|
function expandNamespacedMarkdownLinks(markdown) {
|
||||||
|
const lines = String(markdown || "").split("\n");
|
||||||
|
const result = [];
|
||||||
|
let fence = null;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const fenceMatch = line.match(/^\s*(```+|~~~+)/);
|
||||||
|
if (fenceMatch) {
|
||||||
|
const marker = fenceMatch[1].charAt(0);
|
||||||
|
fence = fence === null ? marker : (fence === marker ? null : fence);
|
||||||
|
result.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (fence !== null || /^\s{4}/.test(line)) {
|
||||||
|
result.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.push(line.replace(/(!?\[[^\]\n]*\]\()([\p{L}\p{N}._-]+):([\p{L}\p{N}._-]+)(\))/gu,
|
||||||
|
(_match, before, namespace, slug, after) => `${before}${pageRoute(pageReference(namespace, slug))}${after}`));
|
||||||
|
}
|
||||||
|
return result.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* goal : Expand classic WikiWords to temporary Markdown links before Marked renders them.
|
||||||
|
* pre : markdown may contain paragraphs, lists, tables, quotes and headings.
|
||||||
|
* post : Code, Todo markers, URLs and existing Markdown links remain unchanged.
|
||||||
|
* result : Render-only Markdown with WikiWord links.
|
||||||
|
*/
|
||||||
function expandWikiMentions(markdown, currentSlug = null) {
|
function expandWikiMentions(markdown, currentSlug = null) {
|
||||||
const aliases = pageMentionMap(currentSlug);
|
const aliases = pageMentionMap(currentSlug);
|
||||||
const lines = String(markdown || "").split("\n");
|
const lines = String(markdown || "").split("\n");
|
||||||
const result = [];
|
const result = [];
|
||||||
let fence = null;
|
let fence = null;
|
||||||
|
const wikiWordPattern = /(?<![\p{L}\p{N}._-])(?:([\p{L}\p{N}._-]+):)?((?:\p{Lu}\p{Ll}+){2,})(?![\p{L}\p{N}._-])/gu;
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
const fenceMatch = line.match(/^\s*(```+|~~~+)/);
|
const fenceMatch = line.match(/^\s*(```+|~~~+)/);
|
||||||
@@ -260,29 +377,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
const protectedRanges = markdownProtectedRanges(line);
|
const protectedRanges = markdownProtectedRanges(line);
|
||||||
const words = Array.from(line.matchAll(/\p{L}+/gu));
|
|
||||||
const replacements = [];
|
const replacements = [];
|
||||||
|
for (const match of line.matchAll(wikiWordPattern)) {
|
||||||
for (const word of words) {
|
const start = match.index;
|
||||||
const start = word.index;
|
const end = start + match[0].length;
|
||||||
const end = start + word[0].length;
|
|
||||||
if (positionIsProtected(start, end, protectedRanges)) continue;
|
if (positionIsProtected(start, end, protectedRanges)) continue;
|
||||||
|
const namespace = match[1] || "";
|
||||||
const target = camelCaseTarget(word[0], aliases);
|
const target = camelCaseTarget(match[2], aliases, namespace);
|
||||||
if (target) {
|
if (target) replacements.push({ start, end, page: target });
|
||||||
replacements.push({ start, end, page: target });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (replacements.length === 0) {
|
|
||||||
result.push(line);
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let expanded = line;
|
let expanded = line;
|
||||||
for (let index = replacements.length - 1; index >= 0; index -= 1) {
|
for (let index = replacements.length - 1; index >= 0; index -= 1) {
|
||||||
const replacement = replacements[index];
|
const replacement = replacements[index];
|
||||||
const link = `[${replacement.page.title}](#/${encodeURIComponent(replacement.page.slug)})`;
|
const link = `[${replacement.page.title}](${pageRoute(replacement.page.slug)})`;
|
||||||
expanded = expanded.slice(0, replacement.start) + link + expanded.slice(replacement.end);
|
expanded = expanded.slice(0, replacement.start) + link + expanded.slice(replacement.end);
|
||||||
}
|
}
|
||||||
result.push(expanded);
|
result.push(expanded);
|
||||||
@@ -291,8 +399,15 @@
|
|||||||
return result.join("\n");
|
return result.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* goal : Render wiki Markdown using the same EasyMDE/Marked pipeline everywhere.
|
||||||
|
* pre : markdown is source text; pageSlug is optional context for Todo links.
|
||||||
|
* post : The returned HTML is sanitized with DOMPurify.
|
||||||
|
* result : Safe HTML for preview, reader view or history view.
|
||||||
|
*/
|
||||||
function renderMarkdown(markdown, pageSlug = null) {
|
function renderMarkdown(markdown, pageSlug = null) {
|
||||||
const withWikiLinks = expandWikiMentions(markdown || "", pageSlug);
|
const withExplicitWikiLinks = expandNamespacedMarkdownLinks(markdown || "");
|
||||||
|
const withWikiLinks = expandWikiMentions(withExplicitWikiLinks, pageSlug);
|
||||||
const withTodos = expandTodoMarkup(withWikiLinks, pageSlug);
|
const withTodos = expandTodoMarkup(withWikiLinks, pageSlug);
|
||||||
const html = easyMDE.markdown(withTodos);
|
const html = easyMDE.markdown(withTodos);
|
||||||
const withImages = applyImageWidthMarkup(html);
|
const withImages = applyImageWidthMarkup(html);
|
||||||
@@ -300,13 +415,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function slugTitle(slug) {
|
function slugTitle(slug) {
|
||||||
return (slug || "")
|
const pageSlug = splitPageReference(slug || "").slug;
|
||||||
|
return pageSlug
|
||||||
.split("-")
|
.split("-")
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||||
.join(" ");
|
.join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Page display and navigation
|
||||||
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
function setPageActionVisibility(pageExists) {
|
function setPageActionVisibility(pageExists) {
|
||||||
if (!can("editor")) return;
|
if (!can("editor")) return;
|
||||||
$("edit-page").classList.remove("hidden");
|
$("edit-page").classList.remove("hidden");
|
||||||
@@ -336,11 +456,15 @@
|
|||||||
const details = $("page-details");
|
const details = $("page-details");
|
||||||
details.replaceChildren();
|
details.replaceChildren();
|
||||||
|
|
||||||
const fields = [
|
const fields = [];
|
||||||
|
if (page.namespace) {
|
||||||
|
fields.push(`${tr("namespace", "Namespace")}: ${page.namespace}`);
|
||||||
|
}
|
||||||
|
fields.push(
|
||||||
`${tr("created", "Created")} ${pageDisplayDate(page.createdAt)} ${tr("by", "by")} ${page.createdBy}`,
|
`${tr("created", "Created")} ${pageDisplayDate(page.createdAt)} ${tr("by", "by")} ${page.createdBy}`,
|
||||||
`${tr("modified", "Modified")} ${pageDisplayDate(page.updatedAt)} ${tr("by", "by")} ${page.updatedBy}`,
|
`${tr("modified", "Modified")} ${pageDisplayDate(page.updatedAt)} ${tr("by", "by")} ${page.updatedBy}`,
|
||||||
`${tr("version", "Version")} ${page.currentVersion}`
|
`${tr("version", "Version")} ${page.currentVersion}`
|
||||||
];
|
);
|
||||||
|
|
||||||
for (const text of fields) {
|
for (const text of fields) {
|
||||||
const span = document.createElement("span");
|
const span = document.createElement("span");
|
||||||
@@ -377,7 +501,7 @@
|
|||||||
candidate = href.slice(1);
|
candidate = href.slice(1);
|
||||||
} else if (href.startsWith("./") && href.indexOf("/", 2) === -1 && !href.includes("?") && !href.includes("#")) {
|
} else if (href.startsWith("./") && href.indexOf("/", 2) === -1 && !href.includes("?") && !href.includes("#")) {
|
||||||
candidate = href.slice(2);
|
candidate = href.slice(2);
|
||||||
} else if (!href.includes("/") && !href.includes(":") && !href.includes("#") && !href.includes("?")) {
|
} else if (!href.includes("/") && !href.includes("#") && !href.includes("?")) {
|
||||||
candidate = href;
|
candidate = href;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,6 +540,10 @@
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// EasyMDE editor setup and editing support
|
||||||
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
function initializeHighlighting() {
|
function initializeHighlighting() {
|
||||||
if (!window.hljs) return;
|
if (!window.hljs) return;
|
||||||
if (typeof window.hljs.registerAliases === "function" && window.hljs.getLanguage("scheme")) {
|
if (typeof window.hljs.registerAliases === "function" && window.hljs.getLanguage("scheme")) {
|
||||||
@@ -435,6 +563,11 @@
|
|||||||
easyMDE.codemirror.refresh();
|
easyMDE.codemirror.refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* goal : Configure the single EasyMDE instance used for page editing.
|
||||||
|
* pre : Vendor scripts and the editor textarea are loaded.
|
||||||
|
* post : easyMDE is ready with toolbar, uploads, Raw mode and live TOC updates.
|
||||||
|
*/
|
||||||
function initializeEditor() {
|
function initializeEditor() {
|
||||||
if (!window.EasyMDE) {
|
if (!window.EasyMDE) {
|
||||||
throw new Error("EasyMDE is not installed. Open /setup to repair the frontend setup.");
|
throw new Error("EasyMDE is not installed. Open /setup to repair the frontend setup.");
|
||||||
@@ -535,6 +668,11 @@
|
|||||||
container.style.setProperty("--editor-status-height", `${statusHeight}px`);
|
container.style.setProperty("--editor-status-height", `${statusHeight}px`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* goal : Show the editor and synchronize its sticky chrome and TOC.
|
||||||
|
* pre : easyMDE has been initialized.
|
||||||
|
* post : Editor view is visible and sized for the current viewport.
|
||||||
|
*/
|
||||||
function activateEditor() {
|
function activateEditor() {
|
||||||
show("editor-view");
|
show("editor-view");
|
||||||
renderEditorToc();
|
renderEditorToc();
|
||||||
@@ -635,6 +773,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* goal : Build the reader TOC from rendered headings and Markdown source lines.
|
||||||
|
* pre : state.currentPage and #markdown-preview represent the same page.
|
||||||
|
* post : TOC navigation scrolls below sticky chrome; editors get section-edit links.
|
||||||
|
*/
|
||||||
function renderPageToc() {
|
function renderPageToc() {
|
||||||
const article = $("markdown-preview");
|
const article = $("markdown-preview");
|
||||||
const usedIds = new Set();
|
const usedIds = new Set();
|
||||||
@@ -766,6 +909,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* goal : Render the sticky history breadcrumb as real navigation links.
|
||||||
|
* pre : items are ordered from wiki home to the current context.
|
||||||
|
* post : #breadcrumbs contains clickable previous page locations.
|
||||||
|
*/
|
||||||
function renderBreadcrumbs(items) {
|
function renderBreadcrumbs(items) {
|
||||||
const breadcrumbs = $("breadcrumbs");
|
const breadcrumbs = $("breadcrumbs");
|
||||||
breadcrumbs.replaceChildren();
|
breadcrumbs.replaceChildren();
|
||||||
@@ -806,6 +954,11 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* goal : Build the page breadcrumb from the per-tab visit history.
|
||||||
|
* pre : state.pages and breadcrumbTrail are current.
|
||||||
|
* post : The sticky breadcrumb shows Home, previous pages and optional suffix.
|
||||||
|
*/
|
||||||
function pageBreadcrumbs(page, suffix = null) {
|
function pageBreadcrumbs(page, suffix = null) {
|
||||||
const firstPage = startPage();
|
const firstPage = startPage();
|
||||||
const items = [];
|
const items = [];
|
||||||
@@ -862,6 +1015,11 @@
|
|||||||
document.title = state.siteTitle;
|
document.title = state.siteTitle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* goal : Return to the first/start page from every normal or special view.
|
||||||
|
* pre : Page metadata has been loaded.
|
||||||
|
* post : Breadcrumb history is cleared and the start page is opened.
|
||||||
|
*/
|
||||||
async function goHome() {
|
async function goHome() {
|
||||||
const firstPage = startPage();
|
const firstPage = startPage();
|
||||||
clearBreadcrumbTrail();
|
clearBreadcrumbTrail();
|
||||||
@@ -878,23 +1036,38 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* goal : Render the secondary page list grouped by namespace.
|
||||||
|
* pre : state.pages contains current page metadata.
|
||||||
|
* post : #page-list reflects the current page set.
|
||||||
|
*/
|
||||||
function renderPageList() {
|
function renderPageList() {
|
||||||
const list = $("page-list");
|
const list = $("page-list");
|
||||||
list.replaceChildren();
|
list.replaceChildren();
|
||||||
|
let previousNamespace = null;
|
||||||
for (const page of state.pages) {
|
for (const page of state.pages) {
|
||||||
|
const namespace = page.namespace || "";
|
||||||
|
if (namespace !== previousNamespace) {
|
||||||
|
const heading = document.createElement("div");
|
||||||
|
heading.className = "namespace-heading";
|
||||||
|
heading.textContent = namespaceLabel(namespace);
|
||||||
|
list.append(heading);
|
||||||
|
previousNamespace = namespace;
|
||||||
|
}
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = `#/${encodeURIComponent(page.slug)}`;
|
link.href = pageRoute(page.slug);
|
||||||
link.className = "page-link";
|
link.className = "page-link";
|
||||||
link.textContent = page.title;
|
link.textContent = page.title;
|
||||||
link.classList.toggle("active", state.currentPage?.slug === page.slug);
|
link.classList.toggle("active", state.currentPage?.slug === page.slug);
|
||||||
link.addEventListener("click", (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
location.hash = `#/${encodeURIComponent(page.slug)}`;
|
|
||||||
});
|
|
||||||
list.append(link);
|
list.append(link);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* goal : Refresh current page metadata from PostgreSQL through the API.
|
||||||
|
* pre : The user is authenticated.
|
||||||
|
* post : state.pages, site identity, page list and template list are refreshed.
|
||||||
|
*/
|
||||||
async function loadPages() {
|
async function loadPages() {
|
||||||
const result = await api("/api/pages");
|
const result = await api("/api/pages");
|
||||||
state.pages = result.pages;
|
state.pages = result.pages;
|
||||||
@@ -905,7 +1078,7 @@
|
|||||||
|
|
||||||
|
|
||||||
function templatePages() {
|
function templatePages() {
|
||||||
return state.pages.filter((page) => page.slug.toLocaleLowerCase().startsWith("template-"));
|
return state.pages.filter((page) => (page.pageSlug || splitPageReference(page.slug).slug).toLocaleLowerCase().startsWith("template-"));
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateTemplateSelect() {
|
function updateTemplateSelect() {
|
||||||
@@ -928,6 +1101,11 @@
|
|||||||
select.value = Array.from(select.options).some((option) => option.value === selected) ? selected : "";
|
select.value = Array.from(select.options).some((option) => option.value === selected) ? selected : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* goal : Replace editor Markdown with a selected template-* wiki page.
|
||||||
|
* pre : slug identifies a readable template page.
|
||||||
|
* post : Editor contents are replaced after confirmation when necessary.
|
||||||
|
*/
|
||||||
async function applyTemplate(slug) {
|
async function applyTemplate(slug) {
|
||||||
if (!slug) return;
|
if (!slug) return;
|
||||||
const template = await api(`/api/pages/${encodeURIComponent(slug)}`);
|
const template = await api(`/api/pages/${encodeURIComponent(slug)}`);
|
||||||
@@ -969,6 +1147,11 @@
|
|||||||
updateBookmarkAction();
|
updateBookmarkAction();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* goal : Open one page in reader mode.
|
||||||
|
* pre : slug is a root or namespace-qualified page reference.
|
||||||
|
* post : Current page state, breadcrumb, TOC and bookmark action are updated.
|
||||||
|
*/
|
||||||
async function openPage(slug) {
|
async function openPage(slug) {
|
||||||
const page = await api(`/api/pages/${encodeURIComponent(slug)}`);
|
const page = await api(`/api/pages/${encodeURIComponent(slug)}`);
|
||||||
state.currentPage = page;
|
state.currentPage = page;
|
||||||
@@ -988,23 +1171,33 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateEditorSlugInfo() {
|
function updateEditorSlugInfo() {
|
||||||
|
const namespace = $("editor-namespace")?.value.trim() || "";
|
||||||
if (!state.editingNew && state.currentPage) {
|
if (!state.editingNew && state.currentPage) {
|
||||||
$("editor-slug-info").textContent = `${tr("page-address", "Page address")}: /${state.currentPage.slug}`;
|
const rawSlug = state.currentPage.pageSlug || splitPageReference(state.currentPage.slug).slug;
|
||||||
|
$("editor-slug-info").textContent = `${tr("page-address", "Page address")}: ${pageReference(namespace, rawSlug)}`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (state.newPageSlug) {
|
if (state.newPageSlug) {
|
||||||
$("editor-slug-info").textContent = `${tr("page-address", "Page address")}: /${state.newPageSlug}`;
|
const requested = splitPageReference(state.newPageSlug);
|
||||||
|
$("editor-slug-info").textContent = `${tr("page-address", "Page address")}: ${pageReference(namespace || requested.namespace, requested.slug)}`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$("editor-slug-info").textContent = tr("page-address-generated", "Page address will be generated from the title when you save.");
|
$("editor-slug-info").textContent = tr("page-address-generated", "Page address will be generated from the title when you save.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* goal : Open an empty editor for a not-yet-existing page reference.
|
||||||
|
* pre : requestedSlug is null or a compact root/namespaced page reference.
|
||||||
|
* post : Namespace, title, template and Markdown controls are initialized for creation.
|
||||||
|
*/
|
||||||
function beginNewPage(requestedSlug = null) {
|
function beginNewPage(requestedSlug = null) {
|
||||||
state.editingNew = true;
|
state.editingNew = true;
|
||||||
state.currentPage = null;
|
state.currentPage = null;
|
||||||
state.newPageSlug = requestedSlug;
|
state.newPageSlug = requestedSlug;
|
||||||
const translationPage = requestedSlug && requestedSlug === state.translationPage;
|
const translationPage = requestedSlug && requestedSlug === state.translationPage;
|
||||||
$("editor-title").value = translationPage ? tr("translations", "Translations") : "";
|
$("editor-title").value = translationPage ? tr("translations", "Translations") : "";
|
||||||
|
$("editor-namespace").value = requestedSlug ? splitPageReference(requestedSlug).namespace : "";
|
||||||
|
$("editor-namespace").disabled = Boolean(translationPage);
|
||||||
$("editor-tags").value = "";
|
$("editor-tags").value = "";
|
||||||
if (translationPage) {
|
if (translationPage) {
|
||||||
easyMDE.value(state.translationTemplate || "");
|
easyMDE.value(state.translationTemplate || "");
|
||||||
@@ -1019,6 +1212,11 @@
|
|||||||
$("editor-title").focus();
|
$("editor-title").focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* goal : Open the current page in EasyMDE.
|
||||||
|
* pre : state.currentPage is a current page, or newPageSlug identifies a missing page.
|
||||||
|
* post : Editor fields contain the page title, namespace, tags and Markdown.
|
||||||
|
*/
|
||||||
function beginEditPage() {
|
function beginEditPage() {
|
||||||
if (!state.currentPage) {
|
if (!state.currentPage) {
|
||||||
if (state.newPageSlug) {
|
if (state.newPageSlug) {
|
||||||
@@ -1029,6 +1227,8 @@
|
|||||||
state.editingNew = false;
|
state.editingNew = false;
|
||||||
state.newPageSlug = null;
|
state.newPageSlug = null;
|
||||||
$("editor-title").value = state.currentPage.title;
|
$("editor-title").value = state.currentPage.title;
|
||||||
|
$("editor-namespace").value = state.currentPage.namespace || "";
|
||||||
|
$("editor-namespace").disabled = state.currentPage.slug === state.translationPage;
|
||||||
$("editor-tags").value = (state.currentPage.tags || []).join(", ");
|
$("editor-tags").value = (state.currentPage.tags || []).join(", ");
|
||||||
easyMDE.value(state.currentPage.markdown);
|
easyMDE.value(state.currentPage.markdown);
|
||||||
$("edit-summary").value = "";
|
$("edit-summary").value = "";
|
||||||
@@ -1038,8 +1238,14 @@
|
|||||||
activateEditor();
|
activateEditor();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* goal : Save the current editor contents as a new or updated wiki page.
|
||||||
|
* pre : EasyMDE is active and title/namespace fields contain editor input.
|
||||||
|
* post : A successful save refreshes page metadata and opens the stored page.
|
||||||
|
*/
|
||||||
async function savePage() {
|
async function savePage() {
|
||||||
const title = $("editor-title").value.trim();
|
const title = $("editor-title").value.trim();
|
||||||
|
const namespace = $("editor-namespace").value.trim();
|
||||||
const currentSlug = state.editingNew ? state.newPageSlug : state.currentPage?.slug;
|
const currentSlug = state.editingNew ? state.newPageSlug : state.currentPage?.slug;
|
||||||
const markdown = easyMDE.value();
|
const markdown = easyMDE.value();
|
||||||
const tags = parseTags($("editor-tags").value);
|
const tags = parseTags($("editor-tags").value);
|
||||||
@@ -1050,12 +1256,14 @@
|
|||||||
if (state.editingNew) {
|
if (state.editingNew) {
|
||||||
const body = {
|
const body = {
|
||||||
title,
|
title,
|
||||||
|
namespace,
|
||||||
markdown,
|
markdown,
|
||||||
tags,
|
tags,
|
||||||
summary: summary || tr("created-page", "Created page")
|
summary: summary || tr("created-page", "Created page")
|
||||||
};
|
};
|
||||||
if (state.newPageSlug) {
|
if (state.newPageSlug) {
|
||||||
body.slug = state.newPageSlug;
|
const requested = splitPageReference(state.newPageSlug);
|
||||||
|
body.slug = pageReference(namespace || requested.namespace, requested.slug);
|
||||||
}
|
}
|
||||||
page = await api("/api/pages", {
|
page = await api("/api/pages", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -1067,6 +1275,7 @@
|
|||||||
method: "PUT",
|
method: "PUT",
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
title,
|
title,
|
||||||
|
namespace,
|
||||||
markdown,
|
markdown,
|
||||||
tags,
|
tags,
|
||||||
baseVersion: state.currentPage.currentVersion,
|
baseVersion: state.currentPage.currentVersion,
|
||||||
@@ -1130,6 +1339,11 @@
|
|||||||
return result.url;
|
return result.url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* goal : Upload dropped/selected files and insert Markdown references at the cursor.
|
||||||
|
* pre : The current page has already been saved once.
|
||||||
|
* post : Uploaded files are stored through the API and referenced from the editor.
|
||||||
|
*/
|
||||||
async function uploadFiles(files) {
|
async function uploadFiles(files) {
|
||||||
const fileList = Array.from(files || []);
|
const fileList = Array.from(files || []);
|
||||||
if (fileList.length === 0) return;
|
if (fileList.length === 0) return;
|
||||||
@@ -1319,6 +1533,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* goal : Show immutable versions of the current page and diff actions.
|
||||||
|
* pre : state.currentPage identifies an existing page.
|
||||||
|
* post : History view contains newest-first versions.
|
||||||
|
*/
|
||||||
async function showHistory() {
|
async function showHistory() {
|
||||||
if (!state.currentPage) return;
|
if (!state.currentPage) return;
|
||||||
state.previousView = "page-view";
|
state.previousView = "page-view";
|
||||||
@@ -1382,6 +1601,11 @@
|
|||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* goal : Show uploads no current page references and their last historical uses.
|
||||||
|
* pre : Current user has the admin role.
|
||||||
|
* post : Admin view offers safe deletion only for orphaned uploads.
|
||||||
|
*/
|
||||||
async function loadOrphanedUploadsAdmin() {
|
async function loadOrphanedUploadsAdmin() {
|
||||||
renderBreadcrumbs([
|
renderBreadcrumbs([
|
||||||
{ label: state.siteTitle, href: "/" },
|
{ label: state.siteTitle, href: "/" },
|
||||||
@@ -1451,6 +1675,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* goal : Render user administration from the current server-side user list.
|
||||||
|
* pre : Current user has the admin role.
|
||||||
|
* post : User rows allow supported create/update/delete operations.
|
||||||
|
*/
|
||||||
async function loadUsersAdmin() {
|
async function loadUsersAdmin() {
|
||||||
renderBreadcrumbs([
|
renderBreadcrumbs([
|
||||||
{ label: state.siteTitle, href: "/" },
|
{ label: state.siteTitle, href: "/" },
|
||||||
@@ -1544,6 +1773,11 @@
|
|||||||
renderPageList();
|
renderPageList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* goal : Resolve the current hash route to a normal or special wiki view.
|
||||||
|
* pre : Session and page metadata have been loaded.
|
||||||
|
* post : Exactly one application view is made active.
|
||||||
|
*/
|
||||||
async function route() {
|
async function route() {
|
||||||
const todoMatch = location.hash.match(/^#todo\/([^/]+)\/(\d+)$/);
|
const todoMatch = location.hash.match(/^#todo\/([^/]+)\/(\d+)$/);
|
||||||
if (todoMatch) {
|
if (todoMatch) {
|
||||||
@@ -1592,6 +1826,10 @@
|
|||||||
return date.toLocaleString(state.language || undefined);
|
return date.toLocaleString(state.language || undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Special wiki views: Recent, Bookmarks and Todo
|
||||||
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
async function showRecent() {
|
async function showRecent() {
|
||||||
state.previousView = state.currentPage ? "page-view" : "recent-view";
|
state.previousView = state.currentPage ? "page-view" : "recent-view";
|
||||||
renderBreadcrumbs([
|
renderBreadcrumbs([
|
||||||
@@ -1642,6 +1880,11 @@
|
|||||||
await loadBookmarks();
|
await loadBookmarks();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* goal : Show bookmarks grouped first by page namespace and then user section.
|
||||||
|
* pre : The user is authenticated.
|
||||||
|
* post : The Bookmarks special view contains the latest bookmark state.
|
||||||
|
*/
|
||||||
async function showBookmarks() {
|
async function showBookmarks() {
|
||||||
state.previousView = state.currentPage ? "page-view" : "bookmarks-view";
|
state.previousView = state.currentPage ? "page-view" : "bookmarks-view";
|
||||||
renderBreadcrumbs([
|
renderBreadcrumbs([
|
||||||
@@ -1662,53 +1905,71 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const groups = new Map();
|
const namespaceGroups = new Map();
|
||||||
for (const bookmark of state.bookmarks) {
|
for (const bookmark of state.bookmarks) {
|
||||||
|
const namespace = bookmark.namespace || "";
|
||||||
|
if (!namespaceGroups.has(namespace)) namespaceGroups.set(namespace, new Map());
|
||||||
|
const sections = namespaceGroups.get(namespace);
|
||||||
const section = bookmark.section || "";
|
const section = bookmark.section || "";
|
||||||
if (!groups.has(section)) groups.set(section, []);
|
if (!sections.has(section)) sections.set(section, []);
|
||||||
groups.get(section).push(bookmark);
|
sections.get(section).push(bookmark);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [section, bookmarks] of groups.entries()) {
|
for (const [namespace, sections] of namespaceGroups.entries()) {
|
||||||
const sectionElement = document.createElement("section");
|
const namespaceSection = document.createElement("section");
|
||||||
sectionElement.className = "bookmark-section";
|
const namespaceHeading = document.createElement("h2");
|
||||||
const heading = document.createElement("h2");
|
namespaceHeading.textContent = namespaceLabel(namespace);
|
||||||
heading.textContent = section || tr("bookmarks", "Bookmarks");
|
namespaceSection.append(namespaceHeading);
|
||||||
sectionElement.append(heading);
|
|
||||||
|
|
||||||
for (const bookmark of bookmarks) {
|
for (const [section, bookmarks] of sections.entries()) {
|
||||||
const row = document.createElement("div");
|
const sectionElement = document.createElement("section");
|
||||||
row.className = "bookmark-row";
|
sectionElement.className = "bookmark-section";
|
||||||
const link = document.createElement("a");
|
if (section) {
|
||||||
link.href = `#/${encodeURIComponent(bookmark.slug)}`;
|
const heading = document.createElement("h3");
|
||||||
link.textContent = bookmark.title;
|
heading.textContent = section;
|
||||||
const actions = document.createElement("span");
|
sectionElement.append(heading);
|
||||||
actions.className = "bookmark-section-actions";
|
}
|
||||||
const move = document.createElement("a");
|
|
||||||
move.href = "#";
|
for (const bookmark of bookmarks) {
|
||||||
move.textContent = tr("move", "Move");
|
const row = document.createElement("div");
|
||||||
move.addEventListener("click", async (event) => {
|
row.className = "bookmark-row";
|
||||||
event.preventDefault();
|
const link = document.createElement("a");
|
||||||
await saveBookmark(bookmark.slug, bookmark.section || "");
|
link.href = pageRoute(bookmark.slug);
|
||||||
await showBookmarks();
|
link.textContent = bookmark.title;
|
||||||
});
|
const actions = document.createElement("span");
|
||||||
const separator = document.createTextNode(" · ");
|
actions.className = "bookmark-section-actions";
|
||||||
const remove = document.createElement("a");
|
const move = document.createElement("a");
|
||||||
remove.href = "#";
|
move.href = "#";
|
||||||
remove.textContent = tr("remove", "Remove");
|
move.textContent = tr("move", "Move");
|
||||||
remove.addEventListener("click", async (event) => {
|
move.addEventListener("click", async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
await removeBookmark(bookmark.slug);
|
await saveBookmark(bookmark.slug, bookmark.section || "");
|
||||||
await showBookmarks();
|
await showBookmarks();
|
||||||
});
|
});
|
||||||
actions.append(move, separator, remove);
|
const separator = document.createTextNode(" · ");
|
||||||
row.append(link, actions);
|
const remove = document.createElement("a");
|
||||||
sectionElement.append(row);
|
remove.href = "#";
|
||||||
|
remove.textContent = tr("remove", "Remove");
|
||||||
|
remove.addEventListener("click", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
await removeBookmark(bookmark.slug);
|
||||||
|
await showBookmarks();
|
||||||
|
});
|
||||||
|
actions.append(move, separator, remove);
|
||||||
|
row.append(link, actions);
|
||||||
|
sectionElement.append(row);
|
||||||
|
}
|
||||||
|
namespaceSection.append(sectionElement);
|
||||||
}
|
}
|
||||||
target.append(sectionElement);
|
target.append(namespaceSection);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* goal : Show Todo items grouped by the namespace of their source page.
|
||||||
|
* pre : Todo index is available through /api/todos.
|
||||||
|
* post : Optional target Todo is scrolled into view and highlighted.
|
||||||
|
*/
|
||||||
async function showTodos(targetSlug = null, targetNumber = null) {
|
async function showTodos(targetSlug = null, targetNumber = null) {
|
||||||
state.previousView = state.currentPage ? "page-view" : "todo-view";
|
state.previousView = state.currentPage ? "page-view" : "todo-view";
|
||||||
renderBreadcrumbs([
|
renderBreadcrumbs([
|
||||||
@@ -1727,12 +1988,21 @@
|
|||||||
list.append(empty);
|
list.append(empty);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
let todoNamespace = null;
|
||||||
for (const item of result.items) {
|
for (const item of result.items) {
|
||||||
|
const namespace = item.namespace || "";
|
||||||
|
if (namespace !== todoNamespace) {
|
||||||
|
const heading = document.createElement("h2");
|
||||||
|
heading.className = "namespace-heading";
|
||||||
|
heading.textContent = namespaceLabel(namespace);
|
||||||
|
list.append(heading);
|
||||||
|
todoNamespace = namespace;
|
||||||
|
}
|
||||||
const row = document.createElement("article");
|
const row = document.createElement("article");
|
||||||
row.className = "todo-item";
|
row.className = "todo-item";
|
||||||
row.id = `todo-${item.slug}-${item.number}`;
|
row.id = `todo-${item.slug}-${item.number}`;
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = `#/${encodeURIComponent(item.slug)}`;
|
link.href = pageRoute(item.slug);
|
||||||
link.textContent = item.title;
|
link.textContent = item.title;
|
||||||
const text = document.createElement("div");
|
const text = document.createElement("div");
|
||||||
text.className = "todo-item-text";
|
text.className = "todo-item-text";
|
||||||
@@ -1753,6 +2023,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
// Wiki graph support
|
||||||
|
////////////////////////////////////////////////////////////////////////////////
|
||||||
|
|
||||||
function pageLinks(markdown) {
|
function pageLinks(markdown) {
|
||||||
const container = document.createElement("div");
|
const container = document.createElement("div");
|
||||||
container.innerHTML = renderMarkdown(markdown || "");
|
container.innerHTML = renderMarkdown(markdown || "");
|
||||||
@@ -1766,6 +2040,12 @@
|
|||||||
return links;
|
return links;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* goal : Build the wiki link graph from current rendered page links.
|
||||||
|
* pre : state.pages contains current pages.
|
||||||
|
* post : No page state is changed.
|
||||||
|
* result : Graph nodes and directed edges.
|
||||||
|
*/
|
||||||
async function loadGraphData() {
|
async function loadGraphData() {
|
||||||
const pageSlugs = new Set(state.pages.map((page) => page.slug));
|
const pageSlugs = new Set(state.pages.map((page) => page.slug));
|
||||||
const edges = [];
|
const edges = [];
|
||||||
@@ -1785,6 +2065,11 @@
|
|||||||
return { nodes: state.pages, edges };
|
return { nodes: state.pages, edges };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* goal : Render a clickable SVG graph for all or contextual wiki pages.
|
||||||
|
* pre : data contains graph nodes/edges using compact page references.
|
||||||
|
* post : #graph-view contains the new graph; node clicks navigate to pages.
|
||||||
|
*/
|
||||||
function renderWikiGraph(data, focusSlug = null) {
|
function renderWikiGraph(data, focusSlug = null) {
|
||||||
const svg = $("wiki-graph");
|
const svg = $("wiki-graph");
|
||||||
svg.replaceChildren();
|
svg.replaceChildren();
|
||||||
@@ -1854,6 +2139,11 @@
|
|||||||
svg.append(nodeLayer);
|
svg.append(nodeLayer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* goal : Open the complete wiki graph special view.
|
||||||
|
* pre : User can read pages.
|
||||||
|
* post : Full graph data is loaded and rendered.
|
||||||
|
*/
|
||||||
async function showGraph() {
|
async function showGraph() {
|
||||||
state.previousView = state.currentPage ? "page-view" : "graph-view";
|
state.previousView = state.currentPage ? "page-view" : "graph-view";
|
||||||
renderBreadcrumbs([
|
renderBreadcrumbs([
|
||||||
@@ -1871,6 +2161,11 @@
|
|||||||
renderWikiGraph(data);
|
renderWikiGraph(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* goal : Open the graph containing the current page and its direct neighbours.
|
||||||
|
* pre : state.currentPage identifies an existing page.
|
||||||
|
* post : Context graph is rendered with the current page highlighted.
|
||||||
|
*/
|
||||||
async function showContextGraph() {
|
async function showContextGraph() {
|
||||||
if (!state.currentPage) return;
|
if (!state.currentPage) return;
|
||||||
|
|
||||||
@@ -1928,11 +2223,21 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* goal : Start periodic connectivity checks for the wiki backend.
|
||||||
|
* pre : Browser application initialization has completed.
|
||||||
|
* post : Offline/online state is updated approximately every 15 seconds.
|
||||||
|
*/
|
||||||
function startKeepAlive() {
|
function startKeepAlive() {
|
||||||
window.setInterval(pingServer, 15000);
|
window.setInterval(pingServer, 15000);
|
||||||
pingServer();
|
pingServer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* goal : Bootstrap the authenticated browser application.
|
||||||
|
* pre : index.html and all pinned vendor scripts are loaded.
|
||||||
|
* post : Session, translations, pages, editor, navigation and keep-alive are ready.
|
||||||
|
*/
|
||||||
async function initialize() {
|
async function initialize() {
|
||||||
state.session = await api("/api/session");
|
state.session = await api("/api/session");
|
||||||
if (!state.session.authenticated) {
|
if (!state.session.authenticated) {
|
||||||
@@ -2037,6 +2342,8 @@
|
|||||||
route();
|
route();
|
||||||
});
|
});
|
||||||
$("save-page").addEventListener("click", savePage);
|
$("save-page").addEventListener("click", savePage);
|
||||||
|
$("editor-namespace").addEventListener("input", updateEditorSlugInfo);
|
||||||
|
|
||||||
$("template-select").addEventListener("change", (event) => {
|
$("template-select").addEventListener("change", (event) => {
|
||||||
applyTemplate(event.target.value).catch((error) => {
|
applyTemplate(event.target.value).catch((error) => {
|
||||||
$("save-status").textContent = error.message;
|
$("save-status").textContent = error.message;
|
||||||
|
|||||||
+11
-3
@@ -1,5 +1,9 @@
|
|||||||
#lang racket/base
|
#lang racket/base
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;; Built-in UI translations and wiki-page translation overrides.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
(require db
|
(require db
|
||||||
racket/file
|
racket/file
|
||||||
racket/list
|
racket/list
|
||||||
@@ -14,10 +18,14 @@
|
|||||||
translation-page-slug
|
translation-page-slug
|
||||||
translation-page-template)
|
translation-page-template)
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;; Supporting functions
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
(define english
|
(define english
|
||||||
(hash
|
(hash
|
||||||
'users "Users" 'translations "Translations" 'orphaned-uploads "Orphaned uploads" 'no-orphaned-uploads "No orphaned uploads." 'uploaded-by "uploaded by" 'last-used "Last used" 'never-referenced "Never referenced by a saved page." 'delete-orphaned-upload-confirm "Delete this orphaned upload?" 'recent "Recent" 'recent-changes "Recent changes" 'bookmarks "Bookmarks" 'bookmark "Bookmark" 'bookmarked "Bookmarked" 'bookmark-section "Bookmark section" 'move "Move" 'remove "Remove" 'no-bookmarks "No bookmarks yet." 'no-recent-pages "No recent pages." 'changed-by "changed by" 'todo "Todo" 'todo-list "Todo list" 'graph "Graph" 'wiki-graph "Wiki graph" 'graph-summary "{pages} pages, {links} links" 'context "context" 'context-graph "Context graph" 'context-graph-summary "{pages} related pages, {links} links" 'admin "Admin" 'role-reader "reader" 'role-editor "editor" 'role-admin "admin"
|
'users "Users" 'translations "Translations" 'orphaned-uploads "Orphaned uploads" 'no-orphaned-uploads "No orphaned uploads." 'uploaded-by "uploaded by" 'last-used "Last used" 'never-referenced "Never referenced by a saved page." 'delete-orphaned-upload-confirm "Delete this orphaned upload?" 'recent "Recent" 'recent-changes "Recent changes" 'bookmarks "Bookmarks" 'bookmark "Bookmark" 'bookmarked "Bookmarked" 'bookmark-section "Bookmark section" 'move "Move" 'remove "Remove" 'no-bookmarks "No bookmarks yet." 'no-recent-pages "No recent pages." 'changed-by "changed by" 'todo "Todo" 'todo-list "Todo list" 'graph "Graph" 'wiki-graph "Wiki graph" 'graph-summary "{pages} pages, {links} links" 'context "context" 'context-graph "Context graph" 'context-graph-summary "{pages} related pages, {links} links" 'admin "Admin" 'role-reader "reader" 'role-editor "editor" 'role-admin "admin"
|
||||||
'search-wiki "Search wiki" 'contents "Contents" 'pages "Pages"
|
'search-wiki "Search wiki" 'contents "Contents" 'pages "Pages" 'namespace "Namespace" 'namespace-placeholder "RWS" 'root-namespace "Root"
|
||||||
'edit "Edit" 'edit-section "Edit section" 'template "Template" 'no-template "No template" 'replace-with-template "Replace the current page content with template {template}?" 'history "History" 'delete "Delete" 'page-not-found "Page not found"
|
'edit "Edit" 'edit-section "Edit section" 'template "Template" 'no-template "No template" 'replace-with-template "Replace the current page content with template {template}?" 'history "History" 'delete "Delete" 'page-not-found "Page not found"
|
||||||
'cancel "Cancel" 'save "Save" 'page-title "Page title" 'tags "Tags (comma separated)"
|
'cancel "Cancel" 'save "Save" 'page-title "Page title" 'tags "Tags (comma separated)"
|
||||||
'version-summary "Version summary (optional)" 'search "Search" 'page-history "Page history"
|
'version-summary "Version summary (optional)" 'search "Search" 'page-history "Page history"
|
||||||
@@ -50,7 +58,7 @@
|
|||||||
(define dutch
|
(define dutch
|
||||||
(hash
|
(hash
|
||||||
'users "Gebruikers" 'translations "Vertalingen" 'orphaned-uploads "Verweeste uploads" 'no-orphaned-uploads "Geen verweeste uploads." 'uploaded-by "geüpload door" 'last-used "Laatst gebruikt" 'never-referenced "Nooit door een opgeslagen pagina gerefereerd." 'delete-orphaned-upload-confirm "Deze verweeste upload verwijderen?" 'recent "Recent" 'recent-changes "Recent gewijzigd" 'bookmarks "Bookmarks" 'bookmark "Bookmark" 'bookmarked "Gebookmarkt" 'bookmark-section "Bookmark-hoofdstuk" 'move "Verplaatsen" 'remove "Verwijderen" 'no-bookmarks "Nog geen bookmarks." 'no-recent-pages "Nog geen recent gewijzigde pagina's." 'changed-by "gewijzigd door" 'todo "Todo" 'todo-list "Todo-lijst" 'graph "Graph" 'wiki-graph "Wiki-graaf" 'graph-summary "{pages} pagina's, {links} links" 'context "context" 'context-graph "Contextgraaf" 'context-graph-summary "{pages} gerelateerde pagina's, {links} links" 'admin "Admin" 'role-reader "lezer" 'role-editor "redacteur" 'role-admin "admin"
|
'users "Gebruikers" 'translations "Vertalingen" 'orphaned-uploads "Verweeste uploads" 'no-orphaned-uploads "Geen verweeste uploads." 'uploaded-by "geüpload door" 'last-used "Laatst gebruikt" 'never-referenced "Nooit door een opgeslagen pagina gerefereerd." 'delete-orphaned-upload-confirm "Deze verweeste upload verwijderen?" 'recent "Recent" 'recent-changes "Recent gewijzigd" 'bookmarks "Bookmarks" 'bookmark "Bookmark" 'bookmarked "Gebookmarkt" 'bookmark-section "Bookmark-hoofdstuk" 'move "Verplaatsen" 'remove "Verwijderen" 'no-bookmarks "Nog geen bookmarks." 'no-recent-pages "Nog geen recent gewijzigde pagina's." 'changed-by "gewijzigd door" 'todo "Todo" 'todo-list "Todo-lijst" 'graph "Graph" 'wiki-graph "Wiki-graaf" 'graph-summary "{pages} pagina's, {links} links" 'context "context" 'context-graph "Contextgraaf" 'context-graph-summary "{pages} gerelateerde pagina's, {links} links" 'admin "Admin" 'role-reader "lezer" 'role-editor "redacteur" 'role-admin "admin"
|
||||||
'search-wiki "Wiki doorzoeken" 'contents "Inhoud" 'pages "Pagina's"
|
'search-wiki "Wiki doorzoeken" 'contents "Inhoud" 'pages "Pagina's" 'namespace "Namespace" 'namespace-placeholder "RWS" 'root-namespace "Hoofdnamespace"
|
||||||
'edit "Bewerken" 'edit-section "Sectie bewerken" 'template "Sjabloon" 'no-template "Geen sjabloon" 'replace-with-template "De huidige pagina-inhoud vervangen door sjabloon {template}?" 'history "Geschiedenis" 'delete "Verwijderen" 'page-not-found "Pagina niet gevonden"
|
'edit "Bewerken" 'edit-section "Sectie bewerken" 'template "Sjabloon" 'no-template "Geen sjabloon" 'replace-with-template "De huidige pagina-inhoud vervangen door sjabloon {template}?" 'history "Geschiedenis" 'delete "Verwijderen" 'page-not-found "Pagina niet gevonden"
|
||||||
'cancel "Annuleren" 'save "Opslaan" 'page-title "Paginatitel" 'tags "Tags (komma-gescheiden)"
|
'cancel "Annuleren" 'save "Opslaan" 'page-title "Paginatitel" 'tags "Tags (komma-gescheiden)"
|
||||||
'version-summary "Versiesamenvatting (optioneel)" 'search "Zoeken" 'page-history "Paginageschiedenis"
|
'version-summary "Versiesamenvatting (optioneel)" 'search "Zoeken" 'page-history "Paginageschiedenis"
|
||||||
@@ -202,7 +210,7 @@
|
|||||||
(λ (db)
|
(λ (db)
|
||||||
(define markdown
|
(define markdown
|
||||||
(query-maybe-value db
|
(query-maybe-value db
|
||||||
"SELECT markdown FROM pages WHERE slug = $1 AND archived = FALSE"
|
"SELECT markdown FROM pages WHERE namespace = '' AND slug = $1 AND archived = FALSE"
|
||||||
(translation-page-slug)))
|
(translation-page-slug)))
|
||||||
(if markdown (parse-overrides markdown) (hash)))))))
|
(if markdown (parse-overrides markdown) (hash)))))))
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user