Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
56afce6
docs: multi-format printing plan (roadmap + spike protocol T5-T7)
SamAnaniasCases Aug 28, 2026
d078d41
p10: multi-format groundwork - detection + processor registry
SamAnaniasCases Aug 28, 2026
b928e11
p11: image printing - JPG/PNG/WebP via Pillow processor
SamAnaniasCases Aug 28, 2026
19a7223
p12: office printing - DOCX/XLSX/PPTX/ODF via LibreOffice headless
SamAnaniasCases Aug 28, 2026
0be3eab
p13: text/CSV printing - reportlab renderer (MVP format set complete)
SamAnaniasCases Aug 28, 2026
5527c7c
docs: record multi-format stopping point - code complete p10-p13, pap…
SamAnaniasCases Aug 28, 2026
1f1a1f4
fix: spike_t5 --paper accepts uppercase (A4 -> a4) via str.lower
SamAnaniasCases Aug 29, 2026
601e721
docs: record T5 and T7 PASS on real paper - T6 (office) is the last o…
SamAnaniasCases Aug 29, 2026
00e0b58
docs: CLI install recipe for LibreOffice (curl direct MSI, faster tha…
SamAnaniasCases Aug 29, 2026
e278450
docs: T6 PASS - multi-format MVP fully verified on real hardware
SamAnaniasCases Aug 29, 2026
b4d1632
docs: README status - all formats verified on paper
SamAnaniasCases Aug 29, 2026
0572f01
p14: queue management - cancel overhaul, retry, SQLite persistence
SamAnaniasCases Aug 29, 2026
2131425
p15: reliability - printer readiness check + SumatraPDF exit-code cat…
SamAnaniasCases Aug 29, 2026
1109a9e
docs: Phase 6 landed note in roadmap
SamAnaniasCases Aug 29, 2026
4089a1d
p16: print options & dialog UI (Phase 7/v2)
SamAnaniasCases Aug 29, 2026
f349724
feat(logo): add a new logo and refactor the root readme
SamAnaniasCases Aug 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,31 @@ PRINTER_NAME=
# Full path to SumatraPDF.exe (Phase 5's PDF printing method). Empty = search
# standard install locations. If set here, it is used as-is (no fallback).
SUMATRA_PATH=

# ------------------------------------------------------------------
# Multi-format printing (docs/MULTI_FORMAT_PLAN.md). Phase 1 stores these;
# each becomes active in the phase that needs it.
# ------------------------------------------------------------------

# Paper size for SumatraPDF's print settings. Empty (default) = the driver
# chooses the paper — the spike-T4-proven behavior. Set e.g. A4 only after
# spike T5 confirmed this printer honors the setting. Images are laid out
# on A4 when this is empty.
PAPER_SIZE=

# Office documents (DOC/DOCX/XLS/XLSX/PPT/PPTX/ODF) are converted to PDF by
# LibreOffice Headless (Phase 3). ENABLE_OFFICE=0 turns office formats off
# without uninstalling anything; they are also refused while LibreOffice is
# not installed (its "convert" needs ~400 MB RAM while running).
ENABLE_OFFICE=1

# Explicit path to soffice.exe. Empty = search the standard install
# locations (C:\Program Files\LibreOffice\program\soffice.exe).
LO_PATH=

# Seconds an office conversion may run before the service kills LibreOffice.
CONVERT_TIMEOUT_S=120

# Job history database (SQLite, Phase 5). Default: logs/jobs.sqlite3 inside
# the project folder. Delete the file to reset job history.
# JOB_DB_PATH=
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,6 @@ logs/*
.coverage
.coverage.*
htmlcov/

# Z-Code
.zcode/
437 changes: 328 additions & 109 deletions README.md

Large diffs are not rendered by default.

84 changes: 70 additions & 14 deletions app/api/jobs.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,25 @@
"""
Jobs API (Phase 7) — see what happened, poll status, cancel mistakes.
Jobs API (Phase 7; queue management in p14) — see what happened, poll
status, cancel mistakes, retry failures.

Endpoints (SOURCE_OF_TRUTH Section 11):
GET /jobs — recent jobs and their statuses
GET /jobs/{id} — one job (what the phone UI polls: "done yet?")
DELETE /jobs/{id} — cancel a job that hasn't reached the print queue
Endpoints (SOURCE_OF_TRUTH Section 11, extended by p14):
GET /jobs — recent jobs and their statuses
GET /jobs/{id} — one job (what the phone UI polls: "done yet?")
DELETE /jobs/{id} — cancel a job (queued/converting/printing)
POST /jobs/{id}/retry — re-print a failed job from its stored upload
"""

import logging

from fastapi import APIRouter, Depends, HTTPException

from app.models.printing import PrintJob
from app.services import jobs
from app.models.printing import JobStatus, PrintJob
from app.printer import windows
from app.services import jobs, pipeline, uploads
from app.services.auth import require_pin
from app.services.uploads import upload_path

router = APIRouter()
logger = logging.getLogger(__name__)


@router.get("/jobs", response_model=list[PrintJob])
Expand All @@ -34,19 +39,70 @@ def one_job(job_id: str):

@router.delete("/jobs/{job_id}", response_model=PrintJob)
def cancel(job_id: str, _: None = Depends(require_pin)):
"""Cancel a queued job (Section 11: "you will queue the wrong file")."""
"""Cancel a job (Section 11: "you will queue the wrong file").

p14: cancellation works while queued, converting AND printing. The
printing case is best-effort — our queued spooler jobs are purged via
win32print, but paper that already fed into the printer cannot be
recalled; the pipeline never marks a cancelled job done.
"""
job = jobs.get_job(job_id)
if job is None:
raise HTTPException(status_code=404, detail=f"No job with id '{job_id}'.")
was_printing = job.status == JobStatus.PRINTING

ok, message = jobs.cancel_job(job_id)
if not ok:
raise HTTPException(status_code=409, detail=message)

# Remove the stored file so cancelled uploads don't fill the disk.
try:
upload_path(job_id).unlink(missing_ok=True)
except OSError:
pass # cleanup failure must not fail the cancel
# Best-effort spooler purge for anything already handed to Windows.
# Never fails the cancel — a purge hiccup must not 500 the request.
if was_printing:
try:
removed = windows.cancel_spooler_jobs(
windows.resolve_printer_name(), job_id
)
if removed:
logger.info(
"purged %d spooler job(s) for cancelled job %s", removed, job_id
)
except Exception:
logger.warning(
"spooler purge failed for cancelled job %s", job_id, exc_info=True
)

# Remove the stored file(s) so cancelled uploads don't fill the disk.
# delete_job_files covers the source upload and its converted PDF.
uploads.delete_job_files(job_id)

return jobs.get_job(job_id)


@router.post("/jobs/{job_id}/retry", response_model=PrintJob)
def retry(job_id: str, _: None = Depends(require_pin)):
"""Re-print a failed job (p14).

Failed jobs keep their uploaded file precisely for this. The pipeline
re-runs from conversion — a transient failure (printer offline, a
LibreOffice hiccup) becomes a second chance without re-uploading.
"""
job = jobs.get_job(job_id)
if job is None:
raise HTTPException(status_code=404, detail=f"No job with id '{job_id}'.")
if job.status != JobStatus.FAILED:
raise HTTPException(
status_code=409,
detail=f"Job is '{job.status}' — only failed jobs can be retried.",
)

source = jobs.get_source(job_id)
if source is None or not source[0].is_file():
raise HTTPException(
status_code=409,
detail="The uploaded file for this job is gone — upload it again.",
)

jobs.reset_for_retry(job_id)
pipeline.start_job(job_id, source[0], source[1], options=job.options)
logger.info("job %s queued for retry", job_id)
return jobs.get_job(job_id)
84 changes: 57 additions & 27 deletions app/api/print.py
Original file line number Diff line number Diff line change
@@ -1,67 +1,97 @@
"""
POST /print — accept a PDF upload (Phase 4).
POST /print — accept a printable file (Phase 4; multi-format in p10).

Printing itself arrives in Phase 5; this endpoint proves the *transfer* half
of the pipeline: phone → HTTP → validated bytes on disk, intact.
Printing itself arrives in Phase 5's background thread; this endpoint
proves the *transfer* half of the pipeline: phone → HTTP → validated bytes
on disk, intact.

Phase 1 (multi-format refactor) keeps the behavior PDF-only, but the flow
is now format-agnostic (docs/MULTI_FORMAT_PLAN.md §8):

1. FastAPI/python-multipart parse the request and hand us the bytes.
2. validate_upload() applies the Section 8 checks (type, content,
availability, size) and returns the detected category. A category
prints once its processor is registered AND available on this
machine (PDF, images and text always; office additionally needs
LibreOffice installed / ENABLE_OFFICE=1). Refusals explain which
gate fired.
3. save_upload() stores the bytes under a unique job id, keeping the
real extension.
4. The job is registered (category recorded) and handed to the
background pipeline: convert → print → cleanup.

Returns 201 with the job id. Errors: 401 (bad PIN), 415 (unsupported file
or lying extension), 413 (too large), 500 (disk trouble).
"""

import logging
from pathlib import Path

from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile

from app.models.printing import PrintAccepted
from app.detection import DEFAULT_EXTENSIONS
from app.models.printing import PrintAccepted, validate_print_options
from app.services import jobs, pipeline
from app.services.auth import require_pin
from app.services.uploads import UploadError, save_upload, validate_pdf
from app.services.uploads import UploadError, save_upload, validate_upload

router = APIRouter()
logger = logging.getLogger(__name__)


@router.post("/print", response_model=PrintAccepted, status_code=201)
async def print_pdf(
async def print_file(
file: UploadFile = File(...),
copies: int = Form(1),
pages: str = Form(""),
paper: str = Form(""),
color_mode: str = Form("color"),
_: None = Depends(require_pin), # PIN required only when API_PIN is set
):
"""Accept a PDF exactly like a web form uploads a photo: a
"""Accept a file exactly like a web form uploads a photo: a
multipart/form-data POST whose file field is named "file".

Flow (SOURCE_OF_TRUTH Section 5, stages 2-7):
1. FastAPI/python-multipart parse the request and hand us the bytes.
2. validate_pdf() applies the Section 8 checks (type, size).
3. save_upload() stores them under a unique job_id in uploads/.
4. The job is registered in the in-memory tracker (Phase 7).
5. pipeline.start_job() submits it to the Windows print queue in a
background thread; status moves queued → done/failed there.

Returns 201 with the job id. Errors: 401 (bad PIN), 415 (not a PDF),
413 (too large), 500 (disk trouble).
"""
The print options (copies, pages, paper, color_mode) are all optional
with safe defaults — Phase 7; see PrintOptions for the allowlists."""
data = await file.read()
filename = file.filename or ""

try:
validate_pdf(file.filename or "", data)
category = validate_upload(filename, data)
except UploadError as exc:
logger.warning("rejected upload %r: %s", file.filename, exc)
logger.warning("rejected upload %r: %s", filename, exc)
raise HTTPException(status_code=exc.status_code, detail=str(exc))

try:
job_id, path = save_upload(data)
options = validate_print_options(copies, pages, paper, color_mode).model_dump()
except ValueError as exc:
logger.warning("rejected upload %r: bad print options: %s", filename, exc)
raise HTTPException(status_code=422, detail=str(exc))

# Store under the real extension; a client that sent no usable filename
# gets the canonical one for its (magic-proven) category.
ext = Path(filename).suffix.lower() or DEFAULT_EXTENSIONS[category]

try:
job_id, path = save_upload(data, ext=ext)
except OSError as exc:
# Disk full, permissions, antivirus blocking writes... a clean 500
# beats an unhandled exception crashing the request (Section 14).
logger.exception("could not store upload %r", file.filename)
logger.exception("could not store upload %r", filename)
raise HTTPException(status_code=500, detail=f"Could not store upload: {exc}")

jobs.create_job(job_id, file.filename or "unknown.pdf", len(data), path)
pipeline.start_job(job_id, path)
jobs.create_job(
job_id, filename or f"unknown{ext}", len(data), path, format=category,
options=options,
)
pipeline.start_job(job_id, path, category, options=options)
logger.info(
"job %s received: %s (%d bytes)", job_id, file.filename, len(data)
"job %s received: %s (%d bytes, %s)", job_id, filename, len(data), category
)

return PrintAccepted(
job_id=job_id,
status="queued",
filename=file.filename or "unknown.pdf",
filename=filename or f"unknown{ext}",
size_bytes=len(data),
)
Loading
Loading