"""core/docs.py — turn a record into a document worth sending.
Phase 7. Two outputs from one renderer:
* a **themed HTML page**, which goes straight onto the display surface (Phase
4e) so the user can look at it before it leaves the building;
* a **PDF**, rendered with the Chromium that is already in the Space image.
The PDF part is the reason this is not a PDF library. We have no reportlab and
are not adding one: the same HTML that looks right on the phone is the PDF, so
there is one design instead of two that drift. It is also why the invoice looks
like the dashboard — `core.theme` supplies the tokens, same as every other page
JARVIS produces.
"""
from __future__ import annotations
import html
import os
import re
import time
from pathlib import Path
from typing import Any, Optional
from core import theme
from core.data_paths import data_root
MAX_PAGES = 1
def _e(s: Any) -> str:
return html.escape(str(s if s is not None else ""))
def money(v: float, currency: str = "EUR") -> str:
"""Enough to be readable aloud and unambiguous on an invoice."""
sym = {"EUR": "€", "USD": "$", "GBP": "£", "TRY": "₺"}.get(currency, "")
n = f"{abs(v):,.2f}"
return f"{'-' if v < 0 else ''}{sym}{n}" if sym else f"{v:,.2f} {currency}"
def _money(v: float, cur: str = "EUR") -> str:
return money(v, cur)
# ── invoice ──────────────────────────────────────────────────────────────────
def invoice_html(inv: dict, *, from_name: str = "", from_detail: str = "",
payment_note: str = "") -> str:
"""A real invoice document, in our theme."""
cur = inv.get("currency", "EUR")
rows = []
for i, it in enumerate(inv.get("items", [])):
rows.append(
f"
| {i + 1} | "
f"{_e(it.get('description'))} | "
f"{_e(it.get('qty'))} | "
f"{_e(money(it.get('amount'), cur))} |
")
tax_pct = inv.get("tax_rate") or 0
# 20.0 reads like a bug on an invoice; 20 reads like a rate
tax_pct_s = str(int(tax_pct)) if float(tax_pct) == int(float(tax_pct)) else str(tax_pct)
# built directly rather than rewritten out of a afterwards: string
# surgery on generated HTML is how a document ends up subtly wrong
row = ("display:flex;justify-content:space-between;padding:4px 0;"
"border-bottom:1px solid var(--border)")
tax_line = ("VAT ("
+ tax_pct_s + "%)"
+ _e(money(inv.get("tax", 0), cur)) + "
") if tax_pct else ""
stamp = ""
st = (inv.get("status") or "draft").upper()
colour = {"PAID": "var(--green)", "SENT": "var(--pri)",
"OVERDUE": "var(--red)", "VOID": "var(--text-dim)"}.get(
st, "var(--acc2)")
if inv.get("overdue"):
stamp = "OVERDUE"
colour = "var(--red)"
elif st:
stamp = st
body = f"""
INVOICE
{_e(inv.get('number'))}
{'
' + stamp + '
' if stamp else ''}
issued {_e(inv.get('issued'))}
due {_e(inv.get('due'))}
FROM
{_e(from_name or 'Your name')}
{'
' + _e(from_detail).replace(chr(10), '
') + '
' if from_detail else ''}
BILL TO
{_e(inv.get('client_name'))}
{'
' + _e(inv.get('company')) + '
' if inv.get('company') else ''}
{'
' + _e(inv.get('client_email')) + '
' if inv.get('client_email') else ''}
{'
' + _e(inv.get('address')).replace(chr(10), '
') + '
' if inv.get('address') else ''}
| # | DESCRIPTION | QTY |
AMOUNT |
{''.join(rows)}
SUBTOTAL
{_e(money(inv.get('subtotal', 0), cur))}
{tax_line}
TOTAL{_e(money(inv.get('total', 0), cur))}
{('
PAYMENT TERMS
' + _e(inv.get('notes')).replace(chr(10), '
') + '
') if inv.get('notes') else ''}
{('
' + _e(payment_note) + '
') if payment_note else ''}
Generated by JARVIS · {_e(time.strftime('%Y-%m-%d %H:%M'))}
"""
return theme.inject(body=body)
# ── proposal ─────────────────────────────────────────────────────────────────
def proposal_html(p: dict, *, from_name: str = "", from_detail: str = "") -> str:
cur = p.get("currency", "EUR")
items = "".join(
f"
| {_e(i.get('description') or i.get('desc') or i.get('title'))} | "
f"{_e(money(i.get('amount', i.get('price', 0)), cur))} |
"
for i in (p.get("items") or []))
paras = "".join(f"{_e(par)}
" for par in
re.split(r"\n\s*\n", str(p.get("body") or "")) if par.strip())
body = f"""
PROPOSAL
{_e(p.get('title'))}
for {_e(p.get('client_name') or 'you')} · prepared {_e(p.get('created'))}
· valid until {_e(p.get('valid_until'))}
{paras or '
No scope written yet.
'}
{('
| ITEM | '
'AMOUNT |
' + items + '
') if items else ''}
NEXT STEP
Say yes and this becomes an invoice the same day.
{'
' + _e(from_name) + '
' if from_name else ''}
{'
' + _e(from_detail).replace(chr(10), '
') + '
' if from_detail else ''}
Generated by JARVIS · {_e(time.strftime('%Y-%m-%d %H:%M'))}
"""
return theme.inject(body=body)
# ── pdf ──────────────────────────────────────────────────────────────────────
def to_pdf(html_text: str, name: str = "document") -> Optional[Path]:
"""Render with the Chromium already in the image. Returns None when no
browser is available (a desktop install), so the caller can fall back to
showing the HTML rather than failing the request."""
out_dir = data_root() / "documents"
out_dir.mkdir(parents=True, exist_ok=True)
safe = re.sub(r"[^A-Za-z0-9_.-]", "-", str(name))[:60] or "document"
target = out_dir / f"{safe}-{int(time.time())}.pdf"
tmp = out_dir / f".{safe}-{int(time.time())}.html"
try:
tmp.write_text(html_text, encoding="utf-8")
from playwright.sync_api import sync_playwright
with sync_playwright() as p:
browser = None
for exe in (os.environ.get("JARVIS_E2E_CHROMIUM"),
"/repl/tools/bin/chromium",
"/root/.cache/ms-playwright"):
if exe and Path(exe).exists():
browser = p.chromium.launch(headless=True, executable_path=exe,
args=["--no-sandbox"])
break
if browser is None:
browser = p.chromium.launch(headless=True,
args=["--no-sandbox"])
try:
page = browser.new_page()
page.goto(tmp.as_uri(), wait_until="load", timeout=30000)
page.emulate_media(media="print")
page.pdf(path=str(target), format="A4", print_background=True,
margin={"top": "12mm", "bottom": "12mm",
"left": "10mm", "right": "10mm"})
finally:
browser.close()
return target if target.exists() else None
except Exception:
return None
finally:
try:
tmp.unlink()
except Exception:
pass
def store_html(html_text: str, name: str = "document") -> Path:
"""Keep the HTML too — it is what the display surface shows, and it is
readable on a phone without a PDF viewer."""
out_dir = data_root() / "documents"
out_dir.mkdir(parents=True, exist_ok=True)
safe = re.sub(r"[^A-Za-z0-9_.-]", "-", str(name))[:60] or "document"
target = out_dir / f"{safe}-{int(time.time())}.html"
target.write_text(html_text, encoding="utf-8")
return target
def list_documents(limit: int = 40) -> list[dict]:
out_dir = data_root() / "documents"
if not out_dir.is_dir():
return []
rows = []
for p in out_dir.iterdir():
try:
rows.append({"name": p.name, "bytes": p.stat().st_size,
"kind": p.suffix.lstrip(".").lower() or "file",
"age_s": round(time.time() - p.stat().st_mtime)})
except OSError:
continue
rows.sort(key=lambda r: r["age_s"])
return rows[:max(1, min(int(limit or 40), 200))]