commit ab993e98e16a5d4813eb9dca244ae798dad91564 Author: Lucas Orth Date: Sun Sep 6 11:45:56 2026 +0200 Visitenkarten-Scanner: Stapelscan, Extraktion, Übersicht, vCard-Export 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 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c5bbb6e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +.git +.gitea +.venv +data +tests +__pycache__ +*.pyc +*.log +.env +.pytest_cache diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..bc4bac3 --- /dev/null +++ b/.env.example @@ -0,0 +1,33 @@ +# Vorlage fuer das Gitea-Secret DOTENV (Repo -> Einstellungen -> Actions -> Secrets). +# Der komplette Inhalt dieser Datei kommt mehrzeilig in das Secret. + +# --- Zugang ----------------------------------------------------------------- +# Leer lassen schaltet die Anmeldung ab. Auf einer oeffentlich erreichbaren +# Domain muss hier ein Passwort stehen. +APP_PASSWORD= +# Zufaellig, mindestens 32 Zeichen: openssl rand -hex 32 +SECRET_KEY= + +# --- Auslesen der Karten ---------------------------------------------------- +ANTHROPIC_API_KEY= +# Standard ist claude-opus-5. claude-haiku-4-5 ist deutlich guenstiger. +ANTHROPIC_MODEL=claude-opus-5 +EXTRACT_CONCURRENCY=5 + +# --- Erinnerungsmails ------------------------------------------------------- +SMTP_HOST= +SMTP_PORT=587 +SMTP_USER= +SMTP_PASSWORD= +SMTP_FROM= +SMTP_STARTTLS=1 +# Empfaenger aller Erinnerungen. +REMINDER_TO= + +# --- Sonstiges -------------------------------------------------------------- +DATA_DIR=/data +PORT=8080 +# Fuer den Link in der Erinnerungsmail. +PUBLIC_BASE_URL=https://business-card-scanner.lucas-orth.de +# Ortsnamen ueber Nominatim aufloesen (0 schaltet es ab). +REVERSE_GEOCODE=1 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2d1b6f8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +# Entwicklung auf Windows, Ausfuehrung auf Linux: im Repo immer LF. +* text=auto eol=lf +*.png binary diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..98f750a --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,50 @@ +name: Build & Deploy + +on: + push: + tags: + - 'v*' + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + # Intern klonen statt ueber die oeffentliche Domain: Gitea laeuft auf + # demselben Host, der Weg ueber die oeffentliche IP findet nicht + # zurueck (NAT-Hairpin). + - uses: actions/checkout@v4 + with: + github-server-url: http://gitea:3000 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Abhaengigkeiten installieren + run: pip install -r requirements-dev.txt + + - name: Tests + run: pytest -q + + - name: Tag ermitteln + run: echo "IMAGE_TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV + + # Ein einziges Repo-Secret DOTENV mit dem kompletten .env-Inhalt. + # Ueber env: statt direkter Interpolation, damit Anfuehrungszeichen, + # Backticks und $ in den Werten unangetastet bleiben. + - name: .env aus Secret erzeugen + env: + DOTENV: ${{ secrets.DOTENV }} + run: | + if [ -z "$DOTENV" ]; then + echo "Secret DOTENV ist leer oder nicht gesetzt." + exit 1 + fi + umask 077 + echo "$DOTENV" > .env + + - name: Deployen + run: docker compose up -d --build --remove-orphans --wait --wait-timeout 300 + + - name: Alte Layer aufraeumen + run: docker image prune -f diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d7e5121 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +.venv/ +data/ +__pycache__/ +*.pyc +.pytest_cache/ +.env +.claude/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..49f155e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,125 @@ +# CLAUDE.md + +Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed. + +**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment. + +## 1. Think Before Coding + +**Don't assume. Don't hide confusion. Surface tradeoffs.** + +Before implementing: +- State your assumptions explicitly. If uncertain, ask. +- If multiple interpretations exist, present them - don't pick silently. +- If a simpler approach exists, say so. Push back when warranted. +- If something is unclear, stop. Name what's confusing. Ask. + +## 2. Simplicity First + +**Minimum code that solves the problem. Nothing speculative.** + +- No features beyond what was asked. +- No abstractions for single-use code. +- No "flexibility" or "configurability" that wasn't requested. +- No error handling for impossible scenarios. +- If you write 200 lines and it could be 50, rewrite it. + +Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify. + +## 3. Surgical Changes + +**Touch only what you must. Clean up only your own mess.** + +When editing existing code: +- Don't "improve" adjacent code, comments, or formatting. +- Don't refactor things that aren't broken. +- Match existing style, even if you'd do it differently. +- If you notice unrelated dead code, mention it - don't delete it. + +When your changes create orphans: +- Remove imports/variables/functions that YOUR changes made unused. +- Don't remove pre-existing dead code unless asked. + +The test: Every changed line should trace directly to the user's request. + +## 4. Goal-Driven Execution + +**Define success criteria. Loop until verified.** + +Transform tasks into verifiable goals: +- "Add validation" → "Write tests for invalid inputs, then make them pass" +- "Fix the bug" → "Write a test that reproduces it, then make it pass" +- "Refactor X" → "Ensure tests pass before and after" + +For multi-step tasks, state a brief plan: +``` +1. [Step] → verify: [check] +2. [Step] → verify: [check] +3. [Step] → verify: [check] +``` + +Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification. + +--- + +**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes. + +--- + +# Projekthinweise + +## Sprache + +Code-Kommentare, Docstrings, Oberflaeche und Commit-Nachrichten auf Deutsch. +Bezeichner im Code auf Englisch. + +## Deployment + +Diese App wird per Tag-Push deployt, nicht manuell hochgeladen. Die +verbindlichen Konventionen stehen in **DEPLOY.md**. Vor Aenderungen an +`Dockerfile`, `docker-compose.yml` oder `.gitea/workflows/` dort nachsehen. + +Kurzfassung der Regeln, die man leicht bricht: + +- Kein `ports:` im Compose. Der Proxy erreicht den Container ueber das Netz + `nginx-proxy-manager_default` unter seinem `container_name`. +- `name:` im Compose ist Pflicht (fester Projektname), sonst legt der CI-Job + einen zweiten Stack an. +- Keine relativen Bind-Mounts. Compose laeuft im Job-Container, der Pfad zeigt + auf dem Host ins Leere. Persistente Daten in named volumes mit festem `name:`. +- Healthcheck ist Pflicht, der Deploy nutzt ihn als Gate. Hier zeigt er auf + `/healthz`. Abweichung von der Vorlage: `python -c` statt `node -e`, weil das + Image kein Node enthaelt. +- Secrets kommen aus dem Repo-Secret `DOTENV`, nie in die Compose-Datei. +- Tests laufen im Workflow **vor** dem Deploy-Schritt. + +## Entscheidungen, die nicht aus dem Code hervorgehen + +**Kein separater OCR-Schritt.** Das Kartenbild geht direkt an das +Vision-Modell. Tesseract vorzuschalten wuerde Layout und Schriftgroessen +wegwerfen - genau die Information, aus der die Feldzuordnung entsteht. + +**OpenCV macht die Geometrie, das Modell den Inhalt.** Bounding Boxes vom +Modell zu erfragen waere naheliegend, ihre Koordinaten sind aber zu ungenau +fuer saubere Zuschnitte. Umgekehrt kann OpenCV nichts lesen. + +**Das ganze Stapelfoto in einem Aufruf funktioniert nicht.** Die API skaliert +grosse Bilder herunter; jede Karte landet dann bei ein paar hundert Pixeln +Breite, und Telefonnummern in 7-Punkt-Schrift sind darin nicht mehr lesbar. +Deshalb ein Aufruf je Zuschnitt. + +**EXIF schlaegt Browserstandort.** Der Zeitpunkt und Ort des Fotos sind +verlaesslicher als die des Uploads. + +**Serverseitige Speicherung statt Offline-First.** Es gibt ohnehin einen +Server (fuer den API-Schluessel), damit ist SQLite plus Bilder auf der Platte +einfacher als IndexedDB mit Synchronisation. + +**Die 180-Grad-Lage einer Karte ist geometrisch nicht bestimmbar.** Bleibt die +Extraktion leer, wird einmal gedreht nachgefasst. + +## Modellwahl + +Standard ist `claude-opus-5`, ueberschreibbar per `ANTHROPIC_MODEL`. Die +Extraktion laeuft mit `output_config.effort: "low"` - reines Ablesen, keine +Denkarbeit - und mit strukturiertem JSON-Schema statt freiem Text. diff --git a/DEPLOY.md b/DEPLOY.md new file mode 100644 index 0000000..2ab5d06 --- /dev/null +++ b/DEPLOY.md @@ -0,0 +1,185 @@ +# Deployment-Konventionen + +Verbindlich fuer jede App, die auf dem VPS per Tag-Push deployt wird. +Abweichungen brauchen einen Grund, der hier nicht steht. + +## Ablauf + +``` +git tag v1.2.3 && git push --tags + -> Gitea Actions (act_runner auf dem VPS) + -> npm ci / build / test <- rot bricht ab, nichts wird deployt + -> .env aus Repo-Secret DOTENV + -> docker compose up -d --build --wait +``` + +Kein SCP, kein manuelles Neubauen. Der Code landet nie im Dateisystem des +Hosts - der Job-Container klont, baut gegen den Docker-Daemon des Hosts und +startet den Container. + +## Infrastruktur (existiert bereits, nicht neu anlegen) + +| Ding | Wert | +|---|---| +| Git | Gitea, `https://gitea.lucas-orth.de`, Container `gitea`, intern `:3000` | +| Runner | `act_runner`, Container `gitea-runner`, Label `ubuntu-latest` | +| Netz CI | `gitea-ci` - Gitea + Runner + Job-Container | +| Netz Web | `nginx-proxy-manager_default` - Proxy + alle App-Container | +| Job-Image | `catthehacker/ubuntu:act-latest` (node + docker-cli + compose) | + +Die beiden Netze sind bewusst getrennt: der Runner spricht mit Gitea, nicht +mit dem Proxy. App-Container haengen im Web-Netz, nicht im CI-Netz. + +## docker-compose.yml + +```yaml +# Fester Projektname. Ohne das leitet Compose ihn aus dem Verzeichnisnamen +# ab - und der ist im CI-Job ein anderer als auf dem Host. +name: business-card-scanner + +services: + app: + # Versionstag ermoeglicht Rollback ohne Rebuild. + image: business-card-scanner:${IMAGE_TAG:-latest} + build: . + # Fester Name: das ist die Adresse, auf die der Proxy zeigt. + container_name: business-card-scanner + restart: unless-stopped + # Wird im Workflow aus dem Secret DOTENV erzeugt. + env_file: .env + networks: + - nginx-proxy-manager_default + healthcheck: + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8080/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + interval: 60s + timeout: 5s + retries: 3 + +networks: + nginx-proxy-manager_default: + external: true + +volumes: + business-card-scanner-data: + # Fester Name, damit das Volume nicht am Projektnamen haengt. + name: business-card-scanner-data +``` + +**Kein `ports:`.** Der Proxy erreicht den Container ueber das gemeinsame Netz +unter seinem Container-Namen. Ein Port-Mapping wuerde den Dienst zusaetzlich +offen ans Internet haengen. + +**Healthcheck ist Pflicht.** Der Deploy-Schritt nutzt `--wait` und macht ihn +zum Gate. Ohne Healthcheck wird der Lauf gruen, auch wenn der Container in +einer Crash-Schleife haengt. + +**Keine relativen Bind-Mounts** (`./data:/data`). Compose laeuft im +Job-Container, der Pfad wuerde auf dem Host ins Leere zeigen. Persistente +Daten gehoeren in ein named volume mit festem `name:`. + +## .gitea/workflows/deploy.yml + +```yaml +name: Build & Deploy + +on: + push: + tags: + - 'v*' + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + # Intern klonen. Die oeffentliche Domain ist aus Containern heraus + # NICHT erreichbar: Gitea laeuft auf demselben Host, das Paket geht + # an die oeffentliche IP und findet nicht zurueck (NAT-Hairpin). + - uses: actions/checkout@v4 + with: + github-server-url: http://gitea:3000 + + # --- Build & Tests, projektspezifisch --- + + - name: Tag ermitteln + run: echo "IMAGE_TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV + + - name: .env aus Secret erzeugen + env: + DOTENV: ${{ secrets.DOTENV }} + run: | + if [ -z "$DOTENV" ]; then + echo "Secret DOTENV ist leer oder nicht gesetzt." + exit 1 + fi + umask 077 + echo "$DOTENV" > .env + + - name: Deployen + run: docker compose up -d --build --remove-orphans --wait --wait-timeout 180 + + - name: Alte Layer aufraeumen + run: docker image prune -f +``` + +Tests gehoeren **vor** den Deploy-Schritt. Ein roter Lauf darf den Server +nicht anfassen. + +## Dockerfile + +- Multi-Stage: Build-Abhaengigkeiten landen nicht im finalen Image. +- `USER node` bzw. non-root im finalen Stage. +- `EXPOSE 8080` dokumentiert den internen Port (oeffnet nichts). +- Ein Health-Endpoint (`/healthz`), auf den der Healthcheck zeigt. + +## Secrets + +Ein einziges Repo-Secret **`DOTENV`** mit dem kompletten `.env`-Inhalt, +mehrzeilig. Anlegen unter Repo -> Einstellungen -> Actions -> Secrets. + +Gitea speichert Secrets write-only: nach dem Anlegen nicht mehr lesbar, nur +ersetzbar. Die kanonische Fassung gehoert deshalb in den Passwortmanager, +Gitea haelt nur die Arbeitskopie fuer die Pipeline. + +Der Wert wird ueber `env:` an die Shell gereicht, nie direkt interpoliert - +sonst zerlegt der Shell-Parser Werte mit `$`, Backticks oder Quotes. + +## Nginx Proxy Manager + +Neuer Proxy Host: + +| Feld | Wert | +|---|---| +| Domain Names | `business-card-scanner.lucas-orth.de` | +| Scheme | `http` | +| Forward Hostname / IP | `business-card-scanner` (= `container_name`) | +| Forward Port | `8080` | + +Danach Let's-Encrypt-Zertifikat ausstellen und "Force SSL" aktivieren. + +Weil der Container-Name die Adresse ist: aendert sich `container_name`, +zeigt der Proxy-Host ins Leere. + +## Bestehende App umstellen + +Fuer dieses Projekt nicht relevant - es lief nie manuell auf dem Server. + +## Erstes Deployment einer App + +1. Repo in Gitea anlegen, pushen. +2. Secret `DOTENV` setzen. +3. Pruefen, ob schon ein Container mit dem Namen laeuft + (`docker ps -a --filter name=business-card-scanner`). Falls ja: im alten Verzeichnis + einmal `docker compose down`, sonst kollidiert der feste Container-Name. +4. Proxy Host im NPM anlegen. +5. `git tag v1.0.0 && git push origin v1.0.0` + +## Fallstricke + +| Symptom | Ursache | +|---|---| +| Job haengt beim Checkout | `github-server-url` fehlt, klont ueber die oeffentliche Domain | +| `network not found` | App-Container im CI-Netz statt im Web-Netz (oder umgekehrt) | +| Zweiter Container statt Update | `name:` im Compose fehlt, Projektname weicht ab | +| Lauf gruen, App tot | `--wait` fehlt oder kein Healthcheck definiert | +| Volume leer nach Umstellung | Projektname geaendert, altes Volume hiess anders | +| Proxy liefert 502 | `container_name` geaendert oder Container nicht im Web-Netz | diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..eedcb6e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +# Abhaengigkeiten in ein venv bauen, damit der Compiler-Ballast nicht ins +# fertige Image wandert. +FROM python:3.12-slim AS build +RUN python -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +FROM python:3.12-slim +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PATH="/opt/venv/bin:$PATH" \ + DATA_DIR=/data \ + PORT=8080 + +# libglib2.0-0 wird von opencv-python-headless gebraucht. +RUN apt-get update \ + && apt-get install -y --no-install-recommends libglib2.0-0 \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --uid 10001 --create-home app \ + && mkdir -p /data && chown app:app /data + +COPY --from=build /opt/venv /opt/venv +WORKDIR /app +COPY app ./app +COPY static ./static + +USER app +EXPOSE 8080 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..9e3cd00 --- /dev/null +++ b/README.md @@ -0,0 +1,105 @@ +# Business Card Scanner + +Visitenkarten abfotografieren, automatisch freistellen, auslesen und als +Kontakt aufs iPhone holen. Selbst gehostete PWA, ein Container. + +## Was die App macht + +**Stapelscan.** Ein Foto von zwanzig Karten auf dem Tisch reicht. OpenCV findet +die Kartenrechtecke, entzerrt sie perspektivisch und schneidet sie einzeln aus – +ohne manuelle Nacharbeit. Jeder Zuschnitt geht als Bild an das Vision-Modell, +das Name, Firma, Position und Kontaktdaten als strukturiertes JSON zurückgibt. +Ein vorgeschalteter OCR-Schritt wäre kontraproduktiv: er würde Layout und +Schriftgrößen wegwerfen, also genau die Information, aus der die Feldzuordnung +entsteht. + +**Metadaten.** Aufnahmezeit und GPS kommen aus den EXIF-Daten des Fotos, nicht +aus dem Browser – Karten werden oft erst abends am Schreibtisch abfotografiert, +der Browserstandort wäre dann das Wohnzimmer statt der Messe. Die Koordinaten +löst Nominatim in einen Ortsnamen auf. Fehlen EXIF-Daten, greift der +Browserstandort als Rückfallebene. + +**Übersicht.** Alle Karten mit Volltextsuche über Name, Firma, Ort und Notiz. +Filter für „nicht exportiert“, „unvollständig“ und „mit Erinnerung“. + +**Export.** Ein Tippen auf *In Kontakte speichern* liefert eine vCard 3.0, die +iOS direkt in die Kontakte-App übernimmt. Die Karte gilt danach als exportiert. + +**Notizen und Erinnerungen.** Freitext pro Karte. Erinnerungen mit Termin gehen +als Mail raus; verpasste Termine holt der Scheduler beim nächsten Start nach. + +## Stack + +Python 3.12, FastAPI, SQLite, OpenCV, Anthropic SDK. Frontend ohne Build-Schritt: +statisches HTML, CSS und JavaScript, als PWA installierbar. + +## Lokal starten + +```bash +python -m venv .venv && .venv/bin/pip install -r requirements-dev.txt +cp .env.example .env # APP_PASSWORD leer lassen schaltet die Anmeldung ab +DATA_DIR=./data .venv/bin/uvicorn app.main:app --reload --port 8099 +``` + +Ohne `ANTHROPIC_API_KEY` läuft alles außer der Extraktion: Karten werden +freigestellt und gespeichert, die Felder bleiben leer. + +```bash +.venv/bin/pytest -q +``` + +## Deployment + +Tag-Push nach den Konventionen in [DEPLOY.md](DEPLOY.md). Vor dem ersten Tag: + +1. **Secret `DOTENV`** anlegen (Repo → Einstellungen → Actions → Secrets), Inhalt + nach dem Muster in [.env.example](.env.example). `SECRET_KEY` mit + `openssl rand -hex 32` erzeugen. +2. **Proxy Host** im Nginx Proxy Manager: + `business-card-scanner.lucas-orth.de` → `business-card-scanner` : `8080`, + danach Let's-Encrypt-Zertifikat und „Force SSL“. +3. **Deployen:** `git tag v1.0.0 && git push origin v1.0.0` + +### Zugang + +Die App hängt hinter dem öffentlichen Proxy, nicht im Tailnet. Sie enthält +personenbezogene Daten Dritter, deshalb ist `APP_PASSWORD` auf dieser Domain +Pflicht – ohne Passwort ist sie für jeden erreichbar, der die Adresse kennt. +Wer zusätzlich absichern will, trägt im NPM unter *Access List* eine +IP-Beschränkung auf den Tailnet-Bereich ein. + +### Kosten + +Ein Kartenzuschnitt kostet ungefähr zwei Cent bei `claude-opus-5` (Standard). +Ein Stapel von zwanzig Karten liegt damit bei etwa 40 Cent. +`ANTHROPIC_MODEL=claude-haiku-4-5` in der `.env` drückt das auf einen Bruchteil, +zulasten der Trefferquote bei kleiner Schrift und unruhigen Layouts. + +## Aufnahmebedingungen für den Stapelscan + +Die Freistellung ist reine Geometrie und braucht Kontrast zum Untergrund: + +- dunkler, matter, einfarbiger Untergrund +- Karten berühren sich nicht +- Kamera möglichst parallel zur Tischplatte +- volle Auflösung – deshalb die native Kamera, nicht der Livestream + +Findet die Pipeline kein Kartenrechteck, behandelt sie das ganze Foto als eine +Karte und sagt das in der Oberfläche. Einzelne Fehlgriffe lassen sich pro Karte +über *Bearbeiten* korrigieren. + +## Verzeichnisse + +``` +app/ FastAPI-Anwendung + segment.py OpenCV-Pipeline: Karten finden und entzerren + extract.py Vision-Modell, strukturiertes JSON + imaging.py EXIF-Auswertung, Bildkonvertierung + vcard.py vCard 3.0 + reminders.py Scheduler für die Erinnerungsmails +static/ PWA: HTML, CSS, JavaScript, Service Worker +tests/ pytest, inklusive synthetischer Stapelfotos +``` + +Persistente Daten liegen im Volume `business-card-scanner-data` unter `/data`: +`cards.db` und die Kartenbilder in `images/`. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..3f2b019 --- /dev/null +++ b/app/config.py @@ -0,0 +1,55 @@ +"""Konfiguration. Alle Werte kommen aus der Umgebung (im Betrieb aus .env).""" +import os +from pathlib import Path + + +def _s(name: str, default: str = "") -> str: + return os.environ.get(name, default).strip() + + +def _i(name: str, default: int) -> int: + try: + return int(_s(name) or default) + except ValueError: + return default + + +def _b(name: str, default: bool) -> bool: + v = _s(name).lower() + if not v: + return default + return v in ("1", "true", "yes", "on") + + +DATA_DIR = Path(_s("DATA_DIR", "./data")) +IMAGE_DIR = DATA_DIR / "images" +DB_PATH = DATA_DIR / "cards.db" + +PORT = _i("PORT", 8080) + +# Leeres Passwort schaltet die Anmeldung ab - nur fuer die lokale Entwicklung. +APP_PASSWORD = _s("APP_PASSWORD") +SECRET_KEY = _s("SECRET_KEY", "dev-only-insecure-secret") +SESSION_DAYS = _i("SESSION_DAYS", 30) + +ANTHROPIC_API_KEY = _s("ANTHROPIC_API_KEY") +ANTHROPIC_MODEL = _s("ANTHROPIC_MODEL", "claude-opus-5") +EXTRACT_CONCURRENCY = _i("EXTRACT_CONCURRENCY", 5) + +SMTP_HOST = _s("SMTP_HOST") +SMTP_PORT = _i("SMTP_PORT", 587) +SMTP_USER = _s("SMTP_USER") +SMTP_PASSWORD = _s("SMTP_PASSWORD") +SMTP_FROM = _s("SMTP_FROM") or SMTP_USER +SMTP_STARTTLS = _b("SMTP_STARTTLS", True) +REMINDER_TO = _s("REMINDER_TO") + +REVERSE_GEOCODE = _b("REVERSE_GEOCODE", True) +NOMINATIM_URL = _s("NOMINATIM_URL", "https://nominatim.openstreetmap.org/reverse") +USER_AGENT = _s("USER_AGENT", "business-card-scanner/1.0 (self-hosted)") + +PUBLIC_BASE_URL = _s("PUBLIC_BASE_URL").rstrip("/") + + +def ensure_dirs() -> None: + IMAGE_DIR.mkdir(parents=True, exist_ok=True) diff --git a/app/db.py b/app/db.py new file mode 100644 index 0000000..8e26728 --- /dev/null +++ b/app/db.py @@ -0,0 +1,100 @@ +"""SQLite-Zugriff. Eine Verbindung, Schreibzugriffe unter einem Lock.""" +import sqlite3 +import threading +from typing import Any, Iterable + +from . import config + +_lock = threading.Lock() +_conn: sqlite3.Connection | None = None + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS cards ( + id TEXT PRIMARY KEY, + created_at TEXT NOT NULL, + captured_at TEXT, + batch_id TEXT, + image_file TEXT, + source TEXT NOT NULL DEFAULT 'batch', + full_name TEXT, + first_name TEXT, + last_name TEXT, + job_title TEXT, + company TEXT, + department TEXT, + email TEXT, + phone TEXT, + mobile TEXT, + fax TEXT, + website TEXT, + street TEXT, + postal_code TEXT, + city TEXT, + country TEXT, + notes TEXT NOT NULL DEFAULT '', + lat REAL, + lon REAL, + place TEXT, + geo_source TEXT, + extract_status TEXT NOT NULL DEFAULT 'ok', + extract_error TEXT, + exported_at TEXT +); + +CREATE TABLE IF NOT EXISTS reminders ( + id TEXT PRIMARY KEY, + card_id TEXT NOT NULL REFERENCES cards(id) ON DELETE CASCADE, + text TEXT NOT NULL, + due_at TEXT NOT NULL, + created_at TEXT NOT NULL, + sent_at TEXT, + send_error TEXT +); + +CREATE INDEX IF NOT EXISTS idx_cards_created ON cards(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_reminders_due ON reminders(due_at) WHERE sent_at IS NULL; + +CREATE TABLE IF NOT EXISTS geocache ( + key TEXT PRIMARY KEY, + place TEXT, + fetched_at TEXT NOT NULL +); +""" + + +def connect() -> sqlite3.Connection: + global _conn + if _conn is None: + config.ensure_dirs() + _conn = sqlite3.connect(config.DB_PATH, check_same_thread=False) + _conn.row_factory = sqlite3.Row + _conn.execute("PRAGMA journal_mode=WAL") + _conn.execute("PRAGMA foreign_keys=ON") + _conn.executescript(SCHEMA) + _conn.commit() + return _conn + + +def query(sql: str, params: Iterable[Any] = ()) -> list[sqlite3.Row]: + with _lock: + return connect().execute(sql, tuple(params)).fetchall() + + +def query_one(sql: str, params: Iterable[Any] = ()) -> sqlite3.Row | None: + rows = query(sql, params) + return rows[0] if rows else None + + +def execute(sql: str, params: Iterable[Any] = ()) -> None: + with _lock: + conn = connect() + conn.execute(sql, tuple(params)) + conn.commit() + + +def execute_many(statements: list[tuple[str, Iterable[Any]]]) -> None: + with _lock: + conn = connect() + for sql, params in statements: + conn.execute(sql, tuple(params)) + conn.commit() diff --git a/app/extract.py b/app/extract.py new file mode 100644 index 0000000..4a0eba1 --- /dev/null +++ b/app/extract.py @@ -0,0 +1,159 @@ +"""Kontaktdaten aus einem Kartenbild lesen. + +Das Bild geht direkt an das Vision-Modell: Layout, Schriftgroesse und +Gruppierung sind genau die Information, die die Feldzuordnung moeglich macht +- ein vorgeschalteter OCR-Schritt wuerde sie wegwerfen. +""" +import asyncio +import base64 +import json +import logging + +import anthropic + +from . import config + +log = logging.getLogger(__name__) + +FIELDS = [ + "full_name", "first_name", "last_name", "job_title", "company", "department", + "email", "phone", "mobile", "fax", "website", + "street", "postal_code", "city", "country", +] + +SCHEMA = { + "type": "object", + "properties": { + "is_business_card": { + "type": "boolean", + "description": "False, wenn der Ausschnitt keine Visitenkarte zeigt.", + }, + **{name: {"type": ["string", "null"]} for name in FIELDS}, + }, + "required": ["is_business_card", *FIELDS], + "additionalProperties": False, +} + +SYSTEM = """Du liest Visitenkarten aus und gibst die Kontaktdaten strukturiert zurueck. + +Regeln: +- Uebernimm nur, was auf der Karte steht. Nichts ergaenzen, nichts raten. +- Fehlt ein Feld auf der Karte, gib null zurueck. +- Telefonnummern im internationalen Format, wenn die Vorwahl erkennbar ist + (z. B. +49 511 123456), sonst unveraendert. +- phone ist die Festnetznummer, mobile die Mobilnummer. Ist nur eine Nummer + ohne Kennzeichnung angegeben, gehoert sie in phone. +- company ist der Firmenname, job_title die Funktion der Person. +- full_name ist der vollstaendige Name inklusive Titeln wie "Dr.", + first_name und last_name ohne Titel. +- website ohne Protokollpraefix. +- Zeigt der Ausschnitt keine Visitenkarte (Tischkante, Notizzettel, leere + Flaeche), setze is_business_card auf false und alle Felder auf null.""" + +PROMPT = "Lies diese Visitenkarte aus." + +_client = None + + +def available() -> bool: + return bool(config.ANTHROPIC_API_KEY) + + +def _get_client() -> anthropic.AsyncAnthropic: + global _client + if _client is None: + _client = anthropic.AsyncAnthropic(api_key=config.ANTHROPIC_API_KEY) + return _client + + +def _empty(reason: str) -> dict: + return {"is_business_card": False, "_error": reason, **{f: None for f in FIELDS}} + + +def _has_content(result: dict) -> bool: + return any(result.get(field) for field in FIELDS) + + +async def _ask(image_jpeg: bytes) -> dict: + client = _get_client() + messages = [ + { + "role": "user", + "content": [ + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/jpeg", + "data": base64.standard_b64encode(image_jpeg).decode(), + }, + }, + {"type": "text", "text": PROMPT}, + ], + } + ] + common = dict( + model=config.ANTHROPIC_MODEL, + max_tokens=2000, + system=SYSTEM, + messages=messages, + output_config={ + "format": {"type": "json_schema", "schema": SCHEMA}, + "effort": "low", # reines Ablesen, keine Denkarbeit + }, + ) + try: + response = await client.beta.messages.create( + betas=["server-side-fallback-2026-07-01"], + fallbacks="default", + **common, + ) + except anthropic.BadRequestError: + # Der Fallback-Parameter haengt am Modell. Ohne ihn laeuft die Anfrage + # weiter, sie bricht bei einer Ablehnung nur ohne Ersatzmodell ab. + log.warning("Server-side fallback abgelehnt, Anfrage ohne Fallback") + response = await client.messages.create(**common) + + if response.stop_reason == "refusal": + return _empty("Vom Modell abgelehnt") + + text = next((b.text for b in response.content if b.type == "text"), None) + if not text: + return _empty("Leere Antwort") + return json.loads(text) + + +async def extract(image_jpeg: bytes, rotated_jpeg: bytes = None) -> dict: + """Ein Kartenbild auslesen. + + Bleibt das Ergebnis leer, wird einmal um 180 Grad gedreht nachgefasst: + wie herum die Karte auf dem Tisch lag, ist geometrisch nicht bestimmbar. + """ + if not available(): + return _empty("Kein ANTHROPIC_API_KEY gesetzt") + try: + result = await _ask(image_jpeg) + except Exception as exc: # Netzfehler, Rate Limit, ungueltiges JSON + log.exception("Extraktion fehlgeschlagen") + return _empty(f"{type(exc).__name__}: {exc}") + + if not _has_content(result) and rotated_jpeg is not None: + try: + retry = await _ask(rotated_jpeg) + if _has_content(retry): + retry["_rotated"] = True + return retry + except Exception: + log.exception("Zweiter Versuch (gedreht) fehlgeschlagen") + return result + + +async def extract_all(pairs: list) -> list: + """Alle Karten eines Fotos parallel auslesen.""" + limit = asyncio.Semaphore(max(1, config.EXTRACT_CONCURRENCY)) + + async def one(upright: bytes, rotated: bytes) -> dict: + async with limit: + return await extract(upright, rotated) + + return await asyncio.gather(*(one(u, r) for u, r in pairs)) diff --git a/app/geocode.py b/app/geocode.py new file mode 100644 index 0000000..92a7eb8 --- /dev/null +++ b/app/geocode.py @@ -0,0 +1,52 @@ +"""Reverse-Geocoding ueber Nominatim, Ergebnisse werden dauerhaft gecacht.""" +import json +import urllib.parse +import urllib.request +from datetime import datetime, timezone + +from . import config, db + + +def _label(address: dict) -> str: + city = ( + address.get("city") + or address.get("town") + or address.get("village") + or address.get("municipality") + or address.get("county") + ) + detail = address.get("suburb") or address.get("neighbourhood") or address.get("road") + parts = [p for p in (city, detail) if p] + if not parts: + return address.get("state") or address.get("country") or "" + return ", ".join(parts[:2]) + + +def reverse(lat: float | None, lon: float | None) -> str | None: + if lat is None or lon is None or not config.REVERSE_GEOCODE: + return None + + # Auf ~100 m runden: benachbarte Scans teilen sich denselben Cache-Eintrag. + key = f"{lat:.3f},{lon:.3f}" + cached = db.query_one("SELECT place FROM geocache WHERE key = ?", (key,)) + if cached is not None: + return cached["place"] + + url = config.NOMINATIM_URL + "?" + urllib.parse.urlencode( + {"lat": lat, "lon": lon, "format": "jsonv2", "zoom": "16", "accept-language": "de"} + ) + place = None + try: + request = urllib.request.Request(url, headers={"User-Agent": config.USER_AGENT}) + with urllib.request.urlopen(request, timeout=6) as response: + data = json.loads(response.read().decode("utf-8")) + place = _label(data.get("address") or {}) or data.get("display_name") + except Exception: + # Ohne Ortsnamen bleiben die Koordinaten - kein Grund, den Scan abzubrechen. + return None + + db.execute( + "INSERT OR REPLACE INTO geocache (key, place, fetched_at) VALUES (?, ?, ?)", + (key, place, datetime.now(timezone.utc).isoformat()), + ) + return place diff --git a/app/imaging.py b/app/imaging.py new file mode 100644 index 0000000..7c55c25 --- /dev/null +++ b/app/imaging.py @@ -0,0 +1,91 @@ +"""EXIF-Auswertung und Bild-Hilfsfunktionen.""" +import io +from datetime import datetime, timezone + +import numpy as np +from PIL import Image, ImageOps + +# EXIF-Tags, die uns interessieren (Nummern statt Namen: stabil ueber Pillow-Versionen). +_TAG_DATETIME_ORIGINAL = 0x9003 +_TAG_OFFSET_ORIGINAL = 0x9011 +_TAG_GPS_IFD = 0x8825 +_GPS_LAT_REF, _GPS_LAT = 1, 2 +_GPS_LON_REF, _GPS_LON = 3, 4 + + +def _to_degrees(value) -> float | None: + try: + d, m, s = (float(x) for x in value) + except (TypeError, ValueError): + return None + return d + m / 60.0 + s / 3600.0 + + +def read_metadata(raw: bytes) -> dict: + """Aufnahmezeitpunkt und GPS aus den EXIF-Daten des Originalfotos. + + Der Zeitpunkt des Fotos ist verlaesslicher als der des Uploads: Karten + werden oft erst Stunden spaeter am Schreibtisch abfotografiert. + """ + out: dict = {"captured_at": None, "lat": None, "lon": None} + try: + exif = Image.open(io.BytesIO(raw)).getexif() + except Exception: + return out + if not exif: + return out + + taken = exif.get(_TAG_DATETIME_ORIGINAL) + if not taken: + ifd = exif.get_ifd(0x8769) # Exif-IFD + taken = ifd.get(_TAG_DATETIME_ORIGINAL) if ifd else None + offset = ifd.get(_TAG_OFFSET_ORIGINAL) if ifd else None + else: + offset = exif.get(_TAG_OFFSET_ORIGINAL) + if taken: + try: + dt = datetime.strptime(str(taken).strip(), "%Y:%m:%d %H:%M:%S") + iso = f"{dt.isoformat()}{offset}" if offset else dt.isoformat() + parsed = datetime.fromisoformat(iso) + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + out["captured_at"] = parsed.astimezone(timezone.utc).isoformat() + except ValueError: + pass + + gps = exif.get_ifd(_TAG_GPS_IFD) + if gps: + lat = _to_degrees(gps.get(_GPS_LAT)) + lon = _to_degrees(gps.get(_GPS_LON)) + if lat is not None and lon is not None: + if str(gps.get(_GPS_LAT_REF, "N")).upper().startswith("S"): + lat = -lat + if str(gps.get(_GPS_LON_REF, "E")).upper().startswith("W"): + lon = -lon + out["lat"], out["lon"] = round(lat, 6), round(lon, 6) + return out + + +def decode(raw: bytes) -> np.ndarray: + """Bytes zu einem BGR-Array, EXIF-Drehung bereits angewandt.""" + image = Image.open(io.BytesIO(raw)) + image = ImageOps.exif_transpose(image).convert("RGB") + return np.asarray(image)[:, :, ::-1].copy() + + +def encode_jpeg(rgb_or_bgr: np.ndarray, quality: int = 88, bgr: bool = True) -> bytes: + array = rgb_or_bgr[:, :, ::-1] if bgr else rgb_or_bgr + buffer = io.BytesIO() + Image.fromarray(array).save(buffer, format="JPEG", quality=quality, optimize=True) + return buffer.getvalue() + + +def fit_within(image: np.ndarray, max_edge: int) -> np.ndarray: + height, width = image.shape[:2] + longest = max(height, width) + if longest <= max_edge: + return image + scale = max_edge / longest + pil = Image.fromarray(image[:, :, ::-1]) + pil = pil.resize((round(width * scale), round(height * scale)), Image.LANCZOS) + return np.asarray(pil)[:, :, ::-1].copy() diff --git a/app/mailer.py b/app/mailer.py new file mode 100644 index 0000000..c6f4655 --- /dev/null +++ b/app/mailer.py @@ -0,0 +1,33 @@ +"""Mailversand ueber das eigene Postfach (SMTP).""" +import logging +import smtplib +from email.message import EmailMessage + +from . import config + +log = logging.getLogger(__name__) + + +def configured() -> bool: + return bool(config.SMTP_HOST and config.SMTP_FROM and config.REMINDER_TO) + + +def send(subject: str, body: str) -> None: + """Verschickt eine Mail. Wirft bei Fehlern - der Aufrufer entscheidet, + ob nachher erneut versucht wird.""" + if not configured(): + raise RuntimeError("SMTP ist nicht konfiguriert (SMTP_HOST/SMTP_FROM/REMINDER_TO)") + + message = EmailMessage() + message["Subject"] = subject + message["From"] = config.SMTP_FROM + message["To"] = config.REMINDER_TO + message.set_content(body) + + with smtplib.SMTP(config.SMTP_HOST, config.SMTP_PORT, timeout=20) as server: + if config.SMTP_STARTTLS: + server.starttls() + if config.SMTP_USER: + server.login(config.SMTP_USER, config.SMTP_PASSWORD) + server.send_message(message) + log.info("Mail verschickt: %s", subject) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..dbefcb2 --- /dev/null +++ b/app/main.py @@ -0,0 +1,394 @@ +"""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") diff --git a/app/reminders.py b/app/reminders.py new file mode 100644 index 0000000..f5dbae9 --- /dev/null +++ b/app/reminders.py @@ -0,0 +1,90 @@ +"""Faellige Erinnerungen als Mail verschicken. + +Der Scheduler fragt die Datenbank ab, statt Timer im Speicher zu halten: +laeuft der Server zum Zeitpunkt einer Erinnerung nicht, wird sie beim +naechsten Start nachgeholt. +""" +import asyncio +import logging +from datetime import datetime, timezone + +from . import config, db, mailer + +log = logging.getLogger(__name__) + +INTERVAL_SECONDS = 60 + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def _body(row) -> str: + who = row["full_name"] or row["company"] or "Unbekannter Kontakt" + lines = [row["text"], "", "-" * 40, f"Kontakt: {who}"] + if row["company"] and row["full_name"]: + lines.append(f"Firma: {row['company']}") + if row["email"]: + lines.append(f"E-Mail: {row['email']}") + if row["phone"]: + lines.append(f"Telefon: {row['phone']}") + if row["mobile"]: + lines.append(f"Mobil: {row['mobile']}") + if row["place"]: + lines.append(f"Kennengelernt in: {row['place']}") + if config.PUBLIC_BASE_URL: + lines += ["", f"{config.PUBLIC_BASE_URL}/#/karte/{row['card_id']}"] + return "\n".join(lines) + + +def due_reminders() -> list: + return db.query( + """ + SELECT r.id, r.card_id, r.text, r.due_at, + c.full_name, c.company, c.email, c.phone, c.mobile, c.place + FROM reminders r + JOIN cards c ON c.id = r.card_id + WHERE r.sent_at IS NULL AND r.due_at <= ? + ORDER BY r.due_at + """, + (_now(),), + ) + + +def process_due() -> int: + """Alle faelligen Erinnerungen abarbeiten, Anzahl der Versendeten zurueck.""" + rows = due_reminders() + if not rows: + return 0 + if not mailer.configured(): + log.warning("%d faellige Erinnerung(en), aber kein SMTP konfiguriert", len(rows)) + return 0 + + sent = 0 + for row in rows: + who = row["full_name"] or row["company"] or "Kontakt" + try: + mailer.send(f"Erinnerung: {who}", _body(row)) + except Exception as exc: + log.exception("Erinnerung %s konnte nicht verschickt werden", row["id"]) + db.execute( + "UPDATE reminders SET send_error = ? WHERE id = ?", + (f"{type(exc).__name__}: {exc}", row["id"]), + ) + continue + db.execute( + "UPDATE reminders SET sent_at = ?, send_error = NULL WHERE id = ?", + (_now(), row["id"]), + ) + sent += 1 + return sent + + +async def run_forever() -> None: + log.info("Erinnerungs-Scheduler gestartet (Intervall %ds)", INTERVAL_SECONDS) + while True: + try: + await asyncio.to_thread(process_due) + except Exception: + log.exception("Scheduler-Durchlauf fehlgeschlagen") + await asyncio.sleep(INTERVAL_SECONDS) diff --git a/app/security.py b/app/security.py new file mode 100644 index 0000000..030f7bb --- /dev/null +++ b/app/security.py @@ -0,0 +1,49 @@ +"""Anmeldung ueber ein einzelnes Passwort, Sitzung als signiertes Cookie.""" +import hashlib +import hmac +import time + +from fastapi import HTTPException, Request + +from . import config + +COOKIE = "bcs_session" + + +def enabled() -> bool: + return bool(config.APP_PASSWORD) + + +def _sign(payload: str) -> str: + return hmac.new( + config.SECRET_KEY.encode(), payload.encode(), hashlib.sha256 + ).hexdigest() + + +def issue_token() -> str: + expires = str(int(time.time()) + config.SESSION_DAYS * 86400) + return f"{expires}.{_sign(expires)}" + + +def valid_token(token: str | None) -> bool: + if not token or "." not in token: + return False + expires, signature = token.split(".", 1) + if not hmac.compare_digest(signature, _sign(expires)): + return False + try: + return int(expires) > time.time() + except ValueError: + return False + + +def check_password(candidate: str) -> bool: + return hmac.compare_digest(candidate, config.APP_PASSWORD) + + +def require_auth(request: Request) -> None: + """FastAPI-Abhaengigkeit fuer alle geschuetzten Endpunkte.""" + if not enabled(): + return + if not valid_token(request.cookies.get(COOKIE)): + raise HTTPException(status_code=401, detail="Nicht angemeldet") diff --git a/app/segment.py b/app/segment.py new file mode 100644 index 0000000..dedaf63 --- /dev/null +++ b/app/segment.py @@ -0,0 +1,154 @@ +"""Visitenkarten aus einem Foto freistellen. + +Reine Geometrie, kein Modell: Kanten finden, Rechtecke mit Kartenformat +behalten, perspektivisch entzerren. Der Inhalt der Karte spielt hier keine +Rolle - den liest spaeter das Vision-Modell aus dem Zuschnitt. +""" +import cv2 +import numpy as np + +# ISO 7810 ID-1: 85,6 x 54 mm. Toleranz nach unten fuer abweichende Formate. +CARD_RATIO = 85.6 / 54.0 +RATIO_MIN, RATIO_MAX = 1.25, 2.05 + +OUT_WIDTH = 1400 +OUT_HEIGHT = round(OUT_WIDTH / CARD_RATIO) + +DETECT_EDGE = 1600 # Aufloesung, auf der gesucht wird +MIN_AREA_FRACTION = 0.004 # kleiner ist Rauschen, kein Kartenfund +MAX_AREA_FRACTION = 0.60 # groesser ist der Tisch, nicht die Karte +MIN_FILL = 0.80 # Kontur muss ihr eigenes Rechteck fuellen + + +def _masks(gray: np.ndarray) -> list[np.ndarray]: + """Mehrere Binaerbilder, weil je nach Untergrund ein anderes traegt.""" + blurred = cv2.GaussianBlur(gray, (5, 5), 0) + kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5)) + + edges = cv2.dilate(cv2.Canny(blurred, 40, 120), kernel, iterations=2) + edges = cv2.morphologyEx(edges, cv2.MORPH_CLOSE, kernel, iterations=2) + + adaptive = cv2.adaptiveThreshold( + blurred, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 51, 10 + ) + adaptive = cv2.morphologyEx(adaptive, cv2.MORPH_CLOSE, kernel, iterations=2) + + _, otsu = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) + + return [edges, adaptive, otsu, cv2.bitwise_not(otsu)] + + +def _candidates(mask: np.ndarray, image_area: float) -> list[tuple]: + contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + found = [] + for contour in contours: + rect = cv2.minAreaRect(contour) + (_, _), (width, height), _ = rect + if width < 24 or height < 24: + continue + long_side, short_side = max(width, height), min(width, height) + if not RATIO_MIN <= long_side / short_side <= RATIO_MAX: + continue + rect_area = width * height + if not MIN_AREA_FRACTION * image_area <= rect_area <= MAX_AREA_FRACTION * image_area: + continue + fill = cv2.contourArea(contour) / rect_area + if fill < MIN_FILL: + continue + found.append((rect, fill)) + return found + + +def _deduplicate(candidates: list[tuple]) -> list[tuple]: + """Dieselbe Karte wird von mehreren Masken gefunden - besten Treffer behalten.""" + kept: list[tuple] = [] + for rect, fill in sorted(candidates, key=lambda c: -c[1]): + (cx, cy), (width, height), _ = rect + radius = min(width, height) * 0.5 + duplicate = False + for other_rect, _ in kept: + (ox, oy), _, _ = other_rect + if np.hypot(cx - ox, cy - oy) < radius: + duplicate = True + break + if not duplicate: + kept.append((rect, fill)) + return kept + + +def _order_quad(points: np.ndarray) -> np.ndarray: + """Ecken als oben-links, oben-rechts, unten-rechts, unten-links.""" + total = points.sum(axis=1) + diff = points[:, 1] - points[:, 0] + ordered = np.array( + [ + points[np.argmin(total)], # oben links + points[np.argmin(diff)], # oben rechts + points[np.argmax(total)], # unten rechts + points[np.argmax(diff)], # unten links + ], + dtype="float32", + ) + # Hochkant liegende Karte um 90 Grad drehen, damit der Zuschnitt quer ist. + width = np.linalg.norm(ordered[1] - ordered[0]) + height = np.linalg.norm(ordered[3] - ordered[0]) + if height > width: + ordered = np.roll(ordered, -1, axis=0) + return ordered + + +def _reading_order(rects: list) -> list: + """Zeilenweise sortieren, damit die Reihenfolge dem Tisch entspricht.""" + if not rects: + return rects + row_height = np.median([min(r[1]) for r in rects]) * 0.7 + return sorted(rects, key=lambda r: (round(r[0][1] / row_height), r[0][0])) + + +def segment(image: np.ndarray) -> tuple[list[np.ndarray], bool]: + """Liefert die entzerrten Kartenbilder und ob auf das Gesamtbild + zurueckgefallen wurde (kein Kartenrechteck gefunden).""" + height, width = image.shape[:2] + scale = DETECT_EDGE / max(height, width) if max(height, width) > DETECT_EDGE else 1.0 + small = ( + cv2.resize(image, None, fx=scale, fy=scale, interpolation=cv2.INTER_AREA) + if scale < 1.0 + else image + ) + gray = cv2.cvtColor(small, cv2.COLOR_BGR2GRAY) + image_area = float(small.shape[0] * small.shape[1]) + + candidates: list[tuple] = [] + for mask in _masks(gray): + candidates.extend(_candidates(mask, image_area)) + + rects = [rect for rect, _ in _deduplicate(candidates)] + if not rects: + return [_fit_whole(image)], True + + crops = [] + for rect in _reading_order(rects): + quad = _order_quad(cv2.boxPoints(rect)) / scale # zurueck auf volle Aufloesung + target = np.array( + [[0, 0], [OUT_WIDTH, 0], [OUT_WIDTH, OUT_HEIGHT], [0, OUT_HEIGHT]], + dtype="float32", + ) + matrix = cv2.getPerspectiveTransform(quad, target) + crops.append( + cv2.warpPerspective( + image, matrix, (OUT_WIDTH, OUT_HEIGHT), flags=cv2.INTER_CUBIC + ) + ) + return crops, False + + +def _fit_whole(image: np.ndarray) -> np.ndarray: + """Ohne Fund: das ganze Bild als eine Karte behandeln.""" + height, width = image.shape[:2] + if width < height: # Hochformat drehen + image = cv2.rotate(image, cv2.ROTATE_90_CLOCKWISE) + height, width = image.shape[:2] + scale = min(OUT_WIDTH / width, OUT_HEIGHT / height, 1.0) + if scale < 1.0: + image = cv2.resize(image, None, fx=scale, fy=scale, interpolation=cv2.INTER_AREA) + return image diff --git a/app/vcard.py b/app/vcard.py new file mode 100644 index 0000000..f7b90f6 --- /dev/null +++ b/app/vcard.py @@ -0,0 +1,90 @@ +"""vCard 3.0 erzeugen - das Format, das iOS beim Antippen zuverlaessig oeffnet.""" +from datetime import datetime + +_ESCAPES = (("\\", "\\\\"), (";", "\\;"), (",", "\\,"), ("\n", "\\n")) + + +def _escape(value) -> str: + text = str(value) + for char, replacement in _ESCAPES: + text = text.replace(char, replacement) + return text + + +def _line(name: str, value) -> list: + return [name + ":" + _escape(value)] if value else [] + + +def _display_name(card: dict) -> str: + if card.get("full_name"): + return card["full_name"] + joined = " ".join(filter(None, (card.get("first_name"), card.get("last_name")))) + return joined or card.get("company") or "Unbenannt" + + +def _note(card: dict) -> str: + parts = [] + if card.get("notes"): + parts.append(card["notes"]) + + stamp = card.get("captured_at") or card.get("created_at") + scanned = "" + if stamp: + try: + when = datetime.fromisoformat(stamp).strftime("%d.%m.%Y um %H:%M") + scanned = "Gescannt am " + when + except ValueError: + scanned = "Gescannt am " + str(stamp) + if card.get("place"): + scanned = (scanned + " in " if scanned else "Gescannt in ") + card["place"] + if scanned: + parts.append(scanned) + return "\n".join(parts) + + +def build(card: dict) -> str: + lines = ["BEGIN:VCARD", "VERSION:3.0"] + lines.append( + "N:{};{};;;".format( + _escape(card.get("last_name") or ""), _escape(card.get("first_name") or "") + ) + ) + lines.append("FN:" + _escape(_display_name(card))) + lines += _line("ORG", card.get("company")) + lines += _line("TITLE", card.get("job_title")) + if card.get("email"): + lines.append("EMAIL;TYPE=INTERNET,WORK:" + _escape(card["email"])) + if card.get("phone"): + lines.append("TEL;TYPE=WORK,VOICE:" + _escape(card["phone"])) + if card.get("mobile"): + lines.append("TEL;TYPE=CELL:" + _escape(card["mobile"])) + if card.get("fax"): + lines.append("TEL;TYPE=WORK,FAX:" + _escape(card["fax"])) + if card.get("website"): + lines.append("URL:" + _escape(card["website"])) + if any(card.get(k) for k in ("street", "postal_code", "city", "country")): + lines.append( + "ADR;TYPE=WORK:;;{};{};;{};{}".format( + _escape(card.get("street") or ""), + _escape(card.get("city") or ""), + _escape(card.get("postal_code") or ""), + _escape(card.get("country") or ""), + ) + ) + if card.get("lat") is not None and card.get("lon") is not None: + lines.append("GEO:{};{}".format(card["lat"], card["lon"])) + note = _note(card) + if note: + lines.append("NOTE:" + _escape(note)) + lines.append("END:VCARD") + return "\r\n".join(lines) + "\r\n" + + +def build_many(cards: list) -> str: + return "".join(build(card) for card in cards) + + +def filename(card: dict) -> str: + base = _display_name(card).replace(" ", "-") + safe = "".join(c for c in base if c.isalnum() or c in "-_") + return (safe or "kontakt") + ".vcf" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..70cc68a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,39 @@ +# Fester Projektname. Ohne das leitet Compose ihn aus dem Verzeichnisnamen +# ab - und der ist im CI-Job ein anderer als auf dem Host. +name: business-card-scanner + +services: + app: + # Versionstag ermoeglicht Rollback ohne Rebuild. + image: business-card-scanner:${IMAGE_TAG:-latest} + build: . + # Fester Name: das ist die Adresse, auf die der Proxy zeigt. + container_name: business-card-scanner + restart: unless-stopped + # Wird im Workflow aus dem Secret DOTENV erzeugt. + env_file: .env + volumes: + - business-card-scanner-data:/data + networks: + - nginx-proxy-manager_default + # Pflicht: der Deploy-Schritt nutzt --wait und macht das zum Gate. + healthcheck: + test: + - CMD + - python + - -c + - import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8080/healthz', timeout=4).status == 200 else 1) + interval: 60s + timeout: 5s + retries: 3 + +# Kein ports-Mapping: Der Proxy erreicht den Container ueber das gemeinsame +# Netz unter seinem Container-Namen. +networks: + nginx-proxy-manager_default: + external: true + +volumes: + business-card-scanner-data: + # Fester Name, damit das Volume nicht am Projektnamen haengt. + name: business-card-scanner-data diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..839bec2 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest==9.0.1 +httpx==0.28.1 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..0747165 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,7 @@ +fastapi==0.141.1 +uvicorn[standard]==0.41.0 +python-multipart==0.0.20 +anthropic==1.4.0 +opencv-python-headless==4.14.0.94 +numpy==2.2.6 +pillow==12.1.0 diff --git a/static/app.js b/static/app.js new file mode 100644 index 0000000..0e5f668 --- /dev/null +++ b/static/app.js @@ -0,0 +1,576 @@ +/* Visitenkarten-Scanner: Routing, Ansichten, API-Aufrufe. */ +(() => { + "use strict"; + + const FIELD_LABELS = { + full_name: "Name", + first_name: "Vorname", + last_name: "Nachname", + job_title: "Position", + company: "Firma", + department: "Abteilung", + email: "E-Mail", + phone: "Telefon", + mobile: "Mobil", + fax: "Fax", + website: "Web", + street: "Straße", + postal_code: "PLZ", + city: "Ort", + country: "Land", + }; + const FIELD_ORDER = Object.keys(FIELD_LABELS); + + const state = { + route: "scan", + cardId: null, + query: "", + filter: "alle", + cards: [], + stats: { total: 0, offen: 0, erinnerungen: 0 }, + editing: false, + }; + + const $ = (id) => document.getElementById(id); + const screens = { + scan: $("screen-scan"), + karten: $("screen-cards"), + karte: $("screen-detail"), + erinnerungen: $("screen-reminders"), + }; + + const esc = (value) => + String(value ?? "").replace(/[&<>"']/g, (c) => ({ + "&": "&", "<": "<", ">": ">", '"': """, "'": "'", + })[c]); + + // ------------------------------------------------------------------ Helfer + + function toast(message) { + const node = $("toast"); + node.textContent = message; + node.hidden = false; + clearTimeout(toast.timer); + toast.timer = setTimeout(() => { node.hidden = true; }, 3200); + } + + function busy(on, text) { + $("busy-text").textContent = text || "Karten werden gelesen"; + $("busy-overlay").hidden = !on; + } + + async function api(path, options = {}) { + const response = await fetch(path, { credentials: "same-origin", ...options }); + if (response.status === 401) { + $("login-overlay").hidden = false; + throw new Error("Nicht angemeldet"); + } + if (!response.ok) { + let detail = `Fehler ${response.status}`; + try { detail = (await response.json()).detail || detail; } catch (_) {} + throw new Error(detail); + } + return response.status === 204 ? null : response.json(); + } + + const formatDate = (iso) => { + if (!iso) return "–"; + const date = new Date(iso); + return Number.isNaN(date.getTime()) + ? "–" + : date.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" }); + }; + + const formatDateTime = (iso) => { + if (!iso) return "–"; + const date = new Date(iso); + return Number.isNaN(date.getTime()) + ? "–" + : date.toLocaleString("de-DE", { + day: "2-digit", month: "2-digit", year: "numeric", + hour: "2-digit", minute: "2-digit", + }); + }; + + const displayName = (card) => + card.full_name || + [card.first_name, card.last_name].filter(Boolean).join(" ") || + card.company || + "Ohne Namen"; + + /** Position des Browsers als Rückfallebene. Die EXIF-Daten des Fotos sind + * genauer, deshalb blockiert das hier nichts und scheitert leise. */ + function browserPosition() { + return new Promise((resolve) => { + if (!navigator.geolocation) return resolve(null); + navigator.geolocation.getCurrentPosition( + (position) => resolve(position.coords), + () => resolve(null), + { timeout: 6000, maximumAge: 300000 } + ); + }); + } + + // ----------------------------------------------------------------- Routing + + function go(route, cardId) { + location.hash = cardId ? `#/${route}/${cardId}` : `#/${route}`; + } + + function applyRoute() { + const parts = location.hash.replace(/^#\/?/, "").split("/"); + const route = screens[parts[0]] ? parts[0] : "scan"; + state.route = route; + state.cardId = route === "karte" ? parts[1] : null; + + Object.entries(screens).forEach(([name, node]) => { node.hidden = name !== route; }); + document.querySelectorAll(".tab").forEach((tab) => { + const active = tab.dataset.route === route || (route === "karte" && tab.dataset.route === "karten"); + tab.setAttribute("aria-selected", active ? "true" : "false"); + }); + window.scrollTo(0, 0); + + if (route === "karten") loadCards(); + if (route === "karte") loadCard(state.cardId); + if (route === "erinnerungen") loadReminders(); + } + + // ------------------------------------------------------------------- Scan + + async function runScan(file) { + if (!file) return; + busy(true, "Karten werden freigestellt"); + const body = new FormData(); + body.append("file", file); + const coords = await browserPosition(); + if (coords) { + body.append("lat", coords.latitude); + body.append("lon", coords.longitude); + } + busy(true, "Karten werden gelesen"); + try { + const result = await api("/api/scan", { method: "POST", body }); + renderScanResult(result); + await refreshStats(); + toast(`${result.count} Karte${result.count === 1 ? "" : "n"} erfasst`); + } catch (error) { + toast(error.message); + } finally { + busy(false); + } + } + + function renderScanResult(result) { + const rows = result.cards + .map( + (card) => ` + ` + ) + .join(""); + + $("scan-result").innerHTML = ` + +
+ Gefundene Karten + ${result.count} +
+ ${result.fallback + ? `

Kein Kartenrechteck erkannt – das ganze Foto + wurde als eine Karte behandelt. Für einen Stapel hilft ein dunkler, einfarbiger + Untergrund mit Abstand zwischen den Karten.

` + : ""} +
${rows}
`; + } + + // ------------------------------------------------------------- Kartenliste + + async function loadCards() { + const params = new URLSearchParams({ q: state.query, filter: state.filter }); + try { + const data = await api(`/api/cards?${params}`); + state.cards = data.cards; + state.stats = data.stats; + updateCounters(); + renderCards(); + } catch (error) { + toast(error.message); + } + } + + function renderCards() { + const list = $("cards-list"); + if (!state.cards.length) { + list.innerHTML = `
+

Nichts gefunden

+

+ ${state.query || state.filter !== "alle" + ? "Andere Suche oder anderer Filter." + : "Noch keine Karten erfasst."} +

`; + return; + } + + const selectable = state.cards.map((c) => c.id).join(","); + list.innerHTML = ` +
+ ${state.cards.length} Karte${state.cards.length === 1 ? "" : "n"} + Alle als vCard +
+
+ ${state.cards + .map( + (card) => ` + ` + ) + .join("")} +
`; + } + + // ----------------------------------------------------------- Kartendetail + + async function loadCard(cardId) { + try { + const card = await api(`/api/cards/${cardId}`); + renderCard(card); + } catch (error) { + screens.karte.innerHTML = `

${esc(error.message)}

`; + } + } + + function dataTable(card) { + const rows = FIELD_ORDER.filter((field) => card[field]) + .map( + (field) => + `${FIELD_LABELS[field]}${esc(card[field])}` + ) + .join(""); + return rows + ? `${rows}
` + : `

Keine Felder erkannt. Über „Bearbeiten“ nachtragen.

`; + } + + function editForm(card) { + return `
${FIELD_ORDER.map( + (field) => ` + ` + ).join("")}
`; + } + + function renderCard(card) { + const meta = []; + if (card.place) meta.push(esc(card.place)); + if (card.lat != null) meta.push(`${card.lat.toFixed(4)}, ${card.lon.toFixed(4)}`); + + const reminders = card.reminders.length + ? `
${card.reminders + .map( + (reminder) => ` +
+ + ${esc(reminder.text)} + + + + ${esc(formatDateTime(reminder.due_at))} + ${reminder.sent_at ? " · verschickt" : ""} + ${reminder.send_error ? " · Versand fehlgeschlagen" : ""} + +
` + ) + .join("")}
` + : `

Keine Erinnerung gesetzt.

`; + + screens.karte.innerHTML = ` + + +
+

${card.source === "stapel" ? "Aus Stapelscan" : "Einzelscan"}

+

${esc(displayName(card))}

+

+ ${esc([card.job_title, card.company].filter(Boolean).join(" · ") || "Keine Firma erkannt")} +

+
+ + Erfasst + ${esc(formatDate(card.captured_at || card.created_at))} + + + Ort + ${meta.length ? esc(meta[0]) : "Unbekannt"} + +
+
+ + ${card.image_url + ? `Visitenkarte` + : ""} + + + In Kontakte speichern → + + + ${card.extract_status !== "ok" + ? `

Lesefehler: ${esc(card.extract_error || "unbekannt")}

` + : ""} + + + + ${state.editing ? editForm(card) : dataTable(card)} + + + + + + ${reminders} +
+ + + +
+ + + `; + + screens.karte.querySelectorAll("[data-field]").forEach((input) => { + input.addEventListener("change", async () => { + try { + await api(`/api/cards/${card.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ [input.dataset.field]: input.value }), + }); + toast("Gespeichert"); + } catch (error) { + toast(error.message); + } + }); + }); + + $("reminder-form").addEventListener("submit", async (event) => { + event.preventDefault(); + const form = event.currentTarget; + const due = form.elements.due.value; + if (!due) return; + try { + await api(`/api/cards/${card.id}/reminders`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + text: form.elements.text.value, + due_at: new Date(due).toISOString(), + }), + }); + toast("Erinnerung angelegt"); + loadCard(card.id); + refreshStats(); + } catch (error) { + toast(error.message); + } + }); + } + + // ------------------------------------------------------------ Erinnerungen + + async function loadReminders() { + try { + const data = await api("/api/reminders"); + const list = $("reminders-list"); + if (!data.reminders.length) { + list.innerHTML = `

Keine Erinnerungen

+

Erinnerungen legst du auf einer Karte an.

`; + return; + } + list.innerHTML = ` + ${data.mail_configured ? "" : `

Kein SMTP konfiguriert – es wird nichts verschickt.

`} +
${data.reminders + .map( + (reminder) => ` + ` + ) + .join("")}
`; + } catch (error) { + toast(error.message); + } + } + + // -------------------------------------------------------------- Zähler + + function updateCounters() { + $("figure-total").textContent = state.stats.total; + $("figure-open").textContent = state.stats.offen; + $("tab-count-cards").textContent = state.stats.total; + $("tab-count-reminders").textContent = state.stats.erinnerungen; + } + + async function refreshStats() { + try { + const data = await api("/api/cards?filter=alle"); + state.stats = data.stats; + updateCounters(); + } catch (_) { /* Zähler sind nicht kritisch */ } + } + + // ------------------------------------------------------------- Ereignisse + + document.addEventListener("click", (event) => { + const cardButton = event.target.closest("[data-card]"); + if (cardButton) return go("karte", cardButton.dataset.card); + + if (event.target.closest("[data-back]")) { + state.editing = false; + return go("karten"); + } + + if (event.target.closest("[data-toggle-edit]")) { + state.editing = !state.editing; + return loadCard(state.cardId); + } + + const tab = event.target.closest(".tab"); + if (tab) return go(tab.dataset.route); + + const chip = event.target.closest("#filter-chips .chip"); + if (chip) { + state.filter = chip.dataset.filter; + document.querySelectorAll("#filter-chips .chip").forEach((c) => + c.setAttribute("aria-pressed", c === chip ? "true" : "false") + ); + return loadCards(); + } + + const removeReminder = event.target.closest("[data-delete-reminder]"); + if (removeReminder) { + event.preventDefault(); + return api(`/api/reminders/${removeReminder.dataset.deleteReminder}`, { method: "DELETE" }) + .then(() => { loadCard(state.cardId); refreshStats(); }) + .catch((error) => toast(error.message)); + } + + const removeCard = event.target.closest("[data-delete-card]"); + if (removeCard) { + if (!confirm("Karte und Bild endgültig löschen?")) return; + return api(`/api/cards/${removeCard.dataset.deleteCard}`, { method: "DELETE" }) + .then(() => { toast("Karte gelöscht"); go("karten"); }) + .catch((error) => toast(error.message)); + } + }); + + let searchTimer; + $("search-input").addEventListener("input", (event) => { + state.query = event.target.value; + clearTimeout(searchTimer); + searchTimer = setTimeout(loadCards, 220); + }); + + $("search-clear").addEventListener("click", () => { + $("search-input").value = ""; + state.query = ""; + loadCards(); + }); + + ["input-camera", "input-library"].forEach((id) => { + $(id).addEventListener("change", (event) => { + const file = event.target.files[0]; + event.target.value = ""; + runScan(file); + }); + }); + + $("login-form").addEventListener("submit", async (event) => { + event.preventDefault(); + const body = new FormData(); + body.append("password", $("login-password").value); + try { + await fetch("/api/login", { method: "POST", body, credentials: "same-origin" }) + .then((response) => { + if (!response.ok) throw new Error("Passwort stimmt nicht"); + }); + $("login-overlay").hidden = true; + $("login-error").hidden = true; + boot(); + } catch (error) { + $("login-error").textContent = error.message; + $("login-error").hidden = false; + } + }); + + $("logout-button").addEventListener("click", async () => { + await api("/api/logout", { method: "POST" }).catch(() => {}); + location.reload(); + }); + + window.addEventListener("hashchange", applyRoute); + + // --------------------------------------------------------------- Start + + async function boot() { + try { + const session = await fetch("/api/session", { credentials: "same-origin" }).then((r) => r.json()); + if (session.auth_required && !session.authenticated) { + $("login-overlay").hidden = false; + return; + } + $("logout-button").hidden = !session.auth_required; + if (!session.extraction_available) { + toast("Kein API-Schlüssel gesetzt – Karten werden nur zugeschnitten."); + } + await refreshStats(); + applyRoute(); + } catch (error) { + toast("Server nicht erreichbar"); + } + } + + if ("serviceWorker" in navigator) { + navigator.serviceWorker.register("/sw.js", { updateViaCache: "none" }).catch(() => {}); + } + + boot(); +})(); diff --git a/static/icons/icon-192.png b/static/icons/icon-192.png new file mode 100644 index 0000000..a1423bc Binary files /dev/null and b/static/icons/icon-192.png differ diff --git a/static/icons/icon-512.png b/static/icons/icon-512.png new file mode 100644 index 0000000..0526b84 Binary files /dev/null and b/static/icons/icon-512.png differ diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..e3a3f6c --- /dev/null +++ b/static/index.html @@ -0,0 +1,136 @@ + + + + + + + + + +Visitenkarten Scanner + + + + + + + +
+
+ + + Visitenkarten + Scanner + + + +
+ +
+ +
+
+

Stapel oder Einzelkarte

+

Karten
erfassen

+

+ Karten nebeneinander auf einen dunklen, einfarbigen Untergrund legen, + Handy parallel darüber halten. Ein Foto genügt für den ganzen Tisch. +

+
+ + Erfasst + 0 + + + Nicht exportiert + 0 + +
+
+ + + + +

+ Die Kamera liefert die volle Auflösung – bei zwanzig Karten auf einem Foto + ist das der Unterschied zwischen lesbar und nicht lesbar. +

+ +
+
+ + + + + + + + + +
+ + +
+ + + + + + + + + + + + diff --git a/static/manifest.webmanifest b/static/manifest.webmanifest new file mode 100644 index 0000000..248bc4a --- /dev/null +++ b/static/manifest.webmanifest @@ -0,0 +1,16 @@ +{ + "name": "Visitenkarten Scanner", + "short_name": "Karten", + "description": "Visitenkarten fotografieren, auslesen und als Kontakt speichern.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "orientation": "portrait", + "background_color": "#ffffff", + "theme_color": "#000000", + "icons": [ + { "src": "/static/icons/icon-192.png", "sizes": "192x192", "type": "image/png" }, + { "src": "/static/icons/icon-512.png", "sizes": "512x512", "type": "image/png" }, + { "src": "/static/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" } + ] +} diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..1cd8c7b --- /dev/null +++ b/static/style.css @@ -0,0 +1,381 @@ +/* Schwarzweiss, harte Kanten, keine Rundungen. Mobile first. */ + +:root { + color-scheme: light; + --fg: #000; + --bg: #fff; + --muted: #6b6b6b; + --hair: #d5d5d5; + --wash: #f2f2f2; + --sans: -apple-system, BlinkMacSystemFont, "Helvetica Neue", Helvetica, Arial, sans-serif; + --shell: 3px; + --pad: 18px; +} + +* { box-sizing: border-box; } + +html, body { + margin: 0; + padding: 0; + background: var(--wash); + color: var(--fg); + font-family: var(--sans); + font-size: 15px; + line-height: 1.45; + -webkit-text-size-adjust: 100%; +} + +body { overflow-x: hidden; } + +button, input, textarea, select { + font: inherit; + color: inherit; + border-radius: 0; + -webkit-appearance: none; + appearance: none; +} + +a { color: inherit; } + +/* ------------------------------------------------------------------ Typografie */ + +.display { + font-weight: 800; + font-stretch: 85%; + letter-spacing: -0.02em; + text-transform: uppercase; + line-height: 0.95; + margin: 0; +} + +.d-xl { font-size: 40px; } +.d-lg { font-size: 28px; } +.d-md { font-size: 20px; } + +.label { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.16em; + text-transform: uppercase; + margin: 0; +} + +.muted { color: var(--muted); } +.mono-num { font-variant-numeric: tabular-nums; } + +/* ---------------------------------------------------------------------- Gerüst */ + +.app { + max-width: 560px; + margin: 0 auto; + min-height: 100vh; + background: var(--bg); + border-left: 1px solid var(--fg); + border-right: 1px solid var(--fg); + display: flex; + flex-direction: column; +} + +.topbar { + position: sticky; + top: 0; + z-index: 20; + display: flex; + align-items: center; + gap: 12px; + padding: 14px var(--pad); + background: var(--bg); + border-bottom: var(--shell) solid var(--fg); +} + +.mark { + width: 34px; + height: 34px; + flex: 0 0 34px; + background: var(--fg); + display: grid; + place-items: center; +} + +.mark svg { width: 20px; height: 20px; } + +.wordmark { line-height: 1; } +.wordmark .d-md { display: block; } +.wordmark .label { color: var(--muted); margin-top: 3px; } + +.topbar .spacer { margin-left: auto; } + +main { flex: 1; display: flex; flex-direction: column; } +.screen { flex: 1; padding: var(--pad) var(--pad) 28px; } +.screen[hidden] { display: none; } + +.stack > * + * { margin-top: 14px; } +.stack-tight > * + * { margin-top: 8px; } + +/* ---------------------------------------------------------------------- Blöcke */ + +.block { + border: var(--shell) solid var(--fg); + padding: 16px; +} + +.block-invert { + background: var(--fg); + color: var(--bg); + border: var(--shell) solid var(--fg); + padding: 16px; +} + +.block-invert .muted { color: #b4b4b4; } + +.strip { + border: var(--shell) solid var(--fg); + border-top: none; + padding: 12px 16px; + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; +} + +.section-label { + display: flex; + align-items: center; + gap: 10px; + margin: 26px 0 10px; +} + +.section-label::after { + content: ""; + flex: 1; + height: 1px; + background: var(--fg); +} + +.figures { + display: flex; + gap: 30px; + margin-top: 18px; +} + +.figure { display: flex; flex-direction: column; } +.figure .label { color: inherit; opacity: 0.7; margin-bottom: 5px; } + +/* --------------------------------------------------------------------- Buttons */ + +.btn { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + width: 100%; + padding: 16px; + background: var(--bg); + border: var(--shell) solid var(--fg); + font-weight: 800; + font-size: 14px; + letter-spacing: 0.06em; + text-transform: uppercase; + text-decoration: none; + cursor: pointer; + text-align: left; +} + +.btn-primary { background: var(--fg); color: var(--bg); } +.btn:disabled { opacity: 0.35; cursor: not-allowed; } +.btn .arrow { font-weight: 400; font-size: 17px; } + +.btn-row { display: flex; gap: 10px; } +.btn-row .btn { flex: 1; } + +.btn-small { + padding: 9px 12px; + font-size: 11px; + letter-spacing: 0.1em; + border-width: 2px; + width: auto; + justify-content: center; +} + +.btn-danger { border-color: var(--fg); color: var(--fg); } + +/* --------------------------------------------------------------------- Formular */ + +.field { display: block; } +.field .label { color: var(--muted); margin-bottom: 5px; } + +.field input, +.field textarea, +.field select { + width: 100%; + padding: 11px 12px; + background: var(--bg); + border: 2px solid var(--fg); +} + +.field input:focus, +.field textarea:focus, +.field select:focus { outline: 3px solid var(--fg); outline-offset: 1px; } + +.field textarea { min-height: 90px; resize: vertical; } + +.search { + display: flex; + border: var(--shell) solid var(--fg); +} + +.search input { flex: 1; border: none; padding: 14px; background: var(--bg); } +.search input:focus { outline: none; } +.search button { + border: none; + border-left: 2px solid var(--fg); + background: var(--bg); + padding: 0 16px; + cursor: pointer; + font-weight: 800; +} + +.chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; } + +.chip { + padding: 7px 11px; + border: 2px solid var(--fg); + background: var(--bg); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + cursor: pointer; +} + +.chip[aria-pressed="true"] { background: var(--fg); color: var(--bg); } + +/* --------------------------------------------------------------------- Tabellen */ + +.rows { border-top: 1px solid var(--fg); } + +.row { + display: block; + width: 100%; + text-align: left; + background: var(--bg); + border: none; + border-bottom: 1px solid var(--hair); + padding: 14px 0; + cursor: pointer; +} + +.row:last-child { border-bottom: 1px solid var(--fg); } +.row-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; } +.row-title { font-weight: 800; font-size: 16px; } +.row-sub { color: var(--muted); font-size: 13px; margin-top: 2px; } +.row-meta { font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--muted); white-space: nowrap; } + +.data { width: 100%; border-collapse: collapse; } +.data th, .data td { padding: 11px 0; border-bottom: 1px solid var(--hair); text-align: left; vertical-align: top; } +.data th { width: 38%; font-weight: 400; color: var(--muted); font-size: 13px; } +.data td { font-weight: 600; word-break: break-word; } +.data tr:last-child th, .data tr:last-child td { border-bottom: none; } + +/* ----------------------------------------------------------------------- Bilder */ + +.card-image { + display: block; + width: 100%; + border: var(--shell) solid var(--fg); + background: var(--wash); +} + +.thumbs { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; } +.thumbs img { width: 100%; border: 2px solid var(--fg); display: block; } + +/* -------------------------------------------------------------------- Navigation */ + +.tabs { + position: sticky; + bottom: 0; + z-index: 20; + display: flex; + background: var(--bg); + border-top: var(--shell) solid var(--fg); +} + +.tab { + flex: 1; + padding: 14px 4px calc(14px + env(safe-area-inset-bottom)); + background: var(--bg); + border: none; + border-left: 1px solid var(--fg); + font-size: 10px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; + cursor: pointer; +} + +.tab:first-child { border-left: none; } +.tab[aria-selected="true"] { background: var(--fg); color: var(--bg); } +.tab .count { display: block; font-size: 16px; font-weight: 800; margin-bottom: 2px; } + +/* ------------------------------------------------------------------- Zustände */ + +.empty { border: 2px dashed var(--fg); padding: 28px 18px; text-align: center; } + +.badge { + display: inline-block; + padding: 3px 7px; + border: 2px solid var(--fg); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.badge-solid { background: var(--fg); color: var(--bg); } + +.overlay { + position: fixed; + inset: 0; + z-index: 60; + background: rgba(0, 0, 0, 0.82); + display: grid; + place-items: center; + padding: var(--pad); +} + +.overlay[hidden] { display: none; } +.overlay .block { background: var(--bg); width: 100%; max-width: 380px; } + +.spinner { + width: 26px; + height: 26px; + border: 3px solid var(--bg); + border-top-color: transparent; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { to { transform: rotate(360deg); } } + +.toast { + position: fixed; + left: 50%; + bottom: 96px; + transform: translateX(-50%); + z-index: 70; + max-width: 90vw; + padding: 12px 16px; + background: var(--fg); + color: var(--bg); + font-size: 13px; + font-weight: 600; +} + +.toast[hidden] { display: none; } + +.hint { font-size: 12px; color: var(--muted); } + +@media (min-width: 600px) { + :root { --pad: 24px; } + .app { border: 1px solid var(--fg); margin: 24px auto; min-height: calc(100vh - 48px); } + .d-xl { font-size: 48px; } +} diff --git a/static/sw.js b/static/sw.js new file mode 100644 index 0000000..803bb0f --- /dev/null +++ b/static/sw.js @@ -0,0 +1,39 @@ +/* Nur die Programmhuelle wird gecacht. Kartendaten und Bilder kommen immer + frisch vom Server - eine veraltete Liste waere schlimmer als keine. */ +const CACHE = "bcs-shell-v1"; +const SHELL = ["/", "/static/style.css", "/static/app.js", "/manifest.webmanifest"]; + +self.addEventListener("install", (event) => { + event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(SHELL))); + self.skipWaiting(); +}); + +self.addEventListener("activate", (event) => { + event.waitUntil( + caches.keys().then((keys) => + Promise.all(keys.filter((key) => key !== CACHE).map((key) => caches.delete(key))) + ) + ); + self.clients.claim(); +}); + +self.addEventListener("fetch", (event) => { + const url = new URL(event.request.url); + if (event.request.method !== "GET" || url.origin !== self.location.origin) return; + if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/vcf")) return; + + // Fuer die Huelle den HTTP-Cache umgehen: sonst liefert ein Deploy noch + // tagelang die alte CSS- und JS-Datei aus. + const shell = SHELL.includes(url.pathname); + event.respondWith( + fetch(event.request, shell ? { cache: "no-store" } : undefined) + .then((response) => { + if (shell) { + const copy = response.clone(); + caches.open(CACHE).then((cache) => cache.put(event.request, copy)); + } + return response; + }) + .catch(() => caches.match(event.request).then((hit) => hit || caches.match("/"))) + ); +}); diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..8f4bf8c --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,16 @@ +"""Testumgebung: eigenes Datenverzeichnis, keine Anmeldung, kein Netzverkehr. + +Muss vor dem Import von app.* laufen - config liest die Umgebung beim Import. +""" +import os +import sys +import tempfile +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) + +os.environ["DATA_DIR"] = tempfile.mkdtemp(prefix="bcs-test-") +os.environ["APP_PASSWORD"] = "" +os.environ["ANTHROPIC_API_KEY"] = "" +os.environ["REVERSE_GEOCODE"] = "0" +os.environ["SMTP_HOST"] = "" diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..b3ab96d --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,59 @@ +"""Erzeugt Testfotos: mehrere Visitenkarten auf einem dunklen Untergrund.""" +import io +import random + +import numpy as np +from PIL import Image, ImageDraw + + +CARD_RATIO = 85.6 / 54.0 + + +def _card(width: int = 420, seed: int = 0) -> Image.Image: + """Eine weisse Karte mit ein paar dunklen Textzeilen.""" + height = round(width / CARD_RATIO) + card = Image.new("RGB", (width, height), "white") + draw = ImageDraw.Draw(card) + rng = random.Random(seed) + draw.line([(0.08 * width, 0.30 * height), (0.62 * width, 0.30 * height)], fill="black", width=6) + draw.line([(0.08 * width, 0.44 * height), (0.48 * width, 0.44 * height)], fill=(70, 70, 70), width=3) + for index in range(3): + y = (0.62 + index * 0.10) * height + draw.line( + [(0.08 * width, y), ((0.30 + rng.random() * 0.35) * width, y)], + fill=(90, 90, 90), + width=2, + ) + return card + + +def photo_with_cards(count: int = 6, size=(2400, 1800), angle_range: int = 8) -> np.ndarray: + """Foto im BGR-Format mit `count` Karten in einem Raster.""" + background = Image.new("RGB", size, (38, 40, 44)) + rng = random.Random(count) + columns = 2 if count <= 6 else 3 + rows = (count + columns - 1) // columns + cell_width = size[0] // columns + cell_height = size[1] // rows + card_width = int(min(cell_width * 0.72, cell_height * 0.72 * CARD_RATIO)) + + for index in range(count): + column, row = index % columns, index // columns + card = _card(card_width, seed=index) + angle = rng.uniform(-angle_range, angle_range) + rotated = card.rotate(angle, expand=True, resample=Image.BICUBIC) + mask = Image.new("L", card.size, 255).rotate( + angle, expand=True, resample=Image.BICUBIC + ) + x = column * cell_width + (cell_width - rotated.width) // 2 + y = row * cell_height + (cell_height - rotated.height) // 2 + background.paste(rotated, (x, y), mask) + + return np.asarray(background)[:, :, ::-1].copy() + + +def photo_bytes(count: int = 6) -> bytes: + array = photo_with_cards(count) + buffer = io.BytesIO() + Image.fromarray(array[:, :, ::-1]).save(buffer, format="JPEG", quality=92) + return buffer.getvalue() diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..bd29018 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,183 @@ +"""Durchstich durch die HTTP-Schnittstelle. + +Ohne ANTHROPIC_API_KEY laeuft die Extraktion in den Leerlauf - Freistellen, +Speichern, Suchen, Bearbeiten und Export werden trotzdem vollstaendig gepruft. +""" +from datetime import datetime, timedelta, timezone + +import pytest +from fastapi.testclient import TestClient + +from app import db, mailer, reminders +from app.main import app +from tests.helpers import photo_bytes + + +@pytest.fixture(scope="module") +def client(): + with TestClient(app) as test_client: + yield test_client + + +@pytest.fixture(scope="module") +def scanned(client): + response = client.post( + "/api/scan", files={"file": ("tisch.jpg", photo_bytes(4), "image/jpeg")} + ) + assert response.status_code == 200 + return response.json() + + +def test_healthz(client): + assert client.get("/healthz").json() == {"status": "ok"} + + +def test_scan_legt_je_karte_einen_datensatz_an(scanned): + assert scanned["count"] == 4 + assert scanned["fallback"] is False + assert len({card["id"] for card in scanned["cards"]}) == 4 + + +def test_jede_karte_hat_ein_gespeichertes_bild(client, scanned): + for card in scanned["cards"]: + image = client.get(card["image_url"]) + assert image.status_code == 200 + assert image.headers["content-type"] == "image/jpeg" + assert len(image.content) > 1000 + + +def test_liste_zaehlt_und_liefert_die_karten(client, scanned): + data = client.get("/api/cards").json() + + assert data["stats"]["total"] >= 4 + assert data["stats"]["offen"] >= 4 + + +def test_bearbeiten_und_suchen(client, scanned): + card_id = scanned["cards"][0]["id"] + + patched = client.patch( + f"/api/cards/{card_id}", + json={"full_name": "Anna Berg", "company": "Berg & Partner", "notes": "Messe"}, + ) + assert patched.status_code == 200 + assert patched.json()["full_name"] == "Anna Berg" + + hits = client.get("/api/cards", params={"q": "berg"}).json()["cards"] + assert [c["id"] for c in hits] == [card_id] + + by_note = client.get("/api/cards", params={"q": "Messe"}).json()["cards"] + assert card_id in [c["id"] for c in by_note] + + +def test_unbekannte_felder_werden_abgewiesen(client, scanned): + response = client.patch( + f"/api/cards/{scanned['cards'][0]['id']}", json={"extract_status": "ok"} + ) + + assert response.status_code == 400 + + +def test_vcard_download_markiert_die_karte_als_exportiert(client, scanned): + card_id = scanned["cards"][1]["id"] + client.patch(f"/api/cards/{card_id}", json={"full_name": "Bea Kurz"}) + + response = client.get(f"/vcf/{card_id}") + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/vcard") + assert "Bea-Kurz.vcf" in response.headers["content-disposition"] + assert "FN:Bea Kurz" in response.text + assert client.get(f"/api/cards/{card_id}").json()["exported_at"] is not None + + +def test_filter_offen_blendet_exportierte_karten_aus(client, scanned): + offen = client.get("/api/cards", params={"filter": "offen"}).json()["cards"] + + assert all(card["exported_at"] is None for card in offen) + + +def test_sammelexport_mehrerer_karten(client, scanned): + ids = ",".join(card["id"] for card in scanned["cards"][:3]) + + response = client.get("/vcf", params={"ids": ids}) + + assert response.text.count("BEGIN:VCARD") == 3 + + +def test_erinnerung_anlegen_und_filtern(client, scanned): + card_id = scanned["cards"][2]["id"] + due = datetime.now(timezone.utc) + timedelta(days=3) + + created = client.post( + f"/api/cards/{card_id}/reminders", + json={"text": "Angebot nachfassen", "due_at": due.isoformat()}, + ) + assert created.status_code == 200 + + detail = client.get(f"/api/cards/{card_id}").json() + assert len(detail["reminders"]) == 1 + + mit_erinnerung = client.get("/api/cards", params={"filter": "erinnerung"}).json() + assert card_id in [c["id"] for c in mit_erinnerung["cards"]] + assert mit_erinnerung["stats"]["erinnerungen"] >= 1 + + +def test_erinnerung_ohne_datum_wird_abgewiesen(client, scanned): + response = client.post( + f"/api/cards/{scanned['cards'][2]['id']}/reminders", + json={"text": "Ohne Termin", "due_at": "morgen"}, + ) + + assert response.status_code == 400 + + +def test_faellige_erinnerung_wird_verschickt(client, scanned, monkeypatch): + card_id = scanned["cards"][3]["id"] + client.patch(f"/api/cards/{card_id}", json={"full_name": "Carl Spät"}) + past = datetime.now(timezone.utc) - timedelta(minutes=5) + client.post( + f"/api/cards/{card_id}/reminders", + json={"text": "Rueckruf", "due_at": past.isoformat()}, + ) + + verschickt = [] + monkeypatch.setattr(mailer, "configured", lambda: True) + monkeypatch.setattr(mailer, "send", lambda subject, body: verschickt.append((subject, body))) + + assert reminders.process_due() == 1 + assert "Carl Spät" in verschickt[0][0] + assert "Rueckruf" in verschickt[0][1] + + # Zweiter Durchlauf darf nicht erneut verschicken. + assert reminders.process_due() == 0 + + +def test_loeschen_entfernt_karte_und_bild(client): + created = client.post( + "/api/scan", files={"file": ("einzel.jpg", photo_bytes(1), "image/jpeg")} + ).json() + card_id = created["cards"][0]["id"] + image_file = db.query_one("SELECT image_file FROM cards WHERE id = ?", (card_id,))["image_file"] + + from app import config + + assert (config.IMAGE_DIR / image_file).exists() + assert client.delete(f"/api/cards/{card_id}").status_code == 200 + assert client.get(f"/api/cards/{card_id}").status_code == 404 + assert not (config.IMAGE_DIR / image_file).exists() + + +def test_kaputter_upload_gibt_400(client): + response = client.post( + "/api/scan", files={"file": ("kaputt.jpg", b"kein bild", "image/jpeg")} + ) + + assert response.status_code == 400 + + +def test_pwa_dateien_werden_ausgeliefert(client): + assert client.get("/").status_code == 200 + assert client.get("/sw.js").status_code == 200 + assert client.get("/manifest.webmanifest").status_code == 200 + assert client.get("/static/app.js").status_code == 200 diff --git a/tests/test_imaging.py b/tests/test_imaging.py new file mode 100644 index 0000000..6e74cd1 --- /dev/null +++ b/tests/test_imaging.py @@ -0,0 +1,72 @@ +import io + +from PIL import Image + +from app import imaging + + +def _jpeg_with_exif(tags: dict) -> bytes: + image = Image.new("RGB", (64, 40), "white") + exif = Image.Exif() + for tag, value in tags.items(): + exif[tag] = value + buffer = io.BytesIO() + image.save(buffer, format="JPEG", exif=exif.tobytes()) + return buffer.getvalue() + + +def test_aufnahmezeit_wird_gelesen(): + raw = _jpeg_with_exif({0x9003: "2026:09:01 14:23:05"}) + + meta = imaging.read_metadata(raw) + + assert meta["captured_at"].startswith("2026-09-01T14:23:05") + + +def test_gps_wird_in_dezimalgrad_umgerechnet(): + raw = _jpeg_with_exif( + {0x8825: {1: "N", 2: (52.0, 19.0, 18.0), 3: "E", 4: (9.0, 48.0, 28.0)}} + ) + + meta = imaging.read_metadata(raw) + + assert meta["lat"] == 52.321667 + assert meta["lon"] == 9.807778 + + +def test_suedliche_und_westliche_koordinaten_werden_negativ(): + raw = _jpeg_with_exif( + {0x8825: {1: "S", 2: (33.0, 55.0, 0.0), 3: "W", 4: (18.0, 25.0, 0.0)}} + ) + + meta = imaging.read_metadata(raw) + + assert meta["lat"] < 0 + assert meta["lon"] < 0 + + +def test_foto_ohne_exif_liefert_leere_metadaten(): + buffer = io.BytesIO() + Image.new("RGB", (32, 32), "white").save(buffer, format="JPEG") + + meta = imaging.read_metadata(buffer.getvalue()) + + assert meta == {"captured_at": None, "lat": None, "lon": None} + + +def test_kaputte_datei_wirft_nicht(): + assert imaging.read_metadata(b"kein bild") == { + "captured_at": None, + "lat": None, + "lon": None, + } + + +def test_dekodieren_liefert_bgr_array(): + buffer = io.BytesIO() + Image.new("RGB", (20, 10), (255, 0, 0)).save(buffer, format="PNG") + + array = imaging.decode(buffer.getvalue()) + + assert array.shape == (10, 20, 3) + assert tuple(array[0, 0]) == (0, 0, 255) # Rot liegt in BGR hinten diff --git a/tests/test_segment.py b/tests/test_segment.py new file mode 100644 index 0000000..446739f --- /dev/null +++ b/tests/test_segment.py @@ -0,0 +1,45 @@ +import numpy as np + +from app import segment +from tests.helpers import photo_with_cards + + +def test_findet_alle_karten_im_stapel(): + photo = photo_with_cards(count=6) + crops, fallback = segment.segment(photo) + + assert fallback is False + assert len(crops) == 6 + + +def test_zuschnitte_haben_kartenformat(): + crops, _ = segment.segment(photo_with_cards(count=4)) + + for crop in crops: + height, width = crop.shape[:2] + assert (width, height) == (segment.OUT_WIDTH, segment.OUT_HEIGHT) + + +def test_zuschnitt_enthaelt_die_karte_und_nicht_den_tisch(): + crops, _ = segment.segment(photo_with_cards(count=4)) + + for crop in crops: + # Die Karte ist weiss, der Untergrund dunkel. Ein Zuschnitt, der + # danebenliegt, waere im Mittel dunkel. + assert crop.mean() > 170 + + +def test_grosser_stapel(): + crops, fallback = segment.segment(photo_with_cards(count=12)) + + assert fallback is False + assert len(crops) == 12 + + +def test_einzelne_formatfuellende_karte_faellt_auf_gesamtbild_zurueck(): + # Randlose Karte: es gibt keine Kante zum Untergrund, also keinen Fund. + card = np.full((1080, 1712, 3), 245, dtype=np.uint8) + crops, fallback = segment.segment(card) + + assert fallback is True + assert len(crops) == 1 diff --git a/tests/test_vcard.py b/tests/test_vcard.py new file mode 100644 index 0000000..da1b045 --- /dev/null +++ b/tests/test_vcard.py @@ -0,0 +1,71 @@ +from app import vcard + +BASE = { + "full_name": "Dr. Anna Berg", + "first_name": "Anna", + "last_name": "Berg", + "job_title": "Geschäftsführerin", + "company": "Berg & Partner GmbH", + "email": "a.berg@berg-partner.de", + "phone": "+49 511 1234560", + "mobile": "+49 170 1234567", + "website": "berg-partner.de", + "street": "Musterweg 3", + "postal_code": "30159", + "city": "Hannover", + "country": "Deutschland", + "notes": "Sucht ein neues CRM.", + "captured_at": "2026-09-01T10:15:00+00:00", + "place": "Hannover, Messegelände", + "lat": 52.3216, + "lon": 9.8078, +} + + +def test_enthaelt_die_pflichtzeilen(): + output = vcard.build(BASE) + + assert output.startswith("BEGIN:VCARD\r\nVERSION:3.0\r\n") + assert output.endswith("END:VCARD\r\n") + assert "FN:Dr. Anna Berg" in output + assert "N:Berg;Anna;;;" in output + assert "TEL;TYPE=CELL:+49 170 1234567" in output + + +def test_kommas_und_semikolons_werden_maskiert(): + output = vcard.build({**BASE, "company": "Berg, Partner; Co"}) + + assert "ORG:Berg\\, Partner\\; Co" in output + + +def test_notiz_traegt_zeit_und_ort(): + note = [line for line in vcard.build(BASE).splitlines() if line.startswith("NOTE:")][0] + + assert "Sucht ein neues CRM." in note + assert "01.09.2026" in note + assert "Messegelände" in note + + +def test_leere_karte_bekommt_trotzdem_einen_namen(): + output = vcard.build({"notes": ""}) + + assert "FN:Unbenannt" in output + + +def test_firma_ersetzt_den_namen_wenn_keiner_erkannt_wurde(): + output = vcard.build({"company": "Berg & Partner GmbH"}) + + assert "FN:Berg & Partner GmbH" in output + + +def test_mehrere_karten_in_einer_datei(): + output = vcard.build_many([BASE, {"full_name": "Bea Kurz"}]) + + assert output.count("BEGIN:VCARD") == 2 + assert output.count("END:VCARD") == 2 + + +def test_dateiname_ist_dateisystemtauglich(): + assert vcard.filename(BASE) == "Dr-Anna-Berg.vcf" + assert vcard.filename({}) == "Unbenannt.vcf" + assert vcard.filename({"full_name": "?!"}) == "kontakt.vcf"