cmap functions, email, architecture documentation.

This commit is contained in:
2026-08-18 03:33:10 +02:00
parent 12f1ed2764
commit 63b7ca0853
33 changed files with 3974 additions and 80 deletions
+3 -3
View File
@@ -7,7 +7,7 @@
(() => {
"use strict";
const debugPrefix = "[racket-wiki:cmap 0.2.84]";
const debugPrefix = "[racket-wiki:cmap 0.2.94]";
function debug(message, details) {
if (details === undefined) {
@@ -1207,7 +1207,7 @@
const expanded = [];
for (const item of selected) {
expanded.push(item);
if (item.kind === "submap" && !item.expanded && item !== this.activeMapRoot) {
if (item.kind === "submap" && item !== this.activeMapRoot) {
expanded.push(...this.items.filter((candidate) => this.isDescendantOf(candidate, item)));
}
}
@@ -2081,7 +2081,7 @@
let lastEditor = null;
window.RacketWikiCmap = {
version: "0.2.84",
version: "0.2.94",
createEditor(canvas, options) {
lastEditor = new CmapEditor(canvas, options);
return lastEditor;
+68
View File
@@ -257,6 +257,54 @@ body.cmap-mode #main {
transform-origin: 0 0;
}
.rw-cmap-embed {
display: block;
margin: 1.25rem 0;
overflow: hidden;
border: 1px solid #cbd4dc;
border-radius: 10px;
background: #fff;
cursor: zoom-in;
}
.rw-cmap-embed > header {
display: flex;
justify-content: space-between;
gap: 18px;
align-items: baseline;
padding: 9px 13px;
border-bottom: 1px solid #e1e6eb;
background: #f7f9fa;
}
.rw-cmap-embed > header span {
color: #65717c;
font-size: 0.85rem;
}
.rw-cmap-embed-loading,
.rw-cmap-embed > .error {
margin: 0;
padding: 18px;
}
.rw-cmap-embed-viewport {
position: relative;
width: 100%;
min-height: 180px;
max-height: 520px;
overflow: hidden;
background: #fff;
}
.rw-cmap-embed-canvas {
min-width: 100%;
min-height: 100%;
pointer-events: none;
background: #fff !important;
box-shadow: none !important;
}
.cmap-zoom-controls {
display: flex;
flex: 0 0 auto;
@@ -628,6 +676,26 @@ body.cmap-mode #main {
background: rgb(22 30 44 / 38%);
}
.cmap-history-dialog {
width: min(720px, calc(100vw - 40px));
max-height: calc(100vh - 60px);
padding: 0;
border: 1px solid #aeb7c4;
border-radius: 10px;
box-shadow: 0 16px 48px rgba(22, 32, 51, 0.22);
}
.cmap-history-dialog::backdrop {
background: rgba(25, 32, 45, 0.38);
}
.cmap-history-panel { padding: 22px; }
.cmap-history-panel > header { display: flex; justify-content: space-between; gap: 16px; align-items: center; }
.cmap-history-panel h2 { margin: 0; }
#cmap-history-list { max-height: min(520px, 60vh); overflow: auto; }
.cmap-history-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; align-items: center; padding: 11px 0; border-bottom: 1px solid #dfe3e8; }
.cmap-history-row:last-child { border-bottom: 0; }
.cmap-unsaved-dialog form {
display: grid;
gap: 14px;
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* racket-wiki cmap component 0.2.84
* racket-wiki cmap component 0.2.94
* Based on cmap v0.1.3.
* (c) 2015 iOnStage
* Released under the MIT License.
+11 -2
View File
@@ -168,9 +168,18 @@ button { cursor: pointer; }
border-bottom: 1px solid #dfe3e8;
}
.history-row { grid-template-columns: minmax(0, 1fr) auto auto; }
.user-row { grid-template-columns: 1.1fr 1.2fr 110px 80px 1fr 140px; }
.user-row { grid-template-columns: 1fr 1.1fr 1.3fr 110px 70px 1fr 140px; }
.user-row input, .user-row select { padding: 6px 7px; }
.user-form { display: grid; grid-template-columns: 1fr 1fr 1fr 120px auto; gap: 8px; margin-bottom: 20px; }
.user-form { display: grid; grid-template-columns: 1fr 1fr 1.2fr 1fr 120px auto; gap: 8px; margin-bottom: 20px; }
.settings-view { max-width: 760px; }
.settings-form { display: grid; gap: 14px; padding: 22px; }
.settings-form label:not(.checkbox-label) { display: grid; gap: 5px; font-weight: 600; }
.settings-form input { padding: 8px 10px; font: inherit; }
.settings-form fieldset { display: grid; gap: 12px; margin: 6px 0; padding: 16px; border: 1px solid #dfe3e8; }
.settings-form legend { font-weight: 700; padding: 0 6px; }
.settings-form .checkbox-label { display: flex; gap: 8px; align-items: center; }
.settings-form .checkbox-label input { width: auto; }
.settings-actions { display: flex; gap: 12px; align-items: center; }
#diff-target { margin-top: 24px; background: white; }
@media (max-width: 900px) {
+61
View File
@@ -42,6 +42,8 @@
<span class="account-separator" aria-hidden="true">·</span>
<span id="account-role" class="muted"></span>
<span class="account-separator" aria-hidden="true">·</span>
<a id="profile-link" href="#profile" data-tr="profile">Profile</a>
<span class="account-separator" aria-hidden="true">·</span>
<a id="logout-link" href="#" data-tr="sign-out">Sign out</a>
<span class="account-separator admin-only hidden" aria-hidden="true">·</span>
<a id="admin-link" class="admin-only hidden" href="#admin" data-tr="admin">Admin</a>
@@ -212,6 +214,8 @@
<div class="cmap-context-separator" role="separator"></div>
<button id="cmap-rename-map" type="button" role="menuitem" data-tr="rename-concept-map" disabled>Rename CMap</button>
<button id="cmap-delete-map" class="danger" type="button" role="menuitem" data-tr="delete-concept-map" disabled>Delete CMap</button>
<button id="cmap-create-snapshot" type="button" role="menuitem" data-tr="create-concept-map-snapshot" disabled>Make snapshot…</button>
<button id="cmap-history" type="button" role="menuitem" data-tr="concept-map-history" disabled>CMap history</button>
<div class="cmap-context-separator" role="separator"></div>
<button id="cmap-add-concept" type="button" role="menuitem" data-tr="add-concept">Add concept</button>
<button id="cmap-add-page" type="button" role="menuitem" data-tr="add-page-node">Add current page</button>
@@ -258,6 +262,25 @@
<div id="diff-target"></div>
</section>
<section id="profile-view" class="hidden settings-view">
<header class="page-header">
<h1 data-tr="profile">Profile</h1>
</header>
<form id="profile-form" class="settings-form card">
<label><span data-tr="username">Username</span><input id="profile-username" disabled></label>
<label><span data-tr="display-name">Display name</span><input id="profile-display-name" required maxlength="200" autocomplete="name"></label>
<label><span data-tr="email-address">Email address</span><input id="profile-email" type="email" maxlength="320" autocomplete="email"></label>
<fieldset>
<legend data-tr="change-password">Change password</legend>
<p class="muted" data-tr="change-password-help">Leave these fields empty if you do not want to change your password.</p>
<label><span data-tr="current-password">Current password</span><input id="profile-current-password" type="password" autocomplete="current-password"></label>
<label><span data-tr="new-password">New password</span><input id="profile-new-password" type="password" minlength="8" maxlength="1024" autocomplete="new-password"></label>
<label><span data-tr="repeat-password">Repeat password</span><input id="profile-repeat-password" type="password" minlength="8" maxlength="1024" autocomplete="new-password"></label>
</fieldset>
<div class="settings-actions"><button type="submit" data-tr="save-profile">Save profile</button><span id="profile-status" class="muted" role="status" aria-live="polite"></span></div>
</form>
</section>
<section id="admin-view" class="hidden">
<header class="page-header">
<h1 data-tr="admin">Admin</h1>
@@ -265,6 +288,7 @@
<p class="admin-version"><span data-tr="software-version">Software version</span>: <strong id="admin-software-version"></strong></p>
<nav class="admin-menu">
<a id="admin-users-link" href="#admin/users" data-tr="users">Users</a>
<a id="admin-mail-link" href="#admin/mail" data-tr="email-and-password-reset">Email and password reset</a>
<a id="admin-aliases-link" href="#admin/aliases" data-tr="page-aliases">Page aliases</a>
<a id="admin-translations-link" href="#" data-tr="translations">Translations</a>
<a id="admin-orphaned-uploads-link" href="#admin/orphaned-uploads" data-tr="orphaned-uploads">Orphaned uploads</a>
@@ -296,6 +320,7 @@
<form id="new-user-form" class="user-form">
<input id="new-username" placeholder="Username" data-tr-placeholder="username" required>
<input id="new-display-name" placeholder="Display name" data-tr-placeholder="display-name">
<input id="new-email" type="email" placeholder="Email address" data-tr-placeholder="email-address">
<input id="new-password" type="password" placeholder="Initial password" data-tr-placeholder="initial-password" required>
<select id="new-role">
<option value="reader" data-tr="role-reader">reader</option>
@@ -306,6 +331,30 @@
</form>
<div id="user-list"></div>
</section>
<section id="mail-admin-view" class="hidden settings-view">
<header class="page-header">
<h1 data-tr="email-and-password-reset">Email and password reset</h1>
<a id="close-mail-admin" href="#admin" data-tr="back">Back</a>
</header>
<form id="mail-settings-form" class="settings-form card">
<label><span data-tr="public-wiki-url">Public wiki URL</span><input id="mail-public-url" type="url" placeholder="https://wiki.example.org"></label>
<label><span data-tr="smtp-server">SMTP server</span><input id="mail-smtp-host"></label>
<label><span data-tr="smtp-port">SMTP port</span><input id="mail-smtp-port" type="number" min="1" max="65535" value="587"></label>
<label><span data-tr="sender-address">Sender address</span><input id="mail-smtp-from" type="email"></label>
<label><span data-tr="smtp-username">SMTP username</span><input id="mail-smtp-user" autocomplete="username"></label>
<label><span data-tr="smtp-password">SMTP password</span><input id="mail-smtp-password" type="password" autocomplete="new-password"><small id="mail-password-help" class="muted"></small></label>
<label class="checkbox-label"><input id="mail-smtp-tls" type="checkbox" checked><span data-tr="use-starttls">Use STARTTLS</span></label>
<label class="checkbox-label"><input id="mail-smtp-accept-untrusted-certificates" type="checkbox"><span data-tr="accept-untrusted-smtp-certificates">Accept untrusted certificates (trusted local server only)</span></label>
<label><span data-tr="reset-links-per-hour">Maximum reset links per user per hour</span><input id="mail-reset-limit" type="number" min="1" max="20" value="2"></label>
<fieldset>
<legend data-tr="test-smtp-settings">Test SMTP settings</legend>
<label><span data-tr="test-recipient">Test recipient</span><input id="mail-test-recipient" type="email"></label>
<button id="mail-test-button" type="button" data-tr="send-test-mail">Send test email</button>
</fieldset>
<div class="settings-actions"><button type="submit" data-tr="save">Save</button><span id="mail-settings-status" class="muted" role="status" aria-live="polite"></span></div>
</form>
</section>
</main>
</div>
@@ -425,6 +474,7 @@
<p class="muted" data-tr="concept-map-link-help">The selected CMap is inserted as a normal Markdown link.</p>
<div class="cmap-concept-dialog-actions">
<button id="wiki-cmap-link-cancel" type="button" data-tr="cancel">Cancel</button>
<button id="wiki-cmap-embed-submit" type="button" data-tr="embed-concept-map">Embed CMap</button>
<button id="wiki-cmap-link-submit" type="submit" class="primary" data-tr="insert">Insert</button>
</div>
</form>
@@ -442,6 +492,17 @@
</form>
</dialog>
<dialog id="cmap-history-dialog" class="cmap-history-dialog" aria-labelledby="cmap-history-title">
<div class="cmap-history-panel">
<header>
<h2 id="cmap-history-title" data-tr="concept-map-history">CMap history</h2>
<button id="cmap-history-close" type="button" data-tr="close">Close</button>
</header>
<p class="muted" data-tr="concept-map-history-help">Load a historical version into the editor to inspect it. Saving it creates a new current version.</p>
<div id="cmap-history-list"></div>
</div>
</dialog>
<script src="/vendor/purify.min.js"></script>
<script src="/vendor/highlight.min.js"></script>
<script src="/vendor/highlight-scheme.min.js"></script>
+398 -16
View File
@@ -43,6 +43,7 @@
let cmapStatusTimer = null;
let cmapAutosaveTimer = null;
let cmapSavePromise = null;
let cmapEmbedHydrationTimer = null;
const CMAP_AUTOSAVE_DELAY = 1500;
@@ -58,7 +59,7 @@
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", "admin-view", "alias-admin-view", "user-admin-view", "orphaned-uploads-view"]) {
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");
@@ -492,13 +493,123 @@
* 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 withExplicitWikiLinks = expandNamespacedMarkdownLinks(markdown || "");
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 withImages = applyImageWidthMarkup(html);
return DOMPurify.sanitize(withImages);
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) {
@@ -1463,6 +1574,7 @@
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");
@@ -1486,6 +1598,21 @@
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);
}
@@ -2049,6 +2176,10 @@
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");
@@ -2072,6 +2203,7 @@
method: "PUT",
body: JSON.stringify({
displayName: display.value,
email: email.value,
role: role.value,
enabled: enabled.checked,
password: password.value || undefined
@@ -2088,11 +2220,75 @@
await loadUsersAdmin();
});
actions.append(save, remove);
row.append(username, display, role, enabled, password, actions);
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;
@@ -2131,6 +2327,10 @@
* 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]));
@@ -2186,6 +2386,11 @@
return;
}
if (location.hash === "#admin/mail" && can("admin")) {
await loadMailSettingsAdmin();
return;
}
if (location.hash === "#admin/aliases" && can("admin")) {
await loadAliasesAdmin();
return;
@@ -2899,7 +3104,7 @@
canvas.replaceChildren();
state.cmapPrototype = null;
console.info("[racket-wiki:cmap-host 0.2.84] resetCmapPrototype", {
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,
@@ -2931,7 +3136,7 @@
}
},
onOpenSubMap: (record) => {
console.info("[racket-wiki:cmap-host 0.2.84] submap state changed", {
console.info("[racket-wiki:cmap-host 0.2.94] submap state changed", {
id: record.id,
expanded: record.expanded,
childMap: record.childMap
@@ -2969,7 +3174,7 @@
relationLabel: tr("relation", "Relation")
});
restoreCmapZoom();
console.info("[racket-wiki:cmap-host 0.2.84] editor stored", {
console.info("[racket-wiki:cmap-host 0.2.94] editor stored", {
editorAvailable: Boolean(prototype.editor),
canvasChildCount: canvas.children.length
});
@@ -3036,6 +3241,8 @@
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() {
@@ -3052,7 +3259,7 @@
documentValue = JSON.parse(documentValue);
}
if (!documentValue || typeof documentValue !== "object" || Array.isArray(documentValue)) {
console.error("[racket-wiki:cmap-host 0.2.84] invalid stored CMap document", {
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
@@ -3152,7 +3359,7 @@
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.84] stored CMap received", {
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,
@@ -3163,7 +3370,7 @@
resetCmapPrototype(conceptMap.document, false);
markCurrentCmapSaved();
const loadedEditor = cmapPrototypeState().editor;
console.info("[racket-wiki:cmap-host 0.2.84] stored CMap loaded", {
console.info("[racket-wiki:cmap-host 0.2.94] stored CMap loaded", {
slug: conceptMap.slug,
editorAvailable: Boolean(loadedEditor),
itemCount: loadedEditor ? loadedEditor.items.length : 0,
@@ -3172,6 +3379,68 @@
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;
@@ -3239,7 +3508,8 @@
}
}
async function saveStoredConceptMap({ automatic = false } = {}) {
async function saveStoredConceptMap({ automatic = false, force = false,
summary = null, snapshotVersion = false } = {}) {
const prototype = cmapPrototypeState();
if (!prototype.editor) return false;
cancelCmapAutosave();
@@ -3247,13 +3517,15 @@
if (cmapSavePromise) {
const firstSaveSucceeded = await cmapSavePromise;
if (!firstSaveSucceeded) return false;
if (cmapHasUnsavedChanges()) return saveStoredConceptMap({ automatic });
if (force || cmapHasUnsavedChanges()) {
return saveStoredConceptMap({ automatic, force, summary, snapshotVersion });
}
return true;
}
const snapshot = currentCmapSnapshot();
if (snapshot === null) return false;
if (snapshot === state.cmapSavedSnapshot) {
if (!force && snapshot === state.cmapSavedSnapshot) {
if (!automatic) showCmapStatus(tr("concept-map-saved", "CMap saved"), true);
return true;
}
@@ -3261,7 +3533,8 @@
const document = JSON.parse(snapshot);
const conceptMapAtStart = state.currentConceptMap;
let saveSucceeded = false;
showCmapStatus(automatic ? tr("autosaving", "Saving automatically…") : tr("saving", "Saving…"));
showCmapStatus(snapshotVersion ? tr("creating-snapshot", "Creating snapshot…") :
(automatic ? tr("autosaving", "Saving automatically…") : tr("saving", "Saving…")));
cmapSavePromise = (async () => {
try {
if (!conceptMapAtStart) {
@@ -3286,6 +3559,8 @@
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
})
});
@@ -3296,7 +3571,8 @@
}
markCurrentCmapSaved(snapshot);
showCmapStatus(
automatic ? tr("concept-map-autosaved", "CMap saved automatically") : tr("concept-map-saved", "CMap saved"),
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;
@@ -3311,6 +3587,17 @@
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([
@@ -3366,6 +3653,10 @@
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 };
}
@@ -3902,6 +4193,12 @@
$("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 || {});
});
@@ -4115,6 +4412,7 @@
event.preventDefault();
insertSelectedWikiCmapLink();
});
$("wiki-cmap-embed-submit").addEventListener("click", insertSelectedWikiCmapEmbed);
$("wiki-cmap-link-dialog").addEventListener("close", () => {
pendingWikiCmapLinkLabel = "";
wikiCmapLinkCombobox.clear();
@@ -4128,6 +4426,7 @@
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(); });
@@ -4157,6 +4456,37 @@
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);
@@ -4165,6 +4495,10 @@
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);
@@ -4195,6 +4529,10 @@
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);
@@ -4247,6 +4585,7 @@
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
@@ -4256,6 +4595,49 @@
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;