143 lines
4.7 KiB
Python
Executable File
143 lines
4.7 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""odysseus-signature — shell wrapper for stored email signature images.
|
|
|
|
The web UI lets you draw a signature; the result lives as a PNG (and
|
|
optional SVG) in the `signatures` table, keyed by id + owner.
|
|
|
|
odysseus-signature list # all signatures (metadata only)
|
|
odysseus-signature show SIG_ID # full record incl. data_png
|
|
odysseus-signature export SIG_ID --png OUT # write PNG bytes to a file
|
|
odysseus-signature delete SIG_ID
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
import sys
|
|
import os, sys
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "_lib"))
|
|
from cli import quiet_logs, emit, fail, common_parser, run, REPO_ROOT as _REPO_ROOT
|
|
quiet_logs()
|
|
|
|
import argparse, base64, json, logging, os, sys
|
|
from pathlib import Path
|
|
|
|
try:
|
|
from sqlalchemy import text
|
|
from core.database import engine
|
|
quiet_logs()
|
|
except ModuleNotFoundError as e:
|
|
sys.stderr.write(f"error: {e}\nhint: run from repo root with venv active.\n")
|
|
sys.exit(2)
|
|
|
|
|
|
def _decode_png_data(data_png: str) -> bytes:
|
|
raw = data_png or ""
|
|
if "," in raw:
|
|
raw = raw.split(",", 1)[1]
|
|
try:
|
|
decoded = base64.b64decode(raw, validate=True)
|
|
except Exception as e:
|
|
fail(f"data_png is not valid base64: {e}")
|
|
if not decoded.startswith(b"\x89PNG\r\n\x1a\n"):
|
|
fail("data_png is not a PNG image")
|
|
return decoded
|
|
|
|
|
|
def cmd_list(args):
|
|
"""No `Signature` SQLAlchemy model is registered for the
|
|
`signatures` table — query via raw SQL so we don't depend on it."""
|
|
with engine.connect() as conn:
|
|
rows = conn.execute(text(
|
|
"SELECT id, owner, name, width, height, "
|
|
" length(data_png) AS png_len, "
|
|
" svg IS NOT NULL AS has_svg, "
|
|
" created_at "
|
|
"FROM signatures ORDER BY created_at DESC"
|
|
)).mappings().all()
|
|
out = [
|
|
{
|
|
"id": r["id"],
|
|
"owner": r["owner"] or "",
|
|
"name": r["name"],
|
|
"width": r["width"],
|
|
"height": r["height"],
|
|
"png_bytes_approx": r["png_len"],
|
|
"has_svg": bool(r["has_svg"]),
|
|
"created_at": str(r["created_at"] or ""),
|
|
} for r in rows
|
|
]
|
|
emit(out, args)
|
|
|
|
|
|
def cmd_show(args):
|
|
with engine.connect() as conn:
|
|
row = conn.execute(text(
|
|
"SELECT id, owner, name, width, height, data_png, svg, created_at "
|
|
"FROM signatures WHERE id = :id"
|
|
), {"id": args.id}).mappings().first()
|
|
if not row:
|
|
fail(f"no signature with id {args.id!r}")
|
|
emit({
|
|
"id": row["id"],
|
|
"owner": row["owner"] or "",
|
|
"name": row["name"],
|
|
"width": row["width"],
|
|
"height": row["height"],
|
|
"data_png": row["data_png"],
|
|
"has_svg": bool(row["svg"]),
|
|
"created_at": str(row["created_at"] or ""),
|
|
}, args)
|
|
|
|
|
|
def cmd_export(args):
|
|
"""Decode the data_png column to bytes and write to `--png OUT`.
|
|
`data_png` is stored as a data URL (`data:image/png;base64,...`).
|
|
We strip the prefix, base64-decode, and write the bytes."""
|
|
with engine.connect() as conn:
|
|
row = conn.execute(text(
|
|
"SELECT data_png FROM signatures WHERE id = :id"
|
|
), {"id": args.id}).mappings().first()
|
|
if not row:
|
|
fail(f"no signature with id {args.id!r}")
|
|
png_bytes = _decode_png_data(row["data_png"] or "")
|
|
out = Path(args.png)
|
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
out.write_bytes(png_bytes)
|
|
emit({"ok": True, "id": args.id, "path": str(out), "bytes": len(png_bytes)}, args)
|
|
|
|
|
|
def cmd_delete(args):
|
|
with engine.begin() as conn:
|
|
res = conn.execute(text("DELETE FROM signatures WHERE id = :id"), {"id": args.id})
|
|
if res.rowcount == 0:
|
|
fail(f"no signature with id {args.id!r}")
|
|
emit({"ok": True, "id": args.id, "deleted": True}, args)
|
|
|
|
|
|
def _build_parser():
|
|
common = argparse.ArgumentParser(add_help=False)
|
|
common.add_argument("--pretty", action="store_true")
|
|
p = argparse.ArgumentParser(prog="odysseus-signature", parents=[common])
|
|
sub = p.add_subparsers(dest="cmd", required=True)
|
|
|
|
pl = sub.add_parser("list", parents=[common])
|
|
pl.set_defaults(func=cmd_list)
|
|
|
|
psh = sub.add_parser("show", parents=[common])
|
|
psh.add_argument("id")
|
|
psh.set_defaults(func=cmd_show)
|
|
|
|
pe = sub.add_parser("export", parents=[common])
|
|
pe.add_argument("id")
|
|
pe.add_argument("--png", required=True, help="output PNG path")
|
|
pe.set_defaults(func=cmd_export)
|
|
|
|
pd = sub.add_parser("delete", parents=[common])
|
|
pd.add_argument("id")
|
|
pd.set_defaults(func=cmd_delete)
|
|
|
|
return p
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(run(_build_parser()))
|