Multiple features. Admin page with verweeste items. Breadcrump working.
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# 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.
|
||||
|
||||
@@ -365,3 +365,18 @@ Pages whose slug starts with `template-` are available in the editor Template dr
|
||||
## 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.
|
||||
|
||||
|
||||
## 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
|
||||
|
||||
(define pkg-authors '(hnmdijkema))
|
||||
(define version "0.2.21")
|
||||
(define version "0.2.25")
|
||||
(define license 'MIT)
|
||||
(define collection "racket-wiki")
|
||||
(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/path
|
||||
racket/string
|
||||
"attachment-references.rkt"
|
||||
"config.rkt"
|
||||
"todo.rkt")
|
||||
|
||||
@@ -11,7 +12,7 @@
|
||||
database-schema-version
|
||||
migrate-database!)
|
||||
|
||||
(define current-schema-version 5)
|
||||
(define current-schema-version 6)
|
||||
|
||||
(define schema-1-statements
|
||||
(list
|
||||
@@ -239,6 +240,27 @@ SQL
|
||||
(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.
|
||||
; pre : db is a writable PostgreSQL connection and config identifies the
|
||||
@@ -265,6 +287,9 @@ SQL
|
||||
(define after-bookmarks (database-schema-version db))
|
||||
(when (= after-bookmarks 4)
|
||||
(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))
|
||||
(when (> resulting-version current-schema-version)
|
||||
(error 'migrate-database!
|
||||
|
||||
+112
-8
@@ -6,6 +6,7 @@
|
||||
racket/list
|
||||
racket/path
|
||||
racket/string
|
||||
"attachment-references.rkt"
|
||||
"config.rkt"
|
||||
"database.rkt"
|
||||
"todo.rkt")
|
||||
@@ -26,6 +27,8 @@
|
||||
list-bookmarks
|
||||
set-bookmark!
|
||||
delete-bookmark!
|
||||
list-orphaned-uploads
|
||||
delete-orphaned-upload!
|
||||
save-upload!
|
||||
uploaded-file)
|
||||
|
||||
@@ -161,12 +164,13 @@
|
||||
(hash-ref item 'text))))
|
||||
|
||||
(define (insert-version! db page-id version title markdown author action summary now tags)
|
||||
(query-exec db
|
||||
#<<SQL
|
||||
(query-value db
|
||||
#<<SQL
|
||||
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)
|
||||
RETURNING id
|
||||
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.
|
||||
@@ -193,8 +197,11 @@ VALUES ($1, $2, $3, $4, 1, $5, $5, $6, $6,
|
||||
RETURNING id
|
||||
SQL
|
||||
slug title markdown (tags->text tags) now author))
|
||||
(insert-version! db page-id 1 title markdown author "create" summary now tags)
|
||||
(replace-todos! db page-id markdown)))))
|
||||
(define page-version-id
|
||||
(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))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
@@ -237,8 +244,12 @@ SET title = $1, markdown = $2, tags = $3, current_version = $4,
|
||||
WHERE id = $7
|
||||
SQL
|
||||
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)
|
||||
(replace-todos! db (vector-ref row 0) markdown)))))
|
||||
(define page-id (vector-ref row 0))
|
||||
(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))
|
||||
|
||||
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
|
||||
@@ -261,7 +272,10 @@ RETURNING id
|
||||
SQL
|
||||
(current-seconds) author slug))
|
||||
(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))
|
||||
|
||||
(define (page-id config slug)
|
||||
@@ -500,6 +514,96 @@ SQL
|
||||
user-id slug)))
|
||||
(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 clean
|
||||
(regexp-replace* #px"[^A-Za-z0-9._ -]" name "_"))
|
||||
|
||||
+18
@@ -394,6 +394,20 @@ CSS
|
||||
(upload-file-response attachment)
|
||||
(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)
|
||||
(require-role
|
||||
config req 'admin
|
||||
@@ -537,6 +551,10 @@ CSS
|
||||
(λ (req slug) (upload-handler config req slug))]
|
||||
[("uploads" (string-arg) (string-arg)) #:method "get"
|
||||
(λ (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"
|
||||
(λ (req) (admin-users-handler config req))]
|
||||
[("api" "admin" "users") #:method "post"
|
||||
|
||||
@@ -1150,3 +1150,54 @@ body.editor-mode .editor-metadata-row {
|
||||
outline: 2px solid #e1c75a;
|
||||
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">
|
||||
<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-orphaned-uploads-link" href="#" data-tr="orphaned-uploads">Orphaned uploads</a>
|
||||
</nav>
|
||||
</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">
|
||||
<header class="page-header">
|
||||
<h1 data-tr="user-administration">User administration</h1>
|
||||
|
||||
+328
-12
@@ -12,7 +12,9 @@
|
||||
translationPage: "wiki-translations",
|
||||
translationTemplate: "",
|
||||
siteTitle: "Racket Wiki",
|
||||
bookmarks: []
|
||||
bookmarks: [],
|
||||
breadcrumbTrail: [],
|
||||
rawMarkdown: window.localStorage.getItem("racket-wiki-raw-markdown") === "true"
|
||||
};
|
||||
|
||||
let easyMDE = null;
|
||||
@@ -20,7 +22,7 @@
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
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);
|
||||
}
|
||||
$("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) {
|
||||
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) {
|
||||
@@ -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() {
|
||||
if (!window.EasyMDE) {
|
||||
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("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("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 })
|
||||
]
|
||||
});
|
||||
|
||||
applyRawMarkdownMode();
|
||||
|
||||
easyMDE.codemirror.on("change", () => {
|
||||
if (!$("editor-view").classList.contains("hidden")) {
|
||||
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) {
|
||||
if (!easyMDE || line === null || line === undefined) return;
|
||||
clearEditorLineHighlight();
|
||||
easyMDE.codemirror.setCursor({ line, ch: 0 });
|
||||
easyMDE.codemirror.scrollIntoView({ line, ch: 0 }, 160);
|
||||
highlightedEditorLine = easyMDE.codemirror.addLineClass(line, "background", "section-edit-highlight");
|
||||
highlightedEditorTimer = window.setTimeout(clearEditorLineHighlight, 4500);
|
||||
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) {
|
||||
const breadcrumbs = $("breadcrumbs");
|
||||
breadcrumbs.replaceChildren();
|
||||
@@ -554,7 +752,8 @@
|
||||
if (item.href) {
|
||||
const link = document.createElement("a");
|
||||
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;
|
||||
breadcrumbs.append(link);
|
||||
} else {
|
||||
@@ -567,13 +766,36 @@
|
||||
}
|
||||
|
||||
function pageBreadcrumbs(page, suffix = null) {
|
||||
const items = [
|
||||
{ label: state.siteTitle, href: "/" },
|
||||
{ label: page?.title || page?.slug || "New page" }
|
||||
];
|
||||
const firstPage = startPage();
|
||||
const items = [];
|
||||
|
||||
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) {
|
||||
items[items.length - 1].href = `#/${encodeURIComponent(page.slug)}`;
|
||||
items.push({ label: suffix });
|
||||
}
|
||||
|
||||
@@ -601,6 +823,7 @@
|
||||
|
||||
async function goHome() {
|
||||
const firstPage = startPage();
|
||||
clearBreadcrumbTrail();
|
||||
if (!firstPage) {
|
||||
await route();
|
||||
return;
|
||||
@@ -708,6 +931,7 @@
|
||||
async function openPage(slug) {
|
||||
const page = await api(`/api/pages/${encodeURIComponent(slug)}`);
|
||||
state.currentPage = page;
|
||||
recordPageVisit(page);
|
||||
state.editingNew = false;
|
||||
state.newPageSlug = null;
|
||||
$("page-title").textContent = page.title;
|
||||
@@ -1109,6 +1333,82 @@
|
||||
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() {
|
||||
renderBreadcrumbs([
|
||||
{ label: state.siteTitle, href: "/" },
|
||||
@@ -1608,6 +1908,7 @@
|
||||
$("account-role").textContent = tr(`role-${state.session.user.role}`, state.session.user.role);
|
||||
updateRoleUi();
|
||||
await loadPages();
|
||||
loadBreadcrumbTrail();
|
||||
await loadBookmarks();
|
||||
await route();
|
||||
startKeepAlive();
|
||||
@@ -1634,10 +1935,23 @@
|
||||
goHome().catch((error) => console.error(error));
|
||||
});
|
||||
$("breadcrumbs").addEventListener("click", (event) => {
|
||||
const home = event.target.closest("a[data-home='true']");
|
||||
if (!home) return;
|
||||
const link = event.target.closest("a[data-home='true'], a[data-breadcrumb-slug]");
|
||||
if (!link) return;
|
||||
|
||||
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)); });
|
||||
$("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-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) => {
|
||||
event.preventDefault();
|
||||
const page = state.pages.find((item) => item.slug === state.translationPage);
|
||||
@@ -1665,6 +1980,7 @@
|
||||
beginNewPage(state.translationPage);
|
||||
});
|
||||
$("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(); });
|
||||
$("cancel-edit").addEventListener("click", () => {
|
||||
if (state.currentPage) {
|
||||
|
||||
+4
-4
@@ -16,7 +16,7 @@
|
||||
|
||||
(define english
|
||||
(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"
|
||||
'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)"
|
||||
@@ -42,14 +42,14 @@
|
||||
'bold "Bold" 'italic "Italic" 'strikethrough "Strikethrough" 'heading "Heading" 'quote "Quote"
|
||||
'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"
|
||||
'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"
|
||||
'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."))
|
||||
|
||||
(define dutch
|
||||
(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"
|
||||
'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)"
|
||||
@@ -75,7 +75,7 @@
|
||||
'bold "Vet" 'italic "Cursief" 'strikethrough "Doorhalen" 'heading "Kop" 'quote "Citaat"
|
||||
'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"
|
||||
'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"
|
||||
'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."))
|
||||
|
||||
Reference in New Issue
Block a user