import "/cmap/cmap-racket-wiki.js"; import { newPageReference, pageReference, slugTitle, splitPageReference } from "./wiki/reference.js"; import { cmapRoute, pageRoute, parseWikiRoute } from "./wiki/routes.js"; import { applyImageWidthMarkup, escapeHtml, expandNamespacedMarkdownLinks, expandTodoMarkup, expandWikiMentions, extractCmapEmbeds, restoreCmapEmbeds } from "./wiki/markdown.js"; import { BreadcrumbTrail } from "./wiki/breadcrumb-trail.js"; import { headingId, markdownHeadings } from "./wiki/page-outline.js"; import { AliasAdmin } from "./wiki/admin/alias-admin.js"; import { ArchivedCmapsAdmin } from "./wiki/admin/archived-cmaps-admin.js"; import { MailAdmin } from "./wiki/admin/mail-admin.js"; import { loadAdminOverview } from "./wiki/admin/overview.js"; import { OrphanedUploadsAdmin } from "./wiki/admin/orphaned-uploads-admin.js"; import { UserAdmin } from "./wiki/admin/user-admin.js"; import { CmapWorkspace } from "./wiki/cmap/workspace.js"; import { ComboBox } from "./widgets/combobox.js"; (() => { "use strict"; /* * racket-wiki browser application. * * The browser application uses direct native modules without a client * framework or build step. This entry module owns application orchestration; * cohesive, independently testable operations live below static/js/wiki/. * Larger functions are documented with goal/pre/post/result-style comments, * following the same readability rules as the Racket sources. */ const state = { session: null, pages: [], pageAliases: [], currentPage: null, editingNew: false, newPageSlug: null, previousView: "page-view", translations: {}, translationPage: "wiki-translations", translationTemplate: "", siteTitle: "Racket Wiki", bookmarks: [], conceptMaps: [], cmapConceptUsage: new Map(), cmapConceptIdsByName: new Map(), cmapPageConcepts: new Map(), currentConceptMap: null, currentConceptMapSource: null, cmapLoadSequence: 0, cmapSavedSnapshot: null, cmapGuardHash: "", cmapPrototype: null, rawMarkdown: false }; let easyMDE = null; let pendingWikiCmapLinkLabel = ""; //////////////////////////////////////////////////////////////////////////////// // General UI and HTTP support //////////////////////////////////////////////////////////////////////////////// const $ = (id) => document.getElementById(id); const wikiCmapLinkCombobox = new ComboBox($("wiki-cmap-link-combobox")); const breadcrumbTrail = new BreadcrumbTrail(window.sessionStorage); const cmapWorkspace = new CmapWorkspace( state, api, tr, can, show, renderBreadcrumbs, renderToc, pageDisplayDate, loadPages, openPage, navigateToHash, canonicalPageReference, renderMarkdown, renderPageCmapConnections, escapeMarkdownLinkLabel ); const aliasAdmin = new AliasAdmin(api, tr, loadPages); const archivedCmapsAdmin = new ArchivedCmapsAdmin( api, tr, pageDisplayDate, cmapWorkspace.loadConceptMaps); const mailAdmin = new MailAdmin(api, tr); const orphanedUploadsAdmin = new OrphanedUploadsAdmin(api, tr); const userAdmin = new UserAdmin(api, tr); function show(viewId) { for (const id of ["page-view", "not-found-view", "editor-view", "rename-view", "search-view", "recent-view", "bookmarks-view", "todo-view", "cmap-view", "history-view", "profile-view", "admin-view", "alias-admin-view", "user-admin-view", "mail-admin-view", "orphaned-uploads-view", "archived-cmaps-view"]) { $(id).classList.toggle("hidden", id !== viewId); } $("page-action-links").classList.toggle("hidden", viewId !== "page-view"); 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 navigateToHash(targetHash) { const navigate = () => { if (location.hash === targetHash) return route(); location.hash = targetHash; return undefined; }; return cmapWorkspace.requestTransition(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 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 renderMarkdown(markdown, pageSlug = null) { const extracted = extractCmapEmbeds(markdown || ""); const withExplicitWikiLinks = expandNamespacedMarkdownLinks(extracted.markdown); const withWikiLinks = expandWikiMentions( withExplicitWikiLinks, state.pages, state.pageAliases, state.conceptMaps, pageSlug); const withTodos = expandTodoMarkup(withWikiLinks, pageSlug, tr("todo", "Todo")); const html = easyMDE.markdown(withTodos); const withEmbeds = restoreCmapEmbeds(html, extracted.embeds, tr("loading", "Loading…")); const withImages = applyImageWidthMarkup(withEmbeds); const safeHtml = DOMPurify.sanitize(withImages); if (extracted.embeds.length) cmapWorkspace.queueEmbedHydration(); if (/\b(?:language|lang)-mermaid\b/.test(safeHtml)) window.RacketWikiMermaid.queue(document); return safeHtml; } //////////////////////////////////////////////////////////////////////////////// // 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 renderPageCmapConnections(page) { const section = $("page-cmap-connections"); const list = $("page-cmap-connections-list"); const pageKey = canonicalPageReference(page.slug).toLocaleLowerCase(); const concepts = [...(state.cmapPageConcepts.get(pageKey)?.values() || [])] .sort((first, second) => first.label.localeCompare(second.label)); list.replaceChildren(); section.classList.toggle("hidden", concepts.length === 0); if (!concepts.length) return; for (const concept of concepts) { const row = document.createElement("div"); row.className = "page-cmap-concept-row"; const description = document.createElement("div"); description.className = "page-cmap-concept-description"; const label = document.createElement("strong"); label.textContent = concept.label; const usage = document.createElement("span"); usage.className = "muted"; usage.textContent = tr("concept-usage-count", "{count} placements across all concept maps") .replace("{count}", String(concept.count)); description.append(label, usage); const maps = document.createElement("nav"); maps.className = "page-cmap-concept-maps"; maps.setAttribute("aria-label", tr("concept-map-locations", "Concept map locations")); for (const placement of [...concept.maps.values()] .sort((first, second) => first.title.localeCompare(second.title))) { const link = document.createElement("a"); link.href = cmapRoute(placement.slug); link.textContent = placement.count > 1 ? `${placement.title} (${placement.count})` : placement.title; maps.append(link); } row.append(description, maps); list.append(row); } } 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; 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."); } if (!window.RacketWikiMermaid) { throw new Error("The Mermaid wiki integration is not installed."); } initializeHighlighting(); window.RacketWikiMermaid.initialize(); 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 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)); }); } /** * 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 breadcrumbTrail.slugs) { 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(); breadcrumbTrail.clear(); 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 || []; cmapWorkspace.clearDescriptionPreviews(); 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)); } breadcrumbTrail.record(page.slug, startPage()?.slug || null); state.editingNew = false; state.newPageSlug = null; $("page-title").textContent = page.title; $("page-meta").textContent = ""; $("markdown-preview").innerHTML = renderMarkdown(page.markdown, page.slug); renderPageDetails(page); renderPageCmapConnections(page); pageBreadcrumbs(page); show("page-view"); setPageActionVisibility(true); renderPageToc(); renderPageList(); updateBookmarkAction(); } 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 cmapWorkspace.loadConceptMaps(); wikiCmapLinkCombobox.setOptions( state.conceptMaps.map((conceptMap) => cmapWorkspace.conceptMapEntry(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})` : `[${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 = `${pageDisplayDate(version.createdAt)}
`; 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 = `
${renderMarkdown(full.markdown, state.currentPage?.slug)}
`; }); 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); }); } /** Show the administration overview and refresh its version information. */ async function showAdmin() { state.previousView = state.currentPage ? "page-view" : "admin-view"; renderBreadcrumbs([ { label: state.siteTitle, href: "/" }, { label: tr("admin", "Admin") } ]); show("admin-view"); await loadAdminOverview(api, tr); } /** Show retained aliases and their current and historical references. */ async function loadAliasesAdmin() { renderBreadcrumbs([ { label: state.siteTitle, href: "/" }, { label: tr("admin", "Admin") }, { label: tr("page-aliases", "Page aliases") } ]); show("alias-admin-view"); await aliasAdmin.load(); } /** Show uploads that have no current page references. */ async function loadOrphanedUploadsAdmin() { renderBreadcrumbs([ { label: state.siteTitle, href: "/" }, { label: tr("admin", "Admin") }, { label: tr("orphaned-uploads", "Orphaned uploads") } ]); show("orphaned-uploads-view"); await orphanedUploadsAdmin.load(); } /** Show archived CMaps that an administrator can restore. */ async function loadArchivedCmapsAdmin() { renderBreadcrumbs([ { label: state.siteTitle, href: "/" }, { label: tr("admin", "Admin"), href: "#admin" }, { label: tr("archived-concept-maps", "Archived CMaps") } ]); show("archived-cmaps-view"); await archivedCmapsAdmin.load(); } /** Show the user administration controller in its application view. */ async function loadUsersAdmin() { renderBreadcrumbs([ { label: state.siteTitle, href: "/" }, { label: tr("admin", "Admin") }, { label: tr("users", "Users") } ]); show("user-admin-view"); await userAdmin.load(); } 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 = ""; } /** Show mail settings and initialize the test recipient for the current user. */ 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"); await mailAdmin.load(state.session.user.email || ""); } 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(); $("page-cmap-connections").classList.add("hidden"); $("page-cmap-connections-list").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. * internals: parseWikiRoute recognizes syntax and decodes parameters. This * dispatcher applies permissions and invokes the matching view. */ async function route() { const currentRoute = parseWikiRoute(location.hash); switch (currentRoute.name) { case "profile": showProfile(); return; case "todo": await showTodos(currentRoute.slug, currentRoute.number); return; case "cmap": await cmapWorkspace.show(currentRoute.slug); return; case "cmaps": await cmapWorkspace.show(); return; case "recent": await showRecent(); return; case "bookmarks": await showBookmarks(); return; case "todos": await showTodos(); return; case "search": $("search-input").value = currentRoute.query; await searchWiki(currentRoute.query); return; case "admin": if (can("admin")) { await showAdmin(); return; } break; case "admin-users": if (can("admin")) { await loadUsersAdmin(); return; } break; case "admin-mail": if (can("admin")) { await loadMailSettingsAdmin(); return; } break; case "admin-aliases": if (can("admin")) { await loadAliasesAdmin(); return; } break; case "admin-orphaned-uploads": if (can("admin")) { await loadOrphanedUploadsAdmin(); return; } break; case "admin-archived-cmaps": if (can("admin")) { await loadArchivedCmapsAdmin(); return; } break; case "page": try { await openPage(currentRoute.slug); } catch (error) { if (error.status !== 404) throw error; if (can("editor")) { showMissingEditablePage(currentRoute.slug); } else { showNotFound(currentRoute.slug); } } return; case "home": break; } if (state.pages.length > 0) { const firstPage = startPage(); location.hash = pageRoute(firstPage.slug); return; } if (can("editor")) { showMissingEditablePage("start"); return; } 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 page markers and TODO-aspect concepts in one Todo view. * 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 todoGroup = null; for (const item of result.items) { const conceptTodo = item.type === "concept"; const group = conceptTodo ? "__concepts__" : (item.namespace || ""); if (group !== todoGroup) { const heading = document.createElement("h2"); heading.className = "namespace-heading"; heading.textContent = conceptTodo ? tr("concept-todos", "Concepts") : namespaceLabel(item.namespace || ""); list.append(heading); todoGroup = group; } const row = document.createElement("article"); row.className = `todo-item${conceptTodo ? " todo-item-concept" : ""}`; row.id = conceptTodo ? `todo-concept-${item.conceptId}` : `todo-${item.slug}-${item.number}`; const link = document.createElement("a"); if (conceptTodo) { const pageTarget = item.descriptionPageSlug || item.pageSlug; const cmapTarget = item.cmapSlug || item.placementCmapSlug; const externalTarget = cmapWorkspace.normalizeExternalUrl(item.externalUrl); link.href = pageTarget ? pageRoute(pageTarget) : (item.cmapSlug ? cmapRoute(item.cmapSlug) : (externalTarget || (cmapTarget ? cmapRoute(cmapTarget) : "#cmaps"))); if (externalTarget && link.href === externalTarget) { link.target = "_blank"; link.rel = "noopener noreferrer"; } } else { link.href = pageRoute(item.slug); } link.textContent = item.title; const text = document.createElement("div"); text.className = "todo-item-text"; text.textContent = item.text || (conceptTodo ? tr("concept-marked-todo", "Concept marked with the TODO aspect.") : ""); const location = document.createElement("span"); location.className = "muted"; location.textContent = conceptTodo ? `${tr("concept-map", "Concept map")}: ${item.placementCmapTitle}` : `${tr("line", "line")} ${item.line}`; row.append(link, text, location); list.append(row); } if (targetSlug && targetNumber) { const target = document.getElementById(`todo-${targetSlug}-${targetNumber}`); if (target) { target.classList.add("todo-item-target"); target.scrollIntoView({ block: "center" }); } } } //////////////////////////////////////////////////////////////////////////////// function 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(); await cmapWorkspace.initialize(); initializeEditor(); $("account-name").textContent = state.session.user.displayName; $("account-role").textContent = tr(`role-${state.session.user.role}`, state.session.user.role); updateRoleUi(); await loadPages(); breadcrumbTrail.load(state.pages.map((page) => page.slug)); 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; } breadcrumbTrail.truncate(slug, startPage()?.slug || null); 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)); }); $("cmaps-link").addEventListener("click", (event) => { event.preventDefault(); const preferred = cmapWorkspace.startSlug(); const target = state.conceptMaps.find((conceptMap) => conceptMap.slug === preferred) || state.conceptMaps[0] || null; navigateToHash(target ? cmapRoute(target.slug) : "#cmaps") .catch((error) => console.error(error)); }); $("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(); }); $("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-archived-cmaps-link").addEventListener("click", (event) => { event.preventDefault(); navigateToHash("#admin/archived-cmaps").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-archived-cmaps").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 (!cmapWorkspace.hasUnsavedChanges()) return; const link = event.target instanceof Element ? event.target.closest("a[href]") : null; if (!link || link.target === "_blank") return; event.preventDefault(); event.stopImmediatePropagation(); cmapWorkspace.requestTransition(() => link.click()).catch((error) => console.error(error)); }, true); window.addEventListener("beforeunload", (event) => { if (!cmapWorkspace.hasUnsavedChanges()) return; event.preventDefault(); event.returnValue = ""; }); window.addEventListener("hashchange", () => { if (cmapWorkspace.hasUnsavedChanges()) { const requestedHash = location.hash; const previousHash = state.cmapGuardHash; history.replaceState(history.state, "", `${location.pathname}${location.search}${previousHash}`); cmapWorkspace.requestTransition(() => { 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"; const text = String(error && error.message || error); const setupPath = "/setup"; const setupIndex = text.indexOf(setupPath); if (setupIndex === -1) { message.textContent = text; } else { message.append(document.createTextNode(text.slice(0, setupIndex))); const setupLink = document.createElement("a"); setupLink.href = setupPath; setupLink.textContent = setupPath; message.append(setupLink, document.createTextNode(text.slice(setupIndex + setupPath.length))); } main.append(message); }); })();