This commit is contained in:
Sorin Savu
2026-07-13 09:51:28 +03:00
parent 0ad947ba56
commit dcc6bba61b
10 changed files with 1612 additions and 8 deletions
+45
View File
@@ -0,0 +1,45 @@
"""MySQL connection helper (PyMySQL).
Connection settings come from environment variables so nothing is hard-coded:
WIKI_DB_HOST default 127.0.0.1
WIKI_DB_PORT default 3306
WIKI_DB_USER default root
WIKI_DB_PASSWORD default "" (empty)
WIKI_DB_NAME default wiki
`connect()` selects the wiki database; `connect(select_db=False)` connects to the
server without a database (used by migrate.py to CREATE DATABASE).
"""
from __future__ import annotations
import os
import pymysql
from pymysql.cursors import DictCursor
def _cfg() -> dict:
return {
"host": os.environ.get("WIKI_DB_HOST", "127.0.0.1"),
"port": int(os.environ.get("WIKI_DB_PORT", "3306")),
"user": os.environ.get("WIKI_DB_USER", "root"),
"password": os.environ.get("WIKI_DB_PASSWORD", ""),
}
def db_name() -> str:
return os.environ.get("WIKI_DB_NAME", "wiki")
def connect(select_db: bool = True) -> pymysql.connections.Connection:
kwargs = _cfg()
if select_db:
kwargs["database"] = db_name()
return pymysql.connect(
cursorclass=DictCursor,
autocommit=True,
charset="utf8mb4",
**kwargs,
)
+25
View File
@@ -0,0 +1,25 @@
"""One-shot diagnostic for the truncated-import bug.
Run in the same shell where WIKI_DB_* are exported: python diag.py
"""
from pathlib import Path
import db
src = Path("work-notes.md").read_text()
print(f"source file: {len(src)} chars")
conn = db.connect()
with conn.cursor() as cur:
cur.execute("SHOW COLUMNS FROM notes LIKE 'body_md'")
col = cur.fetchone()
print(f"body_md column type: {col['Type']!r} <-- should be 'mediumtext'")
cur.execute("SELECT slug, CHAR_LENGTH(body_md) AS n, body_md FROM notes WHERE slug='work-notes'")
row = cur.fetchone()
if not row:
print("no 'work-notes' note found")
else:
print(f"stored body: {row['n']} chars")
if row["n"] < len(src):
print(f"TRUNCATED by {len(src) - row['n']} chars")
print("...last 80 stored chars:", repr(row["body_md"][-80:]))
conn.close()
+602
View File
@@ -0,0 +1,602 @@
<!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>
+77
View File
@@ -0,0 +1,77 @@
"""Create the wiki database + schema, and import the existing work-notes.md.
Idempotent: safe to run repeatedly. Connection comes from the WIKI_DB_* env
vars (see db.py). Run: python migrate.py
"""
from __future__ import annotations
import re
from pathlib import Path
import db
from wikilib import slugify
LEGACY_FILE = Path("work-notes.md")
def _statements(sql: str) -> list[str]:
# naive splitter: our schema has no semicolons inside statements
return [s.strip() for s in sql.split(";") if s.strip()]
def main() -> None:
name = db.db_name()
# 1. create the database (connect without selecting one)
root = db.connect(select_db=False)
with root.cursor() as cur:
cur.execute(
f"CREATE DATABASE IF NOT EXISTS `{name}` "
"CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"
)
root.close()
print(f"database `{name}` ready")
# 2. apply schema
conn = db.connect()
schema = Path("schema.sql").read_text()
with conn.cursor() as cur:
for stmt in _statements(schema):
cur.execute(stmt)
# additive migration for existing installs (MySQL lacks ADD COLUMN IF NOT EXISTS)
try:
cur.execute(
"ALTER TABLE notes ADD COLUMN archived TINYINT(1) NOT NULL DEFAULT 0"
)
print("added notes.archived column")
except Exception:
pass # column already exists
print("schema applied")
# 3. import the legacy single-file note, if present and not already imported
if LEGACY_FILE.exists():
body = LEGACY_FILE.read_text()
slug = "work-notes"
# title = first markdown heading, else a sensible default
m = re.search(r"^#\s+(.+)$", body, re.MULTILINE)
title = m.group(1).strip() if m else "Work Notes"
with conn.cursor() as cur:
cur.execute("SELECT id FROM notes WHERE slug=%s", (slug,))
if cur.fetchone():
print(f"note '{slug}' already exists, skipping import")
else:
cur.execute(
"INSERT INTO notes (slug, title, body_md) VALUES (%s,%s,%s)",
(slug, title, body),
)
print(f"imported {LEGACY_FILE} as note '{slug}' (title: {title!r})")
else:
print(f"{LEGACY_FILE} not found, nothing to import")
conn.close()
print("done")
if __name__ == "__main__":
main()
+56
View File
@@ -0,0 +1,56 @@
"""Pluggable OCR.
The engine is chosen by the OCR_ENGINE env var:
tesseract (default) -- pytesseract + Pillow + the system `tesseract` binary
none -- OCR disabled; images are marked 'skipped'
Swapping in another engine later (e.g. a Claude-vision call) is just another
branch in `run_ocr` behind the same signature. `run_ocr` raises OCRUnavailable
when the configured engine's dependencies are missing (the worker then marks the
row 'skipped' rather than 'failed'), and lets real OCR errors propagate.
"""
from __future__ import annotations
import io
import os
class OCRUnavailable(RuntimeError):
"""The configured OCR engine can't run (missing binary / library)."""
def engine() -> str:
return os.environ.get("OCR_ENGINE", "tesseract").lower()
def enabled() -> bool:
return engine() != "none"
def run_ocr(data: bytes, mime: str) -> str:
"""Return extracted text for an image. Raises OCRUnavailable if the engine
is missing, or any other exception on a genuine OCR failure."""
eng = engine()
if eng == "none":
raise OCRUnavailable("OCR_ENGINE=none")
if eng == "tesseract":
return _tesseract(data)
raise OCRUnavailable(f"unknown OCR_ENGINE={eng!r}")
def _tesseract(data: bytes) -> str:
try:
import pytesseract
from PIL import Image
except ImportError as e:
raise OCRUnavailable(f"pytesseract/Pillow not installed: {e}") from e
try:
img = Image.open(io.BytesIO(data))
except Exception as e: # not a decodable image
raise OCRUnavailable(f"not a decodable image: {e}") from e
try:
return pytesseract.image_to_string(img)
except pytesseract.TesseractNotFoundError as e:
raise OCRUnavailable("tesseract binary not found on PATH") from e
+8
View File
@@ -0,0 +1,8 @@
Flask>=3.0
PyMySQL>=1.1
requests>=2.31
PyYAML>=6.0
flask-sock>=0.7
simple-websocket>=1.0
# Optional, only needed when OCR_ENGINE=tesseract:
# pip install pytesseract Pillow (and the system `tesseract-ocr` binary)
+57
View File
@@ -0,0 +1,57 @@
-- Wiki schema. Run via migrate.py (which also CREATEs the database and imports
-- the existing work-notes.md). All statements are idempotent (IF NOT EXISTS).
CREATE TABLE IF NOT EXISTS notes (
id INT AUTO_INCREMENT PRIMARY KEY,
slug VARCHAR(255) NOT NULL UNIQUE,
title VARCHAR(255) NOT NULL,
body_md MEDIUMTEXT NOT NULL,
archived TINYINT(1) NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FULLTEXT KEY ft_notes (title, body_md)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Snapshots written only when the user supplies a message on save
-- (mirrors the old "Create a new version?" git-commit behaviour).
CREATE TABLE IF NOT EXISTS note_versions (
id INT AUTO_INCREMENT PRIMARY KEY,
note_id INT NOT NULL,
title VARCHAR(255) NOT NULL,
body_md MEDIUMTEXT NOT NULL,
message VARCHAR(500),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_ver_note (note_id),
CONSTRAINT fk_ver_note FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS attachments (
id INT AUTO_INCREMENT PRIMARY KEY,
note_id INT NULL,
filename VARCHAR(255) NOT NULL,
mime_type VARCHAR(128) NOT NULL,
size INT NOT NULL,
sha256 CHAR(64) NOT NULL,
data LONGBLOB NOT NULL,
ocr_text MEDIUMTEXT NULL,
ocr_status ENUM('pending','done','failed','skipped') NOT NULL DEFAULT 'pending',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_sha (sha256),
KEY idx_ocr_status (ocr_status),
CONSTRAINT fk_att_note FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE SET NULL,
FULLTEXT KEY ft_ocr (ocr_text)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- One row per [[wiki link]] found in a note body. to_note_id is NULL while the
-- target note does not yet exist (broken / forward link). Powers backlinks.
CREATE TABLE IF NOT EXISTS links (
id INT AUTO_INCREMENT PRIMARY KEY,
from_note_id INT NOT NULL,
to_slug VARCHAR(255) NOT NULL,
to_note_id INT NULL,
KEY idx_link_from (from_note_id),
KEY idx_link_to_slug (to_slug),
KEY idx_link_to_note (to_note_id),
CONSTRAINT fk_link_from FOREIGN KEY (from_note_id) REFERENCES notes(id) ON DELETE CASCADE,
CONSTRAINT fk_link_to FOREIGN KEY (to_note_id) REFERENCES notes(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+704
View File
@@ -0,0 +1,704 @@
"""Wiki server: multiple MySQL-backed notes with [[wiki links]], file/screenshot
attachments (stored as BLOBs with metadata + pluggable OCR), and full-text search.
Replaces the original 29-line single-file http.server. Run: python server.py
Listens on 0.0.0.0:33333 (same as before). DB settings come from WIKI_DB_* env
vars (see db.py); OCR engine from OCR_ENGINE (see ocr.py).
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import threading
import time
from pathlib import Path
import requests
import yaml
from flask import Flask, Response, abort, jsonify, request, send_from_directory
from flask_sock import Sock
import db
import ocr
from wikilib import parse_links, slugify
app = Flask(__name__)
sock = Sock(app)
HERE = __file__.rsplit("/", 1)[0]
# --------------------------------------------------------------------------- #
# websocket fan-out: notify all open instances when a note changes
# --------------------------------------------------------------------------- #
_ws_clients: set = set()
_ws_lock = threading.Lock()
def broadcast(payload: dict) -> None:
msg = json.dumps(payload)
with _ws_lock:
clients = list(_ws_clients)
for ws in clients:
try:
ws.send(msg)
except Exception:
with _ws_lock:
_ws_clients.discard(ws)
@sock.route("/ws")
def ws(ws):
with _ws_lock:
_ws_clients.add(ws)
try:
while True:
if ws.receive() is None: # blocks; None on disconnect
break
finally:
with _ws_lock:
_ws_clients.discard(ws)
# --------------------------------------------------------------------------- #
# Jira config + ticket lookup (read-only proxy with an in-memory TTL cache)
# --------------------------------------------------------------------------- #
JIRA_CONFIG_PATH = Path(
os.environ.get("WIKI_JIRA_CONFIG", "~/.config/metalsoft-mcp/environments.yaml")
).expanduser()
_JIRA_KEY_RE = re.compile(r"^[A-Z][A-Z0-9]+-\d+$")
_JIRA_SLUG_RE = re.compile(r"^(ms-\d+)(?:-|$)", re.I) # leading Jira id of a note slug
def _jira_key(slug: str | None) -> str | None:
"""Uppercase Jira key if this slug starts with a Jira ticket id, else None.
Matches both "ms-7764" and legacy "ms-7764-bare-metal-..." slugs."""
m = _JIRA_SLUG_RE.match(slug or "")
return m.group(1).upper() if m else None
_jira_cache: dict[str, tuple[float, int, dict]] = {} # key -> (expires, status, payload)
_JIRA_TTL_OK = 600.0
_JIRA_TTL_ERR = 60.0
def _load_jira_cfg() -> dict | None:
"""Read the `jira` block from the MCP environments.yaml. Returns None if the
file is missing/unparseable or the block is absent (Jira features disabled)."""
try:
raw = yaml.safe_load(JIRA_CONFIG_PATH.read_text())
j = (raw or {}).get("jira")
if j and j.get("base_url") and j.get("email") and j.get("api_token"):
return j
except Exception:
pass
return None
_JIRA_CFG = _load_jira_cfg()
def _flatten_adf(node) -> str:
"""Best-effort flatten of Atlassian Document Format to plain text."""
if node is None:
return ""
if isinstance(node, str):
return node
if isinstance(node, list):
return "".join(_flatten_adf(n) for n in node)
if not isinstance(node, dict):
return str(node)
t = node.get("type")
if t == "text":
return node.get("text", "")
inner = _flatten_adf(node.get("content", []))
if t in ("paragraph", "heading"):
return inner + "\n"
if t == "hardBreak":
return "\n"
if t == "listItem":
return "- " + inner
return inner
_JIRA_BUCKET = {"new": "todo", "indeterminate": "inprogress", "done": "done"}
def _fetch_jira(key: str) -> tuple[int, dict]:
"""(status_code, payload) for a Jira key, using the cache. Never raises."""
now = time.time()
cached = _jira_cache.get(key)
if cached and cached[0] > now:
return cached[1], cached[2]
if _JIRA_CFG is None:
return 503, {"error": "jira_not_configured"}
base = _JIRA_CFG["base_url"].rstrip("/")
try:
r = requests.get(
f"{base}/rest/api/3/issue/{key}",
auth=(_JIRA_CFG["email"], _JIRA_CFG["api_token"]),
params={
"fields": "summary,status,issuetype,assignee,priority,description",
"expand": "renderedFields",
},
headers={"Accept": "application/json"},
timeout=15,
)
except requests.RequestException as e:
status, payload = 502, {"error": "jira_unreachable", "detail": str(e)}
_jira_cache[key] = (now + _JIRA_TTL_ERR, status, payload)
return status, payload
if r.status_code == 404:
status, payload = 404, {"error": "not_found"}
_jira_cache[key] = (now + _JIRA_TTL_ERR, status, payload)
return status, payload
if r.status_code >= 400:
status, payload = 502, {"error": "jira_error", "status_code": r.status_code}
_jira_cache[key] = (now + _JIRA_TTL_ERR, status, payload)
return status, payload
f = r.json().get("fields", {})
st = f.get("status") or {}
cat = ((st.get("statusCategory") or {}).get("key")) or "new"
desc = _flatten_adf(f.get("description")).strip()
payload = {
"key": key,
"summary": f.get("summary"),
"status": st.get("name"),
"category": _JIRA_BUCKET.get(cat, "todo"),
"type": (f.get("issuetype") or {}).get("name"),
"assignee": (f.get("assignee") or {}).get("displayName"),
"priority": (f.get("priority") or {}).get("name"),
"url": f"{base}/browse/{key}",
"preview": desc[:280] + ("" if len(desc) > 280 else ""),
}
_jira_cache[key] = (now + _JIRA_TTL_OK, 200, payload)
return 200, payload
_jira_assigned_cache: tuple[float, dict] | None = None
_JIRA_ASSIGNED_TTL = 120.0
def _issue_to_info(issue: dict) -> dict:
base = _JIRA_CFG["base_url"].rstrip("/")
f = issue.get("fields", {})
st = f.get("status") or {}
cat = ((st.get("statusCategory") or {}).get("key")) or "new"
return {
"key": issue["key"],
"summary": f.get("summary"),
"status": st.get("name"),
"category": _JIRA_BUCKET.get(cat, "todo"),
"url": f"{base}/browse/{issue['key']}",
}
def _jira_search(jql: str) -> list[dict]:
"""Run a JQL search; returns a list of issue-info dicts (never raises)."""
if _JIRA_CFG is None:
return []
base = _JIRA_CFG["base_url"].rstrip("/")
try:
r = requests.get(
f"{base}/rest/api/3/search/jql",
auth=(_JIRA_CFG["email"], _JIRA_CFG["api_token"]),
params={"jql": jql, "fields": "summary,status", "maxResults": 100},
headers={"Accept": "application/json"},
timeout=20,
)
if r.status_code != 200:
return []
out = [_issue_to_info(i) for i in r.json().get("issues", [])]
except requests.RequestException:
return []
# seed the per-key cache so badges/headers render instantly
now = time.time()
for info in out:
_jira_cache[info["key"]] = (
now + _JIRA_TTL_OK,
200,
{**info, "type": None, "assignee": None, "priority": None, "preview": ""},
)
return out
def _jira_assigned() -> dict[str, dict]:
"""{KEY: info} for tickets assigned to me that aren't Done (cached)."""
global _jira_assigned_cache
now = time.time()
if _jira_assigned_cache and _jira_assigned_cache[0] > now:
return _jira_assigned_cache[1]
issues = _jira_search(
"assignee = currentUser() AND statusCategory != Done ORDER BY updated DESC"
)
out = {i["key"]: i for i in issues}
_jira_assigned_cache = (now + _JIRA_ASSIGNED_TTL, out)
return out
def _jira_status_many(keys: list[str]) -> dict[str, dict]:
"""{KEY: info} for an explicit set of keys, in one search."""
if not keys:
return {}
jql = "key in (" + ",".join(keys) + ")"
return {i["key"]: i for i in _jira_search(jql)}
# --------------------------------------------------------------------------- #
# helpers
# --------------------------------------------------------------------------- #
def _recompute_links(cur, note_id: int, body_md: str) -> None:
"""Replace this note's outgoing links with the [[...]] refs in body_md,
resolving each target slug to a note id when that note exists."""
cur.execute("DELETE FROM links WHERE from_note_id=%s", (note_id,))
for slug in parse_links(body_md):
cur.execute("SELECT id FROM notes WHERE slug=%s", (slug,))
row = cur.fetchone()
cur.execute(
"INSERT INTO links (from_note_id, to_slug, to_note_id) VALUES (%s,%s,%s)",
(note_id, slug, row["id"] if row else None),
)
def _resolve_forward_links(cur, slug: str, note_id: int) -> None:
"""A note with this slug now exists: point previously-unresolved links at it."""
cur.execute(
"UPDATE links SET to_note_id=%s WHERE to_slug=%s AND to_note_id IS NULL",
(note_id, slug),
)
def _note_payload(cur, note: dict) -> dict:
nid = note["id"]
# outgoing links (with existence flag)
cur.execute(
"SELECT to_slug, to_note_id IS NOT NULL AS exists_flag "
"FROM links WHERE from_note_id=%s",
(nid,),
)
links = [{"slug": r["to_slug"], "exists": bool(r["exists_flag"])} for r in cur.fetchall()]
# backlinks: notes that link here
cur.execute(
"SELECT n.slug, n.title FROM links l JOIN notes n ON n.id=l.from_note_id "
"WHERE l.to_note_id=%s ORDER BY n.title",
(nid,),
)
backlinks = [{"slug": r["slug"], "title": r["title"]} for r in cur.fetchall()]
# attachments
cur.execute(
"SELECT id, filename, mime_type, size, ocr_status FROM attachments "
"WHERE note_id=%s ORDER BY id",
(nid,),
)
attachments = [
{
"id": r["id"],
"filename": r["filename"],
"mime_type": r["mime_type"],
"size": r["size"],
"ocr_status": r["ocr_status"],
"url": f"/attachments/{r['id']}",
}
for r in cur.fetchall()
]
return {
"id": nid,
"slug": note["slug"],
"title": note["title"],
"body_md": note["body_md"],
"archived": bool(note.get("archived")),
"updated_at": note["updated_at"].isoformat() if note.get("updated_at") else None,
"links": links,
"backlinks": backlinks,
"attachments": attachments,
}
# --------------------------------------------------------------------------- #
# pages (SPA shell)
# --------------------------------------------------------------------------- #
@app.get("/")
@app.get("/wiki/<slug>")
def index(slug: str | None = None):
return send_from_directory(HERE, "editor.html")
@app.after_request
def _no_cache_html(resp):
# keep the SPA shell fresh on every reload (avoids serving a stale editor)
if resp.mimetype == "text/html":
resp.headers["Cache-Control"] = "no-store"
return resp
# --------------------------------------------------------------------------- #
# notes API
# --------------------------------------------------------------------------- #
@app.get("/api/notes")
def list_notes():
conn = db.connect()
with conn.cursor() as cur:
cur.execute("SELECT slug, title, updated_at FROM notes ORDER BY updated_at DESC")
rows = cur.fetchall()
conn.close()
return jsonify(
[
{
"slug": r["slug"],
"title": r["title"],
"updated_at": r["updated_at"].isoformat() if r["updated_at"] else None,
}
for r in rows
]
)
@app.get("/api/sidebar")
def sidebar():
"""Four sections blending local notes with live Jira data:
Notes (plain) / In Progress / Backlog / Archive."""
conn = db.connect()
with conn.cursor() as cur:
cur.execute("SELECT slug, title, archived FROM notes ORDER BY updated_at DESC")
notes = cur.fetchall()
conn.close()
assigned = _jira_assigned() # {KEY: info}, one search (cached)
# statuses for local defect pages not already covered by the assigned search
local_keys = []
for n in notes:
k = _jira_key(n["slug"])
if k and not n["archived"] and k not in assigned:
local_keys.append(k)
extra = _jira_status_many(sorted(set(local_keys)))
out = {"notes": [], "inprogress": [], "backlog": [], "archive": []}
seen_keys = set()
for n in notes:
key = _jira_key(n["slug"])
if n["archived"]:
info = assigned.get(key) or extra.get(key) if key else None
out["archive"].append({
"slug": n["slug"], "title": (info or {}).get("summary") or n["title"],
"key": key, "has_page": True, "archived": True,
"url": (info or {}).get("url"),
})
if key:
seen_keys.add(key)
continue
if key:
info = assigned.get(key) or extra.get(key) or {}
cat = info.get("category", "backlog")
bucket = {"inprogress": "inprogress", "todo": "backlog",
"done": "archive"}.get(cat, "backlog")
out[bucket].append({
"slug": n["slug"], "title": info.get("summary") or n["title"],
"key": key, "category": cat, "status": info.get("status"),
"has_page": True, "archived": False, "url": info.get("url"),
})
seen_keys.add(key)
else:
out["notes"].append({
"slug": n["slug"], "title": n["title"],
"key": None, "has_page": True, "archived": False,
})
# assigned Jira tickets without a local page → add by category, has_page=false
for key, info in assigned.items():
if key in seen_keys:
continue
bucket = "inprogress" if info["category"] == "inprogress" else "backlog"
out[bucket].append({
"slug": key.lower(), "title": info.get("summary") or key,
"key": key, "category": info["category"], "status": info.get("status"),
"has_page": False, "archived": False, "url": info.get("url"),
})
for b in out.values():
b.sort(key=lambda e: (e.get("title") or "").lower())
return jsonify(out)
@app.post("/api/notes")
def create_note():
data = request.get_json(force=True)
raw_title = (data.get("title") or "").strip()
body = data.get("body_md") or ""
slug = slugify(data.get("slug") or raw_title or "untitled")
# a Jira-backed note (slug == ticket id) takes its title from Jira, not the client
jkey = _jira_key(slug)
if jkey:
st, pj = _fetch_jira(jkey)
title = pj["summary"] if (st == 200 and pj.get("summary")) else jkey
else:
title = raw_title or "Untitled"
conn = db.connect()
with conn.cursor() as cur:
cur.execute("SELECT id FROM notes WHERE slug=%s", (slug,))
if cur.fetchone():
conn.close()
abort(409, f"a note with slug {slug!r} already exists")
cur.execute(
"INSERT INTO notes (slug, title, body_md) VALUES (%s,%s,%s)",
(slug, title, body),
)
nid = cur.lastrowid
_recompute_links(cur, nid, body)
_resolve_forward_links(cur, slug, nid)
cur.execute("SELECT * FROM notes WHERE id=%s", (nid,))
payload = _note_payload(cur, cur.fetchone())
conn.close()
broadcast({"type": "note", "slug": slug, "origin": request.headers.get("X-Origin")})
return jsonify(payload), 201
@app.get("/api/notes/<slug>")
def get_note(slug: str):
conn = db.connect()
with conn.cursor() as cur:
cur.execute("SELECT * FROM notes WHERE slug=%s", (slug,))
note = cur.fetchone()
if not note:
conn.close()
abort(404, f"no note {slug!r}")
# Jira-backed note: refresh the (locked) title from Jira and attach status
jira = None
jkey = _jira_key(slug)
if jkey:
st, pj = _fetch_jira(jkey)
if st == 200:
if pj.get("summary") and pj["summary"] != note["title"]:
cur.execute(
"UPDATE notes SET title=%s WHERE id=%s", (pj["summary"], note["id"])
)
note["title"] = pj["summary"]
jira = {"key": jkey, "status": pj["status"],
"category": pj["category"], "url": pj["url"]}
else:
jira = {"key": jkey}
payload = _note_payload(cur, note)
payload["jira"] = jira
conn.close()
return jsonify(payload)
@app.put("/api/notes/<slug>")
def update_note(slug: str):
data = request.get_json(force=True)
body = data.get("body_md") or ""
message = (data.get("message") or "").strip()
conn = db.connect()
with conn.cursor() as cur:
cur.execute("SELECT * FROM notes WHERE slug=%s", (slug,))
note = cur.fetchone()
if not note:
conn.close()
abort(404, f"no note {slug!r}")
# Jira-backed notes have a locked, Jira-sourced title; ignore client title
jkey = _jira_key(slug)
if jkey:
st, pj = _fetch_jira(jkey)
title = pj["summary"] if (st == 200 and pj.get("summary")) else note["title"]
else:
title = (data.get("title") or note["title"]).strip()
cur.execute(
"UPDATE notes SET title=%s, body_md=%s WHERE id=%s",
(title, body, note["id"]),
)
_recompute_links(cur, note["id"], body)
if message: # snapshot a version, like the old "Create a new version?"
cur.execute(
"INSERT INTO note_versions (note_id, title, body_md, message) "
"VALUES (%s,%s,%s,%s)",
(note["id"], title, body, message),
)
cur.execute("SELECT * FROM notes WHERE id=%s", (note["id"],))
payload = _note_payload(cur, cur.fetchone())
conn.close()
broadcast({"type": "note", "slug": slug, "origin": request.headers.get("X-Origin")})
return jsonify(payload)
@app.post("/api/notes/<slug>/archive")
def archive_note(slug: str):
archived = bool((request.get_json(force=True) or {}).get("archived", True))
conn = db.connect()
with conn.cursor() as cur:
cur.execute("UPDATE notes SET archived=%s WHERE slug=%s", (1 if archived else 0, slug))
if cur.rowcount == 0:
conn.close()
abort(404, f"no note {slug!r}")
conn.close()
broadcast({"type": "note", "slug": slug, "origin": request.headers.get("X-Origin")})
return jsonify({"slug": slug, "archived": archived})
@app.get("/api/notes/<slug>/backlinks")
def backlinks(slug: str):
conn = db.connect()
with conn.cursor() as cur:
cur.execute("SELECT id FROM notes WHERE slug=%s", (slug,))
note = cur.fetchone()
if not note:
conn.close()
abort(404, f"no note {slug!r}")
cur.execute(
"SELECT n.slug, n.title FROM links l JOIN notes n ON n.id=l.from_note_id "
"WHERE l.to_note_id=%s ORDER BY n.title",
(note["id"],),
)
rows = cur.fetchall()
conn.close()
return jsonify([{"slug": r["slug"], "title": r["title"]} for r in rows])
# --------------------------------------------------------------------------- #
# search (LIKE-based: reliable for short tokens like "MS-7764" that FULLTEXT's
# min-token-length would drop; FULLTEXT indexes are kept in schema for future)
# --------------------------------------------------------------------------- #
@app.get("/api/search")
def search():
q = (request.args.get("q") or "").strip()
if not q:
return jsonify([])
like = f"%{q}%"
conn = db.connect()
with conn.cursor() as cur:
cur.execute(
"SELECT slug, title, "
" CASE WHEN title LIKE %s THEN 'title' ELSE 'body' END AS matched_in "
"FROM notes WHERE title LIKE %s OR body_md LIKE %s "
"ORDER BY updated_at DESC LIMIT 50",
(like, like, like),
)
hits = {r["slug"]: {"slug": r["slug"], "title": r["title"], "matched_in": r["matched_in"]}
for r in cur.fetchall()}
# notes whose attachment OCR text matches
cur.execute(
"SELECT DISTINCT n.slug, n.title FROM attachments a JOIN notes n ON n.id=a.note_id "
"WHERE a.ocr_text LIKE %s LIMIT 50",
(like,),
)
for r in cur.fetchall():
hits.setdefault(
r["slug"], {"slug": r["slug"], "title": r["title"], "matched_in": "attachment"}
)
conn.close()
return jsonify(list(hits.values()))
# --------------------------------------------------------------------------- #
# jira
# --------------------------------------------------------------------------- #
@app.get("/api/jira/<key>")
def jira_ticket(key: str):
if not _JIRA_KEY_RE.match(key):
abort(400, "invalid jira key")
status, payload = _fetch_jira(key)
return jsonify(payload), status
# --------------------------------------------------------------------------- #
# attachments
# --------------------------------------------------------------------------- #
@app.post("/attachments")
def upload_attachment():
if "file" not in request.files:
abort(400, "expected a multipart 'file' field")
f = request.files["file"]
data = f.read()
if not data:
abort(400, "empty file")
sha = hashlib.sha256(data).hexdigest()
mime = f.mimetype or "application/octet-stream"
filename = f.filename or "upload"
note_slug = request.form.get("note")
conn = db.connect()
with conn.cursor() as cur:
note_id = None
if note_slug:
cur.execute("SELECT id FROM notes WHERE slug=%s", (note_slug,))
row = cur.fetchone()
note_id = row["id"] if row else None
# dedupe by content hash: reuse an existing identical blob
cur.execute("SELECT id FROM attachments WHERE sha256=%s", (sha,))
existing = cur.fetchone()
if existing:
aid = existing["id"]
else:
status = "pending" if mime.startswith("image/") and ocr.enabled() else "skipped"
cur.execute(
"INSERT INTO attachments (note_id, filename, mime_type, size, sha256, data, ocr_status) "
"VALUES (%s,%s,%s,%s,%s,%s,%s)",
(note_id, filename, mime, len(data), sha, data, status),
)
aid = cur.lastrowid
conn.close()
return jsonify({"id": aid, "url": f"/attachments/{aid}", "filename": filename, "mime_type": mime}), 201
@app.get("/attachments/<int:aid>")
def get_attachment(aid: int):
conn = db.connect()
with conn.cursor() as cur:
cur.execute(
"SELECT filename, mime_type, data FROM attachments WHERE id=%s", (aid,)
)
row = cur.fetchone()
conn.close()
if not row:
abort(404)
disposition = "inline" if row["mime_type"].startswith("image/") else "attachment"
return Response(
row["data"],
mimetype=row["mime_type"],
headers={"Content-Disposition": f'{disposition}; filename="{row["filename"]}"'},
)
# --------------------------------------------------------------------------- #
# background OCR worker
# --------------------------------------------------------------------------- #
def _ocr_worker(poll_seconds: float = 5.0) -> None:
"""Fill ocr_text for pending image attachments using the configured engine."""
while True:
try:
conn = db.connect()
with conn.cursor() as cur:
cur.execute(
"SELECT id, mime_type, data FROM attachments "
"WHERE ocr_status='pending' LIMIT 5"
)
rows = cur.fetchall()
for r in rows:
try:
text = ocr.run_ocr(r["data"], r["mime_type"])
cur.execute(
"UPDATE attachments SET ocr_text=%s, ocr_status='done' WHERE id=%s",
(text, r["id"]),
)
except ocr.OCRUnavailable:
cur.execute(
"UPDATE attachments SET ocr_status='skipped' WHERE id=%s", (r["id"],)
)
except Exception: # genuine OCR failure on this image
cur.execute(
"UPDATE attachments SET ocr_status='failed' WHERE id=%s", (r["id"],)
)
conn.close()
except Exception:
pass # DB hiccup: try again next tick
time.sleep(poll_seconds)
def start_ocr_worker() -> None:
threading.Thread(target=_ocr_worker, daemon=True).start()
if __name__ == "__main__":
start_ocr_worker()
app.run(host="0.0.0.0", port=33333, threaded=True)
+25
View File
@@ -0,0 +1,25 @@
"""Small shared helpers: slugs and [[wiki link]] parsing."""
from __future__ import annotations
import re
_WIKILINK_RE = re.compile(r"\[\[([^\]]+)\]\]")
_SLUG_STRIP = re.compile(r"[^a-z0-9]+")
def slugify(text: str) -> str:
"""Lowercase, collapse non-alphanumerics to single hyphens, trim.
Mirrors the JS slugify() in editor.html — keep the two in sync.
"""
s = _SLUG_STRIP.sub("-", text.strip().lower()).strip("-")
return s or "untitled"
def parse_links(body_md: str) -> list[str]:
"""Return the distinct target slugs referenced by [[...]] in a note body."""
seen: dict[str, None] = {}
for m in _WIKILINK_RE.finditer(body_md or ""):
seen.setdefault(slugify(m.group(1)), None)
return list(seen)
+13 -8
View File
@@ -20,14 +20,6 @@ admin
4xfC3B6LCMMlRiuQmA2kWWXo3L8=
### MS-8868 -The OS is not reinstalled when the templated id is changed on the server instance group
***TODO:***
Reproduce this and fix. I have two requests to look into this from Alex Bordei and https://chat.metalsoft.io/bigstep/pl/751ugq9xrtg8tkgaz7pz1xs1xc
Sergiu ?
There is a discussion on matermost that Mike will add in the defect
@@ -168,3 +160,16 @@ Also, I was thinking that some checks/loops should be moved in the agent, and no
1. TBD: clarify what the fuck is the requirement here
## OLD Stuff
### MS-8868 -The OS is not reinstalled when the templated id is changed on the server instance group
closed as WONT DO
***TODO:***
Reproduce this and fix. I have two requests to look into this from Alex Bordei and https://chat.metalsoft.io/bigstep/pl/751ugq9xrtg8tkgaz7pz1xs1xc
Sergiu ?
There is a discussion on matermost that Mike will add in the defect