2037 lines
70 KiB
JavaScript
2037 lines
70 KiB
JavaScript
(() => {
|
|
"use strict";
|
|
|
|
const state = {
|
|
session: null,
|
|
pages: [],
|
|
currentPage: null,
|
|
editingNew: false,
|
|
newPageSlug: null,
|
|
previousView: "page-view",
|
|
translations: {},
|
|
translationPage: "wiki-translations",
|
|
translationTemplate: "",
|
|
siteTitle: "Racket Wiki",
|
|
bookmarks: [],
|
|
breadcrumbTrail: [],
|
|
rawMarkdown: window.localStorage.getItem("racket-wiki-raw-markdown") === "true"
|
|
};
|
|
|
|
let easyMDE = null;
|
|
|
|
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", "orphaned-uploads-view"]) {
|
|
$(id).classList.toggle("hidden", id !== viewId);
|
|
}
|
|
$("page-action-links").classList.toggle("hidden", viewId !== "page-view");
|
|
$("context-link").classList.toggle("hidden", !(state.currentPage && ["page-view", "editor-view", "history-view"].includes(viewId)));
|
|
document.body.classList.toggle("editor-mode", viewId === "editor-view");
|
|
}
|
|
|
|
|
|
function tr(key, fallback = key) {
|
|
return state.translations[key] || fallback;
|
|
}
|
|
|
|
function applyTranslations() {
|
|
document.querySelectorAll("[data-tr]").forEach((node) => {
|
|
node.textContent = tr(node.dataset.tr, node.textContent);
|
|
});
|
|
document.querySelectorAll("[data-tr-placeholder]").forEach((node) => {
|
|
node.placeholder = tr(node.dataset.trPlaceholder, node.placeholder);
|
|
});
|
|
document.documentElement.lang = state.language || "en";
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
return String(text)
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """);
|
|
}
|
|
|
|
function expandTodoMarkup(markdown, pageSlug = null) {
|
|
let inFence = false;
|
|
let todoNumber = 0;
|
|
return (markdown || "").split("\n").map((line) => {
|
|
if (/^\s*(```|~~~)/.test(line)) {
|
|
inFence = !inFence;
|
|
return line;
|
|
}
|
|
if (inFence) return line;
|
|
return line.replace(/todo\(([^()\r\n]+)\)/gi, (_match, text) => {
|
|
todoNumber += 1;
|
|
const label = escapeHtml(tr("todo", "Todo"));
|
|
const todoText = escapeHtml(text.trim());
|
|
if (pageSlug) {
|
|
const href = `#todo/${encodeURIComponent(pageSlug)}/${todoNumber}`;
|
|
return `<a class="wiki-todo" href="${href}"><strong>${label}:</strong> ${todoText}</a>`;
|
|
}
|
|
return `<span class="wiki-todo"><strong>${label}:</strong> ${todoText}</span>`;
|
|
});
|
|
}).join("\n");
|
|
}
|
|
|
|
function roleLevel(role) {
|
|
return { reader: 10, editor: 20, admin: 30 }[role] || 0;
|
|
}
|
|
|
|
function can(role) {
|
|
return state.session?.authenticated && roleLevel(state.session.user.role) >= roleLevel(role);
|
|
}
|
|
|
|
function updateRoleUi() {
|
|
document.querySelectorAll(".editor-only").forEach((node) => {
|
|
node.classList.toggle("hidden", !can("editor"));
|
|
});
|
|
document.querySelectorAll(".admin-only").forEach((node) => {
|
|
node.classList.toggle("hidden", !can("admin"));
|
|
});
|
|
}
|
|
|
|
async function api(path, options = {}) {
|
|
const headers = new Headers(options.headers || {});
|
|
if (options.body && !(options.body instanceof Blob) && !(options.body instanceof ArrayBuffer)) {
|
|
headers.set("Content-Type", "application/json");
|
|
}
|
|
if (state.session?.csrfToken && !["GET", "HEAD"].includes((options.method || "GET").toUpperCase())) {
|
|
headers.set("X-CSRF-Token", state.session.csrfToken);
|
|
}
|
|
const response = await fetch(path, { ...options, headers });
|
|
const contentType = response.headers.get("content-type") || "";
|
|
const body = contentType.includes("application/json") ? await response.json() : await response.text();
|
|
if (!response.ok) {
|
|
if (response.status === 401) {
|
|
window.location.replace("/login");
|
|
}
|
|
const error = new Error(body?.error || body || `HTTP ${response.status}`);
|
|
error.status = response.status;
|
|
throw error;
|
|
}
|
|
return body;
|
|
}
|
|
|
|
function applyImageWidthMarkup(html) {
|
|
const imageOptionsSuffix = /(<img\b[^>]*>)\s*\{\s*width\s*=\s*([0-9]+(?:\.[0-9]+)?)(%|px)?(?:\s+(left|center|right))?(?:\s+(float))?(?:\s+float\s*=\s*(left|right))?\s*\}/gi;
|
|
|
|
return (html || "").replace(imageOptionsSuffix, (_match, imageTag, amount, unit, align, floatFlag, floatSide) => {
|
|
const width = `${amount}${unit || "px"}`;
|
|
const closing = imageTag.endsWith("/>") ? "/>" : ">";
|
|
let tagBody = imageTag.slice(0, imageTag.length - closing.length);
|
|
const classes = [];
|
|
const effectiveFloatSide = floatSide || (floatFlag && (align === "left" || align === "right") ? align : null);
|
|
|
|
if (effectiveFloatSide) {
|
|
classes.push(`wiki-image-float-${effectiveFloatSide}`);
|
|
} else if (align) {
|
|
classes.push(`wiki-image-${align}`);
|
|
}
|
|
|
|
if (/\sstyle=(['"])(.*?)\1/i.test(tagBody)) {
|
|
tagBody = tagBody.replace(/\sstyle=(['"])(.*?)\1/i, (_style, quote, value) =>
|
|
` style=${quote}${value}; width: ${width}${quote}`);
|
|
} else {
|
|
tagBody += ` style="width: ${width}"`;
|
|
}
|
|
|
|
if (classes.length > 0) {
|
|
if (/\sclass=(['"])(.*?)\1/i.test(tagBody)) {
|
|
tagBody = tagBody.replace(/\sclass=(['"])(.*?)\1/i, (_class, quote, value) =>
|
|
` class=${quote}${value} ${classes.join(" ")}${quote}`);
|
|
} else {
|
|
tagBody += ` class="${classes.join(" ")}"`;
|
|
}
|
|
}
|
|
|
|
return `${tagBody}${closing}`;
|
|
});
|
|
}
|
|
|
|
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));
|
|
const withImages = applyImageWidthMarkup(html);
|
|
return DOMPurify.sanitize(linkUnlinkedMentions(withImages, pageSlug));
|
|
}
|
|
|
|
function slugTitle(slug) {
|
|
return (slug || "")
|
|
.split("-")
|
|
.filter(Boolean)
|
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
.join(" ");
|
|
}
|
|
|
|
function setPageActionVisibility(pageExists) {
|
|
if (!can("editor")) return;
|
|
$("edit-page").classList.remove("hidden");
|
|
$("delete-page").classList.toggle("hidden", !pageExists);
|
|
$("history-page").classList.toggle("hidden", !pageExists);
|
|
}
|
|
|
|
function pageDisplayDate(seconds) {
|
|
return new Date(seconds * 1000).toLocaleString();
|
|
}
|
|
|
|
function parseTags(text) {
|
|
const seen = new Set();
|
|
const tags = [];
|
|
for (const part of (text || "").split(",")) {
|
|
const tag = part.trim();
|
|
const key = tag.toLocaleLowerCase();
|
|
if (tag && !seen.has(key)) {
|
|
seen.add(key);
|
|
tags.push(tag);
|
|
}
|
|
}
|
|
return tags;
|
|
}
|
|
|
|
function renderPageDetails(page) {
|
|
const details = $("page-details");
|
|
details.replaceChildren();
|
|
|
|
const fields = [
|
|
`${tr("created", "Created")} ${pageDisplayDate(page.createdAt)} ${tr("by", "by")} ${page.createdBy}`,
|
|
`${tr("modified", "Modified")} ${pageDisplayDate(page.updatedAt)} ${tr("by", "by")} ${page.updatedBy}`,
|
|
`${tr("version", "Version")} ${page.currentVersion}`
|
|
];
|
|
|
|
for (const text of fields) {
|
|
const span = document.createElement("span");
|
|
span.textContent = text;
|
|
details.append(span);
|
|
}
|
|
|
|
const tags = Array.isArray(page.tags) ? page.tags : [];
|
|
const tagField = document.createElement("span");
|
|
tagField.className = "page-tags";
|
|
if (tags.length === 0) {
|
|
tagField.textContent = tr("tags-none", "Tags: none");
|
|
} else {
|
|
const label = document.createTextNode(tr("tags-label", "Tags: "));
|
|
tagField.append(label);
|
|
tags.forEach((tag, index) => {
|
|
if (index > 0) tagField.append(document.createTextNode(", "));
|
|
const value = document.createElement("span");
|
|
value.className = "page-tag";
|
|
value.textContent = tag;
|
|
tagField.append(value);
|
|
});
|
|
}
|
|
details.append(tagField);
|
|
}
|
|
|
|
function wikiSlugFromHref(href) {
|
|
if (!href) return null;
|
|
|
|
let candidate = null;
|
|
if (href.startsWith("#/") && !href.includes("?")) {
|
|
candidate = href.slice(2);
|
|
} else if (href.startsWith("/") && href.indexOf("/", 1) === -1 && !href.includes("?") && !href.includes("#")) {
|
|
candidate = href.slice(1);
|
|
} else if (href.startsWith("./") && href.indexOf("/", 2) === -1 && !href.includes("?") && !href.includes("#")) {
|
|
candidate = href.slice(2);
|
|
} else if (!href.includes("/") && !href.includes(":") && !href.includes("#") && !href.includes("?")) {
|
|
candidate = href;
|
|
}
|
|
|
|
if (!candidate) return null;
|
|
|
|
const reserved = new Set(["api", "uploads", "setup", "login", "vendor", "css", "js", "index.html"]);
|
|
let decoded;
|
|
try {
|
|
decoded = decodeURIComponent(candidate);
|
|
} catch (_error) {
|
|
return null;
|
|
}
|
|
return reserved.has(decoded) ? null : decoded;
|
|
}
|
|
|
|
function installWikiLinkNavigation() {
|
|
$("markdown-preview").addEventListener("click", (event) => {
|
|
const link = event.target.closest("a");
|
|
if (!link || event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
|
|
return;
|
|
}
|
|
const slug = wikiSlugFromHref(link.getAttribute("href"));
|
|
if (!slug) return;
|
|
event.preventDefault();
|
|
location.hash = `#/${encodeURIComponent(slug)}`;
|
|
});
|
|
}
|
|
|
|
function toolbarButton(name, action, icon, title, options = {}) {
|
|
return {
|
|
name,
|
|
action,
|
|
className: `fa fa-${icon}`,
|
|
title,
|
|
...options
|
|
};
|
|
}
|
|
|
|
function initializeHighlighting() {
|
|
if (!window.hljs) return;
|
|
if (typeof window.hljs.registerAliases === "function" && window.hljs.getLanguage("scheme")) {
|
|
window.hljs.registerAliases(["racket"], { languageName: "scheme" });
|
|
}
|
|
}
|
|
|
|
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.");
|
|
}
|
|
if (!window.DOMPurify) {
|
|
throw new Error("DOMPurify is not installed. Open /setup to repair the frontend setup.");
|
|
}
|
|
|
|
initializeHighlighting();
|
|
|
|
easyMDE = new EasyMDE({
|
|
element: $("markdown-editor"),
|
|
autoDownloadFontAwesome: false,
|
|
autoRefresh: { delay: 200 },
|
|
forceSync: true,
|
|
lineNumbers: true,
|
|
lineWrapping: true,
|
|
indentWithTabs: false,
|
|
tabSize: 2,
|
|
minHeight: "520px",
|
|
nativeSpellcheck: true,
|
|
spellChecker: false,
|
|
previewImagesInEditor: true,
|
|
previewClass: ["editor-preview", "markdown-body", "preview-pane"],
|
|
sideBySideFullscreen: false,
|
|
syncSideBySidePreviewScroll: true,
|
|
status: ["lines", "words", "cursor"],
|
|
uploadImage: true,
|
|
imageMaxSize: 50 * 1024 * 1024,
|
|
imageAccept: "image/png,image/jpeg,image/gif,image/webp",
|
|
imageUploadFunction: (file, onSuccess, onError) => {
|
|
uploadImageForEasyMDE(file)
|
|
.then(onSuccess)
|
|
.catch((error) => onError(error.message));
|
|
},
|
|
previewRender: (plainText) => easyMDE ? renderMarkdown(plainText, state.currentPage?.slug || state.newPageSlug) : "",
|
|
renderingConfig: {
|
|
codeSyntaxHighlighting: true,
|
|
hljs: window.hljs,
|
|
sanitizerFunction: (html) => DOMPurify.sanitize(html)
|
|
},
|
|
toolbar: [
|
|
toolbarButton("bold", EasyMDE.toggleBold, "bold", tr("bold", "Bold")),
|
|
toolbarButton("italic", EasyMDE.toggleItalic, "italic", tr("italic", "Italic")),
|
|
toolbarButton("strikethrough", EasyMDE.toggleStrikethrough, "strikethrough", tr("strikethrough", "Strikethrough")),
|
|
toolbarButton("heading", EasyMDE.toggleHeadingSmaller, "header", tr("heading", "Heading")),
|
|
"|",
|
|
toolbarButton("quote", EasyMDE.toggleBlockquote, "quote-left", tr("quote", "Quote")),
|
|
toolbarButton("unordered-list", EasyMDE.toggleUnorderedList, "list-ul", tr("bulleted-list", "Bulleted list")),
|
|
toolbarButton("ordered-list", EasyMDE.toggleOrderedList, "list-ol", tr("numbered-list", "Numbered list")),
|
|
toolbarButton("check-list", EasyMDE.toggleCheckList, "check-square-o", tr("checklist", "Checklist")),
|
|
toolbarButton("code", EasyMDE.toggleCodeBlock, "code", tr("code-block", "Code block")),
|
|
toolbarButton("table", EasyMDE.drawTable, "table", tr("table", "Table")),
|
|
"|",
|
|
toolbarButton("link", EasyMDE.drawLink, "link", tr("link", "Link")),
|
|
toolbarButton("upload-image", EasyMDE.drawUploadedImage, "picture-o", tr("upload-image", "Upload image")),
|
|
toolbarButton("file", () => $("file-input").click(), "paperclip", tr("upload-file", "Upload file")),
|
|
toolbarButton("horizontal-rule", EasyMDE.drawHorizontalRule, "minus", tr("horizontal-rule", "Horizontal rule")),
|
|
"|",
|
|
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();
|
|
}
|
|
});
|
|
|
|
installGeneralFileDrop();
|
|
window.addEventListener("resize", () => {
|
|
if (!$("editor-view").classList.contains("hidden")) {
|
|
updateEditorChromeMetrics();
|
|
easyMDE.codemirror.refresh();
|
|
}
|
|
});
|
|
}
|
|
|
|
function editorContainer() {
|
|
return easyMDE.codemirror.getWrapperElement().closest(".EasyMDEContainer");
|
|
}
|
|
|
|
function updateEditorChromeMetrics() {
|
|
if (!easyMDE) return;
|
|
const container = editorContainer();
|
|
const toolbar = container.querySelector(".editor-toolbar");
|
|
const statusbar = container.querySelector(".editor-statusbar");
|
|
const toolbarHeight = toolbar ? toolbar.offsetHeight : 0;
|
|
const statusHeight = statusbar ? statusbar.offsetHeight : 0;
|
|
container.style.setProperty("--editor-toolbar-height", `${toolbarHeight}px`);
|
|
container.style.setProperty("--editor-status-height", `${statusHeight}px`);
|
|
}
|
|
|
|
function activateEditor() {
|
|
show("editor-view");
|
|
renderEditorToc();
|
|
requestAnimationFrame(() => {
|
|
easyMDE.codemirror.refresh();
|
|
const wideScreen = window.matchMedia("(min-width: 901px)").matches;
|
|
if (wideScreen && !easyMDE.isSideBySideActive()) {
|
|
easyMDE.toggleSideBySide();
|
|
}
|
|
updateEditorChromeMetrics();
|
|
easyMDE.codemirror.refresh();
|
|
});
|
|
}
|
|
|
|
function headingId(text, usedIds) {
|
|
const base = text
|
|
.trim()
|
|
.toLocaleLowerCase()
|
|
.normalize("NFKD")
|
|
.replace(/[\u0300-\u036f]/g, "")
|
|
.replace(/[^\p{L}\p{N}]+/gu, "-")
|
|
.replace(/^-+|-+$/g, "") || "section";
|
|
let id = base;
|
|
let number = 2;
|
|
while (usedIds.has(id)) {
|
|
id = `${base}-${number}`;
|
|
number += 1;
|
|
}
|
|
usedIds.add(id);
|
|
return id;
|
|
}
|
|
|
|
function markdownHeadings(markdown) {
|
|
const headings = [];
|
|
let inFence = false;
|
|
const fencePattern = /^\s*(```|~~~)/;
|
|
|
|
(markdown || "").replace(/\r\n/g, "\n").split("\n").forEach((line, lineNumber) => {
|
|
if (fencePattern.test(line)) {
|
|
inFence = !inFence;
|
|
return;
|
|
}
|
|
if (inFence) return;
|
|
|
|
const match = line.match(/^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$/);
|
|
if (!match) return;
|
|
headings.push({
|
|
level: match[1].length,
|
|
text: match[2].trim(),
|
|
line: lineNumber
|
|
});
|
|
});
|
|
return headings;
|
|
}
|
|
|
|
function renderToc(entries, onSelect, onEdit = null) {
|
|
const toc = $("toc-list");
|
|
toc.replaceChildren();
|
|
|
|
if (entries.length === 0) {
|
|
const empty = document.createElement("div");
|
|
empty.className = "toc-empty";
|
|
empty.textContent = tr("no-headings", "No headings");
|
|
toc.append(empty);
|
|
return;
|
|
}
|
|
|
|
for (const entry of entries) {
|
|
const row = document.createElement("div");
|
|
row.className = "toc-row";
|
|
|
|
const link = document.createElement("a");
|
|
link.href = entry.href || "#";
|
|
link.className = `toc-link toc-level-${Math.min(entry.level, 4)}`;
|
|
link.textContent = entry.text;
|
|
link.addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
onSelect(entry);
|
|
});
|
|
row.append(link);
|
|
|
|
if (onEdit) {
|
|
const edit = document.createElement("a");
|
|
edit.href = "#";
|
|
edit.className = "toc-edit-link";
|
|
edit.textContent = "✎";
|
|
edit.title = tr("edit-section", "Edit section");
|
|
edit.setAttribute("aria-label", `${tr("edit-section", "Edit section")}: ${entry.text}`);
|
|
edit.addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
onEdit(entry);
|
|
});
|
|
row.append(edit);
|
|
}
|
|
|
|
toc.append(row);
|
|
}
|
|
}
|
|
|
|
function renderPageToc() {
|
|
const article = $("markdown-preview");
|
|
const usedIds = new Set();
|
|
const sourceHeadings = markdownHeadings(state.currentPage?.markdown || "");
|
|
const entries = Array.from(article.querySelectorAll("h1, h2, h3, h4, h5, h6")).map((heading, index) => {
|
|
const text = heading.textContent.trim();
|
|
const id = heading.id || headingId(text, usedIds);
|
|
heading.id = id;
|
|
return {
|
|
level: Number(heading.tagName.slice(1)),
|
|
text,
|
|
href: `#${id}`,
|
|
element: heading,
|
|
line: sourceHeadings[index]?.line ?? null
|
|
};
|
|
});
|
|
|
|
renderToc(
|
|
entries,
|
|
(entry) => {
|
|
entry.element.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
history.replaceState(null, "", `#/${encodeURIComponent(state.currentPage.slug)}`);
|
|
},
|
|
can("editor")
|
|
? (entry) => beginEditPageAtLine(entry.line)
|
|
: null
|
|
);
|
|
}
|
|
|
|
function renderEditorToc() {
|
|
if (!easyMDE) return;
|
|
const entries = markdownHeadings(easyMDE.value());
|
|
renderToc(entries, (entry) => {
|
|
easyMDE.codemirror.setCursor({ line: entry.line, ch: 0 });
|
|
easyMDE.codemirror.scrollIntoView({ line: entry.line, ch: 0 }, 120);
|
|
easyMDE.codemirror.focus();
|
|
});
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
function beginEditPageAtLine(line) {
|
|
beginEditPage();
|
|
requestAnimationFrame(() => {
|
|
requestAnimationFrame(() => focusEditorLine(line));
|
|
});
|
|
}
|
|
|
|
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();
|
|
|
|
if (!items || items.length === 0) {
|
|
breadcrumbs.classList.add("hidden");
|
|
return;
|
|
}
|
|
|
|
breadcrumbs.classList.remove("hidden");
|
|
|
|
const label = document.createElement("span");
|
|
label.className = "breadcrumb-label";
|
|
label.textContent = `${tr("you-are-here", "You are here")}:`;
|
|
breadcrumbs.append(label);
|
|
|
|
items.forEach((item, index) => {
|
|
if (index > 0) {
|
|
const separator = document.createElement("span");
|
|
separator.className = "breadcrumb-separator";
|
|
separator.textContent = "»";
|
|
breadcrumbs.append(separator);
|
|
}
|
|
|
|
if (item.href) {
|
|
const link = document.createElement("a");
|
|
link.href = item.href;
|
|
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 {
|
|
const current = document.createElement("span");
|
|
current.className = "breadcrumb-current";
|
|
current.textContent = item.label;
|
|
breadcrumbs.append(current);
|
|
}
|
|
});
|
|
}
|
|
|
|
function pageBreadcrumbs(page, suffix = null) {
|
|
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.push({ label: suffix });
|
|
}
|
|
|
|
renderBreadcrumbs(items);
|
|
}
|
|
|
|
function startPage() {
|
|
const namedStartPage = state.pages.find((page) => page.slug === "start") || null;
|
|
if (namedStartPage) return namedStartPage;
|
|
if (state.pages.length === 0) return null;
|
|
|
|
return state.pages.reduce((first, page) => {
|
|
if (!first) return page;
|
|
return Number(page.createdAt) < Number(first.createdAt) ? page : first;
|
|
}, null);
|
|
}
|
|
|
|
function updateWikiIdentity() {
|
|
const firstPage = startPage();
|
|
state.siteTitle = firstPage?.title || "Racket Wiki";
|
|
$("wiki-brand").textContent = state.siteTitle;
|
|
$("wiki-brand").href = firstPage ? `#/${encodeURIComponent(firstPage.slug)}` : "#";
|
|
document.title = state.siteTitle;
|
|
}
|
|
|
|
async function goHome() {
|
|
const firstPage = startPage();
|
|
clearBreadcrumbTrail();
|
|
if (!firstPage) {
|
|
await route();
|
|
return;
|
|
}
|
|
|
|
const targetHash = `#/${encodeURIComponent(firstPage.slug)}`;
|
|
if (location.hash === targetHash) {
|
|
await openPage(firstPage.slug);
|
|
} else {
|
|
location.hash = targetHash;
|
|
}
|
|
}
|
|
|
|
function renderPageList() {
|
|
const list = $("page-list");
|
|
list.replaceChildren();
|
|
for (const page of state.pages) {
|
|
const link = document.createElement("a");
|
|
link.href = `#/${encodeURIComponent(page.slug)}`;
|
|
link.className = "page-link";
|
|
link.textContent = page.title;
|
|
link.classList.toggle("active", state.currentPage?.slug === page.slug);
|
|
link.addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
location.hash = `#/${encodeURIComponent(page.slug)}`;
|
|
});
|
|
list.append(link);
|
|
}
|
|
}
|
|
|
|
async function loadPages() {
|
|
const result = await api("/api/pages");
|
|
state.pages = result.pages;
|
|
updateWikiIdentity();
|
|
renderPageList();
|
|
updateTemplateSelect();
|
|
}
|
|
|
|
|
|
function templatePages() {
|
|
return state.pages.filter((page) => page.slug.toLocaleLowerCase().startsWith("template-"));
|
|
}
|
|
|
|
function updateTemplateSelect() {
|
|
const select = $("template-select");
|
|
const selected = select.value;
|
|
select.replaceChildren();
|
|
|
|
const none = document.createElement("option");
|
|
none.value = "";
|
|
none.textContent = tr("no-template", "No template");
|
|
select.append(none);
|
|
|
|
for (const page of templatePages()) {
|
|
const option = document.createElement("option");
|
|
option.value = page.slug;
|
|
option.textContent = page.title;
|
|
select.append(option);
|
|
}
|
|
|
|
select.value = Array.from(select.options).some((option) => option.value === selected) ? selected : "";
|
|
}
|
|
|
|
async function applyTemplate(slug) {
|
|
if (!slug) return;
|
|
const template = await api(`/api/pages/${encodeURIComponent(slug)}`);
|
|
const current = easyMDE.value();
|
|
|
|
if (current.trim() !== "") {
|
|
const message = tr("replace-with-template", "Replace the current page content with template {template}?")
|
|
.replace("{template}", template.title);
|
|
if (!window.confirm(message)) {
|
|
$("template-select").value = "";
|
|
return;
|
|
}
|
|
}
|
|
|
|
easyMDE.value(template.markdown || "");
|
|
$("template-select").value = "";
|
|
renderEditorToc();
|
|
easyMDE.codemirror.focus();
|
|
}
|
|
|
|
function bookmarkForSlug(slug) {
|
|
return state.bookmarks.find((bookmark) => bookmark.slug === slug) || null;
|
|
}
|
|
|
|
function updateBookmarkAction() {
|
|
const link = $("bookmark-page");
|
|
if (!state.currentPage) {
|
|
link.classList.add("hidden");
|
|
return;
|
|
}
|
|
link.classList.remove("hidden");
|
|
const bookmark = bookmarkForSlug(state.currentPage.slug);
|
|
link.textContent = bookmark ? tr("bookmarked", "Bookmarked") : tr("bookmark", "Bookmark");
|
|
}
|
|
|
|
async function loadBookmarks() {
|
|
const result = await api("/api/bookmarks");
|
|
state.bookmarks = result.bookmarks || [];
|
|
updateBookmarkAction();
|
|
}
|
|
|
|
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;
|
|
$("page-meta").textContent = "";
|
|
$("markdown-preview").innerHTML = renderMarkdown(page.markdown, page.slug);
|
|
renderPageDetails(page);
|
|
pageBreadcrumbs(page);
|
|
show("page-view");
|
|
setPageActionVisibility(true);
|
|
renderPageToc();
|
|
renderPageList();
|
|
updateBookmarkAction();
|
|
}
|
|
|
|
function updateEditorSlugInfo() {
|
|
if (!state.editingNew && state.currentPage) {
|
|
$("editor-slug-info").textContent = `${tr("page-address", "Page address")}: /${state.currentPage.slug}`;
|
|
return;
|
|
}
|
|
if (state.newPageSlug) {
|
|
$("editor-slug-info").textContent = `${tr("page-address", "Page address")}: /${state.newPageSlug}`;
|
|
return;
|
|
}
|
|
$("editor-slug-info").textContent = tr("page-address-generated", "Page address will be generated from the title when you save.");
|
|
}
|
|
|
|
function beginNewPage(requestedSlug = null) {
|
|
state.editingNew = true;
|
|
state.currentPage = null;
|
|
state.newPageSlug = requestedSlug;
|
|
const translationPage = requestedSlug && requestedSlug === state.translationPage;
|
|
$("editor-title").value = translationPage ? tr("translations", "Translations") : "";
|
|
$("editor-tags").value = "";
|
|
if (translationPage) {
|
|
easyMDE.value(state.translationTemplate || "");
|
|
} else {
|
|
easyMDE.value("");
|
|
}
|
|
$("edit-summary").value = "";
|
|
$("save-status").textContent = "";
|
|
$("template-select").value = "";
|
|
updateEditorSlugInfo();
|
|
activateEditor();
|
|
$("editor-title").focus();
|
|
}
|
|
|
|
function beginEditPage() {
|
|
if (!state.currentPage) {
|
|
if (state.newPageSlug) {
|
|
beginNewPage(state.newPageSlug);
|
|
}
|
|
return;
|
|
}
|
|
state.editingNew = false;
|
|
state.newPageSlug = null;
|
|
$("editor-title").value = state.currentPage.title;
|
|
$("editor-tags").value = (state.currentPage.tags || []).join(", ");
|
|
easyMDE.value(state.currentPage.markdown);
|
|
$("edit-summary").value = "";
|
|
$("save-status").textContent = "";
|
|
$("template-select").value = "";
|
|
updateEditorSlugInfo();
|
|
activateEditor();
|
|
}
|
|
|
|
async function savePage() {
|
|
const title = $("editor-title").value.trim();
|
|
const markdown = easyMDE.value();
|
|
const tags = parseTags($("editor-tags").value);
|
|
const summary = $("edit-summary").value.trim();
|
|
$("save-status").textContent = tr("saving", "Saving…");
|
|
try {
|
|
let page;
|
|
if (state.editingNew) {
|
|
const body = {
|
|
title,
|
|
markdown,
|
|
tags,
|
|
summary: summary || tr("created-page", "Created page")
|
|
};
|
|
if (state.newPageSlug) {
|
|
body.slug = state.newPageSlug;
|
|
}
|
|
page = await api("/api/pages", {
|
|
method: "POST",
|
|
body: JSON.stringify(body)
|
|
});
|
|
} else {
|
|
const slug = state.currentPage.slug;
|
|
page = await api(`/api/pages/${encodeURIComponent(slug)}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify({
|
|
title,
|
|
markdown,
|
|
tags,
|
|
baseVersion: state.currentPage.currentVersion,
|
|
summary: summary || tr("edited-page", "Edited page")
|
|
})
|
|
});
|
|
}
|
|
state.currentPage = page;
|
|
state.editingNew = false;
|
|
state.newPageSlug = null;
|
|
await loadPages();
|
|
if (page.slug === state.translationPage) {
|
|
const translationData = await api("/api/translations");
|
|
state.language = translationData.language;
|
|
state.translationTemplate = translationData.template || state.translationTemplate;
|
|
state.translations = translationData.translations || {};
|
|
applyTranslations();
|
|
$("account-role").textContent = tr(`role-${state.session.user.role}`, state.session.user.role);
|
|
}
|
|
location.hash = `#/${encodeURIComponent(page.slug)}`;
|
|
await openPage(page.slug);
|
|
$("save-status").textContent = tr("saved", "Saved");
|
|
} catch (error) {
|
|
$("save-status").textContent = error.message;
|
|
}
|
|
}
|
|
|
|
function insertTextAtCursor(text) {
|
|
const doc = easyMDE.codemirror.getDoc();
|
|
doc.replaceSelection(text, "end");
|
|
easyMDE.codemirror.focus();
|
|
}
|
|
|
|
function isInlineImage(file) {
|
|
return ["image/png", "image/jpeg", "image/gif", "image/webp"].includes(file.type);
|
|
}
|
|
|
|
function requireUploadablePage() {
|
|
if (state.editingNew || !state.currentPage) {
|
|
throw new Error("Save a new page once before uploading files.");
|
|
}
|
|
return state.currentPage.slug;
|
|
}
|
|
|
|
async function uploadOneFile(file) {
|
|
const slug = requireUploadablePage();
|
|
$("save-status").textContent = `${tr("uploading", "Uploading")} ${file.name}…`;
|
|
return api(`/api/pages/${encodeURIComponent(slug)}/upload`, {
|
|
method: "POST",
|
|
headers: { "X-File-Name": file.name },
|
|
body: file
|
|
});
|
|
}
|
|
|
|
async function uploadImageForEasyMDE(file) {
|
|
if (!isInlineImage(file)) {
|
|
throw new Error("Only PNG, JPEG, GIF and WebP can be inserted as images.");
|
|
}
|
|
const result = await uploadOneFile(file);
|
|
$("save-status").textContent = tr("image-upload-complete", "Image upload complete");
|
|
return result.url;
|
|
}
|
|
|
|
async function uploadFiles(files) {
|
|
const fileList = Array.from(files || []);
|
|
if (fileList.length === 0) return;
|
|
|
|
try {
|
|
requireUploadablePage();
|
|
for (const file of fileList) {
|
|
const result = await uploadOneFile(file);
|
|
const escapedName = file.name.replace(/[\[\]]/g, "\\$&");
|
|
const markdown = isInlineImage(file)
|
|
? ``
|
|
: `[${escapedName}](${result.url})`;
|
|
insertTextAtCursor(`\n${markdown}\n`);
|
|
}
|
|
$("save-status").textContent = tr("upload-complete", "Upload complete");
|
|
} catch (error) {
|
|
$("save-status").textContent = error.message;
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
function containsNonImageFile(dataTransfer) {
|
|
const items = Array.from(dataTransfer?.items || []).filter((item) => item.kind === "file");
|
|
return items.some((item) => !item.type.startsWith("image/"));
|
|
}
|
|
|
|
function installGeneralFileDrop() {
|
|
const container = editorContainer();
|
|
if (!container) return;
|
|
|
|
container.addEventListener("dragenter", (event) => {
|
|
if (Array.from(event.dataTransfer?.types || []).includes("Files")) {
|
|
container.classList.add("dragging");
|
|
}
|
|
}, true);
|
|
|
|
container.addEventListener("dragover", (event) => {
|
|
if (containsNonImageFile(event.dataTransfer)) {
|
|
event.preventDefault();
|
|
}
|
|
}, true);
|
|
|
|
container.addEventListener("dragleave", (event) => {
|
|
if (!container.contains(event.relatedTarget)) {
|
|
container.classList.remove("dragging");
|
|
}
|
|
}, true);
|
|
|
|
container.addEventListener("drop", (event) => {
|
|
container.classList.remove("dragging");
|
|
const files = Array.from(event.dataTransfer?.files || []);
|
|
if (files.some((file) => !isInlineImage(file))) {
|
|
event.preventDefault();
|
|
event.stopImmediatePropagation();
|
|
uploadFiles(files).catch(() => {});
|
|
}
|
|
}, true);
|
|
}
|
|
|
|
async function deleteCurrentPage() {
|
|
if (!state.currentPage) return;
|
|
if (!confirm(`Archive page “${state.currentPage.title}”?`)) return;
|
|
await api(`/api/pages/${encodeURIComponent(state.currentPage.slug)}`, { method: "DELETE" });
|
|
state.currentPage = null;
|
|
await loadPages();
|
|
if (state.pages.length > 0) {
|
|
const firstPage = startPage();
|
|
location.hash = `#/${encodeURIComponent(firstPage.slug)}`;
|
|
} else {
|
|
$("page-title").textContent = tr("no-pages", "No pages yet");
|
|
$("markdown-preview").innerHTML = "";
|
|
renderBreadcrumbs([{ label: state.siteTitle }]);
|
|
renderToc([], () => {});
|
|
show("page-view");
|
|
}
|
|
}
|
|
|
|
function buildUnifiedDiff(oldText, newText, oldName, newName) {
|
|
const oldLines = oldText.replace(/\r\n/g, "\n").split("\n");
|
|
const newLines = newText.replace(/\r\n/g, "\n").split("\n");
|
|
const n = oldLines.length;
|
|
const m = newLines.length;
|
|
|
|
if (n * m > 4_000_000) {
|
|
return [
|
|
`--- ${oldName}`,
|
|
`+++ ${newName}`,
|
|
`@@ -1,${n} +1,${m} @@`,
|
|
...oldLines.map((line) => `-${line}`),
|
|
...newLines.map((line) => `+${line}`)
|
|
].join("\n");
|
|
}
|
|
|
|
const table = Array.from({ length: n + 1 }, () => new Uint32Array(m + 1));
|
|
for (let i = n - 1; i >= 0; i--) {
|
|
for (let j = m - 1; j >= 0; j--) {
|
|
table[i][j] = oldLines[i] === newLines[j]
|
|
? table[i + 1][j + 1] + 1
|
|
: Math.max(table[i + 1][j], table[i][j + 1]);
|
|
}
|
|
}
|
|
|
|
const body = [];
|
|
let i = 0;
|
|
let j = 0;
|
|
while (i < n && j < m) {
|
|
if (oldLines[i] === newLines[j]) {
|
|
body.push(` ${oldLines[i]}`);
|
|
i++;
|
|
j++;
|
|
} else if (table[i + 1][j] >= table[i][j + 1]) {
|
|
body.push(`-${oldLines[i++]}`);
|
|
} else {
|
|
body.push(`+${newLines[j++]}`);
|
|
}
|
|
}
|
|
while (i < n) body.push(`-${oldLines[i++]}`);
|
|
while (j < m) body.push(`+${newLines[j++]}`);
|
|
|
|
return [
|
|
`--- ${oldName}`,
|
|
`+++ ${newName}`,
|
|
`@@ -1,${n} +1,${m} @@`,
|
|
...body
|
|
].join("\n");
|
|
}
|
|
|
|
function appendSearchSnippet(target, snippet) {
|
|
const parts = String(snippet || "").split(/(\[\[\[|\]\]\])/);
|
|
let highlighted = false;
|
|
let mark = null;
|
|
for (const part of parts) {
|
|
if (part === "[[[") {
|
|
highlighted = true;
|
|
mark = document.createElement("mark");
|
|
target.append(mark);
|
|
} else if (part === "]]]") {
|
|
highlighted = false;
|
|
mark = null;
|
|
} else if (part) {
|
|
const text = document.createTextNode(part.replace(/\s+/g, " "));
|
|
if (highlighted && mark) mark.append(text);
|
|
else target.append(text);
|
|
}
|
|
}
|
|
}
|
|
|
|
async function searchWiki(query) {
|
|
const text = (query || "").trim();
|
|
if (!text) {
|
|
await route();
|
|
return;
|
|
}
|
|
|
|
const result = await api(`/api/search?q=${encodeURIComponent(text)}`);
|
|
renderBreadcrumbs([
|
|
{ label: state.siteTitle, href: "/" },
|
|
{ label: tr("search", "Search") }
|
|
]);
|
|
renderToc([], () => {});
|
|
show("search-view");
|
|
|
|
const results = $("search-results");
|
|
results.replaceChildren();
|
|
const resultWord = result.results.length === 1 ? tr("result", "result") : tr("results", "results");
|
|
$("search-summary").textContent = `${result.results.length} ${resultWord} ${tr("for", "for")} “${text}”`;
|
|
|
|
for (const item of result.results) {
|
|
const article = document.createElement("article");
|
|
article.className = "search-result";
|
|
const heading = document.createElement("h2");
|
|
const link = document.createElement("a");
|
|
link.href = `#/${encodeURIComponent(item.slug)}`;
|
|
link.textContent = item.title;
|
|
heading.append(link);
|
|
const snippet = document.createElement("p");
|
|
appendSearchSnippet(snippet, item.snippet);
|
|
article.append(heading, snippet);
|
|
results.append(article);
|
|
}
|
|
|
|
if (result.results.length === 0) {
|
|
const empty = document.createElement("p");
|
|
empty.className = "muted";
|
|
empty.textContent = tr("no-matching-pages", "No matching pages.");
|
|
results.append(empty);
|
|
}
|
|
}
|
|
|
|
async function showHistory() {
|
|
if (!state.currentPage) return;
|
|
state.previousView = "page-view";
|
|
pageBreadcrumbs(state.currentPage, "History");
|
|
show("history-view");
|
|
const result = await api(`/api/pages/${encodeURIComponent(state.currentPage.slug)}/history`);
|
|
const list = $("history-list");
|
|
list.replaceChildren();
|
|
$("diff-target").replaceChildren();
|
|
|
|
result.versions.forEach((version, index) => {
|
|
const row = document.createElement("div");
|
|
row.className = "history-row";
|
|
const label = document.createElement("div");
|
|
label.innerHTML = `<strong>${pageDisplayDate(version.createdAt)}</strong><br><span class="muted"></span>`;
|
|
label.querySelector("span").textContent = `${version.author} — ${version.summary}`;
|
|
const view = document.createElement("button");
|
|
view.textContent = tr("view", "View");
|
|
view.addEventListener("click", async () => {
|
|
const full = await api(`/api/pages/${encodeURIComponent(state.currentPage.slug)}/versions/${encodeURIComponent(version.version)}`);
|
|
$("diff-target").innerHTML = `<article class="markdown-body">${renderMarkdown(full.markdown, state.currentPage?.slug)}</article>`;
|
|
});
|
|
const compare = document.createElement("button");
|
|
compare.textContent = index + 1 < result.versions.length ? tr("compare-previous", "Compare previous") : "";
|
|
compare.disabled = index + 1 >= result.versions.length;
|
|
compare.addEventListener("click", async () => {
|
|
const older = result.versions[index + 1];
|
|
const [oldVersion, newVersion] = await Promise.all([
|
|
api(`/api/pages/${encodeURIComponent(state.currentPage.slug)}/versions/${encodeURIComponent(older.version)}`),
|
|
api(`/api/pages/${encodeURIComponent(state.currentPage.slug)}/versions/${encodeURIComponent(version.version)}`)
|
|
]);
|
|
const diff = buildUnifiedDiff(oldVersion.markdown, newVersion.markdown, older.version, version.version);
|
|
const target = $("diff-target");
|
|
target.replaceChildren();
|
|
const ui = new Diff2HtmlUI(target, diff, {
|
|
drawFileList: false,
|
|
matching: "lines",
|
|
outputFormat: "side-by-side",
|
|
highlight: false
|
|
});
|
|
ui.draw();
|
|
});
|
|
row.append(label, view, compare);
|
|
list.append(row);
|
|
});
|
|
}
|
|
|
|
function showAdmin() {
|
|
state.previousView = state.currentPage ? "page-view" : "admin-view";
|
|
renderBreadcrumbs([
|
|
{ label: state.siteTitle, href: "/" },
|
|
{ label: tr("admin", "Admin") }
|
|
]);
|
|
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: "/" },
|
|
{ label: tr("admin", "Admin") },
|
|
{ label: tr("users", "Users") }
|
|
]);
|
|
show("user-admin-view");
|
|
const result = await api("/api/admin/users");
|
|
const list = $("user-list");
|
|
list.replaceChildren();
|
|
for (const user of result.users) {
|
|
const row = document.createElement("div");
|
|
row.className = "user-row";
|
|
const username = document.createElement("strong");
|
|
username.textContent = user.username;
|
|
const display = document.createElement("input");
|
|
display.value = user.displayName;
|
|
const role = document.createElement("select");
|
|
for (const roleName of ["reader", "editor", "admin"]) {
|
|
const option = document.createElement("option");
|
|
option.value = roleName;
|
|
option.textContent = tr(`role-${roleName}`, roleName);
|
|
option.selected = roleName === user.role;
|
|
role.append(option);
|
|
}
|
|
const enabled = document.createElement("input");
|
|
enabled.type = "checkbox";
|
|
enabled.checked = user.enabled;
|
|
const password = document.createElement("input");
|
|
password.type = "password";
|
|
password.placeholder = tr("new-password", "New password");
|
|
password.autocomplete = "new-password";
|
|
const actions = document.createElement("div");
|
|
const save = document.createElement("button");
|
|
save.textContent = tr("save", "Save");
|
|
save.addEventListener("click", async () => {
|
|
await api(`/api/admin/users/${user.id}`, {
|
|
method: "PUT",
|
|
body: JSON.stringify({
|
|
displayName: display.value,
|
|
role: role.value,
|
|
enabled: enabled.checked,
|
|
password: password.value || undefined
|
|
})
|
|
});
|
|
password.value = "";
|
|
});
|
|
const remove = document.createElement("button");
|
|
remove.textContent = tr("delete", "Delete");
|
|
remove.className = "danger";
|
|
remove.addEventListener("click", async () => {
|
|
if (!confirm(`Delete user ${user.username}?`)) return;
|
|
await api(`/api/admin/users/${user.id}`, { method: "DELETE" });
|
|
await loadUsersAdmin();
|
|
});
|
|
actions.append(save, remove);
|
|
row.append(username, display, role, enabled, password, actions);
|
|
list.append(row);
|
|
}
|
|
}
|
|
|
|
function showNotFound(slug) {
|
|
state.currentPage = null;
|
|
state.editingNew = false;
|
|
state.newPageSlug = null;
|
|
$("not-found-message").textContent = `${tr("page-does-not-exist", "The page does not exist.")} (${slug})`;
|
|
renderBreadcrumbs([
|
|
{ label: state.siteTitle, href: "/" },
|
|
{ label: slug }
|
|
]);
|
|
show("not-found-view");
|
|
renderToc([], () => {});
|
|
renderPageList();
|
|
}
|
|
|
|
function showMissingEditablePage(slug) {
|
|
state.currentPage = null;
|
|
state.editingNew = false;
|
|
state.newPageSlug = slug;
|
|
$("page-title").textContent = slugTitle(slug) || slug;
|
|
$("page-meta").textContent = tr("this-page-missing", "This page does not exist yet.");
|
|
$("markdown-preview").innerHTML = "";
|
|
$("page-details").replaceChildren();
|
|
renderBreadcrumbs([
|
|
{ label: state.siteTitle, href: "/" },
|
|
{ label: slugTitle(slug) || slug }
|
|
]);
|
|
show("page-view");
|
|
setPageActionVisibility(false);
|
|
renderToc([], () => {});
|
|
renderPageList();
|
|
}
|
|
|
|
async function route() {
|
|
const todoMatch = location.hash.match(/^#todo\/([^/]+)\/(\d+)$/);
|
|
if (todoMatch) {
|
|
await showTodos(decodeURIComponent(todoMatch[1]), Number(todoMatch[2]));
|
|
return;
|
|
}
|
|
|
|
const match = location.hash.match(/^#\/([^/]+)$/);
|
|
if (match) {
|
|
const slug = decodeURIComponent(match[1]);
|
|
try {
|
|
await openPage(slug);
|
|
} catch (error) {
|
|
if (error.status !== 404) {
|
|
throw error;
|
|
}
|
|
if (can("editor")) {
|
|
showMissingEditablePage(slug);
|
|
} else {
|
|
showNotFound(slug);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
if (state.pages.length > 0) {
|
|
const firstPage = startPage();
|
|
location.hash = `#/${encodeURIComponent(firstPage.slug)}`;
|
|
} else {
|
|
if (can("editor")) {
|
|
showMissingEditablePage("start");
|
|
} else {
|
|
state.currentPage = null;
|
|
$("page-title").textContent = tr("no-pages", "No pages yet");
|
|
$("page-meta").textContent = "";
|
|
$("markdown-preview").innerHTML = "";
|
|
renderBreadcrumbs([{ label: state.siteTitle }]);
|
|
renderToc([], () => {});
|
|
show("page-view");
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
function formatTimestamp(value) {
|
|
const date = new Date(Number(value) * 1000);
|
|
return date.toLocaleString(state.language || undefined);
|
|
}
|
|
|
|
async function showRecent() {
|
|
state.previousView = state.currentPage ? "page-view" : "recent-view";
|
|
renderBreadcrumbs([
|
|
{ label: state.siteTitle, href: "/" },
|
|
{ label: tr("recent", "Recent") }
|
|
]);
|
|
setPageActionVisibility(false);
|
|
show("recent-view");
|
|
|
|
const result = await api("/api/recent");
|
|
const list = $("recent-list");
|
|
list.replaceChildren();
|
|
const pages = result.pages || [];
|
|
if (pages.length === 0) {
|
|
const empty = document.createElement("p");
|
|
empty.className = "muted";
|
|
empty.textContent = tr("no-recent-pages", "No recent pages.");
|
|
list.append(empty);
|
|
return;
|
|
}
|
|
|
|
for (const page of pages) {
|
|
const row = document.createElement("div");
|
|
row.className = "special-page-row";
|
|
const link = document.createElement("a");
|
|
link.href = `#/${encodeURIComponent(page.slug)}`;
|
|
link.textContent = page.title;
|
|
const meta = document.createElement("span");
|
|
meta.className = "special-page-meta";
|
|
meta.textContent = `${formatTimestamp(page.updatedAt)} · ${tr("changed-by", "changed by")} ${page.updatedBy}`;
|
|
row.append(link, meta);
|
|
list.append(row);
|
|
}
|
|
}
|
|
|
|
async function saveBookmark(slug, existingSection = "") {
|
|
const section = window.prompt(tr("bookmark-section", "Bookmark section"), existingSection);
|
|
if (section === null) return;
|
|
await api("/api/bookmarks", {
|
|
method: "POST",
|
|
body: JSON.stringify({ slug, section: section.trim() })
|
|
});
|
|
await loadBookmarks();
|
|
}
|
|
|
|
async function removeBookmark(slug) {
|
|
await api(`/api/bookmarks/${encodeURIComponent(slug)}`, { method: "DELETE" });
|
|
await loadBookmarks();
|
|
}
|
|
|
|
async function showBookmarks() {
|
|
state.previousView = state.currentPage ? "page-view" : "bookmarks-view";
|
|
renderBreadcrumbs([
|
|
{ label: state.siteTitle, href: "/" },
|
|
{ label: tr("bookmarks", "Bookmarks") }
|
|
]);
|
|
setPageActionVisibility(false);
|
|
show("bookmarks-view");
|
|
await loadBookmarks();
|
|
|
|
const target = $("bookmarks-list");
|
|
target.replaceChildren();
|
|
if (state.bookmarks.length === 0) {
|
|
const empty = document.createElement("p");
|
|
empty.className = "muted";
|
|
empty.textContent = tr("no-bookmarks", "No bookmarks yet.");
|
|
target.append(empty);
|
|
return;
|
|
}
|
|
|
|
const groups = new Map();
|
|
for (const bookmark of state.bookmarks) {
|
|
const section = bookmark.section || "";
|
|
if (!groups.has(section)) groups.set(section, []);
|
|
groups.get(section).push(bookmark);
|
|
}
|
|
|
|
for (const [section, bookmarks] of groups.entries()) {
|
|
const sectionElement = document.createElement("section");
|
|
sectionElement.className = "bookmark-section";
|
|
const heading = document.createElement("h2");
|
|
heading.textContent = section || tr("bookmarks", "Bookmarks");
|
|
sectionElement.append(heading);
|
|
|
|
for (const bookmark of bookmarks) {
|
|
const row = document.createElement("div");
|
|
row.className = "bookmark-row";
|
|
const link = document.createElement("a");
|
|
link.href = `#/${encodeURIComponent(bookmark.slug)}`;
|
|
link.textContent = bookmark.title;
|
|
const actions = document.createElement("span");
|
|
actions.className = "bookmark-section-actions";
|
|
const move = document.createElement("a");
|
|
move.href = "#";
|
|
move.textContent = tr("move", "Move");
|
|
move.addEventListener("click", async (event) => {
|
|
event.preventDefault();
|
|
await saveBookmark(bookmark.slug, bookmark.section || "");
|
|
await showBookmarks();
|
|
});
|
|
const separator = document.createTextNode(" · ");
|
|
const remove = document.createElement("a");
|
|
remove.href = "#";
|
|
remove.textContent = tr("remove", "Remove");
|
|
remove.addEventListener("click", async (event) => {
|
|
event.preventDefault();
|
|
await removeBookmark(bookmark.slug);
|
|
await showBookmarks();
|
|
});
|
|
actions.append(move, separator, remove);
|
|
row.append(link, actions);
|
|
sectionElement.append(row);
|
|
}
|
|
target.append(sectionElement);
|
|
}
|
|
}
|
|
|
|
async function showTodos(targetSlug = null, targetNumber = null) {
|
|
state.previousView = state.currentPage ? "page-view" : "todo-view";
|
|
renderBreadcrumbs([
|
|
{ label: state.siteTitle, href: "/" },
|
|
{ label: tr("todo", "Todo") }
|
|
]);
|
|
renderToc([], () => {});
|
|
show("todo-view");
|
|
const result = await api("/api/todos");
|
|
const list = $("todo-list");
|
|
list.replaceChildren();
|
|
if (result.items.length === 0) {
|
|
const empty = document.createElement("p");
|
|
empty.className = "muted";
|
|
empty.textContent = tr("no-todos", "No todo items.");
|
|
list.append(empty);
|
|
return;
|
|
}
|
|
for (const item of result.items) {
|
|
const row = document.createElement("article");
|
|
row.className = "todo-item";
|
|
row.id = `todo-${item.slug}-${item.number}`;
|
|
const link = document.createElement("a");
|
|
link.href = `#/${encodeURIComponent(item.slug)}`;
|
|
link.textContent = item.title;
|
|
const text = document.createElement("div");
|
|
text.className = "todo-item-text";
|
|
text.textContent = item.text;
|
|
const location = document.createElement("span");
|
|
location.className = "muted";
|
|
location.textContent = `${tr("line", "line")} ${item.line}`;
|
|
row.append(link, text, location);
|
|
list.append(row);
|
|
}
|
|
|
|
if (targetSlug && targetNumber) {
|
|
const target = document.getElementById(`todo-${targetSlug}-${targetNumber}`);
|
|
if (target) {
|
|
target.classList.add("todo-item-target");
|
|
target.scrollIntoView({ block: "center" });
|
|
}
|
|
}
|
|
}
|
|
|
|
function pageLinks(markdown) {
|
|
const container = document.createElement("div");
|
|
container.innerHTML = renderMarkdown(markdown || "");
|
|
const links = new Set();
|
|
|
|
for (const link of container.querySelectorAll("a[href]")) {
|
|
const slug = wikiSlugFromHref(link.getAttribute("href"));
|
|
if (slug) links.add(slug);
|
|
}
|
|
|
|
return links;
|
|
}
|
|
|
|
async function loadGraphData() {
|
|
const pageSlugs = new Set(state.pages.map((page) => page.slug));
|
|
const edges = [];
|
|
const seen = new Set();
|
|
|
|
for (const page of state.pages) {
|
|
const full = await api(`/api/pages/${encodeURIComponent(page.slug)}`);
|
|
for (const target of pageLinks(full.markdown)) {
|
|
if (!pageSlugs.has(target) || target === page.slug) continue;
|
|
const key = `${page.slug}\u0000${target}`;
|
|
if (seen.has(key)) continue;
|
|
seen.add(key);
|
|
edges.push({ from: page.slug, to: target });
|
|
}
|
|
}
|
|
|
|
return { nodes: state.pages, edges };
|
|
}
|
|
|
|
function renderWikiGraph(data, focusSlug = null) {
|
|
const svg = $("wiki-graph");
|
|
svg.replaceChildren();
|
|
|
|
const width = 1000;
|
|
const height = 700;
|
|
const centerX = width / 2;
|
|
const centerY = height / 2;
|
|
const radius = Math.min(width, height) * 0.36;
|
|
const positions = new Map();
|
|
const nodes = data.nodes;
|
|
|
|
nodes.forEach((page, index) => {
|
|
const angle = nodes.length <= 1 ? 0 : (Math.PI * 2 * index / nodes.length) - Math.PI / 2;
|
|
positions.set(page.slug, {
|
|
x: nodes.length <= 1 ? centerX : centerX + Math.cos(angle) * radius,
|
|
y: nodes.length <= 1 ? centerY : centerY + Math.sin(angle) * radius
|
|
});
|
|
});
|
|
|
|
const edgeLayer = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
|
edgeLayer.setAttribute("class", "wiki-graph-edges");
|
|
for (const edge of data.edges) {
|
|
const from = positions.get(edge.from);
|
|
const to = positions.get(edge.to);
|
|
if (!from || !to) continue;
|
|
const line = document.createElementNS("http://www.w3.org/2000/svg", "line");
|
|
line.setAttribute("x1", from.x);
|
|
line.setAttribute("y1", from.y);
|
|
line.setAttribute("x2", to.x);
|
|
line.setAttribute("y2", to.y);
|
|
edgeLayer.append(line);
|
|
}
|
|
svg.append(edgeLayer);
|
|
|
|
const nodeLayer = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
|
nodeLayer.setAttribute("class", "wiki-graph-nodes");
|
|
for (const page of nodes) {
|
|
const position = positions.get(page.slug);
|
|
const group = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
|
group.setAttribute("class", page.slug === focusSlug ? "wiki-graph-node wiki-graph-node-focus" : "wiki-graph-node");
|
|
group.setAttribute("transform", `translate(${position.x} ${position.y})`);
|
|
group.setAttribute("tabindex", "0");
|
|
group.setAttribute("role", "link");
|
|
group.setAttribute("aria-label", page.title);
|
|
|
|
const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
|
|
circle.setAttribute("r", "11");
|
|
|
|
const text = document.createElementNS("http://www.w3.org/2000/svg", "text");
|
|
text.setAttribute("x", "17");
|
|
text.setAttribute("y", "4");
|
|
text.textContent = page.title.length > 34 ? `${page.title.slice(0, 31)}…` : page.title;
|
|
|
|
const open = () => { location.hash = `#/${encodeURIComponent(page.slug)}`; };
|
|
group.addEventListener("click", open);
|
|
group.addEventListener("keydown", (event) => {
|
|
if (event.key === "Enter" || event.key === " ") {
|
|
event.preventDefault();
|
|
open();
|
|
}
|
|
});
|
|
|
|
group.append(circle, text);
|
|
nodeLayer.append(group);
|
|
}
|
|
svg.append(nodeLayer);
|
|
}
|
|
|
|
async function showGraph() {
|
|
state.previousView = state.currentPage ? "page-view" : "graph-view";
|
|
renderBreadcrumbs([
|
|
{ label: state.siteTitle, href: "/" },
|
|
{ label: tr("graph", "Graph") }
|
|
]);
|
|
renderToc([], () => {});
|
|
show("graph-view");
|
|
$("graph-title").textContent = tr("wiki-graph", "Wiki graph");
|
|
|
|
const data = await loadGraphData();
|
|
$("graph-summary").textContent = tr("graph-summary", "{pages} pages, {links} links")
|
|
.replace("{pages}", String(data.nodes.length))
|
|
.replace("{links}", String(data.edges.length));
|
|
renderWikiGraph(data);
|
|
}
|
|
|
|
async function showContextGraph() {
|
|
if (!state.currentPage) return;
|
|
|
|
const page = state.currentPage;
|
|
state.previousView = "page-view";
|
|
renderBreadcrumbs([
|
|
{ label: state.siteTitle, href: "/" },
|
|
{ label: page.title, href: `#/${encodeURIComponent(page.slug)}` },
|
|
{ label: tr("context", "context") }
|
|
]);
|
|
renderToc([], () => {});
|
|
show("graph-view");
|
|
$("graph-title").textContent = tr("context-graph", "Context graph");
|
|
|
|
const data = await loadGraphData();
|
|
const relatedSlugs = new Set([page.slug]);
|
|
const contextEdges = [];
|
|
|
|
for (const edge of data.edges) {
|
|
if (edge.from === page.slug || edge.to === page.slug) {
|
|
relatedSlugs.add(edge.from);
|
|
relatedSlugs.add(edge.to);
|
|
contextEdges.push(edge);
|
|
}
|
|
}
|
|
|
|
const contextNodes = data.nodes.filter((node) => relatedSlugs.has(node.slug));
|
|
$("graph-summary").textContent = tr("context-graph-summary", "{pages} related pages, {links} links")
|
|
.replace("{pages}", String(Math.max(0, contextNodes.length - 1)))
|
|
.replace("{links}", String(contextEdges.length));
|
|
renderWikiGraph({ nodes: contextNodes, edges: contextEdges }, page.slug);
|
|
}
|
|
|
|
function setConnectionStatus(status) {
|
|
const node = $("connection-status");
|
|
node.classList.remove("hidden", "online", "offline");
|
|
node.classList.add(status);
|
|
node.textContent = status === "offline" ? tr("offline", "Offline") : tr("online", "Online");
|
|
if (status === "online") {
|
|
window.setTimeout(() => node.classList.add("hidden"), 1800);
|
|
}
|
|
}
|
|
|
|
async function pingServer() {
|
|
const controller = new AbortController();
|
|
const timeout = window.setTimeout(() => controller.abort(), 5000);
|
|
try {
|
|
const response = await fetch("/api/ping", { cache: "no-store", signal: controller.signal });
|
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
if ($("connection-status").classList.contains("offline")) setConnectionStatus("online");
|
|
} catch (_error) {
|
|
setConnectionStatus("offline");
|
|
} finally {
|
|
window.clearTimeout(timeout);
|
|
}
|
|
}
|
|
|
|
function startKeepAlive() {
|
|
window.setInterval(pingServer, 15000);
|
|
pingServer();
|
|
}
|
|
|
|
async function initialize() {
|
|
state.session = await api("/api/session");
|
|
if (!state.session.authenticated) {
|
|
window.location.replace("/login");
|
|
return;
|
|
}
|
|
const translationData = await api("/api/translations");
|
|
state.language = translationData.language;
|
|
state.translationPage = translationData.page;
|
|
state.translationTemplate = translationData.template || "";
|
|
state.translations = translationData.translations || {};
|
|
applyTranslations();
|
|
initializeEditor();
|
|
$("account-name").textContent = state.session.user.displayName;
|
|
$("account-role").textContent = tr(`role-${state.session.user.role}`, state.session.user.role);
|
|
updateRoleUi();
|
|
await loadPages();
|
|
loadBreadcrumbTrail();
|
|
await loadBookmarks();
|
|
await route();
|
|
startKeepAlive();
|
|
}
|
|
|
|
installWikiLinkNavigation();
|
|
|
|
$("search-form").addEventListener("submit", (event) => {
|
|
event.preventDefault();
|
|
searchWiki($("search-input").value).catch((error) => console.error(error));
|
|
});
|
|
|
|
$("bookmark-page").addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
if (!state.currentPage) return;
|
|
const existing = bookmarkForSlug(state.currentPage.slug);
|
|
saveBookmark(state.currentPage.slug, existing?.section || "").catch((error) => console.error(error));
|
|
});
|
|
$("edit-page").addEventListener("click", (event) => { event.preventDefault(); beginEditPage(); });
|
|
$("delete-page").addEventListener("click", (event) => { event.preventDefault(); deleteCurrentPage(); });
|
|
$("history-page").addEventListener("click", (event) => { event.preventDefault(); showHistory(); });
|
|
$("wiki-brand").addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
goHome().catch((error) => console.error(error));
|
|
});
|
|
$("breadcrumbs").addEventListener("click", (event) => {
|
|
const link = event.target.closest("a[data-home='true'], a[data-breadcrumb-slug]");
|
|
if (!link) return;
|
|
|
|
event.preventDefault();
|
|
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)); });
|
|
$("todo-link").addEventListener("click", (event) => { event.preventDefault(); showTodos().catch((error) => console.error(error)); });
|
|
$("graph-link").addEventListener("click", (event) => { event.preventDefault(); showGraph().catch((error) => console.error(error)); });
|
|
$("context-link").addEventListener("click", (event) => { event.preventDefault(); showContextGraph().catch((error) => console.error(error)); });
|
|
$("logout-link").addEventListener("click", async (event) => {
|
|
event.preventDefault();
|
|
await api("/api/logout", { method: "POST", body: "{}" });
|
|
window.location.replace("/login");
|
|
});
|
|
$("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);
|
|
if (page) {
|
|
location.hash = `#/${encodeURIComponent(state.translationPage)}`;
|
|
return;
|
|
}
|
|
renderBreadcrumbs([
|
|
{ label: state.siteTitle, href: "/" },
|
|
{ label: tr("translations", "Translations") }
|
|
]);
|
|
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) {
|
|
openPage(state.currentPage.slug);
|
|
return;
|
|
}
|
|
if (state.newPageSlug) {
|
|
state.newPageSlug = null;
|
|
location.hash = "";
|
|
return;
|
|
}
|
|
route();
|
|
});
|
|
$("save-page").addEventListener("click", savePage);
|
|
$("template-select").addEventListener("change", (event) => {
|
|
applyTemplate(event.target.value).catch((error) => {
|
|
$("save-status").textContent = error.message;
|
|
event.target.value = "";
|
|
});
|
|
});
|
|
$("file-input").addEventListener("change", (event) => {
|
|
uploadFiles(event.target.files).catch(() => {});
|
|
event.target.value = "";
|
|
});
|
|
|
|
$("new-user-form").addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
await api("/api/admin/users", {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
username: $("new-username").value,
|
|
displayName: $("new-display-name").value || $("new-username").value,
|
|
password: $("new-password").value,
|
|
role: $("new-role").value,
|
|
enabled: true
|
|
})
|
|
});
|
|
event.target.reset();
|
|
await loadUsersAdmin();
|
|
});
|
|
|
|
window.addEventListener("hashchange", () => route().catch(console.error));
|
|
|
|
initialize().catch((error) => {
|
|
console.error(error);
|
|
const main = $("main");
|
|
main.replaceChildren();
|
|
const message = document.createElement("div");
|
|
message.className = "card error";
|
|
message.textContent = error.message;
|
|
main.append(message);
|
|
});
|
|
})();
|