cmap functions, email, architecture documentation.
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user