#!/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()))