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
+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)