Selbst gehostete PWA, die Visitenkarten von einem Foto freistellt, ausliest und als Kontakt bereitstellt. - Stapelscan: OpenCV findet die Kartenrechtecke über mehrere Binärmasken, entzerrt sie perspektivisch und schneidet sie einzeln aus. Ohne Fund gilt das ganze Foto als eine Karte. - Extraktion: ein Aufruf je Zuschnitt an das Vision-Modell mit JSON-Schema. Kein vorgeschaltetes OCR - das würde Layout und Schriftgrößen wegwerfen, aus denen die Feldzuordnung entsteht. - Metadaten: Aufnahmezeit und GPS aus den EXIF-Daten des Fotos, Ortsname über Nominatim, Browserstandort nur als Rückfallebene. - Übersicht mit Volltextsuche und Filtern, Detailansicht mit Korrekturmaske. - Notizfeld je Karte, Erinnerungen per Mail inklusive Nachholen verpasster Termine nach einem Neustart. - vCard 3.0 einzeln und als Sammeldatei, Karte gilt danach als exportiert. - Anmeldung über ein Passwort, Sitzung als signiertes Cookie. - Deployment per Tag-Push nach den Konventionen in DEPLOY.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
395 lines
13 KiB
Python
395 lines
13 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
|
|
|
|
import cv2
|
|
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, 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",
|
|
]
|
|
|
|
|
|
@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.")
|
|
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["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."""
|
|
meta = imaging.read_metadata(raw)
|
|
image = imaging.decode(raw)
|
|
crops, fallback = segment.segment(image)
|
|
encoded = [
|
|
(
|
|
imaging.encode_jpeg(crop),
|
|
imaging.encode_jpeg(cv2.rotate(crop, cv2.ROTATE_180)),
|
|
)
|
|
for crop in crops
|
|
]
|
|
return meta, encoded, fallback
|
|
|
|
|
|
@app.post("/api/scan", dependencies=[auth])
|
|
async def scan(
|
|
file: UploadFile,
|
|
lat: float = Form(None),
|
|
lon: float = Form(None),
|
|
) -> 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"])
|
|
|
|
results = await extract.extract_all(encoded)
|
|
|
|
batch_id = uuid.uuid4().hex
|
|
created = now_iso()
|
|
source = "einzel" if fallback else "stapel"
|
|
cards = []
|
|
for (upright, _), result in zip(encoded, results):
|
|
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,
|
|
"extract_status": "fehler" if result.get("_error") else "ok",
|
|
"extract_error": result.get("_error"),
|
|
}
|
|
for field in extract.FIELDS:
|
|
columns[field] = (result.get(field) or None)
|
|
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,))))
|
|
|
|
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",
|
|
]
|
|
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 <> 'ok' OR (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
|
|
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,
|
|
},
|
|
}
|
|
|
|
|
|
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,)
|
|
)
|
|
]
|
|
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)
|
|
if row["image_file"]:
|
|
(config.IMAGE_DIR / row["image_file"]).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) -> FileResponse:
|
|
row = _card_or_404(card_id)
|
|
path = config.IMAGE_DIR / (row["image_file"] or "")
|
|
if not row["image_file"] or not path.exists():
|
|
raise HTTPException(status_code=404, detail="Kein Bild gespeichert")
|
|
return FileResponse(path, media_type="image/jpeg")
|
|
|
|
|
|
# ---------------------------------------------------------------------------- Export
|
|
|
|
def _mark_exported(ids: list) -> None:
|
|
stamp = now_iso()
|
|
db.execute_many(
|
|
[("UPDATE cards SET exported_at = ? WHERE id = ?", (stamp, i)) for i in ids]
|
|
)
|
|
|
|
|
|
@app.get("/vcf/{card_id}", dependencies=[auth])
|
|
def download_vcard(card_id: str) -> Response:
|
|
card = dict(_card_or_404(card_id))
|
|
_mark_exported([card_id])
|
|
return Response(
|
|
content=vcard.build(card),
|
|
media_type="text/vcard; charset=utf-8",
|
|
headers={"Content-Disposition": f'attachment; filename="{vcard.filename(card)}"'},
|
|
)
|
|
|
|
|
|
@app.get("/vcf", dependencies=[auth])
|
|
def download_vcards(ids: str = "") -> Response:
|
|
wanted = [i for i in ids.split(",") if i.strip()]
|
|
if not wanted:
|
|
raise HTTPException(status_code=400, detail="Keine Karten ausgewaehlt")
|
|
placeholders = ", ".join("?" for _ in wanted)
|
|
rows = db.query(f"SELECT * FROM cards WHERE id IN ({placeholders})", wanted)
|
|
if not rows:
|
|
raise HTTPException(status_code=404, detail="Karten nicht gefunden")
|
|
_mark_exported([r["id"] for r in rows])
|
|
return Response(
|
|
content=vcard.build_many([dict(r) for r in rows]),
|
|
media_type="text/vcard; charset=utf-8",
|
|
headers={"Content-Disposition": 'attachment; filename="kontakte.vcf"'},
|
|
)
|
|
|
|
|
|
# ----------------------------------------------------------------------- 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")
|