705 lines
25 KiB
Python
705 lines
25 KiB
Python
"""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)
|