upgrades
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user