4670 lines
175 KiB
JavaScript
4670 lines
175 KiB
JavaScript
(() => {
|
|
"use strict";
|
|
|
|
/*
|
|
* racket-wiki browser application.
|
|
*
|
|
* The source is deliberately kept as one direct script instead of a client
|
|
* framework. Larger functions are documented with goal/pre/post/result-style
|
|
* comments, following the same readability rules as the Racket sources.
|
|
* Markdown source remains authoritative; Todo markup, WikiWords, namespace
|
|
* references and image options are expanded only for rendering.
|
|
*/
|
|
|
|
const state = {
|
|
session: null,
|
|
pages: [],
|
|
pageAliases: [],
|
|
currentPage: null,
|
|
editingNew: false,
|
|
newPageSlug: null,
|
|
previousView: "page-view",
|
|
translations: {},
|
|
translationPage: "wiki-translations",
|
|
translationTemplate: "",
|
|
siteTitle: "Racket Wiki",
|
|
bookmarks: [],
|
|
breadcrumbTrail: [],
|
|
graphData: null,
|
|
contextDockPosition: null,
|
|
contextDockData: null,
|
|
conceptMaps: [],
|
|
currentConceptMap: null,
|
|
cmapLoadSequence: 0,
|
|
cmapSavedSnapshot: null,
|
|
cmapGuardHash: "",
|
|
cmapPrototype: null,
|
|
rawMarkdown: window.localStorage.getItem("racket-wiki-raw-markdown") === "true"
|
|
};
|
|
|
|
let easyMDE = null;
|
|
let pendingCmapTransition = null;
|
|
let pendingWikiCmapLinkLabel = "";
|
|
let cmapStatusTimer = null;
|
|
let cmapAutosaveTimer = null;
|
|
let cmapSavePromise = null;
|
|
let cmapEmbedHydrationTimer = null;
|
|
|
|
const CMAP_AUTOSAVE_DELAY = 1500;
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// General UI and HTTP support
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
const $ = (id) => document.getElementById(id);
|
|
|
|
const cmapMapCombobox = new window.RacketWikiComboBox($("cmap-map-combobox"));
|
|
const cmapPageCombobox = new window.RacketWikiComboBox($("cmap-concept-page-combobox"));
|
|
const cmapLinkCombobox = new window.RacketWikiComboBox($("cmap-concept-cmap-combobox"));
|
|
const wikiCmapLinkCombobox = new window.RacketWikiComboBox($("wiki-cmap-link-combobox"));
|
|
|
|
function show(viewId) {
|
|
for (const id of ["page-view", "not-found-view", "editor-view", "rename-view", "search-view", "recent-view", "bookmarks-view", "todo-view", "graph-view", "cmap-view", "history-view", "profile-view", "admin-view", "alias-admin-view", "user-admin-view", "mail-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");
|
|
document.body.classList.toggle("cmap-mode", viewId === "cmap-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.querySelectorAll("[data-tr-title]").forEach((node) => {
|
|
node.title = tr(node.dataset.trTitle, node.title);
|
|
});
|
|
document.querySelectorAll("[data-tr-aria-label]").forEach((node) => {
|
|
node.setAttribute("aria-label", tr(node.dataset.trAriaLabel, node.getAttribute("aria-label")));
|
|
});
|
|
document.documentElement.lang = state.language || "en";
|
|
}
|
|
|
|
function escapeHtml(text) {
|
|
return String(text)
|
|
.replaceAll("&", "&")
|
|
.replaceAll("<", "<")
|
|
.replaceAll(">", ">")
|
|
.replaceAll('"', """);
|
|
}
|
|
|
|
/**
|
|
* goal : Split a wiki page reference into namespace and page slug.
|
|
* pre : reference is a string such as "roadmap" or "racket:roadmap".
|
|
* post : No application state is changed.
|
|
* result : An object with namespace and slug fields.
|
|
*/
|
|
function splitPageReference(reference) {
|
|
const value = String(reference || "");
|
|
const separator = value.indexOf(":");
|
|
if (separator < 0) return { namespace: "", slug: value };
|
|
return { namespace: value.slice(0, separator), slug: value.slice(separator + 1) };
|
|
}
|
|
|
|
/**
|
|
* goal : Build the compact external reference for a wiki page.
|
|
* pre : namespace and slug are already suitable wiki identifiers.
|
|
* post : No application state is changed.
|
|
* result : slug for root pages, otherwise namespace:slug.
|
|
*/
|
|
function pageReference(namespace, slug) {
|
|
const cleanNamespace = String(namespace || "").trim();
|
|
return cleanNamespace ? `${cleanNamespace}:${slug}` : slug;
|
|
}
|
|
|
|
function pageRoute(reference) {
|
|
return `#/${encodeURIComponent(reference)}`;
|
|
}
|
|
|
|
function cmapRoute(slug) {
|
|
return `#cmap/${encodeURIComponent(slug)}`;
|
|
}
|
|
|
|
function navigateToHash(targetHash) {
|
|
const navigate = () => {
|
|
if (location.hash === targetHash) return route();
|
|
location.hash = targetHash;
|
|
return undefined;
|
|
};
|
|
return requestCmapTransition(navigate);
|
|
}
|
|
|
|
function namespaceLabel(namespace) {
|
|
return namespace || tr("root-namespace", "Root");
|
|
}
|
|
|
|
function canonicalPageReference(reference) {
|
|
const value = String(reference || "");
|
|
const key = value.toLocaleLowerCase();
|
|
const alias = state.pageAliases.find((item) => String(item.slug || "").toLocaleLowerCase() === key);
|
|
return alias ? alias.targetSlug : value;
|
|
}
|
|
|
|
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"));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* goal : Perform one authenticated API request and decode its response.
|
|
* pre : path identifies a racket-wiki API route.
|
|
* post : CSRF and JSON headers are applied when needed; 401 redirects to login.
|
|
* result : Decoded JSON/text response or a thrown Error.
|
|
*/
|
|
async function api(path, options = {}) {
|
|
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;
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Markdown rendering and wiki-link syntax
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
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 wikiWordParts(text) {
|
|
const value = String(text || "");
|
|
if (!/^(?:\p{Lu}\p{Ll}+){2,}$/u.test(value)) return [];
|
|
return value.match(/\p{Lu}\p{Ll}+/gu) || [];
|
|
}
|
|
|
|
/**
|
|
* goal : Resolve a classic WikiWord to an existing page or a new page target.
|
|
* pre : text contains only the candidate WikiWord and aliases is built from state.pages.
|
|
* post : No page is created; this only creates a render target.
|
|
* result : {slug,title} or null when text is not a valid WikiWord.
|
|
*/
|
|
function camelCaseTarget(text, aliases, namespace = "") {
|
|
const parts = wikiWordParts(text);
|
|
if (parts.length === 0) return null;
|
|
|
|
const aliasKey = `${String(namespace || "").toLocaleLowerCase()}:${normalizeMentionText(text)}`;
|
|
const existing = aliases.get(aliasKey);
|
|
if (existing) {
|
|
return { slug: existing.slug, title: existing.title };
|
|
}
|
|
|
|
const pageSlug = parts.map((part) => part.toLocaleLowerCase()).join("-");
|
|
return {
|
|
slug: pageReference(namespace, pageSlug),
|
|
title: parts.join(" ")
|
|
};
|
|
}
|
|
|
|
function mentionAliases(page) {
|
|
const aliases = new Set();
|
|
const rawSlug = page.pageSlug || splitPageReference(page.slug).slug;
|
|
const values = [page.title, slugTitle(rawSlug), rawSlug.replaceAll("-", " ")];
|
|
for (const value of values) {
|
|
if (!value) continue;
|
|
aliases.add(value);
|
|
for (const wikiWord of value.match(/(?:\p{Lu}\p{Ll}+){2,}/gu) || []) {
|
|
aliases.add(wikiWord);
|
|
}
|
|
}
|
|
return Array.from(aliases)
|
|
.map((value) => ({ text: value, key: normalizeMentionText(value) }))
|
|
.filter((alias) => alias.key.length >= 5);
|
|
}
|
|
|
|
/**
|
|
* goal : Build an ambiguity-aware lookup table for WikiWords.
|
|
* pre : state.pages contains the current page metadata.
|
|
* post : No state is changed.
|
|
* result : Map keys include the page namespace, so equal names may exist in different namespaces.
|
|
*/
|
|
function pageMentionMap(currentSlug = null) {
|
|
const map = new Map();
|
|
for (const page of state.pages) {
|
|
if (page.slug === currentSlug) continue;
|
|
const namespace = String(page.namespace || "").toLocaleLowerCase();
|
|
for (const alias of mentionAliases(page)) {
|
|
const namespacedKey = `${namespace}:${alias.key}`;
|
|
if (!map.has(namespacedKey)) {
|
|
map.set(namespacedKey, page);
|
|
} else if (map.get(namespacedKey)?.slug !== page.slug) {
|
|
map.set(namespacedKey, null);
|
|
}
|
|
|
|
const rootKey = `:${alias.key}`;
|
|
if (!map.has(rootKey)) {
|
|
map.set(rootKey, page);
|
|
} else if (map.get(rootKey)?.slug !== page.slug) {
|
|
map.set(rootKey, null);
|
|
}
|
|
}
|
|
}
|
|
for (const alias of state.pageAliases) {
|
|
const targetPage = state.pages.find((page) => page.slug === alias.targetSlug);
|
|
if (!targetPage || targetPage.slug === currentSlug) continue;
|
|
const namespace = String(alias.namespace || "").toLocaleLowerCase();
|
|
const aliasPage = { title: alias.title, pageSlug: alias.pageSlug, slug: alias.slug };
|
|
for (const candidate of mentionAliases(aliasPage)) {
|
|
const key = `${namespace}:${candidate.key}`;
|
|
if (!map.has(key)) map.set(key, targetPage);
|
|
const rootKey = `:${candidate.key}`;
|
|
if (!map.has(rootKey)) map.set(rootKey, targetPage);
|
|
}
|
|
}
|
|
return map;
|
|
}
|
|
|
|
/**
|
|
* goal : Resolve cmap:WikiWord against the stored concept-map catalogue.
|
|
* pre : state.conceptMaps contains the readable stored maps.
|
|
* post : No map is opened and no editor state is changed.
|
|
* result : The unique matching CMap or null for no/ambiguous matches.
|
|
*/
|
|
function cmapMentionTarget(text) {
|
|
const key = normalizeMentionText(text);
|
|
const matches = state.conceptMaps.filter((conceptMap) => {
|
|
const aliases = [conceptMap.title, conceptMap.slug, slugTitle(conceptMap.slug)];
|
|
return aliases.some((alias) => normalizeMentionText(alias) === key);
|
|
});
|
|
return matches.length === 1 ? matches[0] : null;
|
|
}
|
|
|
|
function markdownProtectedRanges(line) {
|
|
const ranges = [];
|
|
const patterns = [
|
|
/todo\([^()\n]*\)/gi,
|
|
/!?\[[^\]\n]*\]\([^\)\n]*\)/g,
|
|
/`+[^`\n]*`+/g,
|
|
/<https?:\/\/[^>\n]+>/gi,
|
|
/https?:\/\/[^\s<>()]+/gi
|
|
];
|
|
|
|
for (const pattern of patterns) {
|
|
for (const match of line.matchAll(pattern)) {
|
|
ranges.push({ start: match.index, end: match.index + match[0].length });
|
|
}
|
|
}
|
|
return ranges;
|
|
}
|
|
|
|
function positionIsProtected(start, end, ranges) {
|
|
for (const range of ranges) {
|
|
if (start < range.end && end > range.start) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
|
|
/**
|
|
* goal : Rewrite explicit Markdown targets such as (racket:roadmap) to the internal hash route.
|
|
* pre : markdown is source text; fenced code must remain untouched.
|
|
* post : Only explicit wiki-looking link destinations are rewritten for rendering.
|
|
* result : Render-only Markdown; stored Markdown is never changed.
|
|
*/
|
|
function expandNamespacedMarkdownLinks(markdown) {
|
|
const lines = String(markdown || "").split("\n");
|
|
const result = [];
|
|
let fence = null;
|
|
|
|
for (const line of lines) {
|
|
const fenceMatch = line.match(/^\s*(```+|~~~+)/);
|
|
if (fenceMatch) {
|
|
const marker = fenceMatch[1].charAt(0);
|
|
fence = fence === null ? marker : (fence === marker ? null : fence);
|
|
result.push(line);
|
|
continue;
|
|
}
|
|
if (fence !== null || /^\s{4}/.test(line)) {
|
|
result.push(line);
|
|
continue;
|
|
}
|
|
|
|
result.push(line.replace(/(!?\[[^\]\n]*\]\()([\p{L}\p{N}._-]+):([\p{L}\p{N}._-]+)(\))/gu,
|
|
(_match, before, namespace, slug, after) => {
|
|
const target = namespace.toLocaleLowerCase() === "cmap" ?
|
|
cmapRoute(slug) : pageRoute(pageReference(namespace, slug));
|
|
return `${before}${target}${after}`;
|
|
}));
|
|
}
|
|
return result.join("\n");
|
|
}
|
|
|
|
/**
|
|
* goal : Expand classic WikiWords to temporary Markdown links before Marked renders them.
|
|
* pre : markdown may contain paragraphs, lists, tables, quotes and headings.
|
|
* post : Code, Todo markers, URLs and existing Markdown links remain unchanged.
|
|
* result : Render-only Markdown with WikiWord links.
|
|
*/
|
|
function expandWikiMentions(markdown, currentSlug = null) {
|
|
const aliases = pageMentionMap(currentSlug);
|
|
const lines = String(markdown || "").split("\n");
|
|
const result = [];
|
|
let fence = null;
|
|
const wikiWordPattern = /(?<![\p{L}\p{N}._-])(?:([\p{L}\p{N}._-]+):)?((?:\p{Lu}\p{Ll}+){2,})(?![\p{L}\p{N}._-])/gu;
|
|
|
|
for (const line of lines) {
|
|
const fenceMatch = line.match(/^\s*(```+|~~~+)/);
|
|
if (fenceMatch) {
|
|
const marker = fenceMatch[1].charAt(0);
|
|
if (fence === null) {
|
|
fence = marker;
|
|
} else if (fence === marker) {
|
|
fence = null;
|
|
}
|
|
result.push(line);
|
|
continue;
|
|
}
|
|
|
|
if (fence !== null || /^\s{4}/.test(line)) {
|
|
result.push(line);
|
|
continue;
|
|
}
|
|
|
|
const protectedRanges = markdownProtectedRanges(line);
|
|
const replacements = [];
|
|
for (const match of line.matchAll(wikiWordPattern)) {
|
|
const start = match.index;
|
|
const end = start + match[0].length;
|
|
if (positionIsProtected(start, end, protectedRanges)) continue;
|
|
const namespace = match[1] || "";
|
|
if (namespace.toLocaleLowerCase() === "cmap") {
|
|
const conceptMap = cmapMentionTarget(match[2]);
|
|
if (conceptMap) replacements.push({ start, end, conceptMap });
|
|
} else {
|
|
const page = camelCaseTarget(match[2], aliases, namespace);
|
|
if (page) replacements.push({ start, end, page });
|
|
}
|
|
}
|
|
|
|
let expanded = line;
|
|
for (let index = replacements.length - 1; index >= 0; index -= 1) {
|
|
const replacement = replacements[index];
|
|
const link = replacement.conceptMap ?
|
|
`[${replacement.conceptMap.title}](${cmapRoute(replacement.conceptMap.slug)})` :
|
|
`[${replacement.page.title}](${pageRoute(replacement.page.slug)})`;
|
|
expanded = expanded.slice(0, replacement.start) + link + expanded.slice(replacement.end);
|
|
}
|
|
result.push(expanded);
|
|
}
|
|
|
|
return result.join("\n");
|
|
}
|
|
|
|
/**
|
|
* goal : Render wiki Markdown using the same EasyMDE/Marked pipeline everywhere.
|
|
* pre : markdown is source text; pageSlug is optional context for Todo links.
|
|
* post : The returned HTML is sanitized with DOMPurify.
|
|
* result : Safe HTML for preview, reader view or history view.
|
|
*/
|
|
function extractCmapEmbeds(markdown) {
|
|
const embeds = [];
|
|
let fence = null;
|
|
const lines = String(markdown || "").split("\n").map((line) => {
|
|
const fenceMatch = line.match(/^\s*(`{3,}|~{3,})/);
|
|
if (fenceMatch) {
|
|
const marker = fenceMatch[1].charAt(0);
|
|
if (fence === null) fence = marker;
|
|
else if (fence === marker) fence = null;
|
|
return line;
|
|
}
|
|
if (fence !== null) return line;
|
|
const match = line.match(/^\s*\{\{cmap:([^{}\n]+)\}\}\s*$/i);
|
|
if (!match) return line;
|
|
const token = `RACKETWIKICMAPEMBED${embeds.length}TOKEN`;
|
|
embeds.push({ token, reference: match[1].trim() });
|
|
return token;
|
|
});
|
|
return { markdown: lines.join("\n"), embeds };
|
|
}
|
|
|
|
function restoreCmapEmbeds(html, embeds) {
|
|
let result = html;
|
|
for (const embed of embeds) {
|
|
const placeholder = `<section class="rw-cmap-embed" data-cmap-reference="${escapeHtml(embed.reference)}"><div class="rw-cmap-embed-loading">${escapeHtml(tr("loading", "Loading…"))}</div></section>`;
|
|
result = result.replace(`<p>${embed.token}</p>`, placeholder).replace(embed.token, placeholder);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function queueCmapEmbedHydration() {
|
|
if (cmapEmbedHydrationTimer !== null) window.clearTimeout(cmapEmbedHydrationTimer);
|
|
cmapEmbedHydrationTimer = window.setTimeout(() => {
|
|
cmapEmbedHydrationTimer = null;
|
|
hydrateCmapEmbeds(document).catch((error) => console.error(error));
|
|
}, 0);
|
|
}
|
|
|
|
function renderMarkdown(markdown, pageSlug = null) {
|
|
const extracted = extractCmapEmbeds(markdown || "");
|
|
const withExplicitWikiLinks = expandNamespacedMarkdownLinks(extracted.markdown);
|
|
const withWikiLinks = expandWikiMentions(withExplicitWikiLinks, pageSlug);
|
|
const withTodos = expandTodoMarkup(withWikiLinks, pageSlug);
|
|
const html = easyMDE.markdown(withTodos);
|
|
const withEmbeds = restoreCmapEmbeds(html, extracted.embeds);
|
|
const withImages = applyImageWidthMarkup(withEmbeds);
|
|
const safeHtml = DOMPurify.sanitize(withImages);
|
|
if (extracted.embeds.length) queueCmapEmbedHydration();
|
|
return safeHtml;
|
|
}
|
|
|
|
async function hydrateCmapEmbeds(root) {
|
|
const embeds = Array.from(root.querySelectorAll(".rw-cmap-embed:not([data-cmap-hydrated])"));
|
|
for (const embed of embeds) {
|
|
embed.dataset.cmapHydrated = "loading";
|
|
const conceptMap = cmapMentionTarget(embed.dataset.cmapReference || "");
|
|
if (!conceptMap) {
|
|
embed.dataset.cmapHydrated = "error";
|
|
embed.replaceChildren();
|
|
const message = document.createElement("p");
|
|
message.className = "error";
|
|
message.textContent = tr("concept-map-not-found", "CMap not found");
|
|
embed.append(message);
|
|
continue;
|
|
}
|
|
try {
|
|
const stored = await api(`/api/cmaps/${encodeURIComponent(conceptMap.slug)}`);
|
|
const documentValue = decodeStoredConceptMapDocument(stored);
|
|
embed.replaceChildren();
|
|
embed.dataset.cmapSlug = conceptMap.slug;
|
|
embed.title = tr("embedded-concept-map-help", "Double-click to open this CMap.");
|
|
const header = document.createElement("header");
|
|
const title = document.createElement("strong");
|
|
title.textContent = conceptMap.title;
|
|
const hint = document.createElement("span");
|
|
hint.textContent = tr("embedded-concept-map-help", "Double-click to open this CMap.");
|
|
header.append(title, hint);
|
|
const viewport = document.createElement("div");
|
|
viewport.className = "rw-cmap-embed-viewport";
|
|
const canvas = document.createElement("div");
|
|
canvas.className = "cmap-canvas cmap-page-guides-hidden rw-cmap-embed-canvas";
|
|
viewport.append(canvas);
|
|
embed.append(header, viewport);
|
|
const editor = window.RacketWikiCmap.createEditor(canvas, {
|
|
Cmap: window.Cmap,
|
|
renderItem: (record) => cmapNodeHtml(record)
|
|
});
|
|
editor.loadDocument(documentValue);
|
|
const visible = editor.items.filter((item) => editor.isEffectiveItemVisible(item));
|
|
if (visible.length) {
|
|
const left = Math.min(...visible.map((item) => Number(item.node.attr("x")))) - 24;
|
|
const top = Math.min(...visible.map((item) => Number(item.node.attr("y")))) - 24;
|
|
const right = Math.max(...visible.map((item) => Number(item.node.attr("x")) + Number(item.node.attr("width")))) + 24;
|
|
const bottom = Math.max(...visible.map((item) => Number(item.node.attr("y")) + Number(item.node.attr("height")))) + 24;
|
|
const availableWidth = Math.max(320, embed.clientWidth - 2);
|
|
const scale = Math.min(1, availableWidth / Math.max(1, right - left), 520 / Math.max(1, bottom - top));
|
|
editor.zoomFactor = scale;
|
|
editor.map.zoom(scale);
|
|
viewport.style.height = `${Math.max(180, Math.ceil((bottom - top) * scale))}px`;
|
|
window.requestAnimationFrame(() => {
|
|
viewport.scrollLeft = Math.max(0, left * scale);
|
|
viewport.scrollTop = Math.max(0, top * scale);
|
|
});
|
|
}
|
|
embed.dataset.cmapHydrated = "ready";
|
|
embed.addEventListener("dblclick", () => {
|
|
navigateToHash(cmapRoute(conceptMap.slug)).catch((error) => console.error(error));
|
|
});
|
|
} catch (error) {
|
|
embed.dataset.cmapHydrated = "error";
|
|
embed.replaceChildren();
|
|
const message = document.createElement("p");
|
|
message.className = "error";
|
|
message.textContent = error.message;
|
|
embed.append(message);
|
|
}
|
|
}
|
|
}
|
|
|
|
function slugTitle(slug) {
|
|
const pageSlug = splitPageReference(slug || "").slug;
|
|
return pageSlug
|
|
.split("-")
|
|
.filter(Boolean)
|
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
.join(" ");
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Page display and navigation
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
function setPageActionVisibility(pageExists) {
|
|
if (!can("editor")) return;
|
|
$("edit-page").classList.remove("hidden");
|
|
$("rename-page").classList.toggle("hidden", !pageExists || state.currentPage?.slug === state.translationPage);
|
|
$("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 = [];
|
|
if (page.namespace) {
|
|
fields.push(`${tr("namespace", "Namespace")}: ${page.namespace}`);
|
|
}
|
|
fields.push(
|
|
`${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("?")) {
|
|
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 cmapSlugFromHref(href) {
|
|
if (!href) return null;
|
|
const candidate = href.startsWith("#cmap/") ? href.slice(6) :
|
|
(href.toLocaleLowerCase().startsWith("cmap:") ? href.slice(5) : null);
|
|
if (!candidate || candidate.includes("/") || candidate.includes("?") || candidate.includes("#")) return null;
|
|
try {
|
|
return decodeURIComponent(candidate);
|
|
} catch (_error) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
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 cmapSlug = cmapSlugFromHref(link.getAttribute("href"));
|
|
if (cmapSlug) {
|
|
event.preventDefault();
|
|
location.hash = cmapRoute(cmapSlug);
|
|
return;
|
|
}
|
|
const slug = wikiSlugFromHref(link.getAttribute("href"));
|
|
if (!slug) return;
|
|
event.preventDefault();
|
|
location.hash = `#/${encodeURIComponent(slug)}`;
|
|
});
|
|
}
|
|
|
|
const editorToolbarIcons = new Map();
|
|
|
|
function toolbarButton(name, action, icon, title, options = {}) {
|
|
editorToolbarIcons.set(name, icon);
|
|
return {
|
|
name,
|
|
action,
|
|
className: `rw-mde-button rw-mde-${name}`,
|
|
title,
|
|
...options
|
|
};
|
|
}
|
|
|
|
function installLucideIcons() {
|
|
if (!window.lucide || typeof window.lucide.createIcons !== "function") {
|
|
throw new Error("Lucide is not installed. Open /setup to repair the frontend setup.");
|
|
}
|
|
for (const [name, iconName] of editorToolbarIcons) {
|
|
const button = document.querySelector(`.EasyMDEContainer .editor-toolbar .rw-mde-${name}`);
|
|
if (!button) continue;
|
|
const icon = document.createElement("i");
|
|
icon.dataset.lucide = iconName;
|
|
icon.setAttribute("aria-hidden", "true");
|
|
button.replaceChildren(icon);
|
|
}
|
|
window.lucide.createIcons();
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// EasyMDE editor setup and editing support
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
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();
|
|
}
|
|
|
|
/**
|
|
* goal : Configure the single EasyMDE instance used for page editing.
|
|
* pre : Vendor scripts and the editor textarea are loaded.
|
|
* post : easyMDE is ready with toolbar, uploads, Raw mode and live TOC updates.
|
|
*/
|
|
function initializeEditor() {
|
|
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, "heading", tr("heading", "Heading")),
|
|
"|",
|
|
toolbarButton("quote", EasyMDE.toggleBlockquote, "quote", tr("quote", "Quote")),
|
|
toolbarButton("unordered-list", EasyMDE.toggleUnorderedList, "list", tr("bulleted-list", "Bulleted list")),
|
|
toolbarButton("ordered-list", EasyMDE.toggleOrderedList, "list-ordered", tr("numbered-list", "Numbered list")),
|
|
toolbarButton("check-list", EasyMDE.toggleCheckList, "list-checks", 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("cmap-link", () => {
|
|
openWikiCmapLinkDialog().catch((error) => {
|
|
$("save-status").textContent = error.message;
|
|
console.error(error);
|
|
});
|
|
}, "share-2", tr("link-concept-map", "Link to CMap")),
|
|
toolbarButton("upload-image", EasyMDE.drawUploadedImage, "image-plus", 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-2", tr("undo", "Undo")),
|
|
toolbarButton("redo", EasyMDE.redo, "redo-2", tr("redo", "Redo")),
|
|
toolbarButton("raw-markdown", toggleRawMarkdown, "file-text", tr("raw-markdown", "Raw Markdown"), { noDisable: true }),
|
|
toolbarButton("preview", EasyMDE.togglePreview, "eye", tr("preview", "Preview"), { noDisable: true }),
|
|
toolbarButton("side-by-side", EasyMDE.toggleSideBySide, "columns-2", tr("side-by-side", "Side by side"), { noDisable: true, noMobile: true }),
|
|
toolbarButton("fullscreen", EasyMDE.toggleFullScreen, "maximize", tr("fullscreen", "Fullscreen"), { noDisable: true, noMobile: true })
|
|
]
|
|
});
|
|
|
|
installLucideIcons();
|
|
|
|
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`);
|
|
}
|
|
|
|
/**
|
|
* goal : Show the editor and synchronize its sticky chrome and TOC.
|
|
* pre : easyMDE has been initialized.
|
|
* post : Editor view is visible and sized for the current viewport.
|
|
*/
|
|
function activateEditor() {
|
|
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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* goal : Build the reader TOC from rendered headings and Markdown source lines.
|
|
* pre : state.currentPage and #markdown-preview represent the same page.
|
|
* post : TOC navigation scrolls below sticky chrome; editors get section-edit links.
|
|
*/
|
|
function renderPageToc() {
|
|
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();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* goal : Render the sticky history breadcrumb as real navigation links.
|
|
* pre : items are ordered from wiki home to the current context.
|
|
* post : #breadcrumbs contains clickable previous page locations.
|
|
*/
|
|
function renderBreadcrumbs(items) {
|
|
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);
|
|
}
|
|
});
|
|
}
|
|
|
|
/**
|
|
* goal : Build the page breadcrumb from the per-tab visit history.
|
|
* pre : state.pages and breadcrumbTrail are current.
|
|
* post : The sticky breadcrumb shows Home, previous pages and optional suffix.
|
|
*/
|
|
function pageBreadcrumbs(page, suffix = null) {
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* goal : Return to the first/start page from every normal or special view.
|
|
* pre : Page metadata has been loaded.
|
|
* post : Breadcrumb history is cleared and the start page is opened.
|
|
*/
|
|
async function goHome() {
|
|
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;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* goal : Render the secondary page list grouped by namespace.
|
|
* pre : state.pages contains current page metadata.
|
|
* post : #page-list reflects the current page set.
|
|
*/
|
|
function renderPageList() {
|
|
const list = $("page-list");
|
|
list.replaceChildren();
|
|
let previousNamespace = null;
|
|
for (const page of state.pages) {
|
|
const namespace = page.namespace || "";
|
|
if (namespace !== previousNamespace) {
|
|
const heading = document.createElement("div");
|
|
heading.className = "namespace-heading";
|
|
heading.textContent = namespaceLabel(namespace);
|
|
list.append(heading);
|
|
previousNamespace = namespace;
|
|
}
|
|
const link = document.createElement("a");
|
|
link.href = pageRoute(page.slug);
|
|
link.className = "page-link";
|
|
link.textContent = page.title;
|
|
link.classList.toggle("active", state.currentPage?.slug === page.slug);
|
|
list.append(link);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* goal : Refresh current page metadata from PostgreSQL through the API.
|
|
* pre : The user is authenticated.
|
|
* post : state.pages, site identity, page list and template list are refreshed.
|
|
*/
|
|
async function loadPages() {
|
|
const result = await api("/api/pages");
|
|
state.pages = result.pages;
|
|
state.pageAliases = result.aliases || [];
|
|
state.graphData = null;
|
|
updateWikiIdentity();
|
|
renderPageList();
|
|
updateTemplateSelect();
|
|
}
|
|
|
|
|
|
function templatePages() {
|
|
return state.pages.filter((page) => (page.pageSlug || splitPageReference(page.slug).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 : "";
|
|
}
|
|
|
|
/**
|
|
* goal : Replace editor Markdown with a selected template-* wiki page.
|
|
* pre : slug identifies a readable template page.
|
|
* post : Editor contents are replaced after confirmation when necessary.
|
|
*/
|
|
async function applyTemplate(slug) {
|
|
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();
|
|
}
|
|
|
|
/**
|
|
* goal : Open one page in reader mode.
|
|
* pre : slug is a root or namespace-qualified page reference.
|
|
* post : Current page state, breadcrumb, TOC and bookmark action are updated.
|
|
*/
|
|
async function openPage(slug) {
|
|
const page = await api(`/api/pages/${encodeURIComponent(slug)}`);
|
|
state.currentPage = page;
|
|
if (slug !== page.slug) {
|
|
history.replaceState(null, "", pageRoute(page.slug));
|
|
}
|
|
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();
|
|
if (state.contextDockPosition) {
|
|
refreshContextDock().catch((error) => console.error(error));
|
|
}
|
|
}
|
|
|
|
function updateEditorSlugInfo() {
|
|
const namespace = $("editor-namespace")?.value.trim() || "";
|
|
if (!state.editingNew && state.currentPage) {
|
|
const rawSlug = state.currentPage.pageSlug || splitPageReference(state.currentPage.slug).slug;
|
|
$("editor-slug-info").textContent = `${tr("page-address", "Page address")}: ${pageReference(namespace, rawSlug)}`;
|
|
return;
|
|
}
|
|
if (state.newPageSlug) {
|
|
const requested = splitPageReference(state.newPageSlug);
|
|
$("editor-slug-info").textContent = `${tr("page-address", "Page address")}: ${pageReference(namespace || requested.namespace, requested.slug)}`;
|
|
return;
|
|
}
|
|
$("editor-slug-info").textContent = tr("page-address-generated", "Page address will be generated from the title when you save.");
|
|
}
|
|
|
|
/**
|
|
* goal : Open an empty editor for a not-yet-existing page reference.
|
|
* pre : requestedSlug is null or a compact root/namespaced page reference.
|
|
* post : Namespace, title, template and Markdown controls are initialized for creation.
|
|
*/
|
|
function beginNewPage(requestedSlug = null) {
|
|
state.editingNew = true;
|
|
state.currentPage = null;
|
|
state.newPageSlug = requestedSlug;
|
|
const translationPage = requestedSlug && requestedSlug === state.translationPage;
|
|
$("editor-title").value = translationPage ? tr("translations", "Translations") : "";
|
|
$("editor-namespace").value = requestedSlug ? splitPageReference(requestedSlug).namespace : "";
|
|
$("editor-namespace").disabled = Boolean(translationPage);
|
|
$("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();
|
|
}
|
|
|
|
/**
|
|
* goal : Open the current page in EasyMDE.
|
|
* pre : state.currentPage is a current page, or newPageSlug identifies a missing page.
|
|
* post : Editor fields contain the page title, namespace, tags and Markdown.
|
|
*/
|
|
function beginEditPage() {
|
|
if (!state.currentPage) {
|
|
if (state.newPageSlug) {
|
|
beginNewPage(state.newPageSlug);
|
|
}
|
|
return;
|
|
}
|
|
state.editingNew = false;
|
|
state.newPageSlug = null;
|
|
$("editor-title").value = state.currentPage.title;
|
|
$("editor-namespace").value = state.currentPage.namespace || "";
|
|
// Existing page addresses are changed only through Rename so an alias can be retained.
|
|
$("editor-namespace").disabled = true;
|
|
$("editor-tags").value = (state.currentPage.tags || []).join(", ");
|
|
easyMDE.value(state.currentPage.markdown);
|
|
$("edit-summary").value = "";
|
|
$("save-status").textContent = "";
|
|
$("template-select").value = "";
|
|
updateEditorSlugInfo();
|
|
activateEditor();
|
|
}
|
|
|
|
/**
|
|
* goal : Save the current editor contents as a new or updated wiki page.
|
|
* pre : EasyMDE is active and title/namespace fields contain editor input.
|
|
* post : A successful save refreshes page metadata and opens the stored page.
|
|
*/
|
|
async function savePage() {
|
|
const title = $("editor-title").value.trim();
|
|
const namespace = $("editor-namespace").value.trim();
|
|
const currentSlug = state.editingNew ? state.newPageSlug : state.currentPage?.slug;
|
|
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,
|
|
namespace,
|
|
markdown,
|
|
tags,
|
|
summary: summary || tr("created-page", "Created page")
|
|
};
|
|
if (state.newPageSlug) {
|
|
const requested = splitPageReference(state.newPageSlug);
|
|
body.slug = pageReference(namespace || requested.namespace, requested.slug);
|
|
}
|
|
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,
|
|
namespace,
|
|
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 escapeMarkdownLinkLabel(text) {
|
|
return String(text || "")
|
|
.replaceAll("\\", "\\\\")
|
|
.replaceAll("[", "\\[")
|
|
.replaceAll("]", "\\]");
|
|
}
|
|
|
|
async function openWikiCmapLinkDialog() {
|
|
pendingWikiCmapLinkLabel = easyMDE.codemirror.getDoc().getSelection();
|
|
await loadConceptMaps();
|
|
wikiCmapLinkCombobox.setOptions(
|
|
state.conceptMaps.map((conceptMap) => titledCmapComboboxEntry(conceptMap)), "");
|
|
$("wiki-cmap-link-submit").disabled = state.conceptMaps.length === 0;
|
|
$("wiki-cmap-embed-submit").disabled = state.conceptMaps.length === 0;
|
|
$("wiki-cmap-link").placeholder = state.conceptMaps.length ?
|
|
tr("filter-concept-maps", "Filter concept maps") :
|
|
tr("no-concept-maps", "No saved CMaps");
|
|
$("wiki-cmap-link-dialog").showModal();
|
|
$("wiki-cmap-link").focus();
|
|
}
|
|
|
|
function insertSelectedWikiCmapLink() {
|
|
const slug = wikiCmapLinkCombobox.value();
|
|
if (slug === null || !slug) {
|
|
const input = $("wiki-cmap-link");
|
|
input.setCustomValidity(tr("select-listed-concept-map", "Select a CMap from the list or clear the field."));
|
|
input.reportValidity();
|
|
return false;
|
|
}
|
|
const conceptMap = state.conceptMaps.find((item) => item.slug === slug);
|
|
if (!conceptMap) return false;
|
|
const label = escapeMarkdownLinkLabel(pendingWikiCmapLinkLabel.trim() || conceptMap.title);
|
|
insertTextAtCursor(`[${label}](cmap:${slug})`);
|
|
$("wiki-cmap-link-dialog").close();
|
|
return true;
|
|
}
|
|
|
|
function insertSelectedWikiCmapEmbed() {
|
|
const slug = wikiCmapLinkCombobox.value();
|
|
if (slug === null || !slug) {
|
|
const input = $("wiki-cmap-link");
|
|
input.setCustomValidity(tr("select-listed-concept-map", "Select a CMap from the list or clear the field."));
|
|
input.reportValidity();
|
|
return false;
|
|
}
|
|
const conceptMap = state.conceptMaps.find((item) => item.slug === slug);
|
|
if (!conceptMap) return false;
|
|
insertTextAtCursor(`\n\n{{cmap:${conceptMap.slug}}}\n\n`);
|
|
$("wiki-cmap-link-dialog").close();
|
|
return true;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* goal : Upload dropped/selected files and insert Markdown references at the cursor.
|
|
* pre : The current page has already been saved once.
|
|
* post : Uploaded files are stored through the API and referenced from the editor.
|
|
*/
|
|
async function uploadFiles(files) {
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* goal : Open the rename/move form for the current page.
|
|
* pre : A current page exists and the user can edit pages.
|
|
* post : Rename fields contain current title, namespace and slug.
|
|
*/
|
|
function beginRenamePage() {
|
|
if (!state.currentPage || !can("editor")) return;
|
|
const address = splitPageReference(state.currentPage.slug);
|
|
$("rename-title").value = state.currentPage.title;
|
|
$("rename-namespace").value = state.currentPage.namespace || address.namespace;
|
|
$("rename-slug").value = state.currentPage.pageSlug || address.slug;
|
|
$("rename-status").textContent = "";
|
|
renderBreadcrumbs([
|
|
{ label: state.siteTitle, href: "/" },
|
|
{ label: state.currentPage.title, href: pageRoute(state.currentPage.slug) },
|
|
{ label: tr("rename", "Rename") }
|
|
]);
|
|
show("rename-view");
|
|
}
|
|
|
|
/**
|
|
* goal : Rename/move the current page while retaining the old address as an alias.
|
|
* pre : Rename fields contain a title, namespace and slug accepted by the server.
|
|
* post : Page metadata and aliases are refreshed and the canonical page is opened.
|
|
*/
|
|
async function saveRenamePage() {
|
|
if (!state.currentPage) return;
|
|
const oldReference = state.currentPage.slug;
|
|
$("rename-status").textContent = tr("saving", "Saving…");
|
|
try {
|
|
const page = await api(`/api/pages/${encodeURIComponent(oldReference)}/rename`, {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
title: $("rename-title").value.trim(),
|
|
namespace: $("rename-namespace").value.trim(),
|
|
slug: $("rename-slug").value.trim(),
|
|
summary: tr("rename-summary", "Renamed page")
|
|
})
|
|
});
|
|
await loadPages();
|
|
await loadBookmarks();
|
|
state.currentPage = page;
|
|
const targetHash = pageRoute(page.slug);
|
|
if (location.hash === targetHash) {
|
|
await openPage(page.slug);
|
|
} else {
|
|
location.hash = targetHash;
|
|
}
|
|
} catch (error) {
|
|
$("rename-status").textContent = error.message;
|
|
}
|
|
}
|
|
|
|
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";
|
|
article.dataset.resultType = item.type || "page";
|
|
const heading = document.createElement("h2");
|
|
const type = document.createElement("span");
|
|
type.className = "search-result-type";
|
|
type.textContent = item.type === "cmap" ?
|
|
tr("concept-map", "Concept map") : tr("wiki-page", "Wiki page");
|
|
const link = document.createElement("a");
|
|
link.href = item.type === "cmap" ?
|
|
cmapRoute(item.slug) : pageRoute(item.slug);
|
|
link.textContent = item.title;
|
|
heading.append(type, 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-search-results", "No matching pages or concept maps.");
|
|
results.append(empty);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* goal : Show immutable versions of the current page and diff actions.
|
|
* pre : state.currentPage identifies an existing page.
|
|
* post : History view contains newest-first versions.
|
|
*/
|
|
async function showHistory() {
|
|
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);
|
|
});
|
|
}
|
|
|
|
async function showAdmin() {
|
|
state.previousView = state.currentPage ? "page-view" : "admin-view";
|
|
renderBreadcrumbs([
|
|
{ label: state.siteTitle, href: "/" },
|
|
{ label: tr("admin", "Admin") }
|
|
]);
|
|
show("admin-view");
|
|
const info = await api("/api/admin/info");
|
|
$("admin-software-version").textContent = info.softwareVersion || "?";
|
|
}
|
|
|
|
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`;
|
|
}
|
|
|
|
/**
|
|
* goal : Show retained page aliases and pages still containing an old address.
|
|
* pre : Current user has the admin role.
|
|
* post : Alias administration contains canonical targets and reference links.
|
|
*/
|
|
async function loadAliasesAdmin() {
|
|
renderBreadcrumbs([
|
|
{ label: state.siteTitle, href: "/" },
|
|
{ label: tr("admin", "Admin") },
|
|
{ label: tr("page-aliases", "Page aliases") }
|
|
]);
|
|
show("alias-admin-view");
|
|
$("alias-admin-status").textContent = "";
|
|
const result = await api("/api/admin/aliases");
|
|
const list = $("alias-list");
|
|
list.replaceChildren();
|
|
const aliases = result.aliases || [];
|
|
if (aliases.length === 0) {
|
|
const empty = document.createElement("p");
|
|
empty.className = "muted";
|
|
empty.textContent = tr("no-page-aliases", "No page aliases.");
|
|
list.append(empty);
|
|
return;
|
|
}
|
|
|
|
for (const alias of aliases) {
|
|
const row = document.createElement("article");
|
|
row.className = "alias-row";
|
|
const address = document.createElement("div");
|
|
address.className = "alias-address";
|
|
const target = document.createElement("a");
|
|
target.href = pageRoute(alias.targetSlug);
|
|
target.textContent = alias.targetSlug;
|
|
address.append(
|
|
document.createTextNode(`${tr("old-address", "Old address")}: ${alias.slug} · ${tr("current-address", "Current address")}: `),
|
|
target
|
|
);
|
|
row.append(address);
|
|
|
|
const refs = alias.references || [];
|
|
const currentSummary = document.createElement("div");
|
|
currentSummary.className = "alias-summary";
|
|
currentSummary.textContent = `${tr("current-references", "Current references")}: ${refs.length}`;
|
|
row.append(currentSummary);
|
|
|
|
if (refs.length > 0) {
|
|
const refsList = document.createElement("ul");
|
|
refsList.className = "alias-references";
|
|
for (const ref of refs) {
|
|
const item = document.createElement("li");
|
|
const link = document.createElement("a");
|
|
link.href = pageRoute(ref.slug);
|
|
link.textContent = ref.title;
|
|
item.append(link);
|
|
refsList.append(item);
|
|
}
|
|
row.append(refsList);
|
|
}
|
|
|
|
const historicalCount = Number(alias.historicalReferenceCount || 0);
|
|
const historicalSummary = document.createElement("div");
|
|
historicalSummary.className = "alias-summary muted";
|
|
historicalSummary.textContent = `${tr("historical-references", "Historical references")}: ${historicalCount}`;
|
|
row.append(historicalSummary);
|
|
|
|
const historicalRefs = alias.historicalReferences || [];
|
|
if (historicalRefs.length > 0) {
|
|
const historyList = document.createElement("ul");
|
|
historyList.className = "alias-references alias-history";
|
|
for (const ref of historicalRefs) {
|
|
const item = document.createElement("li");
|
|
item.textContent = `${ref.title} · ${tr("version", "Version")} ${ref.version}`;
|
|
historyList.append(item);
|
|
}
|
|
row.append(historyList);
|
|
}
|
|
|
|
const actions = document.createElement("div");
|
|
actions.className = "alias-actions";
|
|
|
|
const cleanup = document.createElement("button");
|
|
cleanup.type = "button";
|
|
cleanup.textContent = tr("cleanup-alias", "Clean up references");
|
|
cleanup.disabled = refs.length === 0;
|
|
cleanup.addEventListener("click", async () => {
|
|
if (!window.confirm(tr("cleanup-alias-confirm", "Replace all current references to this old address with the current address? Historical versions will not be changed."))) return;
|
|
const cleanupResult = await api(`/api/admin/aliases/${alias.id}/cleanup`, { method: "POST" });
|
|
await loadPages();
|
|
await loadAliasesAdmin();
|
|
$("alias-admin-status").textContent =
|
|
`${tr("cleanup-complete", "Cleanup complete")}: ${cleanupResult.changedPages || 0}`;
|
|
});
|
|
|
|
const remove = document.createElement("button");
|
|
remove.type = "button";
|
|
remove.textContent = tr("delete-alias", "Delete alias");
|
|
remove.disabled = refs.length > 0;
|
|
remove.addEventListener("click", async () => {
|
|
const warning = historicalCount > 0
|
|
? tr("delete-alias-history-confirm", "Delete this alias? Historical page versions still contain this old address and those links may stop resolving.")
|
|
: tr("delete-alias-confirm", "Delete this alias?");
|
|
if (!window.confirm(warning)) return;
|
|
await api(`/api/admin/aliases/${alias.id}`, { method: "DELETE" });
|
|
await loadPages();
|
|
await loadAliasesAdmin();
|
|
});
|
|
|
|
actions.append(cleanup, remove);
|
|
row.append(actions);
|
|
list.append(row);
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* goal : Show uploads no current page references and their last historical uses.
|
|
* pre : Current user has the admin role.
|
|
* post : Admin view offers safe deletion only for orphaned uploads.
|
|
*/
|
|
async function loadOrphanedUploadsAdmin() {
|
|
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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* goal : Render user administration from the current server-side user list.
|
|
* pre : Current user has the admin role.
|
|
* post : User rows allow supported create/update/delete operations.
|
|
*/
|
|
async function loadUsersAdmin() {
|
|
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 email = document.createElement("input");
|
|
email.type = "email";
|
|
email.value = user.email || "";
|
|
email.placeholder = tr("email-address", "Email address");
|
|
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,
|
|
email: email.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, email, role, enabled, password, actions);
|
|
list.append(row);
|
|
}
|
|
}
|
|
|
|
function showProfile() {
|
|
renderBreadcrumbs([
|
|
{ label: state.siteTitle, href: "/" },
|
|
{ label: tr("profile", "Profile") }
|
|
]);
|
|
show("profile-view");
|
|
const user = state.session.user;
|
|
$("profile-username").value = user.username;
|
|
$("profile-display-name").value = user.displayName;
|
|
$("profile-email").value = user.email || "";
|
|
$("profile-current-password").value = "";
|
|
$("profile-new-password").value = "";
|
|
$("profile-repeat-password").value = "";
|
|
$("profile-status").textContent = "";
|
|
}
|
|
|
|
async function loadMailSettingsAdmin() {
|
|
renderBreadcrumbs([
|
|
{ label: state.siteTitle, href: "/" },
|
|
{ label: tr("admin", "Admin"), href: "#admin" },
|
|
{ label: tr("email-and-password-reset", "Email and password reset") }
|
|
]);
|
|
show("mail-admin-view");
|
|
const settings = await api("/api/admin/mail-settings");
|
|
$("mail-public-url").value = settings.publicUrl || window.location.origin;
|
|
$("mail-smtp-host").value = settings.smtpHost || "";
|
|
$("mail-smtp-port").value = settings.smtpPort || "587";
|
|
$("mail-smtp-from").value = settings.smtpFrom || "";
|
|
$("mail-smtp-user").value = settings.smtpUser || "";
|
|
$("mail-smtp-password").value = "";
|
|
$("mail-smtp-tls").checked = settings.smtpTls !== false;
|
|
$("mail-smtp-accept-untrusted-certificates").checked = settings.smtpAcceptUntrustedCertificates === true;
|
|
$("mail-smtp-accept-untrusted-certificates").disabled = !$("mail-smtp-tls").checked;
|
|
$("mail-reset-limit").value = settings.resetLimit || "2";
|
|
$("mail-test-recipient").value = state.session.user.email || "";
|
|
$("mail-password-help").textContent = settings.hasPassword ? tr("smtp-password-kept", "A password is stored; leave empty to keep it.") : "";
|
|
$("mail-settings-status").textContent = "";
|
|
}
|
|
|
|
function mailSettingsFormData() {
|
|
return {
|
|
publicUrl: $("mail-public-url").value,
|
|
smtpHost: $("mail-smtp-host").value,
|
|
smtpPort: $("mail-smtp-port").value,
|
|
smtpFrom: $("mail-smtp-from").value,
|
|
smtpUser: $("mail-smtp-user").value,
|
|
smtpPassword: $("mail-smtp-password").value,
|
|
smtpTls: $("mail-smtp-tls").checked,
|
|
smtpAcceptUntrustedCertificates: $("mail-smtp-accept-untrusted-certificates").checked,
|
|
resetLimit: $("mail-reset-limit").value
|
|
};
|
|
}
|
|
|
|
function smtpErrorMessage(error) {
|
|
const message = error && error.message ? error.message : String(error);
|
|
if (message.includes("certificate verify failed") || message.includes("TLS certificate verification failed")) {
|
|
return tr("smtp-certificate-verification-failed", "The SMTP server certificate could not be verified. Install a valid certificate, or select the local-server exception if this is a trusted local SMTP server.");
|
|
}
|
|
if (message.includes("no protocols available")) {
|
|
return tr("smtp-no-modern-tls", "The SMTP connection attempted an obsolete TLS protocol. Install the current Racket Wiki version, which negotiates modern TLS automatically.");
|
|
}
|
|
return message;
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
/**
|
|
* goal : Resolve the current hash route to a normal or special wiki view.
|
|
* pre : Session and page metadata have been loaded.
|
|
* post : Exactly one application view is made active.
|
|
*/
|
|
async function route() {
|
|
if (location.hash === "#profile") {
|
|
showProfile();
|
|
return;
|
|
}
|
|
const todoMatch = location.hash.match(/^#todo\/([^/]+)\/(\d+)$/);
|
|
if (todoMatch) {
|
|
await showTodos(decodeURIComponent(todoMatch[1]), Number(todoMatch[2]));
|
|
return;
|
|
}
|
|
|
|
const cmapMatch = location.hash.match(/^#cmap\/([^/]+)$/);
|
|
if (cmapMatch) {
|
|
await showCmapPrototype(decodeURIComponent(cmapMatch[1]));
|
|
return;
|
|
}
|
|
|
|
if (location.hash === "#cmaps") {
|
|
await showCmapPrototype();
|
|
return;
|
|
}
|
|
|
|
if (location.hash === "#graph") {
|
|
await showGraph();
|
|
return;
|
|
}
|
|
|
|
if (location.hash === "#recent") {
|
|
await showRecent();
|
|
return;
|
|
}
|
|
|
|
if (location.hash === "#bookmarks") {
|
|
await showBookmarks();
|
|
return;
|
|
}
|
|
|
|
if (location.hash === "#todos") {
|
|
await showTodos();
|
|
return;
|
|
}
|
|
|
|
const searchMatch = location.hash.match(/^#search\/(.+)$/);
|
|
if (searchMatch) {
|
|
const query = decodeURIComponent(searchMatch[1]);
|
|
$("search-input").value = query;
|
|
await searchWiki(query);
|
|
return;
|
|
}
|
|
|
|
if (location.hash === "#admin" && can("admin")) {
|
|
await showAdmin();
|
|
return;
|
|
}
|
|
|
|
if (location.hash === "#admin/users" && can("admin")) {
|
|
await loadUsersAdmin();
|
|
return;
|
|
}
|
|
|
|
if (location.hash === "#admin/mail" && can("admin")) {
|
|
await loadMailSettingsAdmin();
|
|
return;
|
|
}
|
|
|
|
if (location.hash === "#admin/aliases" && can("admin")) {
|
|
await loadAliasesAdmin();
|
|
return;
|
|
}
|
|
|
|
if (location.hash === "#admin/orphaned-uploads" && can("admin")) {
|
|
await loadOrphanedUploadsAdmin();
|
|
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);
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Special wiki views: Recent, Bookmarks and Todo
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
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 items = result.items || [];
|
|
if (items.length === 0) {
|
|
const empty = document.createElement("p");
|
|
empty.className = "muted";
|
|
empty.textContent = tr("no-recent-changes", "No recent changes.");
|
|
list.append(empty);
|
|
return;
|
|
}
|
|
|
|
for (const item of items) {
|
|
const row = document.createElement("div");
|
|
row.className = "special-page-row";
|
|
row.dataset.resultType = item.type;
|
|
const heading = document.createElement("span");
|
|
heading.className = "special-page-heading";
|
|
const type = document.createElement("span");
|
|
type.className = "search-result-type";
|
|
type.textContent = item.type === "cmap" ?
|
|
tr("concept-map", "Concept map") : tr("wiki-page", "Wiki page");
|
|
const link = document.createElement("a");
|
|
link.href = item.type === "cmap" ? cmapRoute(item.slug) : pageRoute(item.slug);
|
|
link.textContent = item.title;
|
|
const meta = document.createElement("span");
|
|
meta.className = "special-page-meta";
|
|
meta.textContent = `${formatTimestamp(item.updatedAt)} · ${tr("changed-by", "changed by")} ${item.updatedBy}`;
|
|
heading.append(type, link);
|
|
row.append(heading, 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();
|
|
}
|
|
|
|
/**
|
|
* goal : Show bookmarks grouped first by page namespace and then user section.
|
|
* pre : The user is authenticated.
|
|
* post : The Bookmarks special view contains the latest bookmark state.
|
|
*/
|
|
async function showBookmarks() {
|
|
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 namespaceGroups = new Map();
|
|
for (const bookmark of state.bookmarks) {
|
|
const namespace = bookmark.namespace || "";
|
|
if (!namespaceGroups.has(namespace)) namespaceGroups.set(namespace, new Map());
|
|
const sections = namespaceGroups.get(namespace);
|
|
const section = bookmark.section || "";
|
|
if (!sections.has(section)) sections.set(section, []);
|
|
sections.get(section).push(bookmark);
|
|
}
|
|
|
|
for (const [namespace, sections] of namespaceGroups.entries()) {
|
|
const namespaceSection = document.createElement("section");
|
|
const namespaceHeading = document.createElement("h2");
|
|
namespaceHeading.textContent = namespaceLabel(namespace);
|
|
namespaceSection.append(namespaceHeading);
|
|
|
|
for (const [section, bookmarks] of sections.entries()) {
|
|
const sectionElement = document.createElement("section");
|
|
sectionElement.className = "bookmark-section";
|
|
if (section) {
|
|
const heading = document.createElement("h3");
|
|
heading.textContent = section;
|
|
sectionElement.append(heading);
|
|
}
|
|
|
|
for (const bookmark of bookmarks) {
|
|
const row = document.createElement("div");
|
|
row.className = "bookmark-row";
|
|
const link = document.createElement("a");
|
|
link.href = pageRoute(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);
|
|
}
|
|
namespaceSection.append(sectionElement);
|
|
}
|
|
target.append(namespaceSection);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* goal : Show Todo items grouped by the namespace of their source page.
|
|
* pre : Todo index is available through /api/todos.
|
|
* post : Optional target Todo is scrolled into view and highlighted.
|
|
*/
|
|
async function showTodos(targetSlug = null, targetNumber = null) {
|
|
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;
|
|
}
|
|
let todoNamespace = null;
|
|
for (const item of result.items) {
|
|
const namespace = item.namespace || "";
|
|
if (namespace !== todoNamespace) {
|
|
const heading = document.createElement("h2");
|
|
heading.className = "namespace-heading";
|
|
heading.textContent = namespaceLabel(namespace);
|
|
list.append(heading);
|
|
todoNamespace = namespace;
|
|
}
|
|
const row = document.createElement("article");
|
|
row.className = "todo-item";
|
|
row.id = `todo-${item.slug}-${item.number}`;
|
|
const link = document.createElement("a");
|
|
link.href = pageRoute(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" });
|
|
}
|
|
}
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Concept map prototype
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
/**
|
|
* goal : Return the in-memory concept-map prototype state.
|
|
* post : A state object exists for the current CMap prototype.
|
|
* result : Object containing the RacketWikiCmap editor instance.
|
|
*/
|
|
function cmapPrototypeState() {
|
|
if (!state.cmapPrototype) {
|
|
state.cmapPrototype = {
|
|
editor: null
|
|
};
|
|
}
|
|
return state.cmapPrototype;
|
|
}
|
|
|
|
/**
|
|
* goal : Render one concept card inside an ionstage/cmap node.
|
|
* pre : record contains label, synopsis and kind.
|
|
* result : Safe HTML used as the node's content.
|
|
*/
|
|
function cmapNodeHtml(record) {
|
|
const safeLabel = escapeHtml(record.label || "");
|
|
const safeSynopsis = escapeHtml(record.synopsis || "");
|
|
const safeKind = escapeHtml(record.kind || "concept");
|
|
const safeImageSource = escapeHtml(record.imageSource || "");
|
|
const image = safeImageSource ? `<img class="cmap-card-image" src="${safeImageSource}" alt="">` : "";
|
|
const linkedCmapClass = record.cmapSlug || record.parentCmapLink ? " cmap-card-linked-cmap" : "";
|
|
return `<div class="cmap-card cmap-card-${safeKind}${linkedCmapClass}">${image}<div class="cmap-card-title">${safeLabel}</div>${safeSynopsis ? `<div class="cmap-card-synopsis">${safeSynopsis}</div>` : ""}</div>`;
|
|
}
|
|
|
|
/**
|
|
* goal : Derive a compact synopsis from the currently opened wiki page.
|
|
* pre : state.currentPage may be #f/null when no page is open.
|
|
* result : Plain text of at most about 150 characters.
|
|
*/
|
|
function currentPageSynopsis() {
|
|
if (!state.currentPage) return "";
|
|
const container = document.createElement("div");
|
|
container.innerHTML = renderMarkdown(state.currentPage.content || "");
|
|
const text = (container.textContent || "").replace(/\s+/g, " ").trim();
|
|
return text.length > 150 ? `${text.slice(0, 147)}…` : text;
|
|
}
|
|
|
|
/**
|
|
* goal : Add a concept-like item to the active CMap prototype.
|
|
* pre : resetCmapPrototype has created the RacketWikiCmap editor.
|
|
* post : The item is draggable, selectable, resizable and linkable.
|
|
* result : The item record created by the interaction layer.
|
|
*/
|
|
function addCmapPrototypeNode(options = {}) {
|
|
const prototype = cmapPrototypeState();
|
|
if (!prototype.editor) return null;
|
|
return prototype.editor.addItem(options);
|
|
}
|
|
|
|
let cmapDialogRecord = null;
|
|
let cmapDialogCreateContext = null;
|
|
let cmapContextCreateContext = {};
|
|
let cmapDialogImageSource = "";
|
|
let cmapDialogImageRead = Promise.resolve();
|
|
|
|
function cmapColorValue(value, fallback = "#f3f6f8") {
|
|
return /^#[0-9a-f]{6}$/i.test(value || "") ? value : fallback;
|
|
}
|
|
|
|
function cmapFontSizeInPoints(value) {
|
|
const size = Number.parseFloat(value);
|
|
if (!Number.isFinite(size)) return 11;
|
|
if (/px$/i.test(value || "")) return size * 0.75;
|
|
return size;
|
|
}
|
|
|
|
function selectCmapFont(fontFamily) {
|
|
const select = $("cmap-concept-font-family");
|
|
const value = fontFamily || "Arial, Helvetica, sans-serif";
|
|
const existing = Array.from(select.options).find((option) => option.value === value);
|
|
if (!existing) {
|
|
const option = document.createElement("option");
|
|
option.value = value;
|
|
option.textContent = value;
|
|
select.append(option);
|
|
}
|
|
select.value = value;
|
|
}
|
|
|
|
function titledCmapComboboxEntry(record) {
|
|
const label = record.title || record.slug;
|
|
return {
|
|
value: record.slug,
|
|
label,
|
|
description: record.slug
|
|
};
|
|
}
|
|
|
|
function populateCmapPageOptions(record) {
|
|
const pages = [...state.pages].sort((a, b) => a.title.localeCompare(b.title));
|
|
const entries = pages.map((page) => titledCmapComboboxEntry(page));
|
|
if (record.pageSlug && !pages.some((page) => page.slug === record.pageSlug)) {
|
|
entries.push({ value: record.pageSlug, label: record.pageSlug });
|
|
}
|
|
cmapPageCombobox.setOptions(entries, record.pageSlug || "");
|
|
$("cmap-concept-page-row").classList.toggle("hidden", record.kind === "submap");
|
|
}
|
|
|
|
function populateCmapLinkOptions(record) {
|
|
const prototype = cmapPrototypeState();
|
|
const entries = [];
|
|
|
|
if (prototype.editor && prototype.editor.activeMapRoot) {
|
|
const parentLabel = `↩ ${tr("parent-concept-map", "Parent concept map")}`;
|
|
entries.push({ value: "__parent__", label: parentLabel });
|
|
}
|
|
|
|
for (const conceptMap of [...state.conceptMaps].sort((a, b) => a.title.localeCompare(b.title))) {
|
|
entries.push(titledCmapComboboxEntry(conceptMap));
|
|
}
|
|
|
|
if (record.cmapSlug && !state.conceptMaps.some((conceptMap) => conceptMap.slug === record.cmapSlug)) {
|
|
entries.push({ value: record.cmapSlug, label: record.cmapSlug });
|
|
}
|
|
const selectedValue = record.parentCmapLink ? "__parent__" : (record.cmapSlug || "");
|
|
cmapLinkCombobox.setOptions(entries, selectedValue);
|
|
$("cmap-concept-cmap-row").classList.toggle("hidden", record.kind === "submap");
|
|
}
|
|
|
|
function updateCmapImagePreview() {
|
|
const row = $("cmap-concept-image-preview-row");
|
|
const preview = $("cmap-concept-image-preview");
|
|
if (!cmapDialogImageSource) {
|
|
row.classList.add("hidden");
|
|
preview.removeAttribute("src");
|
|
return;
|
|
}
|
|
preview.src = cmapDialogImageSource;
|
|
row.classList.remove("hidden");
|
|
}
|
|
|
|
function readCmapImage(file) {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader();
|
|
reader.addEventListener("load", () => resolve(String(reader.result || "")), { once: true });
|
|
reader.addEventListener("error", () => reject(reader.error || new Error("Image could not be read.")), { once: true });
|
|
reader.readAsDataURL(file);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* goal : Open the concept editor without changing its wiki-page identity.
|
|
* pre : record is a non-phrase item in the current CMap editor.
|
|
* post : A modal form shows concept text, presentation and image fields.
|
|
*/
|
|
function openCmapConceptDialog(record) {
|
|
if (!record || record.kind === "phrase") return;
|
|
cmapDialogRecord = record;
|
|
cmapDialogCreateContext = null;
|
|
$("cmap-concept-dialog-title").textContent = tr("edit-concept", "Edit concept");
|
|
cmapDialogImageSource = record.imageSource || "";
|
|
cmapDialogImageRead = Promise.resolve();
|
|
$("cmap-concept-label").value = record.label || "";
|
|
$("cmap-concept-synopsis").value = record.synopsis || "";
|
|
$("cmap-concept-background").value = cmapColorValue(record.backgroundColor);
|
|
$("cmap-concept-background-label").textContent = record.kind === "submap" ?
|
|
tr("main-concept-background-color", "Main concept background color") :
|
|
tr("background-color", "Background color");
|
|
$("cmap-submap-style-fields").classList.toggle("hidden", record.kind !== "submap");
|
|
$("cmap-submap-background").value = cmapColorValue(
|
|
record.submapBackgroundColor, "#edf7e8");
|
|
$("cmap-submap-border").value = cmapColorValue(
|
|
record.submapBorderColor, "#57834a");
|
|
$("cmap-concept-text-color").value = cmapColorValue(record.textColor, "#222222");
|
|
selectCmapFont(record.fontFamily);
|
|
$("cmap-concept-font-size").value = String(Math.round(cmapFontSizeInPoints(record.fontSize)));
|
|
$("cmap-concept-image").value = "";
|
|
populateCmapPageOptions(record);
|
|
populateCmapLinkOptions(record);
|
|
updateCmapImagePreview();
|
|
$("cmap-concept-dialog").showModal();
|
|
$("cmap-concept-label").focus();
|
|
$("cmap-concept-label").select();
|
|
}
|
|
|
|
function openNewCmapConceptDialog(context = {}) {
|
|
cmapDialogRecord = null;
|
|
cmapDialogCreateContext = context;
|
|
cmapDialogImageSource = "";
|
|
cmapDialogImageRead = Promise.resolve();
|
|
$("cmap-concept-dialog-title").textContent = tr("add-concept", "Add concept");
|
|
$("cmap-concept-label").value = "New concept";
|
|
$("cmap-concept-synopsis").value = "";
|
|
$("cmap-concept-background").value = "#fff4cf";
|
|
$("cmap-concept-background-label").textContent = tr("background-color", "Background color");
|
|
$("cmap-submap-style-fields").classList.add("hidden");
|
|
$("cmap-submap-background").value = "#edf7e8";
|
|
$("cmap-submap-border").value = "#57834a";
|
|
$("cmap-concept-text-color").value = "#222222";
|
|
selectCmapFont("Arial, Helvetica, sans-serif");
|
|
$("cmap-concept-font-size").value = "11";
|
|
$("cmap-concept-image").value = "";
|
|
populateCmapPageOptions({ kind: "concept", pageSlug: null });
|
|
populateCmapLinkOptions({ kind: "concept", cmapSlug: null, parentCmapLink: false });
|
|
updateCmapImagePreview();
|
|
$("cmap-concept-dialog").showModal();
|
|
$("cmap-concept-label").focus();
|
|
$("cmap-concept-label").select();
|
|
}
|
|
|
|
function setCmapZoom(value) {
|
|
const prototype = cmapPrototypeState();
|
|
const percent = Math.max(25, Math.min(300, Number(value) || 100));
|
|
$("cmap-zoom-percent").value = String(percent);
|
|
try {
|
|
window.localStorage.setItem(cmapZoomStorageKey(), String(percent));
|
|
} catch (error) {
|
|
console.warn("The CMap zoom factor could not be stored.", error);
|
|
}
|
|
const canvas = $("cmap-canvas");
|
|
canvas.style.setProperty("--cmap-a4-width", `${1123 * percent / 100}px`);
|
|
canvas.style.setProperty("--cmap-a4-height", `${794 * percent / 100}px`);
|
|
return prototype.editor ? prototype.editor.setZoom(percent) : percent;
|
|
}
|
|
|
|
function setCmapPageGuides(visible) {
|
|
$("cmap-canvas").classList.toggle("cmap-page-guides-hidden", !visible);
|
|
$("cmap-toggle-page-guides").setAttribute("aria-checked", String(visible));
|
|
try {
|
|
window.localStorage.setItem("racket-wiki:cmap-a4-page-guides", visible ? "true" : "false");
|
|
} catch (error) {
|
|
console.warn("The CMap page-boundary preference could not be stored.", error);
|
|
}
|
|
}
|
|
|
|
function restoreCmapPageGuides() {
|
|
let visible = true;
|
|
try {
|
|
visible = window.localStorage.getItem("racket-wiki:cmap-a4-page-guides") !== "false";
|
|
} catch (error) {
|
|
console.warn("The CMap page-boundary preference could not be read.", error);
|
|
}
|
|
setCmapPageGuides(visible);
|
|
}
|
|
|
|
function showCmapStatus(message, temporary = false) {
|
|
const status = $("cmap-save-status");
|
|
window.clearTimeout(cmapStatusTimer);
|
|
status.textContent = message || "";
|
|
status.classList.toggle("cmap-status-visible", Boolean(message));
|
|
if (message && temporary) {
|
|
cmapStatusTimer = window.setTimeout(() => {
|
|
status.classList.remove("cmap-status-visible");
|
|
status.textContent = "";
|
|
}, 1800);
|
|
}
|
|
}
|
|
|
|
function cmapZoomStorageKey() {
|
|
const storedMap = state.currentConceptMap ? state.currentConceptMap.slug : "unsaved";
|
|
const prototype = cmapPrototypeState();
|
|
const activeMap = prototype.editor && prototype.editor.activeMapRoot &&
|
|
prototype.editor.activeMapRoot.mapReference ? prototype.editor.activeMapRoot.mapReference.id : "root";
|
|
return `racket-wiki:cmap-zoom:${storedMap}:${activeMap}`;
|
|
}
|
|
|
|
function restoreCmapZoom() {
|
|
let stored = 100;
|
|
try {
|
|
stored = Number(window.localStorage.getItem(cmapZoomStorageKey()));
|
|
} catch (error) {
|
|
console.warn("The stored CMap zoom factor could not be read.", error);
|
|
}
|
|
return setCmapZoom(stored >= 25 && stored <= 300 ? stored : 100);
|
|
}
|
|
|
|
function closeCmapContextMenu() {
|
|
$("cmap-context-menu").classList.add("hidden");
|
|
$("cmap-tools-menu").setAttribute("aria-expanded", "false");
|
|
}
|
|
|
|
function openCmapContextMenu(clientX, clientY, createContext = {}) {
|
|
const menu = $("cmap-context-menu");
|
|
cmapContextCreateContext = createContext;
|
|
menu.classList.remove("hidden");
|
|
const left = Math.max(8, Math.min(clientX, window.innerWidth - menu.offsetWidth - 8));
|
|
const top = Math.max(8, Math.min(clientY, window.innerHeight - menu.offsetHeight - 8));
|
|
menu.style.left = `${left}px`;
|
|
menu.style.top = `${top}px`;
|
|
$("cmap-tools-menu").setAttribute("aria-expanded", "true");
|
|
}
|
|
|
|
function cmapPlacementOptions() {
|
|
const context = cmapContextCreateContext || {};
|
|
const options = {};
|
|
if (context.point) {
|
|
options.x = Math.max(0, context.point.x - 70);
|
|
options.y = Math.max(0, context.point.y - 30);
|
|
}
|
|
if (context.parentSubmap) {
|
|
options.parentSubmap = context.parentSubmap;
|
|
options.submapDepth = context.parentSubmap.submapDepth + 1;
|
|
}
|
|
return options;
|
|
}
|
|
|
|
/**
|
|
* goal : Edit the selected concept presentation and synopsis.
|
|
* pre : One concept, page, submap or linking phrase is selected.
|
|
* post : A concept dialog or the inline phrase editor is opened.
|
|
*/
|
|
function editSelectedCmapNode(record = null) {
|
|
const prototype = cmapPrototypeState();
|
|
const selectedRecord = record || (prototype.editor ? prototype.editor.selected() : null);
|
|
if (!selectedRecord) {
|
|
window.alert(tr("select-one-concept", "Select one concept first."));
|
|
return;
|
|
}
|
|
if (selectedRecord.kind === "phrase") {
|
|
prototype.editor.editPhraseInline(selectedRecord);
|
|
return;
|
|
}
|
|
openCmapConceptDialog(selectedRecord);
|
|
}
|
|
|
|
function cmapKeyboardEditingTarget(target) {
|
|
return target instanceof Element && Boolean(
|
|
target.closest("input, textarea, select, [contenteditable='true']"));
|
|
}
|
|
|
|
function handleCmapKeyboardShortcut(event) {
|
|
if ($("cmap-view").classList.contains("hidden") || $("cmap-concept-dialog").open) return;
|
|
if (cmapKeyboardEditingTarget(event.target)) return;
|
|
|
|
const prototype = cmapPrototypeState();
|
|
const editor = prototype.editor;
|
|
if (!editor) return;
|
|
const commandKey = event.ctrlKey || event.metaKey;
|
|
const key = event.key.toLocaleLowerCase();
|
|
|
|
if (commandKey && !event.altKey && key === "a") {
|
|
event.preventDefault();
|
|
editor.selectAll();
|
|
return;
|
|
}
|
|
if (commandKey && !event.altKey && key === "z") {
|
|
event.preventDefault();
|
|
if (event.shiftKey) {
|
|
editor.redo();
|
|
} else {
|
|
editor.undo();
|
|
}
|
|
return;
|
|
}
|
|
if (commandKey && !event.altKey && !event.shiftKey && key === "y") {
|
|
event.preventDefault();
|
|
editor.redo();
|
|
return;
|
|
}
|
|
if (commandKey && !event.altKey && key === "g") {
|
|
event.preventDefault();
|
|
if (event.shiftKey) {
|
|
editor.ungroupSelection();
|
|
} else {
|
|
groupSelectedCmapItems();
|
|
}
|
|
return;
|
|
}
|
|
if (!commandKey && !event.altKey && !event.shiftKey &&
|
|
(event.key === "Delete" || event.key === "Backspace")) {
|
|
event.preventDefault();
|
|
editor.deleteSelection();
|
|
return;
|
|
}
|
|
if (!commandKey && !event.altKey && !event.shiftKey && event.key === "Escape") {
|
|
editor.clearSelection();
|
|
return;
|
|
}
|
|
if (event.key === "F2" && !event.repeat && !commandKey && !event.altKey &&
|
|
editor.selectedAll().length === 1) {
|
|
event.preventDefault();
|
|
editor.editSelected();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* goal : Add a labelled relation between two sample items.
|
|
* pre : source and target are records from the active prototype.
|
|
* post : source -> linking phrase -> target is visible.
|
|
* result : The new linking-phrase record.
|
|
*/
|
|
function connectCmapPrototypeNodes(source, target, label) {
|
|
const prototype = cmapPrototypeState();
|
|
if (!prototype.editor) return null;
|
|
return prototype.editor.connectWithPhrase(source, target, label || "?????", false);
|
|
}
|
|
|
|
function groupSelectedCmapItems() {
|
|
const prototype = cmapPrototypeState();
|
|
if (!prototype.editor || !prototype.editor.canGroupSelection()) return false;
|
|
const label = window.prompt(
|
|
tr("group-submap-name", "Name of the main concept for the new sub-CMap"),
|
|
tr("sub-concept-map", "Sub concept map"));
|
|
if (!label || !label.trim()) return false;
|
|
return prototype.editor.groupSelection({
|
|
label: label.trim(),
|
|
childMap: label.trim(),
|
|
synopsis: tr("grouped-submap-synopsis", "Grouped sub-concept map.")
|
|
});
|
|
}
|
|
|
|
function populateCmapPrototypeSubmap(record, editor) {
|
|
const baseX = Number(record.node.attr("x"));
|
|
const baseY = Number(record.node.attr("y"));
|
|
const detail = editor.addSubmapItem(record, {
|
|
label: "Detail concept",
|
|
synopsis: "Concept inside the expanded submap.",
|
|
x: baseX + 315,
|
|
y: baseY + 170,
|
|
backgroundColor: "#fff4cf",
|
|
borderColor: "#a97c00"
|
|
});
|
|
editor.connectWithPhrase(record, detail, "contains", false);
|
|
|
|
if (record.submapDepth < 2) {
|
|
const nested = editor.addSubmapItem(record, {
|
|
label: "Nested submap",
|
|
synopsis: "This submap can also be expanded.",
|
|
kind: "submap",
|
|
childMap: `${record.childMap || record.label}/nested`,
|
|
x: baseX + 60,
|
|
y: baseY + 310,
|
|
backgroundColor: "#edf7e8",
|
|
borderColor: "#57834a"
|
|
});
|
|
editor.connectWithPhrase(detail, nested, "contains", false);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* goal : Build a small editable CmapTools-like sample around the current page.
|
|
* pre : The bundled racket-wiki cmap component is loaded.
|
|
* post : Concepts can be selected, resized and linked by dragging the relation handle.
|
|
*/
|
|
function resetCmapPrototype(cmapDocument = null, includeSample = true) {
|
|
const canvas = $("cmap-canvas");
|
|
cancelCmapAutosave();
|
|
state.cmapSavedSnapshot = null;
|
|
closeCmapContextMenu();
|
|
$("cmap-map-navigation").classList.add("hidden");
|
|
$("cmap-active-map-title").textContent = "";
|
|
if (state.cmapPrototype && state.cmapPrototype.editor &&
|
|
typeof state.cmapPrototype.editor.destroy === "function") {
|
|
state.cmapPrototype.editor.destroy();
|
|
}
|
|
canvas.replaceChildren();
|
|
state.cmapPrototype = null;
|
|
|
|
console.info("[racket-wiki:cmap-host 0.2.94] resetCmapPrototype", {
|
|
cmapAvailable: typeof window.Cmap === "function",
|
|
interactionLayerAvailable: Boolean(window.RacketWikiCmap),
|
|
interactionLayerVersion: window.RacketWikiCmap ? window.RacketWikiCmap.version : null,
|
|
cmapStylesheet: Array.from(document.styleSheets)
|
|
.map((sheet) => sheet.href)
|
|
.find((href) => href && href.includes("/cmap/cmap.css")) || null
|
|
});
|
|
|
|
if (typeof window.Cmap !== "function" || !window.RacketWikiCmap) {
|
|
const message = document.createElement("p");
|
|
message.className = "error";
|
|
message.textContent = "The bundled cmap component could not be loaded.";
|
|
canvas.append(message);
|
|
return;
|
|
}
|
|
|
|
const prototype = cmapPrototypeState();
|
|
prototype.editor = window.RacketWikiCmap.createEditor(canvas, {
|
|
Cmap: window.Cmap,
|
|
renderItem: (record) => cmapNodeHtml(record),
|
|
onOpenPage: (record) => {
|
|
if (record.pageSlug) {
|
|
navigateToHash(pageRoute(record.pageSlug)).catch((error) => console.error(error));
|
|
}
|
|
},
|
|
onOpenCmap: (record) => {
|
|
if (record.cmapSlug) {
|
|
navigateToHash(cmapRoute(record.cmapSlug)).catch((error) => console.error(error));
|
|
}
|
|
},
|
|
onOpenSubMap: (record) => {
|
|
console.info("[racket-wiki:cmap-host 0.2.94] submap state changed", {
|
|
id: record.id,
|
|
expanded: record.expanded,
|
|
childMap: record.childMap
|
|
});
|
|
},
|
|
onPopulateSubMap: (record, editor) => populateCmapPrototypeSubmap(record, editor),
|
|
onConfirmDetachFromSubmap: (record, parent) => window.confirm(
|
|
tr("detach-submap-confirm", "Place \"{concept}\" outside submap \"{submap}\"?")
|
|
.replace("{concept}", record.label)
|
|
.replace("{submap}", parent.label)),
|
|
onSelectionChange: (record, connector, selectedRecords) => {
|
|
const selected = Array.isArray(selectedRecords) ? selectedRecords : (record ? [record] : []);
|
|
$("cmap-promote-submap").disabled = !(selected.length === 1 &&
|
|
record && record.kind === "submap" && !record.separateMap);
|
|
$("cmap-edit-selected").disabled = selected.length !== 1;
|
|
$("cmap-group-selected").disabled = !prototype.editor.canGroupSelection();
|
|
$("cmap-ungroup-selected").disabled = !prototype.editor.canUngroupSelection();
|
|
$("cmap-delete-selected").disabled = selected.length === 0 && !connector;
|
|
},
|
|
onHistoryChange: ({ canUndo, canRedo }) => {
|
|
$("cmap-undo").disabled = !canUndo;
|
|
$("cmap-redo").disabled = !canRedo;
|
|
scheduleCmapAutosave();
|
|
},
|
|
onMapChange: (mapReference) => {
|
|
$("cmap-map-navigation").classList.toggle("hidden", !mapReference);
|
|
$("cmap-active-map-title").textContent = mapReference ? mapReference.title : "";
|
|
restoreCmapZoom();
|
|
},
|
|
onEditItem: (record) => editSelectedCmapNode(record),
|
|
onCreateConnectedItem: (context) => openNewCmapConceptDialog(context),
|
|
createRelationLabel: tr("create-relation", "Create relation"),
|
|
editConceptLabel: tr("edit-concept", "Edit concept"),
|
|
resizeConceptLabel: tr("resize-concept", "Resize concept"),
|
|
relationLabel: tr("relation", "Relation")
|
|
});
|
|
restoreCmapZoom();
|
|
console.info("[racket-wiki:cmap-host 0.2.94] editor stored", {
|
|
editorAvailable: Boolean(prototype.editor),
|
|
canvasChildCount: canvas.children.length
|
|
});
|
|
|
|
if (cmapDocument) {
|
|
prototype.editor.loadDocument(cmapDocument);
|
|
return;
|
|
}
|
|
if (!includeSample) {
|
|
prototype.editor.resetHistory();
|
|
return;
|
|
}
|
|
|
|
const page = state.currentPage || state.pages[0] || null;
|
|
const pageNode = addCmapPrototypeNode({
|
|
label: page ? page.title : state.siteTitle,
|
|
synopsis: page ? currentPageSynopsis() : "Wiki page concept",
|
|
kind: "page",
|
|
pageSlug: page ? page.slug : null,
|
|
x: 365,
|
|
y: 220,
|
|
width: 270,
|
|
height: 125,
|
|
backgroundColor: "#e7f2fb",
|
|
borderColor: "#4479a1"
|
|
});
|
|
const conceptNode = addCmapPrototypeNode({
|
|
label: tr("context", "Context"),
|
|
synopsis: "A free concept without a wiki page.",
|
|
x: 70,
|
|
y: 120,
|
|
backgroundColor: "#fff4cf",
|
|
borderColor: "#a97c00"
|
|
});
|
|
const subMapNode = addCmapPrototypeNode({
|
|
label: tr("sub-concept-map", "Sub concept map"),
|
|
synopsis: "Placeholder for an expandable child map.",
|
|
kind: "submap",
|
|
childMap: "prototype-child",
|
|
x: 690,
|
|
y: 360,
|
|
backgroundColor: "#edf7e8",
|
|
borderColor: "#57834a"
|
|
});
|
|
connectCmapPrototypeNodes(conceptNode, pageNode, "describes");
|
|
connectCmapPrototypeNodes(pageNode, subMapNode, "contains");
|
|
prototype.editor.clearSelection();
|
|
prototype.editor.resetHistory();
|
|
}
|
|
|
|
/**
|
|
* goal : Open the persistent CMap workspace without changing wiki pages.
|
|
* pre : User can read the wiki frontend.
|
|
* post : The selected stored CMap or the unsaved starter map is displayed.
|
|
*/
|
|
function renderConceptMapSelector() {
|
|
const input = $("cmap-map-select");
|
|
input.placeholder = state.conceptMaps.length ?
|
|
tr("select-concept-map", "Select a CMap") :
|
|
tr("no-concept-maps", "No saved CMaps");
|
|
cmapMapCombobox.setOptions(
|
|
state.conceptMaps.map((conceptMap) => titledCmapComboboxEntry(conceptMap)),
|
|
state.currentConceptMap ? state.currentConceptMap.slug : "");
|
|
const hasStoredMap = Boolean(state.currentConceptMap);
|
|
$("cmap-rename-map").disabled = !hasStoredMap;
|
|
$("cmap-delete-map").disabled = !hasStoredMap;
|
|
$("cmap-create-snapshot").disabled = !hasStoredMap;
|
|
$("cmap-history").disabled = !hasStoredMap;
|
|
}
|
|
|
|
async function loadConceptMaps() {
|
|
const result = await api("/api/cmaps");
|
|
state.conceptMaps = Array.isArray(result.conceptMaps) ? result.conceptMaps : [];
|
|
state.graphData = null;
|
|
renderConceptMapSelector();
|
|
return state.conceptMaps;
|
|
}
|
|
|
|
function decodeStoredConceptMapDocument(conceptMap) {
|
|
let documentValue = conceptMap.document;
|
|
for (let attempt = 0; attempt < 2 && typeof documentValue === "string"; attempt += 1) {
|
|
documentValue = JSON.parse(documentValue);
|
|
}
|
|
if (!documentValue || typeof documentValue !== "object" || Array.isArray(documentValue)) {
|
|
console.error("[racket-wiki:cmap-host 0.2.94] invalid stored CMap document", {
|
|
slug: conceptMap.slug,
|
|
valueType: Array.isArray(documentValue) ? "array" : typeof documentValue,
|
|
value: documentValue
|
|
});
|
|
throw new Error("The stored CMap document is not a JSON object.");
|
|
}
|
|
return documentValue;
|
|
}
|
|
|
|
function currentCmapSnapshot() {
|
|
const prototype = cmapPrototypeState();
|
|
if (!prototype.editor) return null;
|
|
return JSON.stringify(prototype.editor.toDocument());
|
|
}
|
|
|
|
function markCurrentCmapSaved(snapshot = currentCmapSnapshot()) {
|
|
state.cmapSavedSnapshot = snapshot;
|
|
}
|
|
|
|
function cmapHasUnsavedChanges() {
|
|
if ($("cmap-view").classList.contains("hidden")) return false;
|
|
const currentSnapshot = currentCmapSnapshot();
|
|
return currentSnapshot !== null && state.cmapSavedSnapshot !== null &&
|
|
currentSnapshot !== state.cmapSavedSnapshot;
|
|
}
|
|
|
|
function cancelCmapAutosave() {
|
|
if (cmapAutosaveTimer !== null) {
|
|
window.clearTimeout(cmapAutosaveTimer);
|
|
cmapAutosaveTimer = null;
|
|
}
|
|
}
|
|
|
|
function scheduleCmapAutosave() {
|
|
cancelCmapAutosave();
|
|
if (!can("editor") || !state.currentConceptMap || !cmapHasUnsavedChanges()) return;
|
|
showCmapStatus(tr("autosave-pending", "Changes waiting to be saved"));
|
|
cmapAutosaveTimer = window.setTimeout(() => {
|
|
cmapAutosaveTimer = null;
|
|
saveStoredConceptMap({ automatic: true }).catch((error) => console.error(error));
|
|
}, CMAP_AUTOSAVE_DELAY);
|
|
}
|
|
|
|
function updateStoredConceptMapSummary(conceptMap) {
|
|
const index = state.conceptMaps.findIndex((item) => item.slug === conceptMap.slug);
|
|
if (index >= 0) {
|
|
state.conceptMaps[index] = { ...state.conceptMaps[index], ...conceptMap };
|
|
} else {
|
|
state.conceptMaps.push(conceptMap);
|
|
}
|
|
renderConceptMapSelector();
|
|
}
|
|
|
|
function continueCmapTransition() {
|
|
const transition = pendingCmapTransition;
|
|
if (!transition) return;
|
|
pendingCmapTransition = null;
|
|
$("cmap-unsaved-dialog").close();
|
|
Promise.resolve()
|
|
.then(transition.action)
|
|
.then(() => transition.resolve(true))
|
|
.catch((error) => {
|
|
transition.reject(error);
|
|
});
|
|
}
|
|
|
|
function discardCmapChangesAndContinue() {
|
|
markCurrentCmapSaved();
|
|
continueCmapTransition();
|
|
}
|
|
|
|
function cancelCmapTransition() {
|
|
const transition = pendingCmapTransition;
|
|
pendingCmapTransition = null;
|
|
$("cmap-unsaved-dialog").close();
|
|
if (transition) transition.resolve(false);
|
|
renderConceptMapSelector();
|
|
}
|
|
|
|
function requestCmapTransition(action) {
|
|
if (!cmapHasUnsavedChanges()) {
|
|
return Promise.resolve()
|
|
.then(action)
|
|
.then(() => true);
|
|
}
|
|
if (pendingCmapTransition) return Promise.resolve(false);
|
|
return new Promise((resolve, reject) => {
|
|
pendingCmapTransition = { action, resolve, reject };
|
|
$("cmap-unsaved-dialog").showModal();
|
|
});
|
|
}
|
|
|
|
async function openStoredConceptMap(slug) {
|
|
if (!slug) return;
|
|
const loadSequence = ++state.cmapLoadSequence;
|
|
showCmapStatus(tr("loading", "Loading…"));
|
|
const conceptMap = await api(`/api/cmaps/${encodeURIComponent(slug)}`);
|
|
if (loadSequence !== state.cmapLoadSequence) return;
|
|
conceptMap.document = decodeStoredConceptMapDocument(conceptMap);
|
|
console.info("[racket-wiki:cmap-host 0.2.94] stored CMap received", {
|
|
slug: conceptMap.slug,
|
|
version: conceptMap.currentVersion,
|
|
itemCount: Array.isArray(conceptMap.document.items) ? conceptMap.document.items.length : 0,
|
|
connectorCount: Array.isArray(conceptMap.document.connectors) ? conceptMap.document.connectors.length : 0
|
|
});
|
|
state.currentConceptMap = conceptMap;
|
|
renderConceptMapSelector();
|
|
resetCmapPrototype(conceptMap.document, false);
|
|
markCurrentCmapSaved();
|
|
const loadedEditor = cmapPrototypeState().editor;
|
|
console.info("[racket-wiki:cmap-host 0.2.94] stored CMap loaded", {
|
|
slug: conceptMap.slug,
|
|
editorAvailable: Boolean(loadedEditor),
|
|
itemCount: loadedEditor ? loadedEditor.items.length : 0,
|
|
connectorCount: loadedEditor ? loadedEditor.connectors.length : 0
|
|
});
|
|
showCmapStatus(tr("concept-map-loaded", "CMap loaded"), true);
|
|
}
|
|
|
|
async function loadHistoricalConceptMapVersion(version) {
|
|
const conceptMap = state.currentConceptMap;
|
|
if (!conceptMap) return;
|
|
const historical = await api(
|
|
`/api/cmaps/${encodeURIComponent(conceptMap.slug)}/versions/${encodeURIComponent(version)}`);
|
|
historical.document = decodeStoredConceptMapDocument(historical);
|
|
const currentSnapshot = JSON.stringify(conceptMap.document);
|
|
resetCmapPrototype(historical.document, false);
|
|
state.cmapSavedSnapshot = currentSnapshot;
|
|
showCmapStatus(
|
|
tr("concept-map-version-loaded", "Version {version} loaded; save to make it current.")
|
|
.replace("{version}", String(historical.version)),
|
|
true);
|
|
}
|
|
|
|
async function showConceptMapHistory() {
|
|
const conceptMap = state.currentConceptMap;
|
|
if (!conceptMap) return;
|
|
const result = await api(`/api/cmaps/${encodeURIComponent(conceptMap.slug)}/history`);
|
|
const list = $("cmap-history-list");
|
|
list.replaceChildren();
|
|
for (const version of result.versions || []) {
|
|
const row = document.createElement("div");
|
|
row.className = "cmap-history-row";
|
|
const label = document.createElement("div");
|
|
const heading = document.createElement("strong");
|
|
heading.textContent = `${tr("version", "Version")} ${version.version} — ${version.title}`;
|
|
const meta = document.createElement("div");
|
|
meta.className = "muted";
|
|
const knownSummaries = {
|
|
create: tr("concept-map-created-version", "CMap created"),
|
|
rename: tr("concept-map-renamed-version", "CMap renamed")
|
|
};
|
|
let summary = knownSummaries[version.action] || version.summary;
|
|
if (version.action === "snapshot") {
|
|
const snapshotLabel = tr("snapshot", "Snapshot");
|
|
if (version.summary === "Current state when CMap history was enabled") {
|
|
summary = tr("concept-map-initial-version", "Initial available version");
|
|
} else {
|
|
summary = version.summary === snapshotLabel ? snapshotLabel : `${snapshotLabel} — ${version.summary}`;
|
|
}
|
|
}
|
|
if (version.summary === "Automatic save") summary = tr("automatic-save", "Automatic save");
|
|
if (version.summary === "Manual save") summary = tr("manual-save", "Manual save");
|
|
meta.textContent = `${pageDisplayDate(version.createdAt)} · ${version.author} · ${summary}`;
|
|
label.append(heading, document.createElement("br"), meta);
|
|
const load = document.createElement("button");
|
|
load.type = "button";
|
|
load.textContent = version.version === conceptMap.currentVersion ?
|
|
tr("current-version", "Current") : tr("load-version", "Load version");
|
|
load.disabled = version.version === conceptMap.currentVersion;
|
|
load.addEventListener("click", () => {
|
|
$("cmap-history-dialog").close();
|
|
requestCmapTransition(() => loadHistoricalConceptMapVersion(version.version))
|
|
.catch((error) => showCmapStatus(error.message));
|
|
});
|
|
row.append(label, load);
|
|
list.append(row);
|
|
}
|
|
$("cmap-history-dialog").showModal();
|
|
}
|
|
|
|
async function createStoredConceptMap() {
|
|
const title = window.prompt(tr("concept-map-name", "Concept map name"), "");
|
|
if (!title || !title.trim()) return;
|
|
const conceptMap = await api("/api/cmaps", {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
title: title.trim(),
|
|
document: { schemaVersion: 1, items: [], connectors: [], conceptMaps: [] }
|
|
})
|
|
});
|
|
await loadConceptMaps();
|
|
location.hash = cmapRoute(conceptMap.slug);
|
|
}
|
|
|
|
async function renameStoredConceptMap() {
|
|
cancelCmapAutosave();
|
|
if (cmapHasUnsavedChanges() && !await saveStoredConceptMap({ automatic: true })) return false;
|
|
const conceptMap = state.currentConceptMap;
|
|
if (!conceptMap) return false;
|
|
const title = window.prompt(
|
|
tr("rename-concept-map", "Rename CMap"),
|
|
conceptMap.title);
|
|
if (!title || !title.trim() || title.trim() === conceptMap.title) return false;
|
|
try {
|
|
state.currentConceptMap = await api(
|
|
`/api/cmaps/${encodeURIComponent(conceptMap.slug)}/rename`,
|
|
{
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
title: title.trim(),
|
|
baseVersion: conceptMap.currentVersion
|
|
})
|
|
});
|
|
await loadConceptMaps();
|
|
showCmapStatus(tr("concept-map-renamed", "CMap renamed"), true);
|
|
return true;
|
|
} catch (error) {
|
|
showCmapStatus(error.message);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function deleteStoredConceptMap() {
|
|
cancelCmapAutosave();
|
|
if (cmapSavePromise) await cmapSavePromise;
|
|
const conceptMap = state.currentConceptMap;
|
|
if (!conceptMap) return false;
|
|
const question = tr(
|
|
"delete-concept-map-confirm",
|
|
"Delete concept map \"{title}\"? Links to it will remain but will no longer open the map.")
|
|
.replace("{title}", conceptMap.title);
|
|
if (!window.confirm(question)) return false;
|
|
try {
|
|
await api(`/api/cmaps/${encodeURIComponent(conceptMap.slug)}`, { method: "DELETE" });
|
|
markCurrentCmapSaved();
|
|
state.currentConceptMap = null;
|
|
await loadConceptMaps();
|
|
const target = state.conceptMaps.length ? cmapRoute(state.conceptMaps[0].slug) : "#cmaps";
|
|
await navigateToHash(target);
|
|
showCmapStatus(tr("concept-map-deleted", "CMap deleted"), true);
|
|
return true;
|
|
} catch (error) {
|
|
showCmapStatus(error.message);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
async function saveStoredConceptMap({ automatic = false, force = false,
|
|
summary = null, snapshotVersion = false } = {}) {
|
|
const prototype = cmapPrototypeState();
|
|
if (!prototype.editor) return false;
|
|
cancelCmapAutosave();
|
|
if (automatic && !state.currentConceptMap) return false;
|
|
if (cmapSavePromise) {
|
|
const firstSaveSucceeded = await cmapSavePromise;
|
|
if (!firstSaveSucceeded) return false;
|
|
if (force || cmapHasUnsavedChanges()) {
|
|
return saveStoredConceptMap({ automatic, force, summary, snapshotVersion });
|
|
}
|
|
return true;
|
|
}
|
|
|
|
const snapshot = currentCmapSnapshot();
|
|
if (snapshot === null) return false;
|
|
if (!force && snapshot === state.cmapSavedSnapshot) {
|
|
if (!automatic) showCmapStatus(tr("concept-map-saved", "CMap saved"), true);
|
|
return true;
|
|
}
|
|
|
|
const document = JSON.parse(snapshot);
|
|
const conceptMapAtStart = state.currentConceptMap;
|
|
let saveSucceeded = false;
|
|
showCmapStatus(snapshotVersion ? tr("creating-snapshot", "Creating snapshot…") :
|
|
(automatic ? tr("autosaving", "Saving automatically…") : tr("saving", "Saving…")));
|
|
cmapSavePromise = (async () => {
|
|
try {
|
|
if (!conceptMapAtStart) {
|
|
const title = window.prompt(tr("concept-map-name", "Concept map name"), "");
|
|
if (!title || !title.trim()) {
|
|
showCmapStatus("");
|
|
return false;
|
|
}
|
|
state.currentConceptMap = await api("/api/cmaps", {
|
|
method: "POST",
|
|
body: JSON.stringify({ title: title.trim(), document })
|
|
});
|
|
const savedRoute = cmapRoute(state.currentConceptMap.slug);
|
|
history.replaceState(history.state, "", `${location.pathname}${location.search}${savedRoute}`);
|
|
state.cmapGuardHash = savedRoute;
|
|
await loadConceptMaps();
|
|
} else {
|
|
const savedConceptMap = await api(
|
|
`/api/cmaps/${encodeURIComponent(conceptMapAtStart.slug)}`,
|
|
{
|
|
method: "PUT",
|
|
body: JSON.stringify({
|
|
title: conceptMapAtStart.title,
|
|
baseVersion: conceptMapAtStart.currentVersion,
|
|
summary: summary || (automatic ? tr("automatic-save", "Automatic save") : tr("manual-save", "Manual save")),
|
|
snapshot: snapshotVersion,
|
|
document
|
|
})
|
|
});
|
|
if (state.currentConceptMap && state.currentConceptMap.slug === conceptMapAtStart.slug) {
|
|
state.currentConceptMap = savedConceptMap;
|
|
updateStoredConceptMapSummary(savedConceptMap);
|
|
}
|
|
}
|
|
markCurrentCmapSaved(snapshot);
|
|
showCmapStatus(
|
|
snapshotVersion ? tr("snapshot-created", "Snapshot created") :
|
|
(automatic ? tr("concept-map-autosaved", "CMap saved automatically") : tr("concept-map-saved", "CMap saved")),
|
|
true);
|
|
saveSucceeded = true;
|
|
return true;
|
|
} catch (error) {
|
|
showCmapStatus(error.message);
|
|
return false;
|
|
}
|
|
})().finally(() => {
|
|
cmapSavePromise = null;
|
|
if (saveSucceeded && cmapHasUnsavedChanges()) scheduleCmapAutosave();
|
|
});
|
|
return cmapSavePromise;
|
|
}
|
|
|
|
async function createConceptMapSnapshot() {
|
|
if (!state.currentConceptMap) return false;
|
|
const description = window.prompt(tr("snapshot-description", "Snapshot description"), "");
|
|
if (description === null) return false;
|
|
return saveStoredConceptMap({
|
|
force: true,
|
|
summary: description.trim() || tr("snapshot", "Snapshot"),
|
|
snapshotVersion: true
|
|
});
|
|
}
|
|
|
|
async function showCmapPrototype(requestedSlug = null) {
|
|
state.previousView = state.currentPage ? "page-view" : "cmap-view";
|
|
renderBreadcrumbs([
|
|
{ label: state.siteTitle, href: "/" },
|
|
{ label: tr("cmaps", "CMaps") }
|
|
]);
|
|
renderToc([], () => {});
|
|
show("cmap-view");
|
|
state.cmapGuardHash = location.hash;
|
|
await loadConceptMaps();
|
|
if (requestedSlug && state.conceptMaps.some((conceptMap) => conceptMap.slug === requestedSlug)) {
|
|
await openStoredConceptMap(requestedSlug);
|
|
} else if (requestedSlug) {
|
|
state.currentConceptMap = null;
|
|
resetCmapPrototype(null, false);
|
|
markCurrentCmapSaved();
|
|
renderConceptMapSelector();
|
|
showCmapStatus(tr("concept-map-not-found", "CMap not found"));
|
|
} else if (state.currentConceptMap &&
|
|
state.conceptMaps.some((conceptMap) => conceptMap.slug === state.currentConceptMap.slug)) {
|
|
await openStoredConceptMap(state.currentConceptMap.slug);
|
|
} else if (state.conceptMaps.length) {
|
|
await openStoredConceptMap(state.conceptMaps[0].slug);
|
|
} else {
|
|
state.currentConceptMap = null;
|
|
resetCmapPrototype();
|
|
markCurrentCmapSaved();
|
|
renderConceptMapSelector();
|
|
}
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Wiki graph support
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
function graphNodeId(type, slug) {
|
|
return `${type}\u0000${slug}`;
|
|
}
|
|
|
|
function pageLinks(markdown) {
|
|
const container = document.createElement("div");
|
|
container.innerHTML = renderMarkdown(markdown || "");
|
|
const pages = new Set();
|
|
const conceptMaps = new Set();
|
|
|
|
for (const link of container.querySelectorAll("a[href]")) {
|
|
const href = link.getAttribute("href");
|
|
const cmapSlug = cmapSlugFromHref(href);
|
|
if (cmapSlug) {
|
|
conceptMaps.add(cmapSlug);
|
|
continue;
|
|
}
|
|
const pageSlug = wikiSlugFromHref(href);
|
|
if (pageSlug) pages.add(pageSlug);
|
|
}
|
|
for (const embed of container.querySelectorAll(".rw-cmap-embed[data-cmap-reference]")) {
|
|
const target = cmapMentionTarget(embed.dataset.cmapReference || "");
|
|
if (target) conceptMaps.add(target.slug);
|
|
}
|
|
|
|
return { pages, conceptMaps };
|
|
}
|
|
|
|
function conceptMapLinks(documentValue) {
|
|
const pages = new Set();
|
|
const conceptMaps = new Set();
|
|
const items = documentValue && Array.isArray(documentValue.items) ? documentValue.items : [];
|
|
|
|
for (const item of items) {
|
|
if (item && typeof item.pageSlug === "string" && item.pageSlug) pages.add(item.pageSlug);
|
|
if (item && typeof item.cmapSlug === "string" && item.cmapSlug) conceptMaps.add(item.cmapSlug);
|
|
}
|
|
|
|
return { pages, conceptMaps };
|
|
}
|
|
|
|
/**
|
|
* goal : Build one navigation graph from wiki pages and stored CMaps.
|
|
* pre : Page and CMap catalogues have been loaded.
|
|
* post : The graph is cached until either catalogue is reloaded.
|
|
* result : Typed graph nodes and directed page/CMap edges.
|
|
*/
|
|
async function loadGraphData() {
|
|
if (state.graphData) return state.graphData;
|
|
|
|
const pageSlugs = new Set(state.pages.map((page) => page.slug));
|
|
const cmapSlugs = new Set(state.conceptMaps.map((conceptMap) => conceptMap.slug));
|
|
const nodes = [
|
|
...state.pages.map((page) => ({
|
|
...page,
|
|
id: graphNodeId("page", page.slug),
|
|
type: "page"
|
|
})),
|
|
...state.conceptMaps.map((conceptMap) => ({
|
|
...conceptMap,
|
|
id: graphNodeId("cmap", conceptMap.slug),
|
|
type: "cmap"
|
|
}))
|
|
];
|
|
const edges = [];
|
|
const seen = new Set();
|
|
|
|
const addEdge = (fromType, fromSlug, toType, toSlug) => {
|
|
const from = graphNodeId(fromType, fromSlug);
|
|
const to = graphNodeId(toType, toSlug);
|
|
if (from === to) return;
|
|
const key = `${from}\u0001${to}`;
|
|
if (seen.has(key)) return;
|
|
seen.add(key);
|
|
edges.push({ from, to });
|
|
};
|
|
|
|
for (const page of state.pages) {
|
|
const full = await api(`/api/pages/${encodeURIComponent(page.slug)}`);
|
|
const links = pageLinks(full.markdown);
|
|
for (const target of links.pages) {
|
|
const canonicalTarget = canonicalPageReference(target);
|
|
if (!pageSlugs.has(canonicalTarget) || canonicalTarget === page.slug) continue;
|
|
addEdge("page", page.slug, "page", canonicalTarget);
|
|
}
|
|
for (const target of links.conceptMaps) {
|
|
if (cmapSlugs.has(target)) addEdge("page", page.slug, "cmap", target);
|
|
}
|
|
}
|
|
|
|
for (const conceptMap of state.conceptMaps) {
|
|
const full = await api(`/api/cmaps/${encodeURIComponent(conceptMap.slug)}`);
|
|
const links = conceptMapLinks(decodeStoredConceptMapDocument(full));
|
|
for (const target of links.pages) {
|
|
const canonicalTarget = canonicalPageReference(target);
|
|
if (pageSlugs.has(canonicalTarget)) addEdge("cmap", conceptMap.slug, "page", canonicalTarget);
|
|
}
|
|
for (const target of links.conceptMaps) {
|
|
if (cmapSlugs.has(target)) addEdge("cmap", conceptMap.slug, "cmap", target);
|
|
}
|
|
}
|
|
|
|
state.graphData = { nodes, edges };
|
|
return state.graphData;
|
|
}
|
|
|
|
/**
|
|
* goal : Select the direct incoming and outgoing context of one page.
|
|
* pre : data is a complete graph and focusId identifies a typed graph node.
|
|
* post : No application state is changed.
|
|
* result : A smaller graph containing focus, direct neighbours and directed edges.
|
|
*/
|
|
function contextGraphData(data, focusId) {
|
|
const relatedIds = new Set([focusId]);
|
|
const edges = [];
|
|
|
|
for (const edge of data.edges) {
|
|
if (edge.from === focusId || edge.to === focusId) {
|
|
relatedIds.add(edge.from);
|
|
relatedIds.add(edge.to);
|
|
edges.push(edge);
|
|
}
|
|
}
|
|
|
|
return {
|
|
nodes: data.nodes.filter((node) => relatedIds.has(node.id)),
|
|
edges
|
|
};
|
|
}
|
|
|
|
function graphColumnPositions(slugs, x, height) {
|
|
const positions = new Map();
|
|
if (slugs.length === 0) return positions;
|
|
const top = 90;
|
|
const bottom = height - 90;
|
|
const step = slugs.length === 1 ? 0 : (bottom - top) / (slugs.length - 1);
|
|
slugs.forEach((slug, index) => {
|
|
positions.set(slug, { x, y: slugs.length === 1 ? height / 2 : top + (step * index) });
|
|
});
|
|
return positions;
|
|
}
|
|
|
|
/**
|
|
* goal : Render a clickable directed SVG graph.
|
|
* pre : data contains graph nodes/edges using compact page references.
|
|
* post : The requested SVG is replaced; node clicks navigate to pages.
|
|
* result : none.
|
|
*/
|
|
function renderWikiGraph(data, focusId = null, svgId = "wiki-graph", layout = "circular") {
|
|
const svg = $(svgId);
|
|
svg.replaceChildren();
|
|
|
|
const width = 1000;
|
|
const height = 700;
|
|
const centerX = width / 2;
|
|
const centerY = height / 2;
|
|
const positions = new Map();
|
|
const nodes = data.nodes;
|
|
|
|
if (layout === "context" && focusId) {
|
|
const incoming = new Set();
|
|
const outgoing = new Set();
|
|
for (const edge of data.edges) {
|
|
if (edge.to === focusId && edge.from !== focusId) incoming.add(edge.from);
|
|
if (edge.from === focusId && edge.to !== focusId) outgoing.add(edge.to);
|
|
}
|
|
|
|
const shared = [...incoming].filter((slug) => outgoing.has(slug));
|
|
const incomingOnly = [...incoming].filter((slug) => !outgoing.has(slug));
|
|
const outgoingOnly = [...outgoing].filter((slug) => !incoming.has(slug));
|
|
|
|
positions.set(focusId, { x: centerX, y: centerY });
|
|
for (const [slug, position] of graphColumnPositions(incomingOnly, 180, height)) positions.set(slug, position);
|
|
for (const [slug, position] of graphColumnPositions(outgoingOnly, 820, height)) positions.set(slug, position);
|
|
|
|
if (shared.length > 0) {
|
|
const left = 320;
|
|
const right = 680;
|
|
const step = shared.length === 1 ? 0 : (right - left) / (shared.length - 1);
|
|
shared.forEach((slug, index) => positions.set(slug, { x: shared.length === 1 ? centerX : left + (step * index), y: 105 }));
|
|
}
|
|
} else {
|
|
const radius = Math.min(width, height) * 0.36;
|
|
nodes.forEach((node, index) => {
|
|
const angle = nodes.length <= 1 ? 0 : (Math.PI * 2 * index / nodes.length) - Math.PI / 2;
|
|
positions.set(node.id, {
|
|
x: nodes.length <= 1 ? centerX : centerX + Math.cos(angle) * radius,
|
|
y: nodes.length <= 1 ? centerY : centerY + Math.sin(angle) * radius
|
|
});
|
|
});
|
|
}
|
|
|
|
const defs = document.createElementNS("http://www.w3.org/2000/svg", "defs");
|
|
const marker = document.createElementNS("http://www.w3.org/2000/svg", "marker");
|
|
const markerId = `${svgId}-arrow`;
|
|
marker.setAttribute("id", markerId);
|
|
marker.setAttribute("viewBox", "0 0 10 10");
|
|
marker.setAttribute("refX", "9");
|
|
marker.setAttribute("refY", "5");
|
|
marker.setAttribute("markerWidth", "7");
|
|
marker.setAttribute("markerHeight", "7");
|
|
marker.setAttribute("orient", "auto-start-reverse");
|
|
const markerPath = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
|
markerPath.setAttribute("d", "M 0 0 L 10 5 L 0 10 z");
|
|
marker.append(markerPath);
|
|
defs.append(marker);
|
|
svg.append(defs);
|
|
|
|
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);
|
|
line.setAttribute("marker-end", `url(#${markerId})`);
|
|
if (focusId && edge.to === focusId) line.classList.add("wiki-graph-edge-incoming");
|
|
if (focusId && edge.from === focusId) line.classList.add("wiki-graph-edge-outgoing");
|
|
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 node of nodes) {
|
|
const position = positions.get(node.id);
|
|
if (!position) continue;
|
|
const group = document.createElementNS("http://www.w3.org/2000/svg", "g");
|
|
const classes = ["wiki-graph-node", `wiki-graph-node-${node.type}`];
|
|
if (node.id === focusId) classes.push("wiki-graph-node-focus");
|
|
group.setAttribute("class", classes.join(" "));
|
|
group.setAttribute("transform", `translate(${position.x} ${position.y})`);
|
|
group.setAttribute("tabindex", "0");
|
|
group.setAttribute("role", "link");
|
|
group.setAttribute("aria-label", node.type === "cmap" ?
|
|
`${tr("concept-map", "Concept map")}: ${node.title}` :
|
|
`${tr("wiki-page", "Wiki page")}: ${node.title}`);
|
|
|
|
const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
|
|
circle.setAttribute("r", "11");
|
|
|
|
const title = document.createElementNS("http://www.w3.org/2000/svg", "title");
|
|
title.textContent = group.getAttribute("aria-label");
|
|
|
|
const text = document.createElementNS("http://www.w3.org/2000/svg", "text");
|
|
const incomingSide = layout === "context" && position.x < centerX;
|
|
text.setAttribute("x", incomingSide ? "-17" : "17");
|
|
text.setAttribute("y", "4");
|
|
if (incomingSide) text.setAttribute("text-anchor", "end");
|
|
text.textContent = node.title.length > 34 ? `${node.title.slice(0, 31)}…` : node.title;
|
|
|
|
const open = () => {
|
|
closeContextOverlay();
|
|
navigateToHash(node.type === "cmap" ? cmapRoute(node.slug) : pageRoute(node.slug))
|
|
.catch((error) => console.error(error));
|
|
};
|
|
group.addEventListener("click", open);
|
|
group.addEventListener("keydown", (event) => {
|
|
if (event.key === "Enter" || event.key === " ") {
|
|
event.preventDefault();
|
|
open();
|
|
}
|
|
});
|
|
|
|
group.append(title, circle, text);
|
|
nodeLayer.append(group);
|
|
}
|
|
svg.append(nodeLayer);
|
|
}
|
|
|
|
function contextSummary(data) {
|
|
const focusId = state.currentPage ? graphNodeId("page", state.currentPage.slug) : null;
|
|
let incoming = 0;
|
|
let outgoing = 0;
|
|
for (const edge of data.edges) {
|
|
if (edge.to === focusId) incoming += 1;
|
|
if (edge.from === focusId) outgoing += 1;
|
|
}
|
|
return tr("context-graph-direction-summary", "{incoming} incoming, {outgoing} outgoing")
|
|
.replace("{incoming}", String(incoming))
|
|
.replace("{outgoing}", String(outgoing));
|
|
}
|
|
|
|
/**
|
|
* goal : Open the complete wiki graph special view.
|
|
* pre : User can read pages.
|
|
* post : Full graph data is loaded and rendered.
|
|
*/
|
|
async function showGraph() {
|
|
closeContextOverlay();
|
|
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("combined-wiki-graph", "Wiki and CMap graph");
|
|
|
|
const data = await loadGraphData();
|
|
const pageCount = data.nodes.filter((node) => node.type === "page").length;
|
|
const cmapCount = data.nodes.filter((node) => node.type === "cmap").length;
|
|
$("graph-summary").textContent = tr("combined-graph-summary", "{pages} pages, {cmaps} concept maps, {links} links")
|
|
.replace("{pages}", String(pageCount))
|
|
.replace("{cmaps}", String(cmapCount))
|
|
.replace("{links}", String(data.edges.length));
|
|
renderWikiGraph(data);
|
|
}
|
|
|
|
/**
|
|
* goal : Load direct incoming and outgoing neighbours of the current page.
|
|
* pre : state.currentPage identifies an existing page.
|
|
* post : state.contextDockData contains the current direct context.
|
|
* result : The direct context graph.
|
|
*/
|
|
async function currentContextGraph() {
|
|
if (!state.currentPage) return { nodes: [], edges: [] };
|
|
const data = await loadGraphData();
|
|
const context = contextGraphData(data, graphNodeId("page", state.currentPage.slug));
|
|
state.contextDockData = context;
|
|
return context;
|
|
}
|
|
|
|
function closeContextOverlay() {
|
|
$("context-overlay").classList.add("hidden");
|
|
}
|
|
|
|
/**
|
|
* goal : Show the current page context as a modal overlay without leaving the page.
|
|
* pre : state.currentPage identifies an existing page.
|
|
* post : The overlay contains incoming and outgoing direct neighbours.
|
|
*/
|
|
async function showContextGraphOverlay() {
|
|
if (!state.currentPage) return;
|
|
const data = await currentContextGraph();
|
|
$("context-overlay-title").textContent = `${tr("context-graph", "Context graph")}: ${state.currentPage.title}`;
|
|
$("context-overlay-summary").textContent = contextSummary(data);
|
|
renderWikiGraph(data, graphNodeId("page", state.currentPage.slug), "context-overlay-graph", "context");
|
|
$("context-overlay").classList.remove("hidden");
|
|
}
|
|
|
|
/**
|
|
* goal : Dock the context graph beside or above the current page.
|
|
* pre : position is "right" or "top" and a current page exists.
|
|
* post : Page reading layout reserves space for the live context graph.
|
|
*/
|
|
async function dockContextGraph(position) {
|
|
if (!state.currentPage) return;
|
|
state.contextDockPosition = position;
|
|
closeContextOverlay();
|
|
await refreshContextDock();
|
|
}
|
|
|
|
async function refreshContextDock() {
|
|
if (!state.currentPage || !state.contextDockPosition) return;
|
|
const data = await currentContextGraph();
|
|
const layout = $("page-reading-layout");
|
|
layout.classList.remove("context-dock-right", "context-dock-top");
|
|
layout.classList.add(state.contextDockPosition === "top" ? "context-dock-top" : "context-dock-right");
|
|
$("context-dock").classList.remove("hidden");
|
|
$("context-dock-summary").textContent = contextSummary(data);
|
|
renderWikiGraph(data, graphNodeId("page", state.currentPage.slug), "context-dock-graph", "context");
|
|
}
|
|
|
|
function closeContextDock() {
|
|
state.contextDockPosition = null;
|
|
state.contextDockData = null;
|
|
$("context-dock").classList.add("hidden");
|
|
$("page-reading-layout").classList.remove("context-dock-right", "context-dock-top");
|
|
}
|
|
|
|
/**
|
|
* goal : Open the current direct context in the normal full-size graph view.
|
|
* pre : state.currentPage identifies an existing page.
|
|
* post : Context graph replaces the normal page view until another route is opened.
|
|
*/
|
|
async function showContextGraph() {
|
|
if (!state.currentPage) return;
|
|
|
|
closeContextOverlay();
|
|
const page = state.currentPage;
|
|
state.previousView = "page-view";
|
|
renderBreadcrumbs([
|
|
{ label: state.siteTitle, href: "/" },
|
|
{ label: page.title, href: pageRoute(page.slug) },
|
|
{ label: tr("context", "context") }
|
|
]);
|
|
renderToc([], () => {});
|
|
show("graph-view");
|
|
$("graph-title").textContent = `${tr("context-graph", "Context graph")}: ${page.title}`;
|
|
|
|
const data = await currentContextGraph();
|
|
$("graph-summary").textContent = contextSummary(data);
|
|
renderWikiGraph(data, graphNodeId("page", page.slug), "wiki-graph", "context");
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* goal : Start periodic connectivity checks for the wiki backend.
|
|
* pre : Browser application initialization has completed.
|
|
* post : Offline/online state is updated approximately every 15 seconds.
|
|
*/
|
|
function startKeepAlive() {
|
|
window.setInterval(pingServer, 15000);
|
|
pingServer();
|
|
}
|
|
|
|
/**
|
|
* goal : Bootstrap the authenticated browser application.
|
|
* pre : index.html and all pinned vendor scripts are loaded.
|
|
* post : Session, translations, pages, editor, navigation and keep-alive are ready.
|
|
*/
|
|
async function initialize() {
|
|
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();
|
|
restoreCmapPageGuides();
|
|
initializeEditor();
|
|
$("account-name").textContent = state.session.user.displayName;
|
|
$("account-role").textContent = tr(`role-${state.session.user.role}`, state.session.user.role);
|
|
updateRoleUi();
|
|
await loadPages();
|
|
await loadConceptMaps();
|
|
loadBreadcrumbTrail();
|
|
await loadBookmarks();
|
|
await route();
|
|
startKeepAlive();
|
|
}
|
|
|
|
installWikiLinkNavigation();
|
|
|
|
$("search-form").addEventListener("submit", (event) => {
|
|
event.preventDefault();
|
|
const query = $("search-input").value.trim();
|
|
if (!query) {
|
|
const target = state.currentPage ? pageRoute(state.currentPage.slug) : pageRoute(startPage()?.slug || "start");
|
|
navigateToHash(target).catch((error) => console.error(error));
|
|
return;
|
|
}
|
|
navigateToHash(`#search/${encodeURIComponent(query)}`)
|
|
.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(); });
|
|
$("rename-page").addEventListener("click", (event) => { event.preventDefault(); beginRenamePage(); });
|
|
$("save-rename").addEventListener("click", () => { saveRenamePage().catch(console.error); });
|
|
$("cancel-rename").addEventListener("click", () => { if (state.currentPage) openPage(state.currentPage.slug).catch(console.error); });
|
|
$("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();
|
|
navigateToHash("#recent").catch((error) => console.error(error));
|
|
});
|
|
$("bookmarks-link").addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
navigateToHash("#bookmarks").catch((error) => console.error(error));
|
|
});
|
|
$("todo-link").addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
navigateToHash("#todos").catch((error) => console.error(error));
|
|
});
|
|
$("graph-link").addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
navigateToHash("#graph").catch((error) => console.error(error));
|
|
});
|
|
$("cmaps-link").addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
const currentSlug = state.currentConceptMap &&
|
|
state.conceptMaps.some((conceptMap) => conceptMap.slug === state.currentConceptMap.slug) ?
|
|
state.currentConceptMap.slug : null;
|
|
navigateToHash(currentSlug ? cmapRoute(currentSlug) : "#cmaps")
|
|
.catch((error) => console.error(error));
|
|
});
|
|
function openSelectedConceptMap() {
|
|
const slug = cmapMapCombobox.value();
|
|
if (!slug) return;
|
|
if (state.currentConceptMap && slug === state.currentConceptMap.slug) return;
|
|
navigateToHash(cmapRoute(slug))
|
|
.then((changed) => {
|
|
if (!changed) renderConceptMapSelector();
|
|
})
|
|
.catch((error) => {
|
|
showCmapStatus(error.message);
|
|
console.error(error);
|
|
});
|
|
}
|
|
$("cmap-map-select").addEventListener("change", openSelectedConceptMap);
|
|
$("cmap-new-map").addEventListener("click", () => {
|
|
requestCmapTransition(() => createStoredConceptMap())
|
|
.catch((error) => {
|
|
showCmapStatus(error.message);
|
|
});
|
|
});
|
|
$("cmap-save-map").addEventListener("click", () => saveStoredConceptMap());
|
|
$("cmap-rename-map").addEventListener("click", () => renameStoredConceptMap());
|
|
$("cmap-delete-map").addEventListener("click", () => deleteStoredConceptMap());
|
|
$("cmap-create-snapshot").addEventListener("click", () => {
|
|
createConceptMapSnapshot().catch((error) => showCmapStatus(error.message));
|
|
});
|
|
$("cmap-history").addEventListener("click", () => {
|
|
showConceptMapHistory().catch((error) => showCmapStatus(error.message));
|
|
});
|
|
$("cmap-add-concept").addEventListener("click", () => {
|
|
openNewCmapConceptDialog(cmapContextCreateContext || {});
|
|
});
|
|
$("cmap-add-page").addEventListener("click", () => {
|
|
if (!state.currentPage) return;
|
|
addCmapPrototypeNode({ ...cmapPlacementOptions(), label: state.currentPage.title, synopsis: currentPageSynopsis(), kind: "page", pageSlug: state.currentPage.slug, backgroundColor: "#e7f2fb", borderColor: "#4479a1" });
|
|
});
|
|
$("cmap-add-submap").addEventListener("click", () => {
|
|
const label = window.prompt(tr("add-submap", "Add sub-CMap"), tr("sub-concept-map", "Sub concept map"));
|
|
if (!label) return;
|
|
addCmapPrototypeNode({ ...cmapPlacementOptions(), label, synopsis: "Expandable child-map placeholder.", kind: "submap", childMap: label, backgroundColor: "#edf7e8", borderColor: "#57834a" });
|
|
});
|
|
$("cmap-promote-submap").addEventListener("click", () => {
|
|
const prototype = cmapPrototypeState();
|
|
const record = prototype.editor ? prototype.editor.selected() : null;
|
|
if (!record || record.kind !== "submap") return;
|
|
const name = window.prompt(tr("submap-name", "Name of the new concept map"), record.childMap || record.label);
|
|
if (!name || !name.trim()) return;
|
|
prototype.editor.promoteSubmap(record, name.trim());
|
|
prototype.editor.openSubmapMap(record);
|
|
$("cmap-promote-submap").disabled = true;
|
|
});
|
|
$("cmap-edit-selected").addEventListener("click", () => editSelectedCmapNode());
|
|
$("cmap-undo").addEventListener("click", () => {
|
|
const prototype = cmapPrototypeState();
|
|
if (prototype.editor) prototype.editor.undo();
|
|
});
|
|
$("cmap-redo").addEventListener("click", () => {
|
|
const prototype = cmapPrototypeState();
|
|
if (prototype.editor) prototype.editor.redo();
|
|
});
|
|
$("cmap-select-all").addEventListener("click", () => {
|
|
const prototype = cmapPrototypeState();
|
|
if (prototype.editor) prototype.editor.selectAll();
|
|
});
|
|
$("cmap-group-selected").addEventListener("click", () => {
|
|
groupSelectedCmapItems();
|
|
});
|
|
$("cmap-ungroup-selected").addEventListener("click", () => {
|
|
const prototype = cmapPrototypeState();
|
|
if (prototype.editor) prototype.editor.ungroupSelection();
|
|
});
|
|
$("cmap-delete-selected").addEventListener("click", () => {
|
|
const prototype = cmapPrototypeState();
|
|
if (prototype.editor) prototype.editor.deleteSelection();
|
|
});
|
|
$("cmap-toggle-page-guides").addEventListener("click", () => {
|
|
const visible = $("cmap-toggle-page-guides").getAttribute("aria-checked") !== "true";
|
|
setCmapPageGuides(visible);
|
|
});
|
|
$("cmap-reset").addEventListener("click", () => {
|
|
if (state.currentConceptMap) {
|
|
requestCmapTransition(() => openStoredConceptMap(state.currentConceptMap.slug))
|
|
.catch((error) => console.error(error));
|
|
} else {
|
|
resetCmapPrototype();
|
|
markCurrentCmapSaved();
|
|
}
|
|
});
|
|
$("cmap-map-back").addEventListener("click", () => {
|
|
const prototype = cmapPrototypeState();
|
|
if (prototype.editor) prototype.editor.openParentMap();
|
|
});
|
|
$("cmap-canvas").addEventListener("contextmenu", (event) => {
|
|
event.preventDefault();
|
|
const prototype = cmapPrototypeState();
|
|
if (!prototype.editor) return;
|
|
const point = prototype.editor.canvasPoint(event);
|
|
openCmapContextMenu(event.clientX, event.clientY, {
|
|
point,
|
|
parentSubmap: prototype.editor.submapAtPoint(point)
|
|
});
|
|
});
|
|
$("cmap-tools-menu").addEventListener("click", (event) => {
|
|
const prototype = cmapPrototypeState();
|
|
if (!prototype.editor) return;
|
|
if (!$("cmap-context-menu").classList.contains("hidden")) {
|
|
closeCmapContextMenu();
|
|
return;
|
|
}
|
|
const buttonRect = event.currentTarget.getBoundingClientRect();
|
|
const canvasRect = $("cmap-canvas").getBoundingClientRect();
|
|
const point = prototype.editor.canvasPoint({
|
|
clientX: canvasRect.left + 160,
|
|
clientY: Math.max(canvasRect.top, buttonRect.bottom) + 80
|
|
});
|
|
openCmapContextMenu(buttonRect.left, buttonRect.bottom + 4, {
|
|
point,
|
|
parentSubmap: prototype.editor.submapAtPoint(point)
|
|
});
|
|
});
|
|
$("cmap-context-menu").addEventListener("click", () => closeCmapContextMenu());
|
|
document.addEventListener("pointerdown", (event) => {
|
|
if (!event.target.closest("#cmap-context-menu, #cmap-tools-menu")) closeCmapContextMenu();
|
|
});
|
|
$("cmap-zoom-out").addEventListener("click", () => setCmapZoom(Number($("cmap-zoom-percent").value) - 10));
|
|
$("cmap-zoom-in").addEventListener("click", () => setCmapZoom(Number($("cmap-zoom-percent").value) + 10));
|
|
$("cmap-zoom-reset").addEventListener("click", () => setCmapZoom(100));
|
|
$("cmap-zoom-percent").addEventListener("change", (event) => setCmapZoom(event.target.value));
|
|
$("cmap-concept-page").addEventListener("change", (event) => {
|
|
if (cmapPageCombobox.value()) cmapLinkCombobox.clear();
|
|
});
|
|
$("cmap-concept-cmap").addEventListener("change", (event) => {
|
|
if (cmapLinkCombobox.value()) cmapPageCombobox.clear();
|
|
});
|
|
for (const id of ["cmap-concept-page", "cmap-concept-cmap"]) {
|
|
$(id).addEventListener("input", (event) => event.target.setCustomValidity(""));
|
|
}
|
|
$("cmap-concept-image").addEventListener("change", (event) => {
|
|
const file = event.target.files && event.target.files[0];
|
|
if (!file) return;
|
|
const record = cmapDialogRecord;
|
|
cmapDialogImageRead = readCmapImage(file)
|
|
.then((imageSource) => {
|
|
if (cmapDialogRecord !== record) return;
|
|
cmapDialogImageSource = imageSource;
|
|
updateCmapImagePreview();
|
|
})
|
|
.catch((error) => {
|
|
console.error(error);
|
|
window.alert(error.message);
|
|
});
|
|
});
|
|
$("cmap-concept-image-remove").addEventListener("click", () => {
|
|
cmapDialogImageSource = "";
|
|
cmapDialogImageRead = Promise.resolve();
|
|
$("cmap-concept-image").value = "";
|
|
updateCmapImagePreview();
|
|
});
|
|
$("cmap-concept-cancel").addEventListener("click", () => $("cmap-concept-dialog").close());
|
|
$("cmap-concept-form").addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
await cmapDialogImageRead;
|
|
const record = cmapDialogRecord;
|
|
const createContext = cmapDialogCreateContext;
|
|
const prototype = cmapPrototypeState();
|
|
if ((!record && !createContext) || !prototype.editor) return;
|
|
const label = $("cmap-concept-label").value.trim();
|
|
if (!label) {
|
|
$("cmap-concept-label").focus();
|
|
return;
|
|
}
|
|
const fontSize = Math.max(6, Math.min(54, Number($("cmap-concept-font-size").value) || 11));
|
|
const pageInput = $("cmap-concept-page");
|
|
const cmapInput = $("cmap-concept-cmap");
|
|
const selectedPage = record && record.kind === "submap" ? "" : cmapPageCombobox.value();
|
|
const selectedCmap = record && record.kind === "submap" ? "" : cmapLinkCombobox.value();
|
|
if (selectedPage === null) {
|
|
pageInput.setCustomValidity(tr("select-listed-page", "Select a wiki page from the list or clear the field."));
|
|
pageInput.reportValidity();
|
|
return;
|
|
}
|
|
if (selectedCmap === null) {
|
|
cmapInput.setCustomValidity(tr("select-listed-concept-map", "Select a CMap from the list or clear the field."));
|
|
cmapInput.reportValidity();
|
|
return;
|
|
}
|
|
const linkedPage = selectedPage || null;
|
|
const linkedCmapValue = selectedCmap || "";
|
|
const parentCmapLink = linkedCmapValue === "__parent__";
|
|
const linkedCmap = linkedCmapValue && !parentCmapLink ? linkedCmapValue : null;
|
|
const changes = {
|
|
kind: record && record.kind === "submap" ? "submap" : (linkedPage ? "page" : "concept"),
|
|
label,
|
|
synopsis: $("cmap-concept-synopsis").value,
|
|
pageSlug: linkedPage,
|
|
cmapSlug: linkedCmap,
|
|
parentCmapLink,
|
|
imageSource: cmapDialogImageSource,
|
|
backgroundColor: $("cmap-concept-background").value,
|
|
textColor: $("cmap-concept-text-color").value,
|
|
fontFamily: $("cmap-concept-font-family").value || "Arial, Helvetica, sans-serif",
|
|
fontSize: `${fontSize}pt`
|
|
};
|
|
if (!record || record.kind !== "submap") {
|
|
changes.borderColor = linkedPage ? "#4479a1" :
|
|
((linkedCmap || parentCmapLink) ? "#57834a" : "#a97c00");
|
|
} else {
|
|
changes.submapBackgroundColor = $("cmap-submap-background").value;
|
|
changes.submapBorderColor = $("cmap-submap-border").value;
|
|
}
|
|
if (record) {
|
|
prototype.editor.updateItem(record, changes);
|
|
prototype.editor.selectItem(record);
|
|
} else {
|
|
if (createContext.point) {
|
|
changes.x = Math.max(0, createContext.point.x - 70);
|
|
changes.y = Math.max(0, createContext.point.y - 30);
|
|
}
|
|
if (createContext.parentSubmap) {
|
|
changes.parentSubmap = createContext.parentSubmap;
|
|
changes.submapDepth = createContext.parentSubmap.submapDepth + 1;
|
|
}
|
|
const newRecord = addCmapPrototypeNode(changes);
|
|
if (createContext.source && newRecord) {
|
|
prototype.editor.finishRelation(createContext.source, newRecord);
|
|
} else if (newRecord) {
|
|
prototype.editor.selectItem(newRecord);
|
|
}
|
|
}
|
|
$("cmap-concept-dialog").close();
|
|
});
|
|
$("cmap-concept-dialog").addEventListener("close", () => {
|
|
cmapDialogRecord = null;
|
|
cmapDialogCreateContext = null;
|
|
cmapDialogImageSource = "";
|
|
cmapDialogImageRead = Promise.resolve();
|
|
});
|
|
$("wiki-cmap-link-cancel").addEventListener("click", () => $("wiki-cmap-link-dialog").close());
|
|
$("wiki-cmap-link-form").addEventListener("submit", (event) => {
|
|
event.preventDefault();
|
|
insertSelectedWikiCmapLink();
|
|
});
|
|
$("wiki-cmap-embed-submit").addEventListener("click", insertSelectedWikiCmapEmbed);
|
|
$("wiki-cmap-link-dialog").addEventListener("close", () => {
|
|
pendingWikiCmapLinkLabel = "";
|
|
wikiCmapLinkCombobox.clear();
|
|
});
|
|
$("cmap-unsaved-save").addEventListener("click", async () => {
|
|
if (await saveStoredConceptMap()) continueCmapTransition();
|
|
});
|
|
$("cmap-unsaved-discard").addEventListener("click", discardCmapChangesAndContinue);
|
|
$("cmap-unsaved-cancel").addEventListener("click", cancelCmapTransition);
|
|
$("cmap-unsaved-dialog").addEventListener("cancel", (event) => {
|
|
event.preventDefault();
|
|
cancelCmapTransition();
|
|
});
|
|
$("cmap-history-close").addEventListener("click", () => $("cmap-history-dialog").close());
|
|
$("context-link").addEventListener("click", (event) => { event.preventDefault(); showContextGraphOverlay().catch((error) => console.error(error)); });
|
|
$("context-overlay-close").addEventListener("click", (event) => { event.preventDefault(); closeContextOverlay(); });
|
|
$("context-overlay").addEventListener("click", (event) => { if (event.target === $("context-overlay")) closeContextOverlay(); });
|
|
$("context-dock-right").addEventListener("click", (event) => { event.preventDefault(); dockContextGraph("right").catch(console.error); });
|
|
$("context-dock-top").addEventListener("click", (event) => { event.preventDefault(); dockContextGraph("top").catch(console.error); });
|
|
$("context-open-full").addEventListener("click", (event) => { event.preventDefault(); showContextGraph().catch(console.error); });
|
|
$("context-dock-close").addEventListener("click", (event) => { event.preventDefault(); closeContextDock(); });
|
|
$("context-dock-popup").addEventListener("click", (event) => { event.preventDefault(); showContextGraphOverlay().catch(console.error); });
|
|
document.addEventListener("keydown", (event) => {
|
|
if (event.key === "Escape") closeContextOverlay();
|
|
if ((event.ctrlKey || event.metaKey) && event.key.toLocaleLowerCase() === "s" &&
|
|
!$("cmap-view").classList.contains("hidden")) {
|
|
event.preventDefault();
|
|
if (can("editor")) {
|
|
saveStoredConceptMap()
|
|
.then((saved) => {
|
|
if (saved && pendingCmapTransition) continueCmapTransition();
|
|
})
|
|
.catch((error) => console.error(error));
|
|
}
|
|
return;
|
|
}
|
|
handleCmapKeyboardShortcut(event);
|
|
});
|
|
$("logout-link").addEventListener("click", async (event) => {
|
|
event.preventDefault();
|
|
await api("/api/logout", { method: "POST", body: "{}" });
|
|
window.location.replace("/login");
|
|
});
|
|
$("profile-link").addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
navigateToHash("#profile").catch(console.error);
|
|
});
|
|
$("profile-form").addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
const newPassword = $("profile-new-password").value;
|
|
if (newPassword !== $("profile-repeat-password").value) {
|
|
$("profile-status").textContent = tr("passwords-do-not-match", "Passwords do not match.");
|
|
return;
|
|
}
|
|
try {
|
|
const result = await api("/api/profile", {
|
|
method: "PUT",
|
|
body: JSON.stringify({
|
|
displayName: $("profile-display-name").value,
|
|
email: $("profile-email").value,
|
|
currentPassword: $("profile-current-password").value,
|
|
newPassword
|
|
})
|
|
});
|
|
state.session = result.session;
|
|
$("account-name").textContent = state.session.user.displayName;
|
|
$("profile-current-password").value = "";
|
|
$("profile-new-password").value = "";
|
|
$("profile-repeat-password").value = "";
|
|
$("profile-status").textContent = tr("profile-saved", "Profile saved.");
|
|
} catch (error) {
|
|
$("profile-status").textContent = error.message;
|
|
}
|
|
});
|
|
$("admin-link").addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
navigateToHash("#admin").catch(console.error);
|
|
});
|
|
$("admin-users-link").addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
navigateToHash("#admin/users").catch(console.error);
|
|
});
|
|
$("admin-mail-link").addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
navigateToHash("#admin/mail").catch(console.error);
|
|
});
|
|
$("admin-aliases-link").addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
navigateToHash("#admin/aliases").catch(console.error);
|
|
});
|
|
$("admin-orphaned-uploads-link").addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
navigateToHash("#admin/orphaned-uploads").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();
|
|
navigateToHash("#admin").catch(console.error);
|
|
});
|
|
$("close-user-admin").addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
navigateToHash("#admin").catch(console.error);
|
|
});
|
|
$("close-mail-admin").addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
navigateToHash("#admin").catch(console.error);
|
|
});
|
|
$("close-alias-admin").addEventListener("click", (event) => {
|
|
event.preventDefault();
|
|
navigateToHash("#admin").catch(console.error);
|
|
});
|
|
$("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);
|
|
$("editor-namespace").addEventListener("input", updateEditorSlugInfo);
|
|
|
|
$("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 = "";
|
|
});
|
|
|
|
document.addEventListener("click", (event) => {
|
|
if (!cmapHasUnsavedChanges()) return;
|
|
const link = event.target instanceof Element ? event.target.closest("a[href]") : null;
|
|
if (!link || link.target === "_blank") return;
|
|
event.preventDefault();
|
|
event.stopImmediatePropagation();
|
|
requestCmapTransition(() => link.click()).catch((error) => console.error(error));
|
|
}, true);
|
|
|
|
window.addEventListener("beforeunload", (event) => {
|
|
if (!cmapHasUnsavedChanges()) return;
|
|
event.preventDefault();
|
|
event.returnValue = "";
|
|
});
|
|
|
|
$("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,
|
|
email: $("new-email").value,
|
|
password: $("new-password").value,
|
|
role: $("new-role").value,
|
|
enabled: true
|
|
})
|
|
});
|
|
event.target.reset();
|
|
await loadUsersAdmin();
|
|
});
|
|
|
|
$("mail-settings-form").addEventListener("submit", async (event) => {
|
|
event.preventDefault();
|
|
try {
|
|
await api("/api/admin/mail-settings", {
|
|
method: "PUT",
|
|
body: JSON.stringify(mailSettingsFormData())
|
|
});
|
|
$("mail-smtp-password").value = "";
|
|
$("mail-password-help").textContent = tr("smtp-password-kept", "A password is stored; leave empty to keep it.");
|
|
$("mail-settings-status").textContent = tr("saved", "Saved");
|
|
} catch (error) {
|
|
$("mail-settings-status").textContent = error.message;
|
|
}
|
|
});
|
|
|
|
$("mail-test-button").addEventListener("click", async () => {
|
|
const button = $("mail-test-button");
|
|
const recipient = $("mail-test-recipient").value.trim();
|
|
button.disabled = true;
|
|
$("mail-settings-status").textContent = tr("sending-test-mail", "Sending test email…");
|
|
try {
|
|
await api("/api/admin/mail-settings/test", {
|
|
method: "POST",
|
|
body: JSON.stringify({
|
|
...mailSettingsFormData(),
|
|
recipient
|
|
})
|
|
});
|
|
$("mail-settings-status").textContent = tr("test-mail-sent", "Test email accepted by SMTP server");
|
|
} catch (error) {
|
|
console.error("SMTP test failed", error);
|
|
$("mail-settings-status").textContent = smtpErrorMessage(error);
|
|
} finally {
|
|
button.disabled = false;
|
|
}
|
|
});
|
|
|
|
$("mail-smtp-tls").addEventListener("change", () => {
|
|
const acceptUntrustedCertificates = $("mail-smtp-accept-untrusted-certificates");
|
|
acceptUntrustedCertificates.disabled = !$("mail-smtp-tls").checked;
|
|
if (acceptUntrustedCertificates.disabled) acceptUntrustedCertificates.checked = false;
|
|
});
|
|
|
|
window.addEventListener("hashchange", () => {
|
|
if (cmapHasUnsavedChanges()) {
|
|
const requestedHash = location.hash;
|
|
const previousHash = state.cmapGuardHash;
|
|
history.replaceState(history.state, "", `${location.pathname}${location.search}${previousHash}`);
|
|
requestCmapTransition(() => {
|
|
state.cmapGuardHash = requestedHash;
|
|
if (location.hash === requestedHash) {
|
|
return route();
|
|
}
|
|
location.hash = requestedHash;
|
|
return undefined;
|
|
}).catch(console.error);
|
|
return;
|
|
}
|
|
state.cmapGuardHash = location.hash;
|
|
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);
|
|
});
|
|
})();
|