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 <noreply@anthropic.com>
This commit is contained in:
Lucas Orth
2026-09-06 11:45:56 +02:00
commit ab993e98e1
37 changed files with 3458 additions and 0 deletions

10
.dockerignore Normal file
View File

@@ -0,0 +1,10 @@
.git
.gitea
.venv
data
tests
__pycache__
*.pyc
*.log
.env
.pytest_cache

33
.env.example Normal file
View File

@@ -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

3
.gitattributes vendored Normal file
View File

@@ -0,0 +1,3 @@
# Entwicklung auf Windows, Ausfuehrung auf Linux: im Repo immer LF.
* text=auto eol=lf
*.png binary

View File

@@ -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

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
.venv/
data/
__pycache__/
*.pyc
.pytest_cache/
.env
.claude/

125
CLAUDE.md Normal file
View File

@@ -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.

185
DEPLOY.md Normal file
View File

@@ -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 |

30
Dockerfile Normal file
View File

@@ -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"]

105
README.md Normal file
View File

@@ -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/`.

0
app/__init__.py Normal file
View File

55
app/config.py Normal file
View File

@@ -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)

100
app/db.py Normal file
View File

@@ -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()

159
app/extract.py Normal file
View File

@@ -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))

52
app/geocode.py Normal file
View File

@@ -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

91
app/imaging.py Normal file
View File

@@ -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()

33
app/mailer.py Normal file
View File

@@ -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)

394
app/main.py Normal file
View File

@@ -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")

90
app/reminders.py Normal file
View File

@@ -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)

49
app/security.py Normal file
View File

@@ -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")

154
app/segment.py Normal file
View File

@@ -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

90
app/vcard.py Normal file
View File

@@ -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"

39
docker-compose.yml Normal file
View File

@@ -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

3
requirements-dev.txt Normal file
View File

@@ -0,0 +1,3 @@
-r requirements.txt
pytest==9.0.1
httpx==0.28.1

7
requirements.txt Normal file
View File

@@ -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

576
static/app.js Normal file
View File

@@ -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) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
})[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) => `
<button class="row" data-card="${esc(card.id)}">
<span class="row-head">
<span class="row-title">${esc(displayName(card))}</span>
<span class="row-meta">${card.has_contact ? "OK" : "Leer"}</span>
</span>
<span class="row-sub">${esc(card.company || card.email || "Keine Daten erkannt")}</span>
</button>`
)
.join("");
$("scan-result").innerHTML = `
<div class="section-label"><span class="label">Ergebnis</span></div>
<div class="strip" style="border-top:3px solid #000">
<span class="label">Gefundene Karten</span>
<span class="display d-lg mono-num">${result.count}</span>
</div>
${result.fallback
? `<p class="hint" style="margin-top:10px">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.</p>`
: ""}
<div class="rows" style="margin-top:14px">${rows}</div>`;
}
// ------------------------------------------------------------- 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 = `<div class="empty">
<p class="label">Nichts gefunden</p>
<p class="hint" style="margin:8px 0 0">
${state.query || state.filter !== "alle"
? "Andere Suche oder anderer Filter."
: "Noch keine Karten erfasst."}
</p></div>`;
return;
}
const selectable = state.cards.map((c) => c.id).join(",");
list.innerHTML = `
<div class="strip" style="border-top:3px solid #000">
<span class="label">${state.cards.length} Karte${state.cards.length === 1 ? "" : "n"}</span>
<a class="badge" href="/vcf?ids=${esc(selectable)}" download>Alle als vCard</a>
</div>
<div class="rows">
${state.cards
.map(
(card) => `
<button class="row" data-card="${esc(card.id)}">
<span class="row-head">
<span class="row-title">${esc(displayName(card))}</span>
<span class="row-meta">${esc(formatDate(card.captured_at || card.created_at))}</span>
</span>
<span class="row-sub">
${esc([card.job_title, card.company].filter(Boolean).join(" · ") || "Keine Firma erkannt")}
</span>
<span class="row-sub">${esc(
[
card.place,
card.exported_at ? "exportiert" : "nicht exportiert",
card.extract_status !== "ok" ? "Lesefehler" : null,
]
.filter(Boolean)
.join(" · ")
)}</span>
</button>`
)
.join("")}
</div>`;
}
// ----------------------------------------------------------- Kartendetail
async function loadCard(cardId) {
try {
const card = await api(`/api/cards/${cardId}`);
renderCard(card);
} catch (error) {
screens.karte.innerHTML = `<div class="empty"><p class="label">${esc(error.message)}</p></div>`;
}
}
function dataTable(card) {
const rows = FIELD_ORDER.filter((field) => card[field])
.map(
(field) =>
`<tr><th>${FIELD_LABELS[field]}</th><td>${esc(card[field])}</td></tr>`
)
.join("");
return rows
? `<table class="data">${rows}</table>`
: `<p class="hint">Keine Felder erkannt. Über „Bearbeiten“ nachtragen.</p>`;
}
function editForm(card) {
return `<div class="stack-tight">${FIELD_ORDER.map(
(field) => `
<label class="field">
<span class="label">${FIELD_LABELS[field]}</span>
<input data-field="${field}" value="${esc(card[field] || "")}">
</label>`
).join("")}</div>`;
}
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
? `<div class="rows">${card.reminders
.map(
(reminder) => `
<div class="row" style="cursor:default">
<span class="row-head">
<span class="row-title" style="font-size:14px">${esc(reminder.text)}</span>
<button class="badge" data-delete-reminder="${esc(reminder.id)}">Löschen</button>
</span>
<span class="row-sub">
${esc(formatDateTime(reminder.due_at))}
${reminder.sent_at ? " · verschickt" : ""}
${reminder.send_error ? " · Versand fehlgeschlagen" : ""}
</span>
</div>`
)
.join("")}</div>`
: `<p class="hint">Keine Erinnerung gesetzt.</p>`;
screens.karte.innerHTML = `
<button class="chip" data-back>← Zurück</button>
<div class="block-invert">
<p class="label">${card.source === "stapel" ? "Aus Stapelscan" : "Einzelscan"}</p>
<h1 class="display d-lg" style="margin-top:8px">${esc(displayName(card))}</h1>
<p class="muted" style="margin:6px 0 0">
${esc([card.job_title, card.company].filter(Boolean).join(" · ") || "Keine Firma erkannt")}
</p>
<div class="figures">
<span class="figure">
<span class="label">Erfasst</span>
<span class="display d-md">${esc(formatDate(card.captured_at || card.created_at))}</span>
</span>
<span class="figure">
<span class="label">Ort</span>
<span class="display d-md">${meta.length ? esc(meta[0]) : "Unbekannt"}</span>
</span>
</div>
</div>
${card.image_url
? `<img class="card-image" src="${esc(card.image_url)}" alt="Visitenkarte">`
: ""}
<a class="btn btn-primary" href="/vcf/${esc(card.id)}" download>
In Kontakte speichern <span class="arrow">→</span>
</a>
${card.extract_status !== "ok"
? `<p class="hint">Lesefehler: ${esc(card.extract_error || "unbekannt")}</p>`
: ""}
<div class="section-label">
<span class="label">Daten</span>
</div>
<button class="chip" data-toggle-edit style="margin-bottom:10px">
${state.editing ? "Fertig" : "Bearbeiten"}
</button>
${state.editing ? editForm(card) : dataTable(card)}
<div class="section-label"><span class="label">Notiz</span></div>
<label class="field">
<textarea data-field="notes" placeholder="Worum ging es im Gespräch?">${esc(card.notes || "")}</textarea>
</label>
<div class="section-label"><span class="label">Erinnerung</span></div>
${reminders}
<form class="stack-tight" id="reminder-form" style="margin-top:14px">
<label class="field">
<span class="label">Text</span>
<input name="text" placeholder="Angebot nachfassen" required>
</label>
<label class="field">
<span class="label">Termin</span>
<input name="due" type="datetime-local" required>
</label>
<button class="btn" type="submit">Erinnerung anlegen <span class="arrow">→</span></button>
</form>
<div class="section-label"><span class="label">Karte entfernen</span></div>
<button class="btn btn-danger" data-delete-card="${esc(card.id)}">
Endgültig löschen <span class="arrow">×</span>
</button>`;
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 = `<div class="empty"><p class="label">Keine Erinnerungen</p>
<p class="hint" style="margin:8px 0 0">Erinnerungen legst du auf einer Karte an.</p></div>`;
return;
}
list.innerHTML = `
${data.mail_configured ? "" : `<p class="hint">Kein SMTP konfiguriert – es wird nichts verschickt.</p>`}
<div class="rows">${data.reminders
.map(
(reminder) => `
<button class="row" data-card="${esc(reminder.card_id)}">
<span class="row-head">
<span class="row-title" style="font-size:15px">${esc(reminder.text)}</span>
<span class="row-meta">${reminder.sent_at ? "verschickt" : "offen"}</span>
</span>
<span class="row-sub">${esc(formatDateTime(reminder.due_at))}
· ${esc(reminder.full_name || reminder.company || "Kontakt")}</span>
</button>`
)
.join("")}</div>`;
} 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();
})();

BIN
static/icons/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 B

BIN
static/icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

136
static/index.html Normal file
View File

@@ -0,0 +1,136 @@
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#000000">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="Karten">
<title>Visitenkarten Scanner</title>
<link rel="manifest" href="/manifest.webmanifest">
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
<link rel="icon" href="/static/icons/icon-192.png">
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<div class="app">
<header class="topbar">
<span class="mark" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.2">
<rect x="2" y="5" width="20" height="14"></rect>
<path d="M6 10h5M6 14h8"></path>
</svg>
</span>
<span class="wordmark">
<span class="display d-md">Visitenkarten</span>
<span class="label">Scanner</span>
</span>
<span class="spacer"></span>
<button class="chip" id="logout-button" hidden>Abmelden</button>
</header>
<main>
<!-- ------------------------------------------------------------- Scan -->
<section class="screen stack" id="screen-scan">
<div class="block-invert">
<p class="label">Stapel oder Einzelkarte</p>
<h1 class="display d-xl" style="margin-top:8px">Karten<br>erfassen</h1>
<p class="muted" style="margin:12px 0 0">
Karten nebeneinander auf einen dunklen, einfarbigen Untergrund legen,
Handy parallel darüber halten. Ein Foto genügt für den ganzen Tisch.
</p>
<div class="figures">
<span class="figure">
<span class="label">Erfasst</span>
<span class="display d-lg mono-num" id="figure-total">0</span>
</span>
<span class="figure">
<span class="label">Nicht exportiert</span>
<span class="display d-lg mono-num" id="figure-open">0</span>
</span>
</div>
</div>
<label class="btn btn-primary" for="input-camera">
Foto aufnehmen <span class="arrow">→</span>
<input id="input-camera" type="file" accept="image/*" capture="environment" hidden>
</label>
<label class="btn" for="input-library">
Aus Fotos wählen <span class="arrow">↗</span>
<input id="input-library" type="file" accept="image/*" hidden>
</label>
<p class="hint">
Die Kamera liefert die volle Auflösung – bei zwanzig Karten auf einem Foto
ist das der Unterschied zwischen lesbar und nicht lesbar.
</p>
<div id="scan-result"></div>
</section>
<!-- ------------------------------------------------------------ Karten -->
<section class="screen stack" id="screen-cards" hidden>
<div class="search">
<input id="search-input" type="search" placeholder="Name, Firma, Ort, Notiz …"
autocomplete="off" enterkeyhint="search">
<button id="search-clear" type="button" aria-label="Suche leeren">×</button>
</div>
<div class="chips" id="filter-chips">
<button class="chip" data-filter="alle" aria-pressed="true">Alle</button>
<button class="chip" data-filter="offen" aria-pressed="false">Nicht exportiert</button>
<button class="chip" data-filter="unvollstaendig" aria-pressed="false">Unvollständig</button>
<button class="chip" data-filter="erinnerung" aria-pressed="false">Mit Erinnerung</button>
</div>
<div id="cards-list"></div>
</section>
<!-- ------------------------------------------------------------ Detail -->
<section class="screen stack" id="screen-detail" hidden></section>
<!-- ------------------------------------------------------ Erinnerungen -->
<section class="screen stack" id="screen-reminders" hidden>
<div class="section-label"><span class="label">Erinnerungen</span></div>
<div id="reminders-list"></div>
</section>
</main>
<nav class="tabs" role="tablist">
<button class="tab" role="tab" data-route="scan" aria-selected="true">Scan</button>
<button class="tab" role="tab" data-route="karten" aria-selected="false">
<span class="count mono-num" id="tab-count-cards">0</span>Karten
</button>
<button class="tab" role="tab" data-route="erinnerungen" aria-selected="false">
<span class="count mono-num" id="tab-count-reminders">0</span>Termine
</button>
</nav>
</div>
<!-- ------------------------------------------------------------------ Login -->
<div class="overlay" id="login-overlay" hidden>
<form class="block stack" id="login-form">
<p class="label muted">Zugang</p>
<h2 class="display d-lg">Anmelden</h2>
<label class="field">
<span class="label">Passwort</span>
<input type="password" id="login-password" autocomplete="current-password" required>
</label>
<button class="btn btn-primary" type="submit">Weiter <span class="arrow">→</span></button>
<p class="hint" id="login-error" hidden></p>
</form>
</div>
<!-- -------------------------------------------------------------- Fortschritt -->
<div class="overlay" id="busy-overlay" hidden>
<div class="block-invert stack" style="text-align:center">
<div class="spinner" style="margin:0 auto"></div>
<p class="label" id="busy-text">Karten werden gelesen</p>
</div>
</div>
<div class="toast" id="toast" hidden></div>
<script src="/static/app.js"></script>
</body>
</html>

View File

@@ -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" }
]
}

381
static/style.css Normal file
View File

@@ -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; }
}

39
static/sw.js Normal file
View File

@@ -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("/")))
);
});

16
tests/conftest.py Normal file
View File

@@ -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"] = ""

59
tests/helpers.py Normal file
View File

@@ -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()

183
tests/test_api.py Normal file
View File

@@ -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

72
tests/test_imaging.py Normal file
View File

@@ -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

45
tests/test_segment.py Normal file
View File

@@ -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

71
tests/test_vcard.py Normal file
View File

@@ -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"