Files
business-card-scanner/app/main.py
Lucas Orth bf0a397597
All checks were successful
Build & Deploy / deploy (push) Successful in 10m41s
Anlass, Dubletten, Rückseite, Teilen; Startseite und App-Anmutung
Vier Funktionen:

- Anlass: ein Feld auf der Startseite gilt für alle folgenden Scans und
  wird im Browser gemerkt. Ordnet den Bestand verlässlicher als der
  Ortsname aus den Koordinaten. Durchsuchbar, nachträglich änderbar.
- Dubletten: die Detailansicht zeigt Karten, die dieselbe Person meinen
  könnten - gleiche E-Mail, gleiche Telefonnummer (verglichen werden die
  letzten acht Ziffern, damit +49 511 123456 und 0511/123456 aufeinander
  passen) oder gleicher Nachname bei gleicher Firma.
- Rückseite: nachträglich ein zweites Bild zur Karte. Es füllt nur
  Felder, die die Vorderseite offen gelassen hat.
- Schnellwahl bei Erinnerungen: in 3 Tagen / 1 Woche / 2 Wochen /
  1 Monat, jeweils 9 Uhr. Das Datumsfeld braucht man damit selten.

Teilen als vCard über das System-Teilenblatt, mit Download als
Rückfallebene, wo es kein Teilenblatt gibt.

Oberfläche:

- Startseite führt jetzt: Anlass, große Aufnahmefläche mit Symbol statt
  Textzeile, leise Alternative, darunter die zuletzt erfassten Karten.
  Vorher stand dort eine Überschrift mit Zahlen und man musste raten.
- Weniger Webseite, mehr App: kein Seitenrahmen auf dem Telefon, Symbole
  in der Tableiste mit Zählerblasen, kein Tap-Highlight, keine
  Textmarkierung auf Bedienelementen, Safe-Area oben, Einblendung beim
  Ansichtswechsel, Mindesthöhe 54 px für Schaltflächen.
- Die Marken "exportiert" / "nicht exportiert" sind aus der Liste raus;
  dafür gibt es den Filter.

Neue Spalten occasion und back_image_file kommen per ALTER TABLE in
bestehende Datenbanken - CREATE TABLE IF NOT EXISTS rührt eine
vorhandene Tabelle nicht an.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 15:09:08 +02:00

466 lines
16 KiB
Python

"""HTTP-Schnittstelle und Auslieferung der PWA."""
import asyncio
import logging
import uuid
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from pathlib import Path
from fastapi import Depends, FastAPI, Form, HTTPException, Request, Response, UploadFile
from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse
from fastapi.staticfiles import StaticFiles
from . import (
config, db, duplicates, extract, geocode, imaging, reminders, security,
segment, vcard,
)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
log = logging.getLogger("bcs")
STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
MAX_UPLOAD_BYTES = 30 * 1024 * 1024
EDITABLE = [
"full_name", "first_name", "last_name", "job_title", "company", "department",
"email", "phone", "mobile", "fax", "website",
"street", "postal_code", "city", "country", "notes", "occasion",
]
@asynccontextmanager
async def lifespan(app: FastAPI):
config.ensure_dirs()
db.connect()
if not security.enabled():
log.warning("APP_PASSWORD ist leer - die App ist ohne Anmeldung erreichbar.")
if not extract.available():
log.warning("ANTHROPIC_API_KEY fehlt - Karten werden nur zugeschnitten, nicht ausgelesen.")
# Karten, die beim letzten Herunterfahren noch in der Warteschlange
# standen, wuerden sonst dauerhaft auf "wird gelesen" stehen bleiben.
unfinished = db.query("SELECT id FROM cards WHERE extract_status = 'offen'")
if unfinished:
log.info("%d Karte(n) aus dem letzten Lauf werden nachgeholt", len(unfinished))
start_reading([row["id"] for row in unfinished])
task = asyncio.create_task(reminders.run_forever())
try:
yield
finally:
task.cancel()
app = FastAPI(title="Business Card Scanner", lifespan=lifespan, docs_url=None, redoc_url=None)
auth = Depends(security.require_auth)
def now_iso() -> str:
return datetime.now(timezone.utc).isoformat()
def card_dict(row) -> dict:
card = dict(row)
card["image_url"] = f"/api/cards/{card['id']}/image" if card.get("image_file") else None
card["back_image_url"] = (
f"/api/cards/{card['id']}/image?side=back" if card.get("back_image_file") else None
)
card["has_contact"] = any(
card.get(f) for f in ("full_name", "company", "email", "phone", "mobile")
)
return card
# --------------------------------------------------------------------------- Sitzung
@app.get("/healthz")
def healthz() -> dict:
db.query_one("SELECT 1 AS ok")
return {"status": "ok"}
@app.get("/api/session")
def session(request: Request) -> dict:
return {
"auth_required": security.enabled(),
"authenticated": not security.enabled()
or security.valid_token(request.cookies.get(security.COOKIE)),
"extraction_available": extract.available(),
"mail_configured": bool(config.SMTP_HOST and config.REMINDER_TO),
}
@app.post("/api/login")
def login(response: Response, password: str = Form(...)) -> dict:
if not security.enabled():
return {"ok": True}
if not security.check_password(password):
raise HTTPException(status_code=401, detail="Passwort stimmt nicht")
response.set_cookie(
security.COOKIE,
security.issue_token(),
max_age=config.SESSION_DAYS * 86400,
httponly=True,
samesite="lax",
secure=True,
path="/",
)
return {"ok": True}
@app.post("/api/logout")
def logout(response: Response) -> dict:
response.delete_cookie(security.COOKIE, path="/")
return {"ok": True}
# ------------------------------------------------------------------------------ Scan
def _process_photo(raw: bytes) -> tuple:
"""Rechenlastiger Teil: dekodieren, freistellen, als JPEG kodieren.
Die Zuschnitte werden einzeln kodiert und sofort freigegeben - bei zwanzig
Karten liegen sonst rund 70 MB Rohbild gleichzeitig im Speicher.
"""
meta = imaging.read_metadata(raw)
crops, fallback = segment.segment(imaging.decode(raw))
encoded = []
while crops:
encoded.append(imaging.encode_jpeg(crops.pop(0)))
return meta, encoded, fallback
# Laufende Lesevorgaenge festhalten, sonst raeumt die Garbage Collection sie
# mitten im Lauf weg.
_reading: set = set()
def start_reading(card_ids: list, side: str = "front") -> None:
task = asyncio.create_task(_read_cards(card_ids, side))
_reading.add(task)
task.add_done_callback(_reading.discard)
async def _read_cards(card_ids: list, side: str = "front") -> None:
"""Das Auslesen laeuft nach der Antwort weiter - der Nutzer sieht seine
Karten sofort, die Felder tropfen nach."""
column = "back_image_file" if side == "back" else "image_file"
rows = [db.query_one("SELECT * FROM cards WHERE id = ?", (i,)) for i in card_ids]
rows = [row for row in rows if row is not None and row[column]]
images = [(config.IMAGE_DIR / row[column]).read_bytes() for row in rows]
results = await extract.extract_all(images)
for row, result in zip(rows, results):
rotation = int(result.get("rotation") or 0) % 360
if rotation:
# Die Lage der Karte auf dem Tisch ist geometrisch nicht
# bestimmbar - das Modell sagt uns, wie herum sie gehoert.
path = config.IMAGE_DIR / row[column]
path.write_bytes(
await asyncio.to_thread(imaging.rotate_jpeg, path.read_bytes(), rotation)
)
fields = {f: (result.get(f) or None) for f in extract.FIELDS}
# COALESCE, weil das Auslesen laeuft, waehrend die Karte schon
# sichtbar ist: was in der Zwischenzeit von Hand eingetragen wurde,
# darf das Modell nicht ueberschreiben.
assignments = ", ".join(f"{name} = COALESCE({name}, ?)" for name in fields)
db.execute(
f"UPDATE cards SET {assignments}, extract_status = ?, extract_error = ? "
"WHERE id = ? AND extract_status = 'offen'",
[
*fields.values(),
"fehler" if result.get("_error") else "ok",
result.get("_error"),
row["id"],
],
)
log.info("%d Karte(n) ausgelesen", len(rows))
@app.post("/api/scan", dependencies=[auth])
async def scan(
file: UploadFile,
lat: float = Form(None),
lon: float = Form(None),
occasion: str = Form(""),
) -> JSONResponse:
raw = await file.read()
if not raw:
raise HTTPException(status_code=400, detail="Leere Datei")
if len(raw) > MAX_UPLOAD_BYTES:
raise HTTPException(status_code=413, detail="Foto ist groesser als 30 MB")
try:
meta, encoded, fallback = await asyncio.to_thread(_process_photo, raw)
except Exception as exc:
log.exception("Foto konnte nicht verarbeitet werden")
raise HTTPException(status_code=400, detail=f"Bild nicht lesbar: {exc}") from exc
# Der Ort des Fotos schlaegt den des Uploads: Karten werden oft erst
# abends am Schreibtisch abfotografiert, nicht auf der Messe.
if meta["lat"] is not None:
geo_source = "exif"
elif lat is not None and lon is not None:
meta["lat"], meta["lon"], geo_source = lat, lon, "browser"
else:
geo_source = None
place = await asyncio.to_thread(geocode.reverse, meta["lat"], meta["lon"])
batch_id = uuid.uuid4().hex
created = now_iso()
source = "einzel" if fallback else "stapel"
cards = []
for upright in encoded:
card_id = uuid.uuid4().hex
image_file = f"{card_id}.jpg"
(config.IMAGE_DIR / image_file).write_bytes(upright)
columns = {
"id": card_id,
"created_at": created,
"captured_at": meta["captured_at"] or created,
"batch_id": batch_id,
"image_file": image_file,
"source": source,
"notes": "",
"lat": meta["lat"],
"lon": meta["lon"],
"place": place,
"geo_source": geo_source,
"occasion": occasion.strip() or None,
"extract_status": "offen",
}
names = ", ".join(columns)
placeholders = ", ".join("?" for _ in columns)
db.execute(
f"INSERT INTO cards ({names}) VALUES ({placeholders})", list(columns.values())
)
cards.append(card_dict(db.query_one("SELECT * FROM cards WHERE id = ?", (card_id,))))
# Antwort geht sofort raus, das Auslesen laeuft danach weiter.
start_reading([card["id"] for card in cards])
log.info("Scan %s: %d Karte(n), Fallback=%s", batch_id, len(cards), fallback)
return JSONResponse(
{"batch_id": batch_id, "count": len(cards), "fallback": fallback, "cards": cards}
)
# ----------------------------------------------------------------------------- Karten
@app.get("/api/cards", dependencies=[auth])
def list_cards(q: str = "", filter: str = "alle") -> dict:
sql = "SELECT * FROM cards"
where, params = [], []
if q.strip():
needle = f"%{q.strip()}%"
searchable = [
"full_name", "company", "job_title", "email", "phone", "mobile",
"website", "city", "place", "notes", "department", "street", "occasion",
]
where.append("(" + " OR ".join(f"{c} LIKE ?" for c in searchable) + ")")
params += [needle] * len(searchable)
if filter == "offen":
where.append("exported_at IS NULL")
elif filter == "unvollstaendig":
where.append(
"(extract_status = 'fehler' "
"OR (extract_status = 'ok' AND full_name IS NULL AND company IS NULL))"
)
elif filter == "erinnerung":
where.append(
"id IN (SELECT card_id FROM reminders WHERE sent_at IS NULL)"
)
if where:
sql += " WHERE " + " AND ".join(where)
sql += " ORDER BY COALESCE(captured_at, created_at) DESC, rowid DESC"
rows = [card_dict(r) for r in db.query(sql, params)]
totals = db.query_one(
"""
SELECT COUNT(*) AS total,
SUM(CASE WHEN exported_at IS NULL THEN 1 ELSE 0 END) AS offen,
SUM(CASE WHEN extract_status = 'offen' THEN 1 ELSE 0 END) AS laeuft
FROM cards
"""
)
pending = db.query_one(
"SELECT COUNT(*) AS n FROM reminders WHERE sent_at IS NULL"
)
return {
"cards": rows,
"stats": {
"total": totals["total"] or 0,
"offen": totals["offen"] or 0,
"erinnerungen": pending["n"] or 0,
"laeuft": totals["laeuft"] or 0,
},
}
def _card_or_404(card_id: str):
row = db.query_one("SELECT * FROM cards WHERE id = ?", (card_id,))
if row is None:
raise HTTPException(status_code=404, detail="Karte nicht gefunden")
return row
@app.get("/api/cards/{card_id}", dependencies=[auth])
def get_card(card_id: str) -> dict:
card = card_dict(_card_or_404(card_id))
card["reminders"] = [
dict(r)
for r in db.query(
"SELECT * FROM reminders WHERE card_id = ? ORDER BY due_at", (card_id,)
)
]
card["duplicates"] = duplicates.find(card)
return card
@app.patch("/api/cards/{card_id}", dependencies=[auth])
async def update_card(card_id: str, request: Request) -> dict:
_card_or_404(card_id)
payload = await request.json()
changes = {k: (payload[k] or None) for k in EDITABLE if k in payload}
if "notes" in changes:
changes["notes"] = changes["notes"] or ""
if not changes:
raise HTTPException(status_code=400, detail="Keine bekannten Felder im Aufruf")
assignments = ", ".join(f"{k} = ?" for k in changes)
db.execute(
f"UPDATE cards SET {assignments} WHERE id = ?", [*changes.values(), card_id]
)
return card_dict(_card_or_404(card_id))
@app.delete("/api/cards/{card_id}", dependencies=[auth])
def delete_card(card_id: str) -> dict:
row = _card_or_404(card_id)
for name in (row["image_file"], row["back_image_file"]):
if name:
(config.IMAGE_DIR / name).unlink(missing_ok=True)
db.execute("DELETE FROM cards WHERE id = ?", (card_id,))
return {"ok": True}
@app.get("/api/cards/{card_id}/image", dependencies=[auth])
def card_image(card_id: str, side: str = "front") -> FileResponse:
row = _card_or_404(card_id)
name = row["back_image_file"] if side == "back" else row["image_file"]
path = config.IMAGE_DIR / (name or "")
if not name or not path.exists():
raise HTTPException(status_code=404, detail="Kein Bild gespeichert")
return FileResponse(path, media_type="image/jpeg")
@app.post("/api/cards/{card_id}/back", dependencies=[auth])
async def add_back(card_id: str, file: UploadFile) -> dict:
"""Rueckseite nachtragen. Sie wird ausgelesen, fuellt aber nur Felder, die
die Vorderseite offen gelassen hat - dort steht oft nur die Mobilnummer."""
row = _card_or_404(card_id)
raw = await file.read()
if not raw:
raise HTTPException(status_code=400, detail="Leere Datei")
if len(raw) > MAX_UPLOAD_BYTES:
raise HTTPException(status_code=413, detail="Foto ist groesser als 30 MB")
try:
_, encoded, _ = await asyncio.to_thread(_process_photo, raw)
except Exception as exc:
log.exception("Rueckseite konnte nicht verarbeitet werden")
raise HTTPException(status_code=400, detail=f"Bild nicht lesbar: {exc}") from exc
if not encoded:
raise HTTPException(status_code=400, detail="Keine Karte im Bild gefunden")
name = f"{card_id}-back.jpg"
(config.IMAGE_DIR / name).write_bytes(encoded[0])
db.execute(
"UPDATE cards SET back_image_file = ?, extract_status = 'offen' WHERE id = ?",
(name, card_id),
)
start_reading([card_id], side="back")
return card_dict(_card_or_404(card_id))
# ---------------------------------------------------------------------------- Export
@app.get("/vcf/{card_id}", dependencies=[auth])
def download_vcard(card_id: str) -> Response:
card = dict(_card_or_404(card_id))
db.execute("UPDATE cards SET exported_at = ? WHERE id = ?", (now_iso(), card_id))
return Response(
content=vcard.build(card),
media_type="text/vcard; charset=utf-8",
headers={"Content-Disposition": f'attachment; filename="{vcard.filename(card)}"'},
)
# ----------------------------------------------------------------------- Erinnerungen
@app.get("/api/reminders", dependencies=[auth])
def list_reminders() -> dict:
rows = db.query(
"""
SELECT r.*, c.full_name, c.company
FROM reminders r JOIN cards c ON c.id = r.card_id
ORDER BY r.sent_at IS NOT NULL, r.due_at
"""
)
return {"reminders": [dict(r) for r in rows], "mail_configured": bool(config.SMTP_HOST)}
@app.post("/api/cards/{card_id}/reminders", dependencies=[auth])
async def create_reminder(card_id: str, request: Request) -> dict:
_card_or_404(card_id)
payload = await request.json()
text = (payload.get("text") or "").strip()
due_at = (payload.get("due_at") or "").strip()
if not text:
raise HTTPException(status_code=400, detail="Erinnerungstext fehlt")
try:
parsed = datetime.fromisoformat(due_at.replace("Z", "+00:00"))
except ValueError as exc:
raise HTTPException(status_code=400, detail="Datum nicht lesbar") from exc
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
reminder_id = uuid.uuid4().hex
db.execute(
"INSERT INTO reminders (id, card_id, text, due_at, created_at) VALUES (?, ?, ?, ?, ?)",
(reminder_id, card_id, text, parsed.astimezone(timezone.utc).isoformat(), now_iso()),
)
return dict(db.query_one("SELECT * FROM reminders WHERE id = ?", (reminder_id,)))
@app.delete("/api/reminders/{reminder_id}", dependencies=[auth])
def delete_reminder(reminder_id: str) -> dict:
db.execute("DELETE FROM reminders WHERE id = ?", (reminder_id,))
return {"ok": True}
# --------------------------------------------------------------------------- Statisch
@app.get("/")
def index() -> FileResponse:
return FileResponse(STATIC_DIR / "index.html")
@app.get("/sw.js")
def service_worker() -> FileResponse:
# Muss von der Wurzel kommen, sonst gilt der Service Worker nur fuer /static.
return FileResponse(STATIC_DIR / "sw.js", media_type="application/javascript")
@app.get("/manifest.webmanifest")
def manifest() -> FileResponse:
return FileResponse(STATIC_DIR / "manifest.webmanifest", media_type="application/manifest+json")
@app.get("/robots.txt")
def robots() -> PlainTextResponse:
return PlainTextResponse("User-agent: *\nDisallow: /\n")
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")