603 lines
25 KiB
HTML
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, "<")}</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\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>
|