Multiple features. Admin page with verweeste items. Breadcrump working.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# racket-wiki
|
# racket-wiki
|
||||||
|
|
||||||
Current development version: **0.2.19**.
|
Current development version: **0.2.23**.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
@@ -365,3 +365,18 @@ Pages whose slug starts with `template-` are available in the editor Template dr
|
|||||||
## 0.2.20
|
## 0.2.20
|
||||||
|
|
||||||
Inline `todo(...)` markers render as a yellow `Todo: text` link. Clicking the marker opens the Todo view at the matching item and highlights it. Todo markers inside fenced code blocks remain untouched.
|
Inline `todo(...)` markers render as a yellow `Todo: text` link. Clicking the marker opens the Todo view at the matching item and highlights it. Todo markers inside fenced code blocks remain untouched.
|
||||||
|
|
||||||
|
|
||||||
|
## 0.2.23
|
||||||
|
|
||||||
|
Breadcrumbs now keep a per-browser-tab history of visited wiki pages. The start page remains the fixed root. Earlier pages in the trail are clickable; selecting one truncates the trail at that page, after which navigation continues from there. Returning to the wiki name/Home clears the trail and opens the start page. Special views such as Todo, Recent, Bookmarks, Graph and Admin do not become entries in the page-history breadcrumb. The trail is stored in browser session storage and is limited to the most recent eight non-home wiki pages.
|
||||||
|
|
||||||
|
|
||||||
|
## 0.2.23
|
||||||
|
|
||||||
|
CamelCase and normalized unlinked page mentions are rendered as automatic links to existing wiki pages. Section edit temporarily highlights the target source line. The EasyMDE toolbar contains a Raw Markdown toggle; the preference is kept in the browser.
|
||||||
|
|
||||||
|
|
||||||
|
## 0.2.24
|
||||||
|
|
||||||
|
Attachment references are tracked in database schema 6. Admin contains an Orphaned uploads page showing uploads no current page references, together with their last historical page/version when available.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#lang info
|
#lang info
|
||||||
|
|
||||||
(define pkg-authors '(hnmdijkema))
|
(define pkg-authors '(hnmdijkema))
|
||||||
(define version "0.2.21")
|
(define version "0.2.25")
|
||||||
(define license 'MIT)
|
(define license 'MIT)
|
||||||
(define collection "racket-wiki")
|
(define collection "racket-wiki")
|
||||||
(define pkg-desc
|
(define pkg-desc
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
#lang racket/base
|
||||||
|
|
||||||
|
(require db
|
||||||
|
racket/string)
|
||||||
|
|
||||||
|
(provide replace-current-attachment-references!
|
||||||
|
record-version-attachment-references!
|
||||||
|
rebuild-attachment-references!)
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;; Supporting functions
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
|
(define (attachment-rows db)
|
||||||
|
(query-rows db
|
||||||
|
#<<SQL
|
||||||
|
SELECT a.id, p.slug, a.stored_name
|
||||||
|
FROM attachments a
|
||||||
|
JOIN pages p ON p.id = a.page_id
|
||||||
|
ORDER BY a.id
|
||||||
|
SQL
|
||||||
|
))
|
||||||
|
|
||||||
|
(define (attachment-url slug stored-name)
|
||||||
|
(format "/uploads/~a/~a" slug stored-name))
|
||||||
|
|
||||||
|
(define (record-references! db page-id page-version-id markdown current? referenced-at)
|
||||||
|
(for ((row (in-list (attachment-rows db))))
|
||||||
|
(define attachment-id (vector-ref row 0))
|
||||||
|
(define slug (vector-ref row 1))
|
||||||
|
(define stored-name (vector-ref row 2))
|
||||||
|
(when (string-contains? markdown (attachment-url slug stored-name))
|
||||||
|
(query-exec db
|
||||||
|
#<<SQL
|
||||||
|
INSERT INTO attachment_references
|
||||||
|
(attachment_id, page_id, page_version_id, current_reference, referenced_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
|
SQL
|
||||||
|
attachment-id
|
||||||
|
page-id
|
||||||
|
page-version-id
|
||||||
|
current?
|
||||||
|
referenced-at))))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
;; Provided functions
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Replace the current attachment references for one page.
|
||||||
|
; pre : page-id identifies a page and markdown is its new current source.
|
||||||
|
; post : Current-reference rows for the page reflect markdown exactly.
|
||||||
|
; result : void.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (replace-current-attachment-references! db page-id markdown referenced-at)
|
||||||
|
(query-exec db
|
||||||
|
"DELETE FROM attachment_references WHERE page_id = $1 AND current_reference = TRUE"
|
||||||
|
page-id)
|
||||||
|
(record-references! db page-id sql-null markdown #t referenced-at)
|
||||||
|
(void))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Record attachment references present in an immutable page version.
|
||||||
|
; pre : page-version-id identifies the stored version represented by markdown.
|
||||||
|
; post : Historical-reference rows for that version reflect markdown exactly.
|
||||||
|
; result : void.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (record-version-attachment-references! db page-id page-version-id markdown referenced-at)
|
||||||
|
(query-exec db
|
||||||
|
"DELETE FROM attachment_references WHERE page_version_id = $1 AND current_reference = FALSE"
|
||||||
|
page-version-id)
|
||||||
|
(record-references! db page-id page-version-id markdown #f referenced-at)
|
||||||
|
(void))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Rebuild all attachment references from current pages and page history.
|
||||||
|
; pre : attachment_references and the existing wiki tables are available.
|
||||||
|
; post : The reference table reflects every current and historical Markdown page.
|
||||||
|
; result : void.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (rebuild-attachment-references! db)
|
||||||
|
(query-exec db "DELETE FROM attachment_references")
|
||||||
|
(for ((row (in-list
|
||||||
|
(query-rows db
|
||||||
|
"SELECT id, markdown, updated_at FROM pages WHERE archived = FALSE"))))
|
||||||
|
(replace-current-attachment-references! db
|
||||||
|
(vector-ref row 0)
|
||||||
|
(vector-ref row 1)
|
||||||
|
(vector-ref row 2)))
|
||||||
|
(for ((row (in-list
|
||||||
|
(query-rows db
|
||||||
|
"SELECT id, page_id, markdown, created_at FROM page_versions ORDER BY id"))))
|
||||||
|
(record-version-attachment-references! db
|
||||||
|
(vector-ref row 1)
|
||||||
|
(vector-ref row 0)
|
||||||
|
(vector-ref row 2)
|
||||||
|
(vector-ref row 3)))
|
||||||
|
(void))
|
||||||
+26
-1
@@ -4,6 +4,7 @@
|
|||||||
racket/file
|
racket/file
|
||||||
racket/path
|
racket/path
|
||||||
racket/string
|
racket/string
|
||||||
|
"attachment-references.rkt"
|
||||||
"config.rkt"
|
"config.rkt"
|
||||||
"todo.rkt")
|
"todo.rkt")
|
||||||
|
|
||||||
@@ -11,7 +12,7 @@
|
|||||||
database-schema-version
|
database-schema-version
|
||||||
migrate-database!)
|
migrate-database!)
|
||||||
|
|
||||||
(define current-schema-version 5)
|
(define current-schema-version 6)
|
||||||
|
|
||||||
(define schema-1-statements
|
(define schema-1-statements
|
||||||
(list
|
(list
|
||||||
@@ -239,6 +240,27 @@ SQL
|
|||||||
(record-schema-version! db 5))
|
(record-schema-version! db 5))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
(define (migrate-5->6! db)
|
||||||
|
(query-exec db
|
||||||
|
#<<SQL
|
||||||
|
CREATE TABLE IF NOT EXISTS attachment_references (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
attachment_id BIGINT NOT NULL REFERENCES attachments(id) ON DELETE CASCADE,
|
||||||
|
page_id BIGINT NOT NULL REFERENCES pages(id) ON DELETE CASCADE,
|
||||||
|
page_version_id BIGINT REFERENCES page_versions(id) ON DELETE CASCADE,
|
||||||
|
current_reference BOOLEAN NOT NULL,
|
||||||
|
referenced_at BIGINT NOT NULL
|
||||||
|
)
|
||||||
|
SQL
|
||||||
|
)
|
||||||
|
(query-exec db
|
||||||
|
"CREATE INDEX IF NOT EXISTS attachment_references_attachment_idx ON attachment_references(attachment_id, current_reference, referenced_at DESC)")
|
||||||
|
(query-exec db
|
||||||
|
"CREATE INDEX IF NOT EXISTS attachment_references_page_idx ON attachment_references(page_id, current_reference)")
|
||||||
|
(rebuild-attachment-references! db)
|
||||||
|
(record-schema-version! db 6))
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
; 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
|
||||||
@@ -265,6 +287,9 @@ SQL
|
|||||||
(define after-bookmarks (database-schema-version db))
|
(define after-bookmarks (database-schema-version db))
|
||||||
(when (= after-bookmarks 4)
|
(when (= after-bookmarks 4)
|
||||||
(migrate-4->5! db))
|
(migrate-4->5! db))
|
||||||
|
(define after-todo-reindex (database-schema-version db))
|
||||||
|
(when (= after-todo-reindex 5)
|
||||||
|
(migrate-5->6! 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!
|
||||||
|
|||||||
+112
-8
@@ -6,6 +6,7 @@
|
|||||||
racket/list
|
racket/list
|
||||||
racket/path
|
racket/path
|
||||||
racket/string
|
racket/string
|
||||||
|
"attachment-references.rkt"
|
||||||
"config.rkt"
|
"config.rkt"
|
||||||
"database.rkt"
|
"database.rkt"
|
||||||
"todo.rkt")
|
"todo.rkt")
|
||||||
@@ -26,6 +27,8 @@
|
|||||||
list-bookmarks
|
list-bookmarks
|
||||||
set-bookmark!
|
set-bookmark!
|
||||||
delete-bookmark!
|
delete-bookmark!
|
||||||
|
list-orphaned-uploads
|
||||||
|
delete-orphaned-upload!
|
||||||
save-upload!
|
save-upload!
|
||||||
uploaded-file)
|
uploaded-file)
|
||||||
|
|
||||||
@@ -161,12 +164,13 @@
|
|||||||
(hash-ref item 'text))))
|
(hash-ref item 'text))))
|
||||||
|
|
||||||
(define (insert-version! db page-id version title markdown author action summary now tags)
|
(define (insert-version! db page-id version title markdown author action summary now tags)
|
||||||
(query-exec db
|
(query-value db
|
||||||
#<<SQL
|
#<<SQL
|
||||||
INSERT INTO page_versions(page_id, version, title, markdown, tags, author, action, summary, created_at)
|
INSERT INTO page_versions(page_id, version, title, markdown, tags, author, action, summary, created_at)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
||||||
|
RETURNING id
|
||||||
SQL
|
SQL
|
||||||
page-id version title markdown (tags->text tags) author action summary now))
|
page-id version title markdown (tags->text tags) author action summary now))
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
; goal : Create a wiki page and its first version.
|
; goal : Create a wiki page and its first version.
|
||||||
@@ -193,8 +197,11 @@ VALUES ($1, $2, $3, $4, 1, $5, $5, $6, $6,
|
|||||||
RETURNING id
|
RETURNING id
|
||||||
SQL
|
SQL
|
||||||
slug title markdown (tags->text tags) now author))
|
slug title markdown (tags->text tags) now author))
|
||||||
(insert-version! db page-id 1 title markdown author "create" summary now tags)
|
(define page-version-id
|
||||||
(replace-todos! db page-id markdown)))))
|
(insert-version! db page-id 1 title markdown author "create" summary now tags))
|
||||||
|
(replace-todos! db page-id markdown)
|
||||||
|
(replace-current-attachment-references! db page-id markdown now)
|
||||||
|
(record-version-attachment-references! db page-id page-version-id markdown now)))))
|
||||||
(read-page config slug))
|
(read-page config slug))
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
@@ -237,8 +244,12 @@ SET title = $1, markdown = $2, tags = $3, current_version = $4,
|
|||||||
WHERE id = $7
|
WHERE id = $7
|
||||||
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 (vector-ref row 0))
|
||||||
(insert-version! db (vector-ref row 0) next-version title markdown author "edit" summary now page-tags)
|
(define page-id (vector-ref row 0))
|
||||||
(replace-todos! db (vector-ref row 0) markdown)))))
|
(define page-version-id
|
||||||
|
(insert-version! db page-id next-version title markdown author "edit" summary now page-tags))
|
||||||
|
(replace-todos! db page-id markdown)
|
||||||
|
(replace-current-attachment-references! db page-id markdown now)
|
||||||
|
(record-version-attachment-references! db page-id page-version-id markdown now)))))
|
||||||
(read-page config slug))
|
(read-page config slug))
|
||||||
|
|
||||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
@@ -261,7 +272,10 @@ RETURNING id
|
|||||||
SQL
|
SQL
|
||||||
(current-seconds) author slug))
|
(current-seconds) author slug))
|
||||||
(unless id
|
(unless id
|
||||||
(error 'archive-page! "unknown page: ~a" slug))))
|
(error 'archive-page! "unknown page: ~a" slug))
|
||||||
|
(query-exec db
|
||||||
|
"DELETE FROM attachment_references WHERE page_id = $1 AND current_reference = TRUE"
|
||||||
|
id)))
|
||||||
(void))
|
(void))
|
||||||
|
|
||||||
(define (page-id config slug)
|
(define (page-id config slug)
|
||||||
@@ -500,6 +514,96 @@ SQL
|
|||||||
user-id slug)))
|
user-id slug)))
|
||||||
(void))
|
(void))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : List uploads that no current page references.
|
||||||
|
; pre : PostgreSQL schema 6 or newer is initialized.
|
||||||
|
; post : Attachment and reference rows have only been read.
|
||||||
|
; result : A newest-first list with owner and last historical use metadata.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (list-orphaned-uploads config)
|
||||||
|
(call-with-wiki-database
|
||||||
|
config
|
||||||
|
(λ (db)
|
||||||
|
(for/list ((row (in-list
|
||||||
|
(query-rows db
|
||||||
|
#<<SQL
|
||||||
|
SELECT a.id,
|
||||||
|
a.original_name,
|
||||||
|
a.stored_name,
|
||||||
|
a.mime_type,
|
||||||
|
a.size,
|
||||||
|
a.uploaded_at,
|
||||||
|
a.uploaded_by,
|
||||||
|
owner.slug,
|
||||||
|
owner.title
|
||||||
|
FROM attachments a
|
||||||
|
JOIN pages owner ON owner.id = a.page_id
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM attachment_references current_ref
|
||||||
|
WHERE current_ref.attachment_id = a.id
|
||||||
|
AND current_ref.current_reference = TRUE
|
||||||
|
)
|
||||||
|
ORDER BY a.uploaded_at DESC, a.id DESC
|
||||||
|
SQL
|
||||||
|
))))
|
||||||
|
(define attachment-id (vector-ref row 0))
|
||||||
|
(define last-uses
|
||||||
|
(for/list ((use-row (in-list
|
||||||
|
(query-rows db
|
||||||
|
#<<SQL
|
||||||
|
SELECT p.slug, p.title, pv.version, ar.referenced_at
|
||||||
|
FROM attachment_references ar
|
||||||
|
JOIN pages p ON p.id = ar.page_id
|
||||||
|
LEFT JOIN page_versions pv ON pv.id = ar.page_version_id
|
||||||
|
WHERE ar.attachment_id = $1
|
||||||
|
AND ar.current_reference = FALSE
|
||||||
|
ORDER BY ar.referenced_at DESC, ar.id DESC
|
||||||
|
LIMIT 5
|
||||||
|
SQL
|
||||||
|
attachment-id))))
|
||||||
|
(hash 'slug (vector-ref use-row 0)
|
||||||
|
'title (vector-ref use-row 1)
|
||||||
|
'version (if (sql-null? (vector-ref use-row 2)) #f (vector-ref use-row 2))
|
||||||
|
'referencedAt (vector-ref use-row 3))))
|
||||||
|
(hash 'id attachment-id
|
||||||
|
'originalName (vector-ref row 1)
|
||||||
|
'storedName (vector-ref row 2)
|
||||||
|
'mimeType (vector-ref row 3)
|
||||||
|
'size (vector-ref row 4)
|
||||||
|
'uploadedAt (vector-ref row 5)
|
||||||
|
'uploadedBy (vector-ref row 6)
|
||||||
|
'ownerSlug (vector-ref row 7)
|
||||||
|
'ownerTitle (vector-ref row 8)
|
||||||
|
'lastUses last-uses)))))
|
||||||
|
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
; goal : Delete an upload only when no current page references it.
|
||||||
|
; pre : attachment-id identifies a possible attachment.
|
||||||
|
; post : The attachment and its reference rows are deleted, or an error is raised.
|
||||||
|
; result : void.
|
||||||
|
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||||
|
(define (delete-orphaned-upload! config attachment-id)
|
||||||
|
(call-with-wiki-database
|
||||||
|
config
|
||||||
|
(λ (db)
|
||||||
|
(call-with-transaction
|
||||||
|
db
|
||||||
|
(λ ()
|
||||||
|
(define current-count
|
||||||
|
(query-value db
|
||||||
|
"SELECT COUNT(*) FROM attachment_references WHERE attachment_id = $1 AND current_reference = TRUE"
|
||||||
|
attachment-id))
|
||||||
|
(when (> current-count 0)
|
||||||
|
(error 'delete-orphaned-upload! "attachment is still referenced by a current page"))
|
||||||
|
(define deleted-id
|
||||||
|
(query-maybe-value db
|
||||||
|
"DELETE FROM attachments WHERE id = $1 RETURNING id"
|
||||||
|
attachment-id))
|
||||||
|
(unless deleted-id
|
||||||
|
(error 'delete-orphaned-upload! "unknown attachment: ~a" attachment-id))))))
|
||||||
|
(void))
|
||||||
|
|
||||||
(define (safe-file-name name)
|
(define (safe-file-name name)
|
||||||
(define clean
|
(define clean
|
||||||
(regexp-replace* #px"[^A-Za-z0-9._ -]" name "_"))
|
(regexp-replace* #px"[^A-Za-z0-9._ -]" name "_"))
|
||||||
|
|||||||
+18
@@ -394,6 +394,20 @@ CSS
|
|||||||
(upload-file-response attachment)
|
(upload-file-response attachment)
|
||||||
(json-error 404 "File not found")))))
|
(json-error 404 "File not found")))))
|
||||||
|
|
||||||
|
(define (admin-orphaned-uploads-handler config req)
|
||||||
|
(require-role
|
||||||
|
config req 'admin
|
||||||
|
(λ (_session)
|
||||||
|
(json-response (hash 'uploads (list-orphaned-uploads config))))))
|
||||||
|
|
||||||
|
(define (admin-delete-orphaned-upload-handler config req id)
|
||||||
|
(require-write-role
|
||||||
|
config req 'admin
|
||||||
|
(λ (_session)
|
||||||
|
(with-handlers ((exn:fail? (λ (e) (json-error 400 (exn-message e)))))
|
||||||
|
(delete-orphaned-upload! config id)
|
||||||
|
(json-response (hash 'ok #t))))))
|
||||||
|
|
||||||
(define (admin-users-handler config req)
|
(define (admin-users-handler config req)
|
||||||
(require-role
|
(require-role
|
||||||
config req 'admin
|
config req 'admin
|
||||||
@@ -537,6 +551,10 @@ CSS
|
|||||||
(λ (req slug) (upload-handler config req slug))]
|
(λ (req slug) (upload-handler config req slug))]
|
||||||
[("uploads" (string-arg) (string-arg)) #:method "get"
|
[("uploads" (string-arg) (string-arg)) #:method "get"
|
||||||
(λ (req slug stored-name) (upload-get-handler config req slug stored-name))]
|
(λ (req slug stored-name) (upload-get-handler config req slug stored-name))]
|
||||||
|
[("api" "admin" "uploads" "orphaned") #:method "get"
|
||||||
|
(λ (req) (admin-orphaned-uploads-handler config req))]
|
||||||
|
[("api" "admin" "uploads" "orphaned" (integer-arg)) #:method "delete"
|
||||||
|
(λ (req id) (admin-delete-orphaned-upload-handler config req id))]
|
||||||
[("api" "admin" "users") #:method "get"
|
[("api" "admin" "users") #:method "get"
|
||||||
(λ (req) (admin-users-handler config req))]
|
(λ (req) (admin-users-handler config req))]
|
||||||
[("api" "admin" "users") #:method "post"
|
[("api" "admin" "users") #:method "post"
|
||||||
|
|||||||
@@ -1150,3 +1150,54 @@ body.editor-mode .editor-metadata-row {
|
|||||||
outline: 2px solid #e1c75a;
|
outline: 2px solid #e1c75a;
|
||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 0.2.23: source-mode and section editing */
|
||||||
|
.EasyMDEContainer.raw-markdown .CodeMirror .cm-header-1,
|
||||||
|
.EasyMDEContainer.raw-markdown .CodeMirror .cm-header-2,
|
||||||
|
.EasyMDEContainer.raw-markdown .CodeMirror .cm-header-3,
|
||||||
|
.EasyMDEContainer.raw-markdown .CodeMirror .cm-header-4,
|
||||||
|
.EasyMDEContainer.raw-markdown .CodeMirror .cm-header-5,
|
||||||
|
.EasyMDEContainer.raw-markdown .CodeMirror .cm-header-6,
|
||||||
|
.EasyMDEContainer.raw-markdown .CodeMirror .cm-strong,
|
||||||
|
.EasyMDEContainer.raw-markdown .CodeMirror .cm-em {
|
||||||
|
font-size: inherit !important;
|
||||||
|
line-height: inherit !important;
|
||||||
|
font-weight: normal !important;
|
||||||
|
font-style: normal !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.EasyMDEContainer .CodeMirror .section-edit-highlight {
|
||||||
|
background: #fff3a3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wiki-auto-link {
|
||||||
|
text-decoration-style: dotted;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orphaned-uploads-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orphaned-upload-row {
|
||||||
|
padding: 12px 0;
|
||||||
|
border-bottom: 1px solid var(--wiki-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.orphaned-upload-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orphaned-upload-uses {
|
||||||
|
margin: 4px 0 0 18px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orphaned-upload-uses {
|
||||||
|
margin: 4px 0 0 18px;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -158,9 +158,18 @@
|
|||||||
<nav class="admin-menu">
|
<nav class="admin-menu">
|
||||||
<a id="admin-users-link" href="#" data-tr="users">Users</a>
|
<a id="admin-users-link" href="#" data-tr="users">Users</a>
|
||||||
<a id="admin-translations-link" href="#" data-tr="translations">Translations</a>
|
<a id="admin-translations-link" href="#" data-tr="translations">Translations</a>
|
||||||
|
<a id="admin-orphaned-uploads-link" href="#" data-tr="orphaned-uploads">Orphaned uploads</a>
|
||||||
</nav>
|
</nav>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="orphaned-uploads-view" class="hidden">
|
||||||
|
<header class="page-header">
|
||||||
|
<h1 data-tr="orphaned-uploads">Orphaned uploads</h1>
|
||||||
|
<a id="close-orphaned-uploads" href="#" data-tr="back">Back</a>
|
||||||
|
</header>
|
||||||
|
<div id="orphaned-uploads-list" class="orphaned-uploads-list"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="user-admin-view" class="hidden">
|
<section id="user-admin-view" class="hidden">
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<h1 data-tr="user-administration">User administration</h1>
|
<h1 data-tr="user-administration">User administration</h1>
|
||||||
|
|||||||
+328
-12
@@ -12,7 +12,9 @@
|
|||||||
translationPage: "wiki-translations",
|
translationPage: "wiki-translations",
|
||||||
translationTemplate: "",
|
translationTemplate: "",
|
||||||
siteTitle: "Racket Wiki",
|
siteTitle: "Racket Wiki",
|
||||||
bookmarks: []
|
bookmarks: [],
|
||||||
|
breadcrumbTrail: [],
|
||||||
|
rawMarkdown: window.localStorage.getItem("racket-wiki-raw-markdown") === "true"
|
||||||
};
|
};
|
||||||
|
|
||||||
let easyMDE = null;
|
let easyMDE = null;
|
||||||
@@ -20,7 +22,7 @@
|
|||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
function show(viewId) {
|
function show(viewId) {
|
||||||
for (const id of ["page-view", "not-found-view", "editor-view", "search-view", "recent-view", "bookmarks-view", "todo-view", "graph-view", "history-view", "admin-view", "user-admin-view"]) {
|
for (const id of ["page-view", "not-found-view", "editor-view", "search-view", "recent-view", "bookmarks-view", "todo-view", "graph-view", "history-view", "admin-view", "user-admin-view", "orphaned-uploads-view"]) {
|
||||||
$(id).classList.toggle("hidden", id !== viewId);
|
$(id).classList.toggle("hidden", id !== viewId);
|
||||||
}
|
}
|
||||||
$("page-action-links").classList.toggle("hidden", viewId !== "page-view");
|
$("page-action-links").classList.toggle("hidden", viewId !== "page-view");
|
||||||
@@ -148,9 +150,112 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeMentionText(text) {
|
||||||
|
return String(text || "")
|
||||||
|
.normalize("NFKD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.toLocaleLowerCase()
|
||||||
|
.replace(/[^\p{L}\p{N}]+/gu, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function camelCaseParts(text) {
|
||||||
|
return String(text || "").match(/[A-Z][a-z0-9]+(?:[A-Z][A-Za-z0-9]*)+/g) || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function mentionAliases(page) {
|
||||||
|
const aliases = new Set();
|
||||||
|
const values = [page.title, slugTitle(page.slug), page.slug.replaceAll("-", " ")];
|
||||||
|
for (const value of values) {
|
||||||
|
if (!value) continue;
|
||||||
|
aliases.add(value);
|
||||||
|
for (const camel of camelCaseParts(value)) aliases.add(camel);
|
||||||
|
}
|
||||||
|
return Array.from(aliases)
|
||||||
|
.map((value) => ({ text: value, key: normalizeMentionText(value) }))
|
||||||
|
.filter((alias) => alias.key.length >= 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pageMentionMap(currentSlug = null) {
|
||||||
|
const map = new Map();
|
||||||
|
for (const page of state.pages) {
|
||||||
|
if (page.slug === currentSlug) continue;
|
||||||
|
for (const alias of mentionAliases(page)) {
|
||||||
|
if (!map.has(alias.key)) {
|
||||||
|
map.set(alias.key, page);
|
||||||
|
} else if (map.get(alias.key)?.slug !== page.slug) {
|
||||||
|
map.set(alias.key, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function linkUnlinkedMentions(html, currentSlug = null) {
|
||||||
|
const host = document.createElement("div");
|
||||||
|
host.innerHTML = html || "";
|
||||||
|
const aliases = pageMentionMap(currentSlug);
|
||||||
|
if (aliases.size === 0) return host.innerHTML;
|
||||||
|
|
||||||
|
const walker = document.createTreeWalker(host, NodeFilter.SHOW_TEXT);
|
||||||
|
const textNodes = [];
|
||||||
|
while (walker.nextNode()) textNodes.push(walker.currentNode);
|
||||||
|
|
||||||
|
for (const textNode of textNodes) {
|
||||||
|
const parent = textNode.parentElement;
|
||||||
|
if (!parent || parent.closest("a, code, pre, script, style, textarea")) continue;
|
||||||
|
const text = textNode.nodeValue || "";
|
||||||
|
const words = Array.from(text.matchAll(/[\p{L}\p{N}]+/gu));
|
||||||
|
if (words.length === 0) continue;
|
||||||
|
|
||||||
|
const replacements = [];
|
||||||
|
let wordIndex = 0;
|
||||||
|
while (wordIndex < words.length) {
|
||||||
|
let match = null;
|
||||||
|
const maxWords = Math.min(5, words.length - wordIndex);
|
||||||
|
for (let count = maxWords; count >= 1; count -= 1) {
|
||||||
|
const first = words[wordIndex];
|
||||||
|
const last = words[wordIndex + count - 1];
|
||||||
|
const start = first.index;
|
||||||
|
const end = last.index + last[0].length;
|
||||||
|
const candidate = text.slice(start, end);
|
||||||
|
const page = aliases.get(normalizeMentionText(candidate));
|
||||||
|
if (page) {
|
||||||
|
match = { start, end, page };
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (match) {
|
||||||
|
replacements.push(match);
|
||||||
|
while (wordIndex < words.length && words[wordIndex].index < match.end) wordIndex += 1;
|
||||||
|
} else {
|
||||||
|
wordIndex += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replacements.length === 0) continue;
|
||||||
|
const fragment = document.createDocumentFragment();
|
||||||
|
let position = 0;
|
||||||
|
for (const replacement of replacements) {
|
||||||
|
if (replacement.start > position) fragment.append(document.createTextNode(text.slice(position, replacement.start)));
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = `#/${encodeURIComponent(replacement.page.slug)}`;
|
||||||
|
link.className = "wiki-auto-link";
|
||||||
|
link.title = tr("automatic-wiki-link", "Automatic wiki link");
|
||||||
|
link.textContent = text.slice(replacement.start, replacement.end);
|
||||||
|
fragment.append(link);
|
||||||
|
position = replacement.end;
|
||||||
|
}
|
||||||
|
if (position < text.length) fragment.append(document.createTextNode(text.slice(position)));
|
||||||
|
textNode.replaceWith(fragment);
|
||||||
|
}
|
||||||
|
|
||||||
|
return host.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
function renderMarkdown(markdown, pageSlug = null) {
|
function renderMarkdown(markdown, pageSlug = null) {
|
||||||
const html = easyMDE.markdown(expandTodoMarkup(markdown || "", pageSlug));
|
const html = easyMDE.markdown(expandTodoMarkup(markdown || "", pageSlug));
|
||||||
return DOMPurify.sanitize(applyImageWidthMarkup(html));
|
const withImages = applyImageWidthMarkup(html);
|
||||||
|
return DOMPurify.sanitize(linkUnlinkedMentions(withImages, pageSlug));
|
||||||
}
|
}
|
||||||
|
|
||||||
function slugTitle(slug) {
|
function slugTitle(slug) {
|
||||||
@@ -277,6 +382,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyRawMarkdownMode() {
|
||||||
|
if (!easyMDE) return;
|
||||||
|
editorContainer().classList.toggle("raw-markdown", state.rawMarkdown);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRawMarkdown() {
|
||||||
|
state.rawMarkdown = !state.rawMarkdown;
|
||||||
|
window.localStorage.setItem("racket-wiki-raw-markdown", state.rawMarkdown ? "true" : "false");
|
||||||
|
applyRawMarkdownMode();
|
||||||
|
easyMDE.codemirror.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
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.");
|
||||||
@@ -338,12 +455,15 @@
|
|||||||
"|",
|
"|",
|
||||||
toolbarButton("undo", EasyMDE.undo, "undo", tr("undo", "Undo")),
|
toolbarButton("undo", EasyMDE.undo, "undo", tr("undo", "Undo")),
|
||||||
toolbarButton("redo", EasyMDE.redo, "repeat", tr("redo", "Redo")),
|
toolbarButton("redo", EasyMDE.redo, "repeat", tr("redo", "Redo")),
|
||||||
|
toolbarButton("raw-markdown", toggleRawMarkdown, "file-text-o", tr("raw-markdown", "Raw Markdown"), { noDisable: true }),
|
||||||
toolbarButton("preview", EasyMDE.togglePreview, "eye", tr("preview", "Preview"), { noDisable: true }),
|
toolbarButton("preview", EasyMDE.togglePreview, "eye", tr("preview", "Preview"), { noDisable: true }),
|
||||||
toolbarButton("side-by-side", EasyMDE.toggleSideBySide, "columns", tr("side-by-side", "Side by side"), { noDisable: true, noMobile: true }),
|
toolbarButton("side-by-side", EasyMDE.toggleSideBySide, "columns", tr("side-by-side", "Side by side"), { noDisable: true, noMobile: true }),
|
||||||
toolbarButton("fullscreen", EasyMDE.toggleFullScreen, "arrows-alt", tr("fullscreen", "Fullscreen"), { noDisable: true, noMobile: true })
|
toolbarButton("fullscreen", EasyMDE.toggleFullScreen, "arrows-alt", tr("fullscreen", "Fullscreen"), { noDisable: true, noMobile: true })
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
applyRawMarkdownMode();
|
||||||
|
|
||||||
easyMDE.codemirror.on("change", () => {
|
easyMDE.codemirror.on("change", () => {
|
||||||
if (!$("editor-view").classList.contains("hidden")) {
|
if (!$("editor-view").classList.contains("hidden")) {
|
||||||
renderEditorToc();
|
renderEditorToc();
|
||||||
@@ -513,10 +633,26 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let highlightedEditorLine = null;
|
||||||
|
let highlightedEditorTimer = null;
|
||||||
|
|
||||||
|
function clearEditorLineHighlight() {
|
||||||
|
if (!easyMDE || highlightedEditorLine === null) return;
|
||||||
|
easyMDE.codemirror.removeLineClass(highlightedEditorLine, "background", "section-edit-highlight");
|
||||||
|
highlightedEditorLine = null;
|
||||||
|
if (highlightedEditorTimer) {
|
||||||
|
window.clearTimeout(highlightedEditorTimer);
|
||||||
|
highlightedEditorTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function focusEditorLine(line) {
|
function focusEditorLine(line) {
|
||||||
if (!easyMDE || line === null || line === undefined) return;
|
if (!easyMDE || line === null || line === undefined) return;
|
||||||
|
clearEditorLineHighlight();
|
||||||
easyMDE.codemirror.setCursor({ line, ch: 0 });
|
easyMDE.codemirror.setCursor({ line, ch: 0 });
|
||||||
easyMDE.codemirror.scrollIntoView({ line, ch: 0 }, 160);
|
easyMDE.codemirror.scrollIntoView({ line, ch: 0 }, 160);
|
||||||
|
highlightedEditorLine = easyMDE.codemirror.addLineClass(line, "background", "section-edit-highlight");
|
||||||
|
highlightedEditorTimer = window.setTimeout(clearEditorLineHighlight, 4500);
|
||||||
easyMDE.codemirror.focus();
|
easyMDE.codemirror.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -527,6 +663,68 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const breadcrumbStorageKey = "racket-wiki-breadcrumb-trail";
|
||||||
|
const breadcrumbTrailLimit = 8;
|
||||||
|
|
||||||
|
function saveBreadcrumbTrail() {
|
||||||
|
window.sessionStorage.setItem(breadcrumbStorageKey, JSON.stringify(state.breadcrumbTrail));
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadBreadcrumbTrail() {
|
||||||
|
let stored = [];
|
||||||
|
try {
|
||||||
|
stored = JSON.parse(window.sessionStorage.getItem(breadcrumbStorageKey) || "[]");
|
||||||
|
} catch (_error) {
|
||||||
|
stored = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageSlugs = new Set(state.pages.map((page) => page.slug));
|
||||||
|
state.breadcrumbTrail = Array.isArray(stored)
|
||||||
|
? stored.filter((slug) => typeof slug === "string" && pageSlugs.has(slug))
|
||||||
|
: [];
|
||||||
|
saveBreadcrumbTrail();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearBreadcrumbTrail() {
|
||||||
|
state.breadcrumbTrail = [];
|
||||||
|
saveBreadcrumbTrail();
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordPageVisit(page) {
|
||||||
|
if (!page) return;
|
||||||
|
|
||||||
|
const firstPage = startPage();
|
||||||
|
if (firstPage && page.slug === firstPage.slug) {
|
||||||
|
clearBreadcrumbTrail();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingIndex = state.breadcrumbTrail.indexOf(page.slug);
|
||||||
|
if (existingIndex >= 0) {
|
||||||
|
state.breadcrumbTrail = state.breadcrumbTrail.slice(0, existingIndex + 1);
|
||||||
|
} else {
|
||||||
|
state.breadcrumbTrail.push(page.slug);
|
||||||
|
if (state.breadcrumbTrail.length > breadcrumbTrailLimit) {
|
||||||
|
state.breadcrumbTrail = state.breadcrumbTrail.slice(-breadcrumbTrailLimit);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
saveBreadcrumbTrail();
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateBreadcrumbTrail(slug) {
|
||||||
|
const firstPage = startPage();
|
||||||
|
if (firstPage && slug === firstPage.slug) {
|
||||||
|
clearBreadcrumbTrail();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = state.breadcrumbTrail.indexOf(slug);
|
||||||
|
if (index >= 0) {
|
||||||
|
state.breadcrumbTrail = state.breadcrumbTrail.slice(0, index + 1);
|
||||||
|
saveBreadcrumbTrail();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderBreadcrumbs(items) {
|
function renderBreadcrumbs(items) {
|
||||||
const breadcrumbs = $("breadcrumbs");
|
const breadcrumbs = $("breadcrumbs");
|
||||||
breadcrumbs.replaceChildren();
|
breadcrumbs.replaceChildren();
|
||||||
@@ -554,7 +752,8 @@
|
|||||||
if (item.href) {
|
if (item.href) {
|
||||||
const link = document.createElement("a");
|
const link = document.createElement("a");
|
||||||
link.href = item.href;
|
link.href = item.href;
|
||||||
if (item.href === "/") link.dataset.home = "true";
|
if (item.home || item.href === "/") link.dataset.home = "true";
|
||||||
|
if (item.slug) link.dataset.breadcrumbSlug = item.slug;
|
||||||
link.textContent = item.label;
|
link.textContent = item.label;
|
||||||
breadcrumbs.append(link);
|
breadcrumbs.append(link);
|
||||||
} else {
|
} else {
|
||||||
@@ -567,13 +766,36 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function pageBreadcrumbs(page, suffix = null) {
|
function pageBreadcrumbs(page, suffix = null) {
|
||||||
const items = [
|
const firstPage = startPage();
|
||||||
{ label: state.siteTitle, href: "/" },
|
const items = [];
|
||||||
{ label: page?.title || page?.slug || "New page" }
|
|
||||||
];
|
if (firstPage) {
|
||||||
|
items.push({
|
||||||
|
label: state.siteTitle,
|
||||||
|
href: `#/${encodeURIComponent(firstPage.slug)}`,
|
||||||
|
slug: firstPage.slug,
|
||||||
|
home: true
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
items.push({ label: state.siteTitle });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const slug of state.breadcrumbTrail) {
|
||||||
|
const trailPage = state.pages.find((item) => item.slug === slug);
|
||||||
|
if (!trailPage) continue;
|
||||||
|
const isCurrent = page && trailPage.slug === page.slug && !suffix;
|
||||||
|
items.push({
|
||||||
|
label: trailPage.title,
|
||||||
|
href: isCurrent ? null : `#/${encodeURIComponent(trailPage.slug)}`,
|
||||||
|
slug: trailPage.slug
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (page && firstPage && page.slug === firstPage.slug && suffix) {
|
||||||
|
items[0].href = `#/${encodeURIComponent(firstPage.slug)}`;
|
||||||
|
}
|
||||||
|
|
||||||
if (suffix) {
|
if (suffix) {
|
||||||
items[items.length - 1].href = `#/${encodeURIComponent(page.slug)}`;
|
|
||||||
items.push({ label: suffix });
|
items.push({ label: suffix });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -601,6 +823,7 @@
|
|||||||
|
|
||||||
async function goHome() {
|
async function goHome() {
|
||||||
const firstPage = startPage();
|
const firstPage = startPage();
|
||||||
|
clearBreadcrumbTrail();
|
||||||
if (!firstPage) {
|
if (!firstPage) {
|
||||||
await route();
|
await route();
|
||||||
return;
|
return;
|
||||||
@@ -708,6 +931,7 @@
|
|||||||
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;
|
||||||
|
recordPageVisit(page);
|
||||||
state.editingNew = false;
|
state.editingNew = false;
|
||||||
state.newPageSlug = null;
|
state.newPageSlug = null;
|
||||||
$("page-title").textContent = page.title;
|
$("page-title").textContent = page.title;
|
||||||
@@ -1109,6 +1333,82 @@
|
|||||||
show("admin-view");
|
show("admin-view");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatFileSize(size) {
|
||||||
|
const bytes = Number(size || 0);
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOrphanedUploadsAdmin() {
|
||||||
|
renderBreadcrumbs([
|
||||||
|
{ label: state.siteTitle, href: "/" },
|
||||||
|
{ label: tr("admin", "Admin") },
|
||||||
|
{ label: tr("orphaned-uploads", "Orphaned uploads") }
|
||||||
|
]);
|
||||||
|
show("orphaned-uploads-view");
|
||||||
|
const result = await api("/api/admin/uploads/orphaned");
|
||||||
|
const list = $("orphaned-uploads-list");
|
||||||
|
list.replaceChildren();
|
||||||
|
|
||||||
|
if (!result.uploads || result.uploads.length === 0) {
|
||||||
|
const empty = document.createElement("p");
|
||||||
|
empty.className = "muted";
|
||||||
|
empty.textContent = tr("no-orphaned-uploads", "No orphaned uploads.");
|
||||||
|
list.append(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const upload of result.uploads) {
|
||||||
|
const row = document.createElement("article");
|
||||||
|
row.className = "orphaned-upload-row";
|
||||||
|
const title = document.createElement("strong");
|
||||||
|
title.textContent = upload.originalName;
|
||||||
|
const meta = document.createElement("div");
|
||||||
|
meta.className = "muted";
|
||||||
|
meta.textContent = `${upload.mimeType} · ${formatFileSize(upload.size)} · ${tr("uploaded-by", "uploaded by")} ${upload.uploadedBy}`;
|
||||||
|
const history = document.createElement("div");
|
||||||
|
const lastUses = Array.isArray(upload.lastUses) ? upload.lastUses : [];
|
||||||
|
if (lastUses.length > 0) {
|
||||||
|
const label = document.createElement("div");
|
||||||
|
label.textContent = `${tr("last-used", "Last used")}:`;
|
||||||
|
history.append(label);
|
||||||
|
const uses = document.createElement("ul");
|
||||||
|
uses.className = "orphaned-upload-uses";
|
||||||
|
for (const use of lastUses) {
|
||||||
|
const item = document.createElement("li");
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = `#/${encodeURIComponent(use.slug)}`;
|
||||||
|
link.textContent = use.title || use.slug;
|
||||||
|
item.append(link);
|
||||||
|
if (use.version) item.append(document.createTextNode(` · ${tr("version", "Version")} ${use.version}`));
|
||||||
|
uses.append(item);
|
||||||
|
}
|
||||||
|
history.append(uses);
|
||||||
|
} else {
|
||||||
|
history.textContent = tr("never-referenced", "Never referenced by a saved page.");
|
||||||
|
}
|
||||||
|
const actions = document.createElement("div");
|
||||||
|
actions.className = "orphaned-upload-actions";
|
||||||
|
const view = document.createElement("a");
|
||||||
|
view.href = `/uploads/${encodeURIComponent(upload.ownerSlug)}/${encodeURIComponent(upload.storedName)}`;
|
||||||
|
view.target = "_blank";
|
||||||
|
view.rel = "noopener";
|
||||||
|
view.textContent = tr("view", "View");
|
||||||
|
const remove = document.createElement("button");
|
||||||
|
remove.className = "danger";
|
||||||
|
remove.textContent = tr("delete", "Delete");
|
||||||
|
remove.addEventListener("click", async () => {
|
||||||
|
if (!confirm(tr("delete-orphaned-upload-confirm", "Delete this orphaned upload?"))) return;
|
||||||
|
await api(`/api/admin/uploads/orphaned/${upload.id}`, { method: "DELETE" });
|
||||||
|
await loadOrphanedUploadsAdmin();
|
||||||
|
});
|
||||||
|
actions.append(view, remove);
|
||||||
|
row.append(title, meta, history, actions);
|
||||||
|
list.append(row);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function loadUsersAdmin() {
|
async function loadUsersAdmin() {
|
||||||
renderBreadcrumbs([
|
renderBreadcrumbs([
|
||||||
{ label: state.siteTitle, href: "/" },
|
{ label: state.siteTitle, href: "/" },
|
||||||
@@ -1608,6 +1908,7 @@
|
|||||||
$("account-role").textContent = tr(`role-${state.session.user.role}`, state.session.user.role);
|
$("account-role").textContent = tr(`role-${state.session.user.role}`, state.session.user.role);
|
||||||
updateRoleUi();
|
updateRoleUi();
|
||||||
await loadPages();
|
await loadPages();
|
||||||
|
loadBreadcrumbTrail();
|
||||||
await loadBookmarks();
|
await loadBookmarks();
|
||||||
await route();
|
await route();
|
||||||
startKeepAlive();
|
startKeepAlive();
|
||||||
@@ -1634,10 +1935,23 @@
|
|||||||
goHome().catch((error) => console.error(error));
|
goHome().catch((error) => console.error(error));
|
||||||
});
|
});
|
||||||
$("breadcrumbs").addEventListener("click", (event) => {
|
$("breadcrumbs").addEventListener("click", (event) => {
|
||||||
const home = event.target.closest("a[data-home='true']");
|
const link = event.target.closest("a[data-home='true'], a[data-breadcrumb-slug]");
|
||||||
if (!home) return;
|
if (!link) return;
|
||||||
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
goHome().catch((error) => console.error(error));
|
const slug = link.dataset.breadcrumbSlug;
|
||||||
|
if (link.dataset.home === "true") {
|
||||||
|
goHome().catch((error) => console.error(error));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
truncateBreadcrumbTrail(slug);
|
||||||
|
const targetHash = `#/${encodeURIComponent(slug)}`;
|
||||||
|
if (location.hash === targetHash) {
|
||||||
|
openPage(slug).catch((error) => console.error(error));
|
||||||
|
} else {
|
||||||
|
location.hash = targetHash;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
$("recent-link").addEventListener("click", (event) => { event.preventDefault(); showRecent().catch((error) => console.error(error)); });
|
$("recent-link").addEventListener("click", (event) => { event.preventDefault(); showRecent().catch((error) => console.error(error)); });
|
||||||
$("bookmarks-link").addEventListener("click", (event) => { event.preventDefault(); showBookmarks().catch((error) => console.error(error)); });
|
$("bookmarks-link").addEventListener("click", (event) => { event.preventDefault(); showBookmarks().catch((error) => console.error(error)); });
|
||||||
@@ -1651,6 +1965,7 @@
|
|||||||
});
|
});
|
||||||
$("admin-link").addEventListener("click", (event) => { event.preventDefault(); showAdmin(); });
|
$("admin-link").addEventListener("click", (event) => { event.preventDefault(); showAdmin(); });
|
||||||
$("admin-users-link").addEventListener("click", (event) => { event.preventDefault(); loadUsersAdmin().catch(console.error); });
|
$("admin-users-link").addEventListener("click", (event) => { event.preventDefault(); loadUsersAdmin().catch(console.error); });
|
||||||
|
$("admin-orphaned-uploads-link").addEventListener("click", (event) => { event.preventDefault(); loadOrphanedUploadsAdmin().catch(console.error); });
|
||||||
$("admin-translations-link").addEventListener("click", (event) => {
|
$("admin-translations-link").addEventListener("click", (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const page = state.pages.find((item) => item.slug === state.translationPage);
|
const page = state.pages.find((item) => item.slug === state.translationPage);
|
||||||
@@ -1665,6 +1980,7 @@
|
|||||||
beginNewPage(state.translationPage);
|
beginNewPage(state.translationPage);
|
||||||
});
|
});
|
||||||
$("close-history").addEventListener("click", () => show("page-view"));
|
$("close-history").addEventListener("click", () => show("page-view"));
|
||||||
|
$("close-orphaned-uploads").addEventListener("click", (event) => { event.preventDefault(); showAdmin(); });
|
||||||
$("close-user-admin").addEventListener("click", (event) => { event.preventDefault(); showAdmin(); });
|
$("close-user-admin").addEventListener("click", (event) => { event.preventDefault(); showAdmin(); });
|
||||||
$("cancel-edit").addEventListener("click", () => {
|
$("cancel-edit").addEventListener("click", () => {
|
||||||
if (state.currentPage) {
|
if (state.currentPage) {
|
||||||
|
|||||||
+4
-4
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
(define english
|
(define english
|
||||||
(hash
|
(hash
|
||||||
'users "Users" 'translations "Translations" '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"
|
||||||
'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)"
|
||||||
@@ -42,14 +42,14 @@
|
|||||||
'bold "Bold" 'italic "Italic" 'strikethrough "Strikethrough" 'heading "Heading" 'quote "Quote"
|
'bold "Bold" 'italic "Italic" 'strikethrough "Strikethrough" 'heading "Heading" 'quote "Quote"
|
||||||
'bulleted-list "Bulleted list" 'numbered-list "Numbered list" 'checklist "Checklist" 'code-block "Code block"
|
'bulleted-list "Bulleted list" 'numbered-list "Numbered list" 'checklist "Checklist" 'code-block "Code block"
|
||||||
'table "Table" 'link "Link" 'horizontal-rule "Horizontal rule" 'undo "Undo" 'redo "Redo"
|
'table "Table" 'link "Link" 'horizontal-rule "Horizontal rule" 'undo "Undo" 'redo "Redo"
|
||||||
'preview "Preview" 'side-by-side "Side by side" 'fullscreen "Fullscreen"
|
'preview "Preview" 'side-by-side "Side by side" 'fullscreen "Fullscreen" 'raw-markdown "Raw Markdown" 'automatic-wiki-link "Automatic wiki link"
|
||||||
'language "Language" 'language-en "English" 'language-nl "Dutch"
|
'language "Language" 'language-en "English" 'language-nl "Dutch"
|
||||||
'no-headings "No headings" 'uploading "Uploading" 'image-upload-complete "Image upload complete" 'upload-complete "Upload complete"
|
'no-headings "No headings" 'uploading "Uploading" 'image-upload-complete "Image upload complete" 'upload-complete "Upload complete"
|
||||||
'by "by" 'result "result" 'results "results" 'for "for" 'page-does-not-exist "The page does not exist."))
|
'by "by" 'result "result" 'results "results" 'for "for" 'page-does-not-exist "The page does not exist."))
|
||||||
|
|
||||||
(define dutch
|
(define dutch
|
||||||
(hash
|
(hash
|
||||||
'users "Gebruikers" 'translations "Vertalingen" '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"
|
||||||
'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)"
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
'bold "Vet" 'italic "Cursief" 'strikethrough "Doorhalen" 'heading "Kop" 'quote "Citaat"
|
'bold "Vet" 'italic "Cursief" 'strikethrough "Doorhalen" 'heading "Kop" 'quote "Citaat"
|
||||||
'bulleted-list "Opsomming" 'numbered-list "Genummerde lijst" 'checklist "Checklist" 'code-block "Codeblok"
|
'bulleted-list "Opsomming" 'numbered-list "Genummerde lijst" 'checklist "Checklist" 'code-block "Codeblok"
|
||||||
'table "Tabel" 'link "Link" 'horizontal-rule "Horizontale lijn" 'undo "Ongedaan maken" 'redo "Opnieuw"
|
'table "Tabel" 'link "Link" 'horizontal-rule "Horizontale lijn" 'undo "Ongedaan maken" 'redo "Opnieuw"
|
||||||
'preview "Voorbeeld" 'side-by-side "Naast elkaar" 'fullscreen "Volledig scherm"
|
'preview "Voorbeeld" 'side-by-side "Naast elkaar" 'fullscreen "Volledig scherm" 'raw-markdown "Ruwe Markdown" 'automatic-wiki-link "Automatische wikilink"
|
||||||
'language "Taal" 'language-en "Engels" 'language-nl "Nederlands"
|
'language "Taal" 'language-en "Engels" 'language-nl "Nederlands"
|
||||||
'no-headings "Geen koppen" 'uploading "Uploaden" 'image-upload-complete "Afbeelding geüpload" 'upload-complete "Upload voltooid"
|
'no-headings "Geen koppen" 'uploading "Uploaden" 'image-upload-complete "Afbeelding geüpload" 'upload-complete "Upload voltooid"
|
||||||
'by "door" 'result "resultaat" 'results "resultaten" 'for "voor" 'page-does-not-exist "De pagina bestaat niet."))
|
'by "door" 'result "resultaat" 'results "resultaten" 'for "voor" 'page-does-not-exist "De pagina bestaat niet."))
|
||||||
|
|||||||
Reference in New Issue
Block a user