Files
wiki/editor.html
Sorin Savu dcc6bba61b upgrades
2026-07-13 09:51:28 +03:00

603 lines
25 KiB
HTML

<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Wiki</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
<link rel="stylesheet" href="https://uicdn.toast.com/editor/latest/toastui-editor.min.css">
<script src="https://uicdn.toast.com/editor/latest/toastui-editor-all.min.js"></script>
<style>
* { box-sizing: border-box; }
html, body { height: 100%; }
body { margin: 0; font-family: system-ui, sans-serif; display: flex; overflow: hidden; }
#sidebar { width: 260px; border-right: 1px solid #dee2e6; display: flex; flex-direction: column;
overflow: hidden; background: #f8f9fa; flex: 0 0 auto; transition: width .15s ease; }
#sidebar header { padding: 8px; display: flex; gap: 6px; }
#sidebar input { flex: 1; }
#notelist { overflow-y: auto; flex: 1; }
#notelist .item { padding: 6px 12px; cursor: pointer; font-size: 14px; }
#notelist .item:hover { background: #eef2ff; }
#notelist .item.active { background: #e0e7ff; font-weight: 600; }
#notelist .item.nopage { opacity: .6; font-style: italic; }
#notelist .matched { font-size: 11px; color: #888; }
/* collapsible sidebar sections */
.side-section { display: flex; align-items: center; gap: 7px; padding: 8px 10px;
font-size: 13px; text-transform: uppercase; letter-spacing: .03em; color: #1f2937;
font-weight: 700; cursor: pointer; user-select: none;
background: #e9edf3; border-top: 1px solid #d3d9e2; border-bottom: 1px solid #d3d9e2; }
.side-section:hover { background: #dfe5ee; }
.side-section .caret { font-size: 11px; width: 11px; color: #6b7280; }
.side-section .count { margin-left: auto; font-weight: 700; font-size: 11px;
color: #fff; background: #98a2b3; border-radius: 9px; padding: 0 7px; min-width: 20px; text-align: center; }
#main { flex: 1 1 auto; display: flex; flex-direction: column; min-width: 0; }
#main header { padding: 6px 10px; display: flex; gap: 6px; align-items: center; border-bottom: 1px solid #dee2e6; }
#main header h2 { font-size: 16px; margin: 0; flex: 1; padding: 2px 6px; border-radius: 4px; outline: none; min-width: 0; }
#main header h2:hover { background: #f1f3f5; }
#main header h2[contenteditable="true"]:focus { background: #fff; box-shadow: inset 0 0 0 2px #c7d2fe; }
#editor { flex: 1; min-height: 0; }
/* collapsible side panels */
#sidebar.collapsed, #panel.collapsed { width: 0 !important; border: 0 !important; padding: 0 !important; }
#panel { width: 240px; flex: 0 0 auto; border-left: 1px solid #dee2e6; overflow-y: auto; padding: 10px;
font-size: 13px; background: #f8f9fa; transition: width .15s ease; }
#panel h4 { margin: 12px 0 4px; color: #555; }
#panel a { display: block; color: #2456c4; text-decoration: none; padding: 2px 0; }
#panel .att { display: flex; gap: 4px; align-items: center; }
#panel .badge { font-size: 10px; padding: 0 4px; border-radius: 3px; background: #eee; color: #666; }
a.wikilink { color: #2456c4; cursor: pointer; }
a.wikilink.missing { color: #c0392b; border-bottom: 1px dashed #c0392b; }
button { cursor: pointer; }
/* jira badges */
.jira-badge { display: inline-flex; align-items: center; gap: 4px; vertical-align: baseline;
padding: 0 4px; border: 1px solid #dcdce0; border-radius: 4px; background: #fafafe; }
.jira-badge a.jira-key { font-family: ui-monospace, monospace; font-size: 0.92em; color: #2456c4; text-decoration: none; cursor: pointer; }
.jira-badge a.jira-ext { font-size: 0.85em; color: #888; text-decoration: none; }
.jira-badge a.jira-ext:hover { color: #2456c4; }
.side-key { font-family: ui-monospace, monospace; font-size: 11px; color: #8a8a93; margin-right: 4px; }
#jirahead { display: inline-flex; align-items: center; gap: 6px; margin-right: 8px; }
.jira-pill { font-size: 10px; line-height: 1.4; padding: 0 5px; border-radius: 8px;
text-transform: uppercase; letter-spacing: .02em; color: #fff; white-space: nowrap; }
.jira-pill.loading, .jira-pill.unknown { background: #b8b8c0; }
.jira-pill.todo { background: #6b7280; }
.jira-pill.inprogress { background: #2563eb; }
.jira-pill.done { background: #16a34a; }
#jira-hovercard { position: absolute; z-index: 9999; max-width: 360px; display: none;
background: #fff; border: 1px solid #d0d0d8; border-radius: 6px; box-shadow: 0 4px 16px rgba(0,0,0,.15);
padding: 10px 12px; font-size: 12px; line-height: 1.45; color: #222; }
#jira-hovercard .hc-title { font-weight: 600; margin-bottom: 4px; }
#jira-hovercard .hc-meta { color: #666; margin-bottom: 6px; }
#jira-hovercard .hc-preview { color: #444; white-space: pre-wrap; }
</style>
</head>
<body>
<div id="sidebar">
<header>
<input id="search" class="form-control form-control-sm" placeholder="Search…" />
<button class="btn btn-sm btn-primary" onclick="newNote()" title="New page (a Jira id creates a defect page)">+</button>
</header>
<div id="notelist"></div>
</div>
<div id="main">
<header>
<button class="btn btn-sm btn-outline-secondary" onclick="toggleLeft()" title="Toggle sidebar"></button>
<h2 id="title" contenteditable="true" spellcheck="false" title="Click to rename"></h2>
<span id="jirahead"></span>
<span id="status" class="text-muted small">saved</span>
<input id="upload" type="file" style="display:none" onchange="onPick(event)" />
<button class="btn btn-sm btn-outline-secondary" onclick="document.getElementById('upload').click()">Attach</button>
<button id="archiveBtn" class="btn btn-sm btn-outline-secondary" onclick="toggleArchive()">Archive</button>
<button class="btn btn-sm btn-primary" onclick="checkpoint()" title="Save a checkpoint/version">Checkpoint</button>
<button class="btn btn-sm btn-outline-secondary" onclick="toggleRight()" title="Toggle panel"></button>
</header>
<div id="editor"></div>
</div>
<div id="panel"></div>
<div id="jira-hovercard"></div>
<script>
// --- state ---------------------------------------------------------------
let editor, currentSlug = null, currentNote = null, slugSet = new Set();
let loading = false; // guards programmatic content sets
let dirtySince = false; // unsaved edits pending autosave
let saveTimer = null;
const clientId = Math.random().toString(36).slice(2);
// accordion: at most one open section. null-from-storage -> default; "" -> all closed.
let openSection = localStorage.getItem("openSection");
if (openSection === null) openSection = "In Progress";
function setStatus(text, color) {
const s = document.getElementById("status");
s.textContent = text; s.style.color = color || "";
}
// --- panel / section collapse (persisted) --------------------------------
function applyPanelState() {
document.getElementById("sidebar").classList.toggle("collapsed", localStorage.getItem("leftCollapsed") === "1");
document.getElementById("panel").classList.toggle("collapsed", localStorage.getItem("rightCollapsed") === "1");
}
function toggleLeft() {
localStorage.setItem("leftCollapsed", localStorage.getItem("leftCollapsed") === "1" ? "0" : "1");
applyPanelState();
}
function toggleRight() {
localStorage.setItem("rightCollapsed", localStorage.getItem("rightCollapsed") === "1" ? "0" : "1");
applyPanelState();
}
function refreshAccordion() {
document.querySelectorAll("#notelist .side-group").forEach(g => {
const open = g.dataset.title === openSection;
g.querySelector(".side-items").style.display = open ? "" : "none";
g.querySelector(".caret").textContent = open ? "▾" : "▸";
});
}
// --- title helpers -------------------------------------------------------
function titleText() { return document.getElementById("title").textContent.trim(); }
function setTitle(t) { document.getElementById("title").textContent = t; }
function setArchiveButton(archived) {
document.getElementById("archiveBtn").textContent = archived ? "Unarchive" : "Archive";
}
// --- jira badges ---------------------------------------------------------
const jiraCache = new Map(); // key -> Promise<{ok, data}>
function fetchJira(key) {
if (!jiraCache.has(key)) {
jiraCache.set(key, fetch("/api/jira/" + key)
.then(r => r.json().then(data => ({ ok: r.ok, data })))
.catch(() => ({ ok: false, data: {} })));
}
return jiraCache.get(key);
}
const PILL_LABEL = { todo: "TODO", inprogress: "In Progress", done: "Done" };
// slug whose leading segment is a Jira ticket -> uppercase key, else null
// (matches both "ms-7764" and legacy "ms-7764-bare-metal-...")
function jiraKeyFromSlug(slug) {
const m = /^(ms-\d+)(?:-|$)/i.exec(slug || "");
return m ? m[1].toUpperCase() : null;
}
// Inline badge: key navigates to the internal note page, ↗ opens the Jira ticket.
function jiraBadge(key) {
const slug = slugify(key);
const span = document.createElement("span");
span.className = "jira-badge";
const k = document.createElement("a");
k.className = "jira-key";
k.href = "/wiki/" + slug;
k.textContent = key;
k.addEventListener("click", (e) => { e.preventDefault(); openNote(slug); });
const pill = document.createElement("span");
pill.className = "jira-pill loading";
pill.textContent = "…";
const ext = document.createElement("a");
ext.className = "jira-ext";
ext.target = "_blank"; ext.rel = "noopener";
ext.href = "https://metalsoft.atlassian.net/browse/" + key;
ext.textContent = "↗";
ext.title = "Open in Jira";
span.append(k, pill, ext);
fetchJira(key).then(({ ok, data }) => {
if (ok && data.category) {
ext.href = data.url || ext.href;
pill.className = "jira-pill " + data.category;
pill.textContent = PILL_LABEL[data.category] || data.status || "?";
span._jira = data;
} else {
pill.className = "jira-pill unknown";
pill.textContent = data.error === "not_found" ? "?" : "—";
}
});
span.addEventListener("mouseenter", () => showJiraCard(span, key));
span.addEventListener("mouseleave", hideJiraCard);
return span;
}
// header status for a Jira-backed page (title is locked = the Jira summary)
async function renderJiraHead(key) {
const head = document.getElementById("jirahead");
head.innerHTML = "";
if (!key) return;
const { ok, data } = await fetchJira(key);
const k = document.createElement("span"); k.className = "side-key"; k.textContent = key;
const pill = document.createElement("span");
pill.className = "jira-pill " + ((ok && data.category) ? data.category : "unknown");
pill.textContent = (ok && data.category) ? (PILL_LABEL[data.category] || data.status) : "—";
const ext = document.createElement("a");
ext.className = "jira-ext"; ext.target = "_blank"; ext.rel = "noopener";
ext.href = (ok && data.url) ? data.url : "https://metalsoft.atlassian.net/browse/" + key;
ext.textContent = "↗"; ext.title = "Open in Jira";
head.append(k, pill, ext);
}
function showJiraCard(span, key) {
const card = document.getElementById("jira-hovercard");
const d = span._jira;
if (!d) { card.style.display = "none"; return; }
const meta = [d.status, d.type, d.assignee, d.priority].filter(Boolean).join(" · ");
card.innerHTML =
`<div class="hc-title">${key}: ${d.summary || ""}</div>` +
`<div class="hc-meta">${meta}</div>` +
(d.preview ? `<div class="hc-preview">${d.preview.replace(/</g, "&lt;")}</div>` : "");
const r = span.getBoundingClientRect();
card.style.left = (window.scrollX + r.left) + "px";
card.style.top = (window.scrollY + r.bottom + 4) + "px";
card.style.display = "block";
}
function hideJiraCard() {
document.getElementById("jira-hovercard").style.display = "none";
}
// keep in sync with wikilib.slugify (Python)
function slugify(t) {
const s = (t || "").trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
return s || "untitled";
}
// --- api -----------------------------------------------------------------
const H = { "Content-Type": "application/json", "X-Origin": clientId };
const api = {
list: () => fetch("/api/notes").then(r => r.json()),
sidebar: () => fetch("/api/sidebar").then(r => r.json()),
get: (slug) => fetch("/api/notes/" + slug),
create: (body) => fetch("/api/notes", { method: "POST", headers: H, body: JSON.stringify(body) }),
update: (slug, body) => fetch("/api/notes/" + slug, { method: "PUT", headers: H, body: JSON.stringify(body) }),
archive: (slug, archived) => fetch("/api/notes/" + slug + "/archive", { method: "POST", headers: H, body: JSON.stringify({ archived }) }),
search: (q) => fetch("/api/search?q=" + encodeURIComponent(q)).then(r => r.json()),
};
// --- sidebar -------------------------------------------------------------
function itemEl(e) {
const div = document.createElement("div");
div.className = "item" + (e.slug === currentSlug ? " active" : "") + (e.has_page === false ? " nopage" : "");
const line = document.createElement("div");
if (e.key) {
const k = document.createElement("span"); k.className = "side-key"; k.textContent = e.key;
const t = document.createElement("span"); t.textContent = e.title;
line.append(k, t);
} else {
line.textContent = e.title;
}
div.appendChild(line);
if (e.matched_in) {
const m = document.createElement("div"); m.className = "matched";
m.textContent = "match: " + e.matched_in; div.appendChild(m);
}
div.onclick = () => openNote(e.slug);
return div;
}
function sectionEl(title, entries) {
const wrap = document.createElement("div");
wrap.className = "side-group";
wrap.dataset.title = title;
const expanded = openSection === title;
const h = document.createElement("div");
h.className = "side-section";
h.innerHTML = `<span class="caret">${expanded ? "▾" : "▸"}</span><span>${title}</span>` +
`<span class="count">${entries.length}</span>`;
const body = document.createElement("div");
body.className = "side-items";
entries.forEach(e => body.appendChild(itemEl(e)));
body.style.display = expanded ? "" : "none";
h.onclick = () => {
openSection = (openSection === title) ? "" : title; // toggle / accordion
localStorage.setItem("openSection", openSection);
refreshAccordion();
};
wrap.append(h, body);
return wrap;
}
async function renderSidebar() {
const data = await api.sidebar();
const el = document.getElementById("notelist");
el.innerHTML = "";
el.append(
sectionEl("Notes", data.notes),
sectionEl("In Progress", data.inprogress),
sectionEl("Backlog", data.backlog),
sectionEl("Archive", data.archive),
);
slugSet = new Set(
[...data.notes, ...data.inprogress, ...data.backlog, ...data.archive]
.filter(e => e.has_page).map(e => e.slug)
);
}
function renderFlat(items) { // flat list, used for search results
const el = document.getElementById("notelist");
el.innerHTML = "";
items.forEach(n => el.appendChild(itemEl({ ...n, key: jiraKeyFromSlug(n.slug), has_page: true })));
}
// --- note load / save ----------------------------------------------------
function setBody(md) { // programmatic set: never counts as an edit
loading = true;
editor.setMarkdown(md || "", false);
loading = false;
}
function openNote(slug) {
history.pushState({}, "", "/wiki/" + slug);
loadNote(slug);
}
async function loadNote(slug) {
await flushSave(); // persist the page we're leaving (no version)
currentSlug = slug;
const res = await api.get(slug);
if (res.status === 404) {
const jkey = jiraKeyFromSlug(slug);
if (jkey) { // defect with no page yet: auto-create silently
await api.create({ slug, body_md: "" });
return loadNote(slug);
}
if (confirm(`Note "${slug}" doesn't exist. Create it?`)) {
await api.create({ slug, title: slug, body_md: "" });
return loadNote(slug);
}
return;
}
const note = await res.json();
currentNote = note;
setTitle(note.title);
// Jira notes: title is the locked Jira summary; plain notes stay editable
const isJira = !!(note.jira || jiraKeyFromSlug(slug));
document.getElementById("title").setAttribute("contenteditable", isJira ? "false" : "true");
renderJiraHead(isJira ? (note.jira ? note.jira.key : jiraKeyFromSlug(slug)) : null);
setBody(note.body_md);
dirtySince = false;
setStatus("saved");
setArchiveButton(note.archived);
slugSet = new Set([...slugSet, ...note.links.filter(l => l.exists).map(l => l.slug)]);
renderPanel(note);
renderSidebar();
}
// --- save: debounced autosave (no version) + explicit checkpoint (version) ---
function scheduleSave() {
dirtySince = true;
setStatus("●", "#c0392b");
clearTimeout(saveTimer);
saveTimer = setTimeout(flushSave, 700);
}
async function flushSave() {
clearTimeout(saveTimer);
if (!dirtySince || !currentSlug) return;
dirtySince = false;
setStatus("saving…");
await putNote(null);
setStatus("saved");
}
async function putNote(message) {
if (!currentSlug) return null;
const res = await api.update(currentSlug, {
title: titleText(),
body_md: editor.getMarkdown(),
message,
});
const note = await res.json();
currentNote = note;
if (jiraKeyFromSlug(currentSlug)) setTitle(note.title); // keep locked title fresh
renderPanel(note);
renderSidebar();
return note;
}
async function checkpoint() {
if (!currentSlug) return;
await flushSave();
const label = prompt("Checkpoint label (optional):");
await putNote(label && label.trim() ? label.trim() : "checkpoint");
setStatus("checkpoint saved", "#16a34a");
}
async function toggleArchive() {
if (!currentSlug || !currentNote) return;
const next = !currentNote.archived;
await api.archive(currentSlug, next);
currentNote.archived = next;
setArchiveButton(next);
renderSidebar();
}
// New page. If the name is a Jira id (e.g. MS-7764), the server makes it a
// defect page with the title pulled from Jira — no separate action needed.
async function newNote() {
const name = prompt("New page — title, or a Jira id like MS-7764:");
if (!name || !name.trim()) return;
const slug = slugify(name);
const existing = await api.get(slug);
if (existing.ok) { openNote(slug); return; } // already exists → just open
const res = await api.create({ slug, title: name.trim(), body_md: "" });
if (res.status === 409) { alert("A page with that name already exists."); return; }
openNote((await res.json()).slug);
}
// --- right panel: backlinks + attachments --------------------------------
function renderPanel(note) {
const p = document.getElementById("panel");
let html = "<h4>Backlinks</h4>";
html += (note.backlinks || []).length
? note.backlinks.map(b => `<a href="/wiki/${b.slug}">${b.title}</a>`).join("")
: "<em>none</em>";
html += "<h4>Attachments</h4>";
html += (note.attachments || []).length
? note.attachments.map(a =>
`<div class="att"><a href="${a.url}" target="_blank">${a.filename}</a>` +
`<span class="badge">${a.ocr_status}</span></div>`).join("")
: "<em>none</em>";
p.innerHTML = html;
}
async function refreshPanel() {
if (!currentSlug) return;
const r = await api.get(currentSlug);
if (r.ok) renderPanel(await r.json());
}
// --- uploads (paste / drop / picker) -------------------------------------
async function upload(file) {
const fd = new FormData();
fd.append("file", file);
if (currentSlug) fd.append("note", currentSlug);
const res = await fetch("/attachments", { method: "POST", body: fd });
return res.json();
}
// arbitrary (incl. non-image) file: upload + insert a link/image, refresh panel
async function handleFile(file) {
const a = await upload(file);
const md = a.mime_type.startsWith("image/")
? `\n![${a.filename}](${a.url})\n`
: `\n[${a.filename}](${a.url})\n`;
editor.insertText(md);
refreshPanel();
}
function onPick(e) {
const f = e.target.files[0];
if (f) handleFile(f);
e.target.value = "";
}
// --- boot ----------------------------------------------------------------
window.addEventListener("DOMContentLoaded", async () => {
editor = new toastui.Editor({
el: document.getElementById("editor"),
height: "100%",
initialEditType: "markdown",
previewStyle: "vertical",
usageStatistics: false,
// render [[wiki links]] as clickable inline widgets (both modes)
widgetRules: [
{
rule: /\[\[([^\]]+)\]\]/,
toDOM(text) {
const label = text.slice(2, -2);
const slug = slugify(label);
const jkey = jiraKeyFromSlug(slug);
if (jkey) return jiraBadge(jkey); // [[MS-7764]] renders as a status badge
const a = document.createElement("a");
a.className = "wikilink" + (slugSet.has(slug) ? "" : " missing");
a.textContent = label;
a.href = "/wiki/" + slug;
a.addEventListener("click", (e) => { e.preventDefault(); openNote(slug); });
return a;
},
},
{
// Jira keys (project MS), but not when embedded in a URL/word
rule: /(?<![\w./-])MS-\d+(?![\w-])/,
toDOM(text) { return jiraBadge(text); },
},
],
hooks: {
// fires for pasted / dropped / toolbar images — reuse /attachments
addImageBlobHook: async (blob, cb) => {
const a = await upload(blob);
cb(a.url, a.filename);
refreshPanel();
},
},
events: {
change: () => { if (!loading) scheduleSave(); },
},
});
// editable title (plain notes): autosave on rename; Enter commits (blurs)
const titleEl = document.getElementById("title");
titleEl.addEventListener("input", () => { if (!loading) scheduleSave(); });
titleEl.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); titleEl.blur(); }
});
// Ctrl/Cmd+S creates a checkpoint
document.addEventListener("keydown", (e) => {
if ((e.ctrlKey || e.metaKey) && e.key === "s") { e.preventDefault(); checkpoint(); }
});
// drag & drop arbitrary (non-image) files — TUI's hook already handles images
const ed = document.getElementById("editor");
ed.addEventListener("dragover", (e) => e.preventDefault());
ed.addEventListener("drop", (e) => {
const files = [...(e.dataTransfer?.files || [])].filter(f => !f.type.startsWith("image/"));
if (!files.length) return; // let TUI handle image drops itself
e.preventDefault();
files.forEach(handleFile);
});
// flush the last sub-second of edits if the tab is hidden/closed (keepalive PUT)
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden" && dirtySince && currentSlug) {
dirtySince = false;
fetch("/api/notes/" + currentSlug, {
method: "PUT", headers: H, keepalive: true,
body: JSON.stringify({ title: titleText(), body_md: editor.getMarkdown(), message: null }),
});
}
});
document.getElementById("search").addEventListener("input", async (e) => {
const q = e.target.value.trim();
if (!q) return renderSidebar();
renderFlat(await api.search(q));
});
applyPanelState();
connectWS();
window.addEventListener("popstate", boot);
await boot();
});
// live sync: refresh when another instance changes a note
function editorFocused() {
const ed = document.getElementById("editor");
return document.activeElement && ed.contains(document.activeElement);
}
function connectWS() {
const proto = location.protocol === "https:" ? "wss" : "ws";
const ws = new WebSocket(`${proto}://${location.host}/ws`);
ws.onmessage = (ev) => {
let msg; try { msg = JSON.parse(ev.data); } catch (e) { return; }
if (msg.origin === clientId) return; // ignore our own echo
renderSidebar();
if (msg.slug === currentSlug && !dirtySince && !editorFocused()) {
loadNote(currentSlug); // reload an idle, current page
}
};
ws.onclose = () => setTimeout(connectWS, 2000); // auto-reconnect
}
async function boot() {
await renderSidebar();
const m = location.pathname.match(/^\/wiki\/(.+)$/);
if (m) { loadNote(decodeURIComponent(m[1])); return; }
const notes = await api.list();
if (notes.length) loadNote(notes[0].slug);
}
</script>
</body>
</html>