7 Commits
v1.0.0 ... main

Author SHA1 Message Date
Lucas Orth
1e8918e0cd Bildeditor, klickbare Felder, Einstellungen; Kopfzeile und Namensfeld raus
All checks were successful
Build & Deploy / deploy (push) Successful in 1m29s
Bildbearbeitung im Nachgang: Drehen in 90-Grad-Schritten, Ausrichten per
Schieberegler, Zuschneiden per Rahmen mit anfassbaren Ecken. Der Server
rechnet jede Korrektur vom unbearbeiteten Original, das beim ersten
Eingriff daneben abgelegt wird - sonst summieren sich die Verluste über
mehrere Korrekturen, und Zurücksetzen wäre nicht möglich. Zwei
Zuschnitte hintereinander werden ineinander verrechnet.

Der Ort ließ sich nicht übernehmen, weil beide Quellen versagen können:
iOS entfernt beim Weitergeben an eine Webseite oft die GPS-Daten aus dem
Foto, und der Browserstandort scheiterte bisher stumm. Jetzt meldet er
seinen Grund ("Standortfreigabe fehlt"), die Detailansicht zeigt die
Herkunft des Ortes, und der Scan-Ort ist von Hand nachtragbar.

E-Mail, Web, Telefon und Mobil sind in der Tabelle jetzt Links; Straße,
PLZ, Ort und Land stehen als eine Adresszeile, die die eingestellte
Navigations-App öffnet.

Einstellungen als vierter Reiter: Navigations-App (Apple Karten, Google
Maps, OpenStreetMap), Statusübersicht und Abmelden - der Knopf saß
vorher in der Kopfzeile.

Die Kopfzeile ist weg. Sie kostete auf dem Telefon eine Bildschirmzeile
und wiederholte nur, was die Tableiste schon sagt.

Kein full_name mehr, nur Vor- und Nachname; akademische Titel gehören in
den Vornamen. Bestehende Datensätze werden bei der Migration am letzten
Leerzeichen aufgeteilt. Die Spalte bleibt ungenutzt stehen: SQLite baut
zum Löschen die ganze Tabelle neu, der Gewinn wären ein paar Byte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 15:19:10 +02:00
Lucas Orth
bf0a397597 Anlass, Dubletten, Rückseite, Teilen; Startseite und App-Anmutung
All checks were successful
Build & Deploy / deploy (push) Successful in 10m41s
Vier Funktionen:

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

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

Oberfläche:

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 15:09:08 +02:00
Lucas Orth
a7fbc15792 Hintergrundverarbeitung, Bildaufbereitung, Sammelexport entfernt
All checks were successful
Build & Deploy / deploy (push) Successful in 1m24s
Auslesen im Hintergrund: /api/scan antwortet, sobald die Karten
freigestellt und gespeichert sind, und stößt die Extraktion als Task an.
Ein Zwanzigerstapel blockierte vorher den Upload für die ganze Dauer
aller Modellaufrufe. Die Oberfläche zeigt "wird gelesen" und lädt nach,
solange etwas offen ist.

Zwei Folgen davon sind mitbehandelt: Beim Schreiben der Ergebnisse steht
COALESCE, damit ein Handeintrag während des Lesens nicht überschrieben
wird, und offene Karten werden beim Start nachgeholt, statt dauerhaft in
der Warteschlange zu hängen.

Bildaufbereitung des Zuschnitts:
- Der Einzug zieht die erkannten Ecken um 1,5 % zur Mitte, damit kein
  Untergrund im Zuschnitt bleibt.
- Die Beleuchtung wird ausgeglichen (Division durch eine weichgezeichnete
  Fassung), damit das Papier weiß wird statt grau. Dunkle Karten bleiben
  unangetastet - bei ihnen ist das Dunkle das Papier, kein Schatten.
- Das Modell meldet die nötige Drehung im Schema; das gespeicherte Bild
  wird entsprechend gedreht. Geometrisch ist die Lage nicht bestimmbar.

Sammelexport entfernt: Der Button "Alle als vCard" ist weg, mit ihm der
Endpunkt /vcf sowie vcard.build_many und db.execute_many, die dadurch
keinen Aufrufer mehr hatten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 12:56:58 +02:00
Lucas Orth
1f29d5eb27 effort-Parameter modellabhängig senden, Design-Hierarchie, iOS-Datumsfeld
All checks were successful
Build & Deploy / deploy (push) Successful in 1m14s
Fehler: claude-haiku-4-5 lehnt output_config.effort mit einem 400 ab, jede
Karte lief in einen Lesefehler. effort und der server-seitige Fallback
werden jetzt nur an Modelle geschickt, die sie annehmen; ein unbekanntes
Modell mit engerem Parametersatz wird einmal ohne Zusatzparameter
wiederholt, statt die Karte zu verlieren. Tests decken die Zuordnung ab.

Design: Grau kommt als Hierarchiestufe dazu. Drei Ebenen für Fläche
(weiß / grau gefüllt / schwarz), Text (schwarz / --ink-2 / --ink-3) und
Linie (3px / 1px schwarz / graue Haarlinie). Vorher trug alles dieselbe
3px-Kante und dasselbe Schwarz, dadurch war keine Ordnung erkennbar.
Konkret: nicht gewählte Filter treten zurück, Listenzeilen bekommen drei
Textstufen und Statuspillen statt einer gleichförmigen Zeile, das
Kopfband über Listen ist grau hinterlegt, zweitrangige und zerstörende
Aktionen sind leiser als die primäre.

iOS: Datumsfelder zentrieren ihren Wert und sacken in der Höhe ein. Die
WebKit-Pseudoelemente ziehen sie auf die Form der übrigen Felder.

Dazu die Domain in der Dokumentation auf scanner.lucas-orth.de.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 12:36:24 +02:00
Lucas Orth
bc1981f1bd Scan-Pfad entschlacken, Container-Limits setzen
Drei Änderungen, die den Spitzenverbrauch eines Stapelscans senken:

- Zuschnitte werden nicht mehr auf 1400 px hochgerechnet. Auf einem
  Stapelfoto ist eine Karte nur ein paar hundert Pixel breit; sie
  aufzublasen erzeugt vier Mal so viele Pixel ohne mehr Information und
  kostet zusätzlich Bildtokens beim Modellaufruf. OUT_WIDTH ist jetzt
  eine Obergrenze.
- Die um 180 Grad gedrehte Fassung entsteht erst, wenn ein Ergebnis leer
  bleibt, statt für jede Karte auf Vorrat. Das halbiert die
  JPEG-Kodierungen im Normalfall.
- Zuschnitte werden einzeln kodiert und sofort freigegeben, statt
  gesammelt im Speicher zu liegen.

Dazu mem_limit und cpus für den App-Container: ein Ausreißer soll den
Container treffen, nicht den Host, auf dem auch Jitsi und MySQL laufen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 12:22:37 +02:00
Lucas Orth
30db433c9a Ungenutzte PORT-Variable aus dem Dockerfile entfernen
All checks were successful
Build & Deploy / deploy (push) Successful in 1m51s
Der Port steht fest im CMD und im Healthcheck; die Umgebungsvariable
wurde nirgends gelesen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 12:14:04 +02:00
Lucas Orth
208aadc9bc Tests im Dockerfile statt über setup-python
actions/setup-python@v5 lädt seine Runtime von GitHub-Releases und
scheitert auf dem Gitea-Runner - der Lauf brach vor dem Deploy ab.

Die Tests laufen jetzt in einer eigenen Dockerfile-Stufe, die der
Workflow mit --target test aufruft. Damit gilt für sie dieselbe
Python-Version und derselbe Paketstand wie für die Produktion, und der
Schritt braucht nichts außer dem Docker-Daemon, den der Runner ohnehin
nutzt. Ein Bind-Mount des Workspace wäre keine Option gewesen: der liegt
im Job-Container, der Pfad zeigt auf dem Host ins Leere.

Die Teststufe hängt nicht am finalen Image und wird beim Deploy-Build
übersprungen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 12:13:42 +02:00
23 changed files with 1946 additions and 445 deletions

View File

@@ -2,7 +2,6 @@
.gitea
.venv
data
tests
__pycache__
*.pyc
*.log

View File

@@ -16,15 +16,13 @@ jobs:
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
# Kein setup-python: die Action laedt ihre Runtime von GitHub-Releases
# und scheitert auf dem Gitea-Runner. Stattdessen laufen die Tests in
# einer eigenen Stufe des Dockerfiles - gegen dieselbe Python-Version
# und dieselben Pakete wie die Produktion. Ein roter Lauf bricht hier
# ab, bevor irgendetwas auf dem Server angefasst wird.
- name: Tests
run: pytest -q
run: docker build --target test .
- name: Tag ermitteln
run: echo "IMAGE_TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV

View File

@@ -149,7 +149,7 @@ Neuer Proxy Host:
| Feld | Wert |
|---|---|
| Domain Names | `business-card-scanner.lucas-orth.de` |
| Domain Names | `scanner.lucas-orth.de` |
| Scheme | `http` |
| Forward Hostname / IP | `business-card-scanner` (= `container_name`) |
| Forward Port | `8080` |

View File

@@ -1,17 +1,31 @@
# Abhaengigkeiten in ein venv bauen, damit der Compiler-Ballast nicht ins
# fertige Image wandert.
FROM python:3.12-slim AS build
WORKDIR /app
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Testlauf gegen dieselbe Python-Version und dieselben Pakete wie die
# Produktion. Die Stufe haengt nicht am finalen Image und wird bei einem
# normalen Build uebersprungen; der Workflow ruft sie mit --target test auf.
FROM build AS test
RUN apt-get update \
&& apt-get install -y --no-install-recommends libglib2.0-0 \
&& rm -rf /var/lib/apt/lists/*
COPY requirements-dev.txt .
RUN pip install --no-cache-dir -r requirements-dev.txt
COPY app ./app
COPY static ./static
COPY tests ./tests
RUN pytest -q
FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PATH="/opt/venv/bin:$PATH" \
DATA_DIR=/data \
PORT=8080
DATA_DIR=/data
# libglib2.0-0 wird von opencv-python-headless gebraucht.
RUN apt-get update \

View File

@@ -7,11 +7,23 @@ Kontakt aufs iPhone holen. Selbst gehostete PWA, ein Container.
**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.
ohne manuelle Nacharbeit. Der Zuschnitt wird dabei leicht enger gesetzt als die
erkannte Kante, damit kein Untergrund stehen bleibt, und die Beleuchtung wird
ausgeglichen, damit das Papier weiß wird statt grau.
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. Das Modell sagt
außerdem, wie herum die Karte gehört – geometrisch ist das nicht bestimmbar –
und das gespeicherte Bild wird entsprechend gedreht.
**Die Antwort wartet nicht auf das Modell.** Der Scan liefert die freigestellten
Karten sofort zurück, das Auslesen läuft danach im Hintergrund weiter; die
Oberfläche lädt nach. Ein Zwanzigerstapel blockiert damit keine Minute im
Upload. Wer eine Karte in der Zwischenzeit von Hand ergänzt, verliert die
Eingabe nicht – das Modell füllt nur leere Felder. Bricht der Server mitten im
Lauf ab, holt er die offenen Karten beim nächsten Start nach.
**Metadaten.** Aufnahmezeit und GPS kommen aus den EXIF-Daten des Fotos, nicht
aus dem Browser – Karten werden oft erst abends am Schreibtisch abfotografiert,
@@ -19,14 +31,41 @@ 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“.
**Anlass.** Ein Feld auf der Startseite („Hannover Messe 2026“) gilt für alle
folgenden Scans und wird im Browser gemerkt. Verlässlicher als der Ortsname aus
den Koordinaten und die eigentliche Ordnung über den Bestand.
**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.
**Dubletten.** Auf Messen trifft man Leute wieder. Die Detailansicht zeigt
Karten, die dieselbe Person meinen könnten – gleiche E-Mail, gleiche
Telefonnummer (Schreibweise egal, verglichen werden die letzten acht Ziffern)
oder gleicher Nachname bei gleicher Firma.
**Rückseite.** Nachträglich ein zweites Bild zur Karte aufnehmen. Es wird
ausgelesen, füllt aber nur Felder, die die Vorderseite offen gelassen hat –
dort steht oft nur die Mobilnummer.
**Übersicht.** Alle Karten mit Volltextsuche über Name, Firma, Anlass, Ort und
Notiz. Filter für „nicht exportiert“, „unvollständig“ und „mit Erinnerung“.
**Bild nachbearbeiten.** Drehen in 90-Grad-Schritten, Ausrichten über einen
Schieberegler und Zuschneiden per Rahmen. Jede Korrektur rechnet der Server vom
unbearbeiteten Original, das beim ersten Eingriff daneben abgelegt wird – so
summieren sich keine Verluste und *Zurücksetzen* führt zurück zum Scan.
**Klickbare Felder.** E-Mail, Web, Telefon und Mobil sind Handlungen, keine
Zeichenketten. Die Adresse öffnet die Navigations-App, die unter *Mehr*
eingestellt ist (Apple Karten, Google Maps oder OpenStreetMap).
**Export und Teilen.** *In Kontakte speichern* liefert eine vCard 3.0, die iOS
direkt in die Kontakte-App übernimmt. *Als vCard teilen* geht über das
System-Teilenblatt – per AirDrop, Mail oder Nachricht, ohne Umweg über den
Download. Bewusst einzeln: ein Sammelexport schiebt zwanzig Kontakte ungeprüft
ins Adressbuch.
**Notizen und Erinnerungen.** Freitext pro Karte. Erinnerungen mit Termin gehen
als Mail raus; verpasste Termine holt der Scheduler beim nächsten Start nach.
Schnellwahl für die üblichen Abstände, damit das Datumsfeld selten gebraucht
wird.
## Stack
@@ -56,7 +95,7 @@ Tag-Push nach den Konventionen in [DEPLOY.md](DEPLOY.md). Vor dem ersten Tag:
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`,
`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`
@@ -94,7 +133,8 @@ Karte und sagt das in der Oberfläche. Einzelne Fehlgriffe lassen sich pro Karte
app/ FastAPI-Anwendung
segment.py OpenCV-Pipeline: Karten finden und entzerren
extract.py Vision-Modell, strukturiertes JSON
imaging.py EXIF-Auswertung, Bildkonvertierung
duplicates.py Ähnliche Karten finden
imaging.py EXIF, Drehen, Zuschneiden
vcard.py vCard 3.0
reminders.py Scheduler für die Erinnerungsmails
static/ PWA: HTML, CSS, JavaScript, Service Worker

View File

@@ -15,7 +15,9 @@ CREATE TABLE IF NOT EXISTS cards (
captured_at TEXT,
batch_id TEXT,
image_file TEXT,
back_image_file TEXT,
source TEXT NOT NULL DEFAULT 'batch',
occasion TEXT,
full_name TEXT,
first_name TEXT,
last_name TEXT,
@@ -71,10 +73,45 @@ def connect() -> sqlite3.Connection:
_conn.execute("PRAGMA journal_mode=WAL")
_conn.execute("PRAGMA foreign_keys=ON")
_conn.executescript(SCHEMA)
_migrate(_conn)
_conn.commit()
return _conn
# Spalten, die nach dem ersten Deploy dazugekommen sind. CREATE TABLE IF NOT
# EXISTS ruehrt eine bestehende Tabelle nicht an - ohne das hier liefe die
# App auf dem Server mit dem alten Schema weiter.
LATER_COLUMNS = {
"back_image_file": "TEXT",
"occasion": "TEXT",
}
def _migrate(conn: sqlite3.Connection) -> None:
present = {row["name"] for row in conn.execute("PRAGMA table_info(cards)")}
for name, kind in LATER_COLUMNS.items():
if name not in present:
conn.execute(f"ALTER TABLE cards ADD COLUMN {name} {kind}")
_split_legacy_names(conn)
def _split_legacy_names(conn: sqlite3.Connection) -> None:
"""full_name ist als Feld entfallen. Aeltere Datensaetze haben nur diesen
einen Namen - am letzten Leerzeichen trennen, damit sie nicht namenlos
dastehen. Die Spalte bleibt: SQLite baut zum Loeschen die Tabelle neu,
und der Gewinn waere ein paar Byte."""
rows = conn.execute(
"SELECT id, full_name FROM cards "
"WHERE full_name IS NOT NULL AND first_name IS NULL AND last_name IS NULL"
).fetchall()
for row in rows:
first, _, last = row["full_name"].strip().rpartition(" ")
conn.execute(
"UPDATE cards SET first_name = ?, last_name = ? WHERE id = ?",
(first or None, last or None, row["id"]),
)
def query(sql: str, params: Iterable[Any] = ()) -> list[sqlite3.Row]:
with _lock:
return connect().execute(sql, tuple(params)).fetchall()
@@ -91,10 +128,3 @@ def execute(sql: str, params: Iterable[Any] = ()) -> None:
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()

55
app/duplicates.py Normal file
View File

@@ -0,0 +1,55 @@
"""Karten finden, die dieselbe Person meinen.
Auf Messen trifft man Leute wieder, und dieselbe Karte landet ein zweites Mal
im Stapel. Verglichen wird nicht der ganze Datensatz, sondern das, was eine
Person eindeutig macht - in dieser Reihenfolge, weil die Aussagekraft faellt.
"""
from . import db
def _phone_key(value) -> str:
"""Nur die letzten acht Ziffern. Damit passen +49 511 123456,
0049511123456 und 0511/123456 aufeinander."""
digits = "".join(character for character in (value or "") if character.isdigit())
return digits[-8:] if len(digits) >= 8 else ""
def _phone_keys(row) -> set:
return {_phone_key(row["phone"]), _phone_key(row["mobile"])} - {""}
def _clean(value) -> str:
return (value or "").strip().lower()
def _match(card, other) -> str:
email = _clean(card["email"])
if email and _clean(other["email"]) == email:
return "gleiche E-Mail"
if _phone_keys(card) & _phone_keys(other):
return "gleiche Telefonnummer"
last_name, company = _clean(card["last_name"]), _clean(card["company"])
if last_name and company:
if _clean(other["last_name"]) == last_name and _clean(other["company"]) == company:
return "gleicher Name und dieselbe Firma"
return ""
def find(card) -> list:
"""Aehnliche Karten, neueste zuerst."""
others = db.query(
"""
SELECT id, first_name, last_name, company, email, phone, mobile,
occasion, captured_at, created_at
FROM cards
WHERE id <> ? AND extract_status <> 'offen'
ORDER BY COALESCE(captured_at, created_at) DESC
""",
(card["id"],),
)
hits = []
for other in others:
reason = _match(card, other)
if reason:
hits.append({**dict(other), "reason": reason})
return hits

View File

@@ -11,12 +11,12 @@ import logging
import anthropic
from . import config
from . import config, imaging
log = logging.getLogger(__name__)
FIELDS = [
"full_name", "first_name", "last_name", "job_title", "company", "department",
"first_name", "last_name", "job_title", "company", "department",
"email", "phone", "mobile", "fax", "website",
"street", "postal_code", "city", "country",
]
@@ -28,9 +28,17 @@ SCHEMA = {
"type": "boolean",
"description": "False, wenn der Ausschnitt keine Visitenkarte zeigt.",
},
"rotation": {
"type": "integer",
"enum": [0, 90, 180, 270],
"description": (
"Grad im Uhrzeigersinn, um die das Bild gedreht werden muss, "
"damit die Schrift aufrecht steht."
),
},
**{name: {"type": ["string", "null"]} for name in FIELDS},
},
"required": ["is_business_card", *FIELDS],
"required": ["is_business_card", "rotation", *FIELDS],
"additionalProperties": False,
}
@@ -44,17 +52,33 @@ Regeln:
- 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.
- first_name ist der Vorname einschliesslich akademischer Titel ("Dr. Anna"),
last_name der Nachname.
- website ohne Protokollpraefix.
- Zeigt der Ausschnitt keine Visitenkarte (Tischkante, Notizzettel, leere
Flaeche), setze is_business_card auf false und alle Felder auf null."""
Flaeche), setze is_business_card auf false und alle Felder auf null.
- rotation sagt, um wie viel Grad im Uhrzeigersinn das Bild gedreht werden
muss, damit die Schrift aufrecht steht. Steht sie schon richtig, ist der
Wert 0. Steht die Karte auf dem Kopf, ist er 180."""
PROMPT = "Lies diese Visitenkarte aus."
# Nicht jedes Modell nimmt jeden Parameter an: die kleinen Modelle kennen
# output_config.effort nicht (400), der server-seitige Fallback gilt nur fuer
# die groessten.
_NO_EFFORT = ("claude-haiku-", "claude-sonnet-4-5")
_WITH_FALLBACK = ("claude-opus-5", "claude-fable-")
_client = None
def _output_config(with_effort: bool) -> dict:
settings = {"format": {"type": "json_schema", "schema": SCHEMA}}
if with_effort:
settings["effort"] = "low" # reines Ablesen, keine Denkarbeit
return settings
def available() -> bool:
return bool(config.ANTHROPIC_API_KEY)
@@ -67,7 +91,8 @@ def _get_client() -> anthropic.AsyncAnthropic:
def _empty(reason: str) -> dict:
return {"is_business_card": False, "_error": reason, **{f: None for f in FIELDS}}
return {"is_business_card": False, "rotation": 0, "_error": reason,
**{f: None for f in FIELDS}}
def _has_content(result: dict) -> bool:
@@ -92,27 +117,28 @@ async def _ask(image_jpeg: bytes) -> dict:
],
}
]
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
},
)
model = config.ANTHROPIC_MODEL
common = dict(model=model, max_tokens=2000, system=SYSTEM, messages=messages)
effort = not model.startswith(_NO_EFFORT)
try:
response = await client.beta.messages.create(
betas=["server-side-fallback-2026-07-01"],
fallbacks="default",
**common,
if model.startswith(_WITH_FALLBACK):
response = await client.beta.messages.create(
betas=["server-side-fallback-2026-07-01"],
fallbacks="default",
output_config=_output_config(effort),
**common,
)
else:
response = await client.messages.create(
output_config=_output_config(effort), **common
)
except anthropic.BadRequestError as exc:
# Ein Modell mit engerem Parametersatz, als die Listen oben kennen.
# Einmal ohne Zusatzparameter nachfassen, statt die Karte zu verlieren.
log.warning("Anfrage abgelehnt (%s) - Wiederholung ohne Zusatzparameter", exc)
response = await client.messages.create(
output_config=_output_config(with_effort=False), **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")
@@ -123,11 +149,12 @@ async def _ask(image_jpeg: bytes) -> dict:
return json.loads(text)
async def extract(image_jpeg: bytes, rotated_jpeg: bytes = None) -> dict:
async def extract(image_jpeg: bytes) -> 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.
Die gedrehte Fassung entsteht erst hier, nicht auf Vorrat fuer jede Karte.
"""
if not available():
return _empty("Kein ANTHROPIC_API_KEY gesetzt")
@@ -137,23 +164,26 @@ async def extract(image_jpeg: bytes, rotated_jpeg: bytes = None) -> dict:
log.exception("Extraktion fehlgeschlagen")
return _empty(f"{type(exc).__name__}: {exc}")
if not _has_content(result) and rotated_jpeg is not None:
if not _has_content(result):
try:
retry = await _ask(rotated_jpeg)
rotated = await asyncio.to_thread(imaging.rotate_jpeg, image_jpeg, 180)
retry = await _ask(rotated)
if _has_content(retry):
retry["_rotated"] = True
# Der zweite Anlauf sah das Bild gedreht - dann stimmt auch
# seine Rotationsangabe relativ zum gedrehten Bild.
retry["rotation"] = (int(retry.get("rotation") or 0) + 180) % 360
return retry
except Exception:
log.exception("Zweiter Versuch (gedreht) fehlgeschlagen")
return result
async def extract_all(pairs: list) -> list:
async def extract_all(images: 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 def one(image_jpeg: bytes) -> dict:
async with limit:
return await extract(upright, rotated)
return await extract(image_jpeg)
return await asyncio.gather(*(one(u, r) for u, r in pairs))
return await asyncio.gather(*(one(image) for image in images))

View File

@@ -79,3 +79,43 @@ def encode_jpeg(rgb_or_bgr: np.ndarray, quality: int = 88, bgr: bool = True) ->
Image.fromarray(array).save(buffer, format="JPEG", quality=quality, optimize=True)
return buffer.getvalue()
def edit_jpeg(raw: bytes, rotate: int = 0, angle: float = 0.0, crop: dict = None) -> bytes:
"""Drehen, ausrichten, zuschneiden - in dieser Reihenfolge.
Wird immer auf das unbearbeitete Original angewandt, nie auf ein schon
bearbeitetes Bild: sonst summieren sich Verluste bei jeder Korrektur.
"""
image = Image.open(io.BytesIO(raw))
if rotate % 360:
image = image.rotate(-rotate, expand=True)
if angle:
# Ohne expand: der Rahmen bleibt, die Ecken laufen weiss aus. Was
# dabei ueber den Rand geht, schneidet der Nutzer anschliessend weg.
image = image.rotate(
-angle, expand=False, resample=Image.BICUBIC, fillcolor=(255, 255, 255)
)
if crop:
width, height = image.size
left = max(0, round(crop["x"] * width))
top = max(0, round(crop["y"] * height))
right = min(width, round((crop["x"] + crop["w"]) * width))
bottom = min(height, round((crop["y"] + crop["h"]) * height))
if right - left > 10 and bottom - top > 10:
image = image.crop((left, top, right, bottom))
buffer = io.BytesIO()
image.convert("RGB").save(buffer, format="JPEG", quality=90, optimize=True)
return buffer.getvalue()
def rotate_jpeg(raw: bytes, degrees: int) -> bytes:
"""Im Uhrzeigersinn drehen. Arbeitet auf dem fertigen JPEG, nicht auf dem
grossen Rohbild - gebraucht wird das nur fuer einzelne Karten."""
if degrees % 360 == 0:
return raw
image = Image.open(io.BytesIO(raw)).rotate(-degrees, expand=True)
buffer = io.BytesIO()
image.save(buffer, format="JPEG", quality=88, optimize=True)
return buffer.getvalue()

View File

@@ -6,12 +6,14 @@ 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
from . import (
config, db, duplicates, extract, geocode, imaging, reminders, security,
segment, vcard,
)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
log = logging.getLogger("bcs")
@@ -20,9 +22,9 @@ 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",
"first_name", "last_name", "job_title", "company", "department",
"email", "phone", "mobile", "fax", "website",
"street", "postal_code", "city", "country", "notes",
"street", "postal_code", "city", "country", "notes", "occasion", "place",
]
@@ -34,6 +36,14 @@ async def lifespan(app: FastAPI):
log.warning("APP_PASSWORD ist leer - die App ist ohne Anmeldung erreichbar.")
if not extract.available():
log.warning("ANTHROPIC_API_KEY fehlt - Karten werden nur zugeschnitten, nicht ausgelesen.")
# Karten, die beim letzten Herunterfahren noch in der Warteschlange
# standen, wuerden sonst dauerhaft auf "wird gelesen" stehen bleiben.
unfinished = db.query("SELECT id FROM cards WHERE extract_status = 'offen'")
if unfinished:
log.info("%d Karte(n) aus dem letzten Lauf werden nachgeholt", len(unfinished))
start_reading([row["id"] for row in unfinished])
task = asyncio.create_task(reminders.run_forever())
try:
yield
@@ -52,8 +62,11 @@ def now_iso() -> str:
def card_dict(row) -> dict:
card = dict(row)
card["image_url"] = f"/api/cards/{card['id']}/image" if card.get("image_file") else None
card["back_image_url"] = (
f"/api/cards/{card['id']}/image?side=back" if card.get("back_image_file") else None
)
card["has_contact"] = any(
card.get(f) for f in ("full_name", "company", "email", "phone", "mobile")
card.get(f) for f in ("last_name", "company", "email", "phone", "mobile")
)
return card
@@ -104,25 +117,73 @@ def logout(response: Response) -> dict:
# ------------------------------------------------------------------------------ Scan
def _process_photo(raw: bytes) -> tuple:
"""Rechenlastiger Teil: dekodieren, freistellen, als JPEG kodieren."""
"""Rechenlastiger Teil: dekodieren, freistellen, als JPEG kodieren.
Die Zuschnitte werden einzeln kodiert und sofort freigegeben - bei zwanzig
Karten liegen sonst rund 70 MB Rohbild gleichzeitig im Speicher.
"""
meta = imaging.read_metadata(raw)
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
]
crops, fallback = segment.segment(imaging.decode(raw))
encoded = []
while crops:
encoded.append(imaging.encode_jpeg(crops.pop(0)))
return meta, encoded, fallback
# Laufende Lesevorgaenge festhalten, sonst raeumt die Garbage Collection sie
# mitten im Lauf weg.
_reading: set = set()
def start_reading(card_ids: list, side: str = "front") -> None:
task = asyncio.create_task(_read_cards(card_ids, side))
_reading.add(task)
task.add_done_callback(_reading.discard)
async def _read_cards(card_ids: list, side: str = "front") -> None:
"""Das Auslesen laeuft nach der Antwort weiter - der Nutzer sieht seine
Karten sofort, die Felder tropfen nach."""
column = "back_image_file" if side == "back" else "image_file"
rows = [db.query_one("SELECT * FROM cards WHERE id = ?", (i,)) for i in card_ids]
rows = [row for row in rows if row is not None and row[column]]
images = [(config.IMAGE_DIR / row[column]).read_bytes() for row in rows]
results = await extract.extract_all(images)
for row, result in zip(rows, results):
rotation = int(result.get("rotation") or 0) % 360
if rotation:
# Die Lage der Karte auf dem Tisch ist geometrisch nicht
# bestimmbar - das Modell sagt uns, wie herum sie gehoert.
path = config.IMAGE_DIR / row[column]
path.write_bytes(
await asyncio.to_thread(imaging.rotate_jpeg, path.read_bytes(), rotation)
)
fields = {f: (result.get(f) or None) for f in extract.FIELDS}
# COALESCE, weil das Auslesen laeuft, waehrend die Karte schon
# sichtbar ist: was in der Zwischenzeit von Hand eingetragen wurde,
# darf das Modell nicht ueberschreiben.
assignments = ", ".join(f"{name} = COALESCE({name}, ?)" for name in fields)
db.execute(
f"UPDATE cards SET {assignments}, extract_status = ?, extract_error = ? "
"WHERE id = ? AND extract_status = 'offen'",
[
*fields.values(),
"fehler" if result.get("_error") else "ok",
result.get("_error"),
row["id"],
],
)
log.info("%d Karte(n) ausgelesen", len(rows))
@app.post("/api/scan", dependencies=[auth])
async def scan(
file: UploadFile,
lat: float = Form(None),
lon: float = Form(None),
occasion: str = Form(""),
) -> JSONResponse:
raw = await file.read()
if not raw:
@@ -146,13 +207,11 @@ async def scan(
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):
for upright in encoded:
card_id = uuid.uuid4().hex
image_file = f"{card_id}.jpg"
(config.IMAGE_DIR / image_file).write_bytes(upright)
@@ -168,11 +227,9 @@ async def scan(
"lon": meta["lon"],
"place": place,
"geo_source": geo_source,
"extract_status": "fehler" if result.get("_error") else "ok",
"extract_error": result.get("_error"),
"occasion": occasion.strip() or None,
"extract_status": "offen",
}
for field in extract.FIELDS:
columns[field] = (result.get(field) or None)
names = ", ".join(columns)
placeholders = ", ".join("?" for _ in columns)
db.execute(
@@ -180,6 +237,9 @@ async def scan(
)
cards.append(card_dict(db.query_one("SELECT * FROM cards WHERE id = ?", (card_id,))))
# Antwort geht sofort raus, das Auslesen laeuft danach weiter.
start_reading([card["id"] for card in cards])
log.info("Scan %s: %d Karte(n), Fallback=%s", batch_id, len(cards), fallback)
return JSONResponse(
{"batch_id": batch_id, "count": len(cards), "fallback": fallback, "cards": cards}
@@ -195,8 +255,8 @@ def list_cards(q: str = "", filter: str = "alle") -> dict:
if q.strip():
needle = f"%{q.strip()}%"
searchable = [
"full_name", "company", "job_title", "email", "phone", "mobile",
"website", "city", "place", "notes", "department", "street",
"first_name", "last_name", "company", "job_title", "email", "phone", "mobile",
"website", "city", "place", "notes", "department", "street", "occasion",
]
where.append("(" + " OR ".join(f"{c} LIKE ?" for c in searchable) + ")")
params += [needle] * len(searchable)
@@ -204,7 +264,8 @@ def list_cards(q: str = "", filter: str = "alle") -> dict:
where.append("exported_at IS NULL")
elif filter == "unvollstaendig":
where.append(
"(extract_status <> 'ok' OR (full_name IS NULL AND company IS NULL))"
"(extract_status = 'fehler' "
"OR (extract_status = 'ok' AND last_name IS NULL AND company IS NULL))"
)
elif filter == "erinnerung":
where.append(
@@ -218,7 +279,8 @@ def list_cards(q: str = "", filter: str = "alle") -> dict:
totals = db.query_one(
"""
SELECT COUNT(*) AS total,
SUM(CASE WHEN exported_at IS NULL THEN 1 ELSE 0 END) AS offen
SUM(CASE WHEN exported_at IS NULL THEN 1 ELSE 0 END) AS offen,
SUM(CASE WHEN extract_status = 'offen' THEN 1 ELSE 0 END) AS laeuft
FROM cards
"""
)
@@ -231,6 +293,7 @@ def list_cards(q: str = "", filter: str = "alle") -> dict:
"total": totals["total"] or 0,
"offen": totals["offen"] or 0,
"erinnerungen": pending["n"] or 0,
"laeuft": totals["laeuft"] or 0,
},
}
@@ -251,6 +314,7 @@ def get_card(card_id: str) -> dict:
"SELECT * FROM reminders WHERE card_id = ? ORDER BY due_at", (card_id,)
)
]
card["duplicates"] = duplicates.find(card)
return card
@@ -273,34 +337,104 @@ async def update_card(card_id: str, request: Request) -> dict:
@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)
for name in (row["image_file"], row["back_image_file"]):
if name:
(config.IMAGE_DIR / name).unlink(missing_ok=True)
_original_of(name).unlink(missing_ok=True)
db.execute("DELETE FROM cards WHERE id = ?", (card_id,))
return {"ok": True}
@app.get("/api/cards/{card_id}/image", dependencies=[auth])
def card_image(card_id: str) -> FileResponse:
def card_image(card_id: str, side: str = "front") -> FileResponse:
row = _card_or_404(card_id)
path = config.IMAGE_DIR / (row["image_file"] or "")
if not row["image_file"] or not path.exists():
name = row["back_image_file"] if side == "back" else row["image_file"]
path = config.IMAGE_DIR / (name or "")
if not name or not path.exists():
raise HTTPException(status_code=404, detail="Kein Bild gespeichert")
return FileResponse(path, media_type="image/jpeg")
# ---------------------------------------------------------------------------- Export
def _original_of(name: str) -> Path:
"""Unbearbeitete Fassung neben dem Bild. Damit bleibt jede Korrektur
zuruecknehmbar und wird nicht auf eine schon bearbeitete Datei gerechnet."""
return config.IMAGE_DIR / (name[:-4] + "-orig.jpg")
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.post("/api/cards/{card_id}/edit", dependencies=[auth])
async def edit_image(card_id: str, request: Request) -> dict:
row = _card_or_404(card_id)
payload = await request.json()
side = payload.get("side", "front")
name = row["back_image_file"] if side == "back" else row["image_file"]
if not name:
raise HTTPException(status_code=404, detail="Kein Bild gespeichert")
current = config.IMAGE_DIR / name
original = _original_of(name)
if not original.exists():
original.write_bytes(current.read_bytes())
if payload.get("reset"):
current.write_bytes(original.read_bytes())
return card_dict(_card_or_404(card_id))
rotate = int(payload.get("rotate") or 0)
if rotate % 90:
raise HTTPException(status_code=400, detail="Drehung nur in 90-Grad-Schritten")
angle = max(-20.0, min(20.0, float(payload.get("angle") or 0)))
crop = payload.get("crop")
if crop is not None:
try:
crop = {key: float(crop[key]) for key in ("x", "y", "w", "h")}
except (KeyError, TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="Zuschnitt unvollstaendig") from exc
if crop["w"] <= 0.02 or crop["h"] <= 0.02:
raise HTTPException(status_code=400, detail="Zuschnitt zu klein")
edited = await asyncio.to_thread(
imaging.edit_jpeg, original.read_bytes(), rotate, angle, crop
)
current.write_bytes(edited)
return card_dict(_card_or_404(card_id))
@app.post("/api/cards/{card_id}/back", dependencies=[auth])
async def add_back(card_id: str, file: UploadFile) -> dict:
"""Rueckseite nachtragen. Sie wird ausgelesen, fuellt aber nur Felder, die
die Vorderseite offen gelassen hat - dort steht oft nur die Mobilnummer."""
row = _card_or_404(card_id)
raw = await file.read()
if not raw:
raise HTTPException(status_code=400, detail="Leere Datei")
if len(raw) > MAX_UPLOAD_BYTES:
raise HTTPException(status_code=413, detail="Foto ist groesser als 30 MB")
try:
_, encoded, _ = await asyncio.to_thread(_process_photo, raw)
except Exception as exc:
log.exception("Rueckseite konnte nicht verarbeitet werden")
raise HTTPException(status_code=400, detail=f"Bild nicht lesbar: {exc}") from exc
if not encoded:
raise HTTPException(status_code=400, detail="Keine Karte im Bild gefunden")
name = f"{card_id}-back.jpg"
(config.IMAGE_DIR / name).write_bytes(encoded[0])
db.execute(
"UPDATE cards SET back_image_file = ?, extract_status = 'offen' WHERE id = ?",
(name, card_id),
)
start_reading([card_id], side="back")
return card_dict(_card_or_404(card_id))
# ---------------------------------------------------------------------------- Export
@app.get("/vcf/{card_id}", dependencies=[auth])
def download_vcard(card_id: str) -> Response:
card = dict(_card_or_404(card_id))
_mark_exported([card_id])
db.execute("UPDATE cards SET exported_at = ? WHERE id = ?", (now_iso(), card_id))
return Response(
content=vcard.build(card),
media_type="text/vcard; charset=utf-8",
@@ -308,30 +442,13 @@ def download_vcard(card_id: str) -> Response:
)
@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
SELECT r.*, c.first_name, c.last_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
"""

View File

@@ -19,10 +19,14 @@ def _now() -> str:
return datetime.now(timezone.utc).isoformat()
def _name(row) -> str:
return " ".join(filter(None, (row["first_name"], row["last_name"])))
def _body(row) -> str:
who = row["full_name"] or row["company"] or "Unbekannter Kontakt"
who = _name(row) or row["company"] or "Unbekannter Kontakt"
lines = [row["text"], "", "-" * 40, f"Kontakt: {who}"]
if row["company"] and row["full_name"]:
if row["company"] and _name(row):
lines.append(f"Firma: {row['company']}")
if row["email"]:
lines.append(f"E-Mail: {row['email']}")
@@ -41,7 +45,7 @@ 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
c.first_name, c.last_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 <= ?
@@ -62,7 +66,7 @@ def process_due() -> int:
sent = 0
for row in rows:
who = row["full_name"] or row["company"] or "Kontakt"
who = _name(row) or row["company"] or "Kontakt"
try:
mailer.send(f"Erinnerung: {who}", _body(row))
except Exception as exc:

View File

@@ -11,9 +11,14 @@ import numpy as np
CARD_RATIO = 85.6 / 54.0
RATIO_MIN, RATIO_MAX = 1.25, 2.05
OUT_WIDTH = 1400
OUT_WIDTH = 1400 # Obergrenze; kleinere Karten werden nicht hochskaliert
MIN_WIDTH = 600
OUT_HEIGHT = round(OUT_WIDTH / CARD_RATIO)
# Das gefundene Rechteck liegt auf der Kartenkante. Ein schmaler Streifen
# davon ist noch Tisch - der faellt weg, bevor zugeschnitten wird.
INSET = 0.015
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
@@ -129,19 +134,56 @@ def segment(image: np.ndarray) -> tuple[list[np.ndarray], bool]:
crops = []
for rect in _reading_order(rects):
quad = _order_quad(cv2.boxPoints(rect)) / scale # zurueck auf volle Aufloesung
quad = _shrink(quad)
width, height = _target_size(quad)
target = np.array(
[[0, 0], [OUT_WIDTH, 0], [OUT_WIDTH, OUT_HEIGHT], [0, OUT_HEIGHT]],
dtype="float32",
[[0, 0], [width, 0], [width, height], [0, height]], dtype="float32"
)
matrix = cv2.getPerspectiveTransform(quad, target)
crops.append(
cv2.warpPerspective(
image, matrix, (OUT_WIDTH, OUT_HEIGHT), flags=cv2.INTER_CUBIC
)
)
crop = cv2.warpPerspective(image, matrix, (width, height), flags=cv2.INTER_CUBIC)
crops.append(flatten(crop))
return crops, False
def _shrink(quad: np.ndarray, factor: float = INSET) -> np.ndarray:
"""Ecken zur Mitte ziehen, damit kein Untergrund im Zuschnitt bleibt."""
center = quad.mean(axis=0)
return (center + (quad - center) * (1.0 - factor)).astype("float32")
def flatten(card: np.ndarray) -> np.ndarray:
"""Beleuchtung ausgleichen, damit das Papier weiss wird statt grau.
Geteilt wird durch eine stark weichgezeichnete Fassung des Bildes - das
ist die Beleuchtung. Uebrig bleibt der Aufdruck. Dunkle Karten bleiben
unangetastet: bei ihnen ist das Dunkle das Papier, kein Schatten.
"""
gray = cv2.cvtColor(card, cv2.COLOR_BGR2GRAY)
if np.median(gray) < 120:
return card
sigma = max(card.shape[1] / 16.0, 3.0)
illumination = cv2.GaussianBlur(gray, (0, 0), sigma).astype(np.float32)
gain = np.clip(250.0 / np.maximum(illumination, 1.0), 1.0, 2.2)
lifted = card.astype(np.float32) * gain[:, :, None]
return np.clip(lifted, 0, 255).astype(np.uint8)
def _target_size(quad: np.ndarray) -> tuple:
"""Zuschnitt so gross wie die Vorlage, hoechstens OUT_WIDTH.
Auf einem Stapelfoto ist eine Karte nur ein paar hundert Pixel breit.
Sie auf 1400 hochzurechnen erzeugt vier Mal so viele Pixel ohne ein
Quentchen mehr Information - und kostet Speicher, Bildgroesse und
Bildtokens beim Modellaufruf.
"""
long_edge = max(
np.linalg.norm(quad[1] - quad[0]), np.linalg.norm(quad[2] - quad[3])
)
width = int(min(OUT_WIDTH, max(MIN_WIDTH, round(long_edge))))
return width, round(width / CARD_RATIO)
def _fit_whole(image: np.ndarray) -> np.ndarray:
"""Ohne Fund: das ganze Bild als eine Karte behandeln."""
height, width = image.shape[:2]

View File

@@ -16,8 +16,6 @@ def _line(name: str, value) -> list:
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"
@@ -80,10 +78,6 @@ def build(card: dict) -> str:
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 "-_")

View File

@@ -12,6 +12,11 @@ services:
restart: unless-stopped
# Wird im Workflow aus dem Secret DOTENV erzeugt.
env_file: .env
# Deckel gegen Ausreisser: ein Stapelscan braucht im Normalfall deutlich
# unter 500 MB. Laeuft er aus dem Ruder, stirbt der Container - nicht der
# Host, auf dem auch Jitsi und die Datenbank liegen.
mem_limit: 1g
cpus: 1.5
volumes:
- business-card-scanner-data:/data
networks:

View File

@@ -3,7 +3,6 @@
"use strict";
const FIELD_LABELS = {
full_name: "Name",
first_name: "Vorname",
last_name: "Nachname",
job_title: "Position",
@@ -18,6 +17,8 @@
postal_code: "PLZ",
city: "Ort",
country: "Land",
occasion: "Anlass",
place: "Scan-Ort",
};
const FIELD_ORDER = Object.keys(FIELD_LABELS);
@@ -27,7 +28,7 @@
query: "",
filter: "alle",
cards: [],
stats: { total: 0, offen: 0, erinnerungen: 0 },
stats: { total: 0, offen: 0, erinnerungen: 0, laeuft: 0 },
editing: false,
};
@@ -37,6 +38,7 @@
karten: $("screen-cards"),
karte: $("screen-detail"),
erinnerungen: $("screen-reminders"),
mehr: $("screen-mehr"),
};
const esc = (value) =>
@@ -44,6 +46,15 @@
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
})[c]);
const store = {
get(key) {
try { return localStorage.getItem(key) || ""; } catch (_) { return ""; }
},
set(key, value) {
try { localStorage.setItem(key, value); } catch (_) { /* privater Modus */ }
},
};
// ------------------------------------------------------------------ Helfer
function toast(message) {
@@ -55,7 +66,7 @@
}
function busy(on, text) {
$("busy-text").textContent = text || "Karten werden gelesen";
$("busy-text").textContent = text || "Karten werden freigestellt";
$("busy-overlay").hidden = !on;
}
@@ -93,24 +104,84 @@
};
const displayName = (card) =>
card.full_name ||
[card.first_name, card.last_name].filter(Boolean).join(" ") ||
card.company ||
"Ohne Namen";
const NAV_APPS = {
apple: {
label: "Apple Karten",
url: (query) => `https://maps.apple.com/?q=${encodeURIComponent(query)}`,
},
google: {
label: "Google Maps",
url: (query) =>
`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`,
},
osm: {
label: "OpenStreetMap",
url: (query) =>
`https://www.openstreetmap.org/search?query=${encodeURIComponent(query)}`,
},
};
const navApp = () => (NAV_APPS[store.get("navApp")] ? store.get("navApp") : "apple");
const address = (card) =>
[card.street, [card.postal_code, card.city].filter(Boolean).join(" "), card.country]
.filter(Boolean)
.join(", ");
const link = (href, text) =>
`<a href="${esc(href)}" rel="noopener">${esc(text)}</a>`;
const subtitle = (card) =>
[card.job_title, card.company].filter(Boolean).join(" · ") || "Keine Firma erkannt";
/** 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);
if (!navigator.geolocation) return resolve({ error: "Gerät liefert keinen Standort" });
navigator.geolocation.getCurrentPosition(
(position) => resolve(position.coords),
() => resolve(null),
{ timeout: 6000, maximumAge: 300000 }
(position) => resolve({ coords: position.coords }),
(error) =>
resolve({
error:
error.code === error.PERMISSION_DENIED
? "Standortfreigabe fehlt"
: "Standort nicht ermittelbar",
}),
{ enableHighAccuracy: false, timeout: 8000, maximumAge: 300000 }
);
});
}
/** Zustand einer Karte als Pillen. Ob sie schon exportiert wurde, steht
* bewusst nicht dabei - dafür gibt es den Filter. */
function cardTags(card) {
const tags = [];
if (card.occasion) tags.push(["pill-solid", card.occasion]);
else if (card.place) tags.push(["", card.place]);
if (card.extract_status === "offen") tags.push(["", "wird gelesen"]);
if (card.extract_status === "fehler") tags.push(["", "Lesefehler"]);
return tags
.map(([extra, text]) => `<span class="pill ${extra}">${esc(text)}</span>`)
.join("");
}
function cardRow(card) {
return `
<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(subtitle(card))}</span>
<span class="row-tags">${cardTags(card)}</span>
</button>`;
}
// ----------------------------------------------------------------- Routing
function go(route, cardId) {
@@ -125,14 +196,17 @@
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");
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 === "scan") loadRecent();
if (route === "karten") loadCards();
if (route === "karte") loadCard(state.cardId);
if (route === "erinnerungen") loadReminders();
if (route === "mehr") renderSettings(state.session || {});
}
// ------------------------------------------------------------------- Scan
@@ -142,17 +216,22 @@
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);
body.append("occasion", $("occasion-input").value.trim());
const position = await browserPosition();
if (position.coords) {
body.append("lat", position.coords.latitude);
body.append("lon", position.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`);
loadRecent();
const ohneOrt = result.cards.every((card) => !card.place);
toast(
`${result.count} Karte${result.count === 1 ? "" : "n"} erfasst` +
(ohneOrt && position.error ? ` · ohne Ort (${position.error})` : ", wird gelesen")
);
} catch (error) {
toast(error.message);
} finally {
@@ -161,31 +240,43 @@
}
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 class="strip">
<span class="label">${result.count} Karte${result.count === 1 ? "" : "n"} gefunden</span>
<span class="pill">wird gelesen</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>`;
<div class="rows" style="margin-top:12px">
${result.cards.map(cardRow).join("")}
</div>`;
}
async function loadRecent() {
try {
const data = await api("/api/cards?filter=alle");
state.stats = data.stats;
updateCounters();
const recent = data.cards.slice(0, 3);
$("recent-list").innerHTML = recent.length
? `<div class="rows">${recent.map(cardRow).join("")}</div>
${data.cards.length > 3
? `<button class="btn btn-quiet" data-goto="karten" style="margin-top:12px">
Alle ${data.stats.total} Karten <span class="arrow">→</span>
</button>`
: ""}`
: `<div class="empty">
<p class="label">Noch nichts erfasst</p>
<p class="hint" style="margin:8px 0 0">Mach oben ein Foto – der Rest passiert von allein.</p>
</div>`;
schedulePoll();
} catch (error) {
toast(error.message);
}
}
// ------------------------------------------------------------- Kartenliste
@@ -198,11 +289,25 @@
state.stats = data.stats;
updateCounters();
renderCards();
schedulePoll();
} catch (error) {
toast(error.message);
}
}
/** Das Auslesen läuft auf dem Server weiter, nachdem der Scan geantwortet
* hat. Solange noch etwas offen ist, holen wir den Stand nach. */
function schedulePoll() {
clearTimeout(schedulePoll.timer);
if (!state.stats.laeuft) return;
schedulePoll.timer = setTimeout(() => {
if (state.route === "karten") loadCards();
else if (state.route === "karte") loadCard(state.cardId);
else if (state.route === "scan") loadRecent();
else refreshStats();
}, 3000);
}
function renderCards() {
const list = $("cards-list");
if (!state.cards.length) {
@@ -216,37 +321,12 @@
return;
}
const selectable = state.cards.map((c) => c.id).join(",");
list.innerHTML = `
<div class="strip" style="border-top:3px solid #000">
<div class="strip">
<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>
${state.stats.laeuft ? `<span class="pill">${state.stats.laeuft} werden gelesen</span>` : ""}
</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>`;
<div class="rows">${state.cards.map(cardRow).join("")}</div>`;
}
// ----------------------------------------------------------- Kartendetail
@@ -255,20 +335,47 @@
try {
const card = await api(`/api/cards/${cardId}`);
renderCard(card);
if (card.extract_status === "offen") {
state.stats = { ...state.stats, laeuft: Math.max(1, state.stats.laeuft) };
schedulePoll();
}
} catch (error) {
screens.karte.innerHTML = `<div class="empty"><p class="label">${esc(error.message)}</p></div>`;
}
}
/** E-Mail, Web, Nummern und Adresse sind Handlungen, keine Zeichenketten. */
function fieldCell(card, field) {
const value = card[field];
if (field === "email") return link(`mailto:${value}`, value);
if (field === "website") {
return link(/^https?:\/\//i.test(value) ? value : `https://${value}`, value);
}
if (["phone", "mobile", "fax"].includes(field)) {
return link(`tel:${value.replace(/[^\d+]/g, "")}`, value);
}
return esc(value);
}
// Straße, PLZ, Ort und Land stehen in der Tabelle als eine Adresszeile.
const DISPLAY_ORDER = [
"first_name", "last_name", "job_title", "company", "department",
"email", "phone", "mobile", "fax", "website", "address", "occasion", "place",
];
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>`
const postal = address(card);
const rows = DISPLAY_ORDER.map((field) => {
if (field === "address") {
if (!postal) return "";
return `<tr><th>Adresse</th><td>${link(NAV_APPS[navApp()].url(postal), postal)}</td></tr>`;
}
if (!card[field]) return "";
return `<tr><th>${FIELD_LABELS[field]}</th><td>${fieldCell(card, field)}</td></tr>`;
}).filter(Boolean);
return rows.length
? `<table class="data">${rows.join("")}</table>`
: `<p class="hint">Keine Felder erkannt. Über „Bearbeiten“ nachtragen.</p>`;
}
@@ -282,18 +389,49 @@
).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)}`);
function duplicateBlock(card) {
if (!card.duplicates || !card.duplicates.length) return "";
return `
<div class="section-label"><span class="label">Schon einmal erfasst?</span></div>
<div class="rows">
${card.duplicates
.map(
(other) => `
<button class="row" data-card="${esc(other.id)}">
<span class="row-head">
<span class="row-title">${esc(displayName(other))}</span>
<span class="row-meta">${esc(formatDate(other.captured_at || other.created_at))}</span>
</span>
<span class="row-sub">${esc(other.reason)}${other.occasion ? " · " + esc(other.occasion) : ""}</span>
</button>`
)
.join("")}
</div>`;
}
const GEO_SOURCE = { exif: "aus dem Foto", browser: "vom Gerät" };
const QUICK_DATES = [
["In 3 Tagen", 3],
["In 1 Woche", 7],
["In 2 Wochen", 14],
["In 1 Monat", 30],
];
function localInputValue(date) {
const pad = (n) => String(n).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
`T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function renderCard(card) {
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>
<span class="row-title" style="font-size:15px">${esc(reminder.text)}</span>
<button class="badge" data-delete-reminder="${esc(reminder.id)}">Löschen</button>
</span>
<span class="row-sub">
@@ -307,41 +445,57 @@
: `<p class="hint">Keine Erinnerung gesetzt.</p>`;
screens.karte.innerHTML = `
<button class="chip" data-back>← Zurück</button>
<button class="chip" data-back>← Karten</button>
<div class="block-invert">
<p class="label">${card.source === "stapel" ? "Aus Stapelscan" : "Einzelscan"}</p>
<p class="label">${esc(card.occasion || (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>
<p class="muted" style="margin:6px 0 0">${esc(subtitle(card))}</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 class="label">Ort${card.geo_source ? " · " + GEO_SOURCE[card.geo_source] : ""}</span>
<span class="display d-md">${esc(card.place || "Unbekannt")}</span>
</span>
</div>
</div>
${card.image_url
? `<img class="card-image" src="${esc(card.image_url)}" alt="Visitenkarte">`
? `<img class="card-image" src="${esc(card.image_url)}" alt="Vorderseite">
<button class="btn btn-quiet" data-edit-image="front">
Bild bearbeiten <span class="arrow">✎</span>
</button>`
: ""}
${card.back_image_url
? `<img class="card-image" src="${esc(card.back_image_url)}" alt="Rückseite">
<button class="btn btn-quiet" data-edit-image="back">
Rückseite bearbeiten <span class="arrow">✎</span>
</button>`
: `<label class="btn btn-quiet" for="input-back">
Rückseite hinzufügen <span class="arrow">+</span>
<input id="input-back" type="file" accept="image/*" capture="environment" hidden>
</label>`}
<a class="btn btn-primary" href="/vcf/${esc(card.id)}" download>
In Kontakte speichern <span class="arrow">→</span>
</a>
<button class="btn" data-share="${esc(card.id)}">
Als vCard teilen <span class="arrow">↗</span>
</button>
${card.extract_status !== "ok"
${card.extract_status === "offen"
? `<p class="hint">Die Karte wird gerade ausgelesen – die Felder erscheinen gleich.</p>`
: ""}
${card.extract_status === "fehler"
? `<p class="hint">Lesefehler: ${esc(card.extract_error || "unbekannt")}</p>`
: ""}
<div class="section-label">
<span class="label">Daten</span>
</div>
${duplicateBlock(card)}
<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>
@@ -359,7 +513,12 @@
<span class="label">Text</span>
<input name="text" placeholder="Angebot nachfassen" required>
</label>
<label class="field">
<div class="chips" style="margin-top:12px">
${QUICK_DATES.map(
([text, days]) => `<button class="chip" type="button" data-quick="${days}">${text}</button>`
).join("")}
</div>
<label class="field" style="margin-top:12px">
<span class="label">Termin</span>
<input name="due" type="datetime-local" required>
</label>
@@ -367,7 +526,7 @@
</form>
<div class="section-label"><span class="label">Karte entfernen</span></div>
<button class="btn btn-danger" data-delete-card="${esc(card.id)}">
<button class="btn btn-quiet" data-delete-card="${esc(card.id)}">
Endgültig löschen <span class="arrow">×</span>
</button>`;
@@ -386,9 +545,39 @@
});
});
$("reminder-form").addEventListener("submit", async (event) => {
const backInput = $("input-back");
if (backInput) {
backInput.addEventListener("change", async (event) => {
const file = event.target.files[0];
event.target.value = "";
if (!file) return;
busy(true, "Rückseite wird gelesen");
try {
const body = new FormData();
body.append("file", file);
await api(`/api/cards/${card.id}/back`, { method: "POST", body });
toast("Rückseite hinzugefügt");
loadCard(card.id);
} catch (error) {
toast(error.message);
} finally {
busy(false);
}
});
}
const form = $("reminder-form");
form.querySelectorAll("[data-quick]").forEach((button) => {
button.addEventListener("click", () => {
const date = new Date();
date.setDate(date.getDate() + Number(button.dataset.quick));
date.setHours(9, 0, 0, 0);
form.elements.due.value = localInputValue(date);
});
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.currentTarget;
const due = form.elements.due.value;
if (!due) return;
try {
@@ -409,6 +598,76 @@
});
}
/** Teilen über das System-Teilenblatt. Damit landet die vCard per AirDrop,
* Mail oder Nachricht beim Gegenüber, ohne Umweg über den Download. */
async function shareCard(cardId) {
try {
const response = await fetch(`/vcf/${cardId}`, { credentials: "same-origin" });
if (!response.ok) throw new Error("vCard konnte nicht geladen werden");
const disposition = response.headers.get("content-disposition") || "";
const match = disposition.match(/filename="([^"]+)"/);
const blob = await response.blob();
const file = new File([blob], match ? match[1] : "kontakt.vcf", {
type: "text/vcard",
});
if (navigator.canShare && navigator.canShare({ files: [file] })) {
await navigator.share({ files: [file] });
return;
}
// Kein Teilenblatt (Desktop, ältere Browser): dann eben herunterladen.
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = file.name;
link.click();
URL.revokeObjectURL(url);
} catch (error) {
if (error.name !== "AbortError") toast(error.message);
}
}
// ----------------------------------------------------------- Einstellungen
function renderSettings(session) {
const current = navApp();
screens.mehr.innerHTML = `
<div class="section-label"><span class="label">Navigation</span></div>
<p class="hint">Womit Adressen geöffnet werden.</p>
<div class="chips" id="nav-app-chips">
${Object.entries(NAV_APPS)
.map(
([key, app]) =>
`<button class="chip" data-nav-app="${key}" aria-pressed="${key === current}">
${app.label}
</button>`
)
.join("")}
</div>
<div class="section-label"><span class="label">Status</span></div>
<table class="data">
<tr><th>Auslesen</th><td>${session.extraction_available ? "aktiv" : "kein API-Schlüssel"}</td></tr>
<tr><th>Erinnerungsmails</th><td>${session.mail_configured ? "aktiv" : "kein SMTP"}</td></tr>
<tr><th>Anmeldung</th><td>${session.auth_required ? "mit Passwort" : "offen"}</td></tr>
</table>
${session.auth_required
? `<div class="section-label"><span class="label">Zugang</span></div>
<button class="btn btn-quiet" id="logout-button">
Abmelden <span class="arrow">→</span>
</button>`
: `<div class="section-label"><span class="label">Zugang</span></div>
<p class="hint">Ohne Passwort erreichbar – APP_PASSWORD in der .env setzen.</p>`}`;
const logout = $("logout-button");
if (logout) {
logout.addEventListener("click", async () => {
await api("/api/logout", { method: "POST" }).catch(() => {});
location.reload();
});
}
}
// ------------------------------------------------------------ Erinnerungen
async function loadReminders() {
@@ -442,11 +701,15 @@
// -------------------------------------------------------------- Zähler
function badge(id, value) {
const node = $(id);
node.textContent = value;
node.hidden = !value;
}
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;
badge("tab-count-cards", state.stats.total);
badge("tab-count-reminders", state.stats.erinnerungen);
}
async function refreshStats() {
@@ -454,15 +717,210 @@
const data = await api("/api/cards?filter=alle");
state.stats = data.stats;
updateCounters();
schedulePoll();
} catch (_) { /* Zähler sind nicht kritisch */ }
}
// ------------------------------------------------------- Bildbearbeitung
/** Zwei Zuschnitte hintereinander: der zweite bezieht sich auf das Ergebnis
* des ersten, der Server rechnet aber immer vom Original. Also verrechnen. */
function composeCrop(previous, next) {
if (!previous) return next;
return {
x: previous.x + next.x * previous.w,
y: previous.y + next.y * previous.h,
w: next.w * previous.w,
h: next.h * previous.h,
};
}
/** Drehen, ausrichten, zuschneiden. Jede Korrektur wird auf dem Server vom
* unbearbeiteten Original gerechnet - deshalb hält der Editor den ganzen
* Parametersatz und schickt ihn jedes Mal komplett mit. */
function openEditor(cardId, side) {
const overlay = $("editor-overlay");
const source = `/api/cards/${cardId}/image${side === "back" ? "?side=back" : ""}`;
const params = { rotate: 0, angle: 0, crop: null };
let box = null;
let drag = null;
overlay.innerHTML = `
<div class="editor-panel">
<p class="label" style="margin-bottom:12px">
${side === "back" ? "Rückseite" : "Vorderseite"} bearbeiten
</p>
<div class="editor-stage" id="editor-stage"><img id="editor-image" alt=""></div>
<div class="editor-row">
<button class="chip" data-rotate="-90" type="button">↺ 90°</button>
<button class="chip" data-rotate="90" type="button">↻ 90°</button>
<button class="chip" id="editor-crop" type="button" disabled>Zuschneiden</button>
</div>
<div class="slider-row">
<span class="label">Ausrichten</span>
<input type="range" id="editor-angle" min="-20" max="20" step="0.5" value="0">
<output id="editor-angle-out">0°</output>
</div>
<p class="hint" style="margin-top:12px">
Zum Zuschneiden über dem Bild einen Rahmen aufziehen, Ecken zum Feinjustieren anfassen.
</p>
<div class="editor-row">
<button class="chip" id="editor-reset" type="button">Zurücksetzen</button>
<button class="chip" id="editor-done" type="button" aria-pressed="true">Fertig</button>
</div>
</div>`;
overlay.hidden = false;
const stage = $("editor-stage");
const image = $("editor-image");
const cropButton = $("editor-crop");
const reload = () => {
image.src = `${source}${source.includes("?") ? "&" : "?"}t=${Date.now()}`;
};
reload();
function drawBox() {
const existing = stage.querySelector(".crop-box");
if (existing) existing.remove();
cropButton.disabled = !box;
if (!box) return;
const node = document.createElement("div");
node.className = "crop-box";
node.style.left = `${box.x * 100}%`;
node.style.top = `${box.y * 100}%`;
node.style.width = `${box.w * 100}%`;
node.style.height = `${box.h * 100}%`;
node.innerHTML = ["nw", "ne", "se", "sw"]
.map((corner) => `<span class="crop-handle" data-corner="${corner}"></span>`)
.join("");
stage.appendChild(node);
}
function relative(event) {
const bounds = stage.getBoundingClientRect();
return {
x: Math.min(1, Math.max(0, (event.clientX - bounds.left) / bounds.width)),
y: Math.min(1, Math.max(0, (event.clientY - bounds.top) / bounds.height)),
};
}
stage.addEventListener("pointerdown", (event) => {
const handle = event.target.closest(".crop-handle");
const point = relative(event);
stage.setPointerCapture(event.pointerId);
if (handle) {
drag = { corner: handle.dataset.corner };
} else {
drag = { corner: null, origin: point };
box = { x: point.x, y: point.y, w: 0, h: 0 };
}
event.preventDefault();
});
stage.addEventListener("pointermove", (event) => {
if (!drag || !box) return;
const point = relative(event);
if (drag.corner) {
const right = box.x + box.w;
const bottom = box.y + box.h;
if (drag.corner.includes("w")) { box.w = right - point.x; box.x = point.x; }
else { box.w = point.x - box.x; }
if (drag.corner.includes("n")) { box.h = bottom - point.y; box.y = point.y; }
else { box.h = point.y - box.y; }
if (box.w < 0) { box.x += box.w; box.w = -box.w; }
if (box.h < 0) { box.y += box.h; box.h = -box.h; }
} else {
box.x = Math.min(drag.origin.x, point.x);
box.y = Math.min(drag.origin.y, point.y);
box.w = Math.abs(point.x - drag.origin.x);
box.h = Math.abs(point.y - drag.origin.y);
}
drawBox();
});
const endDrag = () => {
drag = null;
if (box && (box.w < 0.03 || box.h < 0.03)) box = null;
drawBox();
};
stage.addEventListener("pointerup", endDrag);
stage.addEventListener("pointercancel", endDrag);
async function apply(extra = {}) {
try {
await api(`/api/cards/${cardId}/edit`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ side, ...params, ...extra }),
});
reload();
} catch (error) {
toast(error.message);
}
}
overlay.querySelectorAll("[data-rotate]").forEach((button) => {
button.addEventListener("click", () => {
params.rotate = (params.rotate + Number(button.dataset.rotate) + 360) % 360;
// Der Zuschnitt bezog sich auf die alte Lage und gilt nicht mehr.
params.crop = null;
box = null;
drawBox();
apply();
});
});
let angleTimer;
const angleInput = $("editor-angle");
angleInput.addEventListener("input", () => {
params.angle = Number(angleInput.value);
$("editor-angle-out").textContent = `${params.angle}°`;
clearTimeout(angleTimer);
angleTimer = setTimeout(apply, 350);
});
cropButton.addEventListener("click", () => {
if (!box) return;
params.crop = composeCrop(params.crop, box);
box = null;
drawBox();
apply();
});
$("editor-reset").addEventListener("click", () => {
params.rotate = 0;
params.angle = 0;
params.crop = null;
box = null;
angleInput.value = 0;
$("editor-angle-out").textContent = "0°";
drawBox();
apply({ reset: true });
});
$("editor-done").addEventListener("click", () => {
overlay.hidden = true;
overlay.innerHTML = "";
loadCard(cardId);
});
}
// ------------------------------------------------------------- Ereignisse
document.addEventListener("click", (event) => {
const shareButton = event.target.closest("[data-share]");
if (shareButton) return shareCard(shareButton.dataset.share);
const editImage = event.target.closest("[data-edit-image]");
if (editImage) return openEditor(state.cardId, editImage.dataset.editImage);
const cardButton = event.target.closest("[data-card]");
if (cardButton) return go("karte", cardButton.dataset.card);
const goto = event.target.closest("[data-goto]");
if (goto) return go(goto.dataset.goto);
if (event.target.closest("[data-back]")) {
state.editing = false;
return go("karten");
@@ -476,6 +934,15 @@
const tab = event.target.closest(".tab");
if (tab) return go(tab.dataset.route);
const navChip = event.target.closest("[data-nav-app]");
if (navChip) {
store.set("navApp", navChip.dataset.navApp);
document.querySelectorAll("[data-nav-app]").forEach((c) =>
c.setAttribute("aria-pressed", c === navChip ? "true" : "false")
);
return toast(`Adressen öffnen in ${NAV_APPS[navChip.dataset.navApp].label}`);
}
const chip = event.target.closest("#filter-chips .chip");
if (chip) {
state.filter = chip.dataset.filter;
@@ -515,6 +982,12 @@
loadCards();
});
// Der Anlass gilt für eine ganze Messe, nicht für einen Scan.
$("occasion-input").value = store.get("occasion");
$("occasion-input").addEventListener("change", (event) => {
store.set("occasion", event.target.value.trim());
});
["input-camera", "input-library"].forEach((id) => {
$(id).addEventListener("change", (event) => {
const file = event.target.files[0];
@@ -528,10 +1001,10 @@
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");
});
const response = await fetch("/api/login", {
method: "POST", body, credentials: "same-origin",
});
if (!response.ok) throw new Error("Passwort stimmt nicht");
$("login-overlay").hidden = true;
$("login-error").hidden = true;
boot();
@@ -541,11 +1014,6 @@
}
});
$("logout-button").addEventListener("click", async () => {
await api("/api/logout", { method: "POST" }).catch(() => {});
location.reload();
});
window.addEventListener("hashchange", applyRoute);
// --------------------------------------------------------------- Start
@@ -557,11 +1025,10 @@
$("login-overlay").hidden = false;
return;
}
$("logout-button").hidden = !session.auth_required;
state.session = session;
if (!session.extraction_available) {
toast("Kein API-Schlüssel gesetzt – Karten werden nur zugeschnitten.");
}
await refreshStats();
applyRoute();
} catch (error) {
toast("Server nicht erreichbar");

View File

@@ -16,64 +16,47 @@
<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="field">
<span class="label">Anlass · gilt für die nächsten Scans</span>
<input id="occasion-input" placeholder="z. B. Hannover Messe 2026"
autocomplete="off" enterkeyhint="done">
</label>
<label class="btn btn-primary" for="input-camera">
Foto aufnehmen <span class="arrow">→</span>
<label class="action" for="input-camera">
<span class="action-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M3 7h3l1.5-2h9L18 7h3v12H3z"></path>
<circle cx="12" cy="13" r="3.6"></circle>
</svg>
</span>
<span class="action-body">
<span class="display d-lg">Foto aufnehmen</span>
<span class="action-hint">
Karten nebeneinander auf einen dunklen Untergrund legen.
Ein Foto reicht für den ganzen Tisch.
</span>
</span>
<input id="input-camera" type="file" accept="image/*" capture="environment" hidden>
</label>
<label class="btn" for="input-library">
<label class="btn btn-quiet" 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>
<div class="section-label"><span class="label">Zuletzt erfasst</span></div>
<div id="recent-list"></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 …"
<input id="search-input" type="search" placeholder="Name, Firma, Anlass, Notiz …"
autocomplete="off" enterkeyhint="search">
<button id="search-clear" type="button" aria-label="Suche leeren">×</button>
</div>
@@ -89,6 +72,9 @@
<!-- ------------------------------------------------------------ Detail -->
<section class="screen stack" id="screen-detail" hidden></section>
<!-- ------------------------------------------------------ Einstellungen -->
<section class="screen stack" id="screen-mehr" hidden></section>
<!-- ------------------------------------------------------ Erinnerungen -->
<section class="screen stack" id="screen-reminders" hidden>
<div class="section-label"><span class="label">Erinnerungen</span></div>
@@ -97,12 +83,43 @@
</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="scan" aria-selected="true">
<span class="tab-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
<path d="M3 7h3l1.5-2h9L18 7h3v12H3z"></path>
<circle cx="12" cy="13" r="3.4"></circle>
</svg>
</span>
<span class="tab-label">Scannen</span>
</button>
<button class="tab" role="tab" data-route="karten" aria-selected="false">
<span class="count mono-num" id="tab-count-cards">0</span>Karten
<span class="tab-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
<rect x="3" y="6" width="18" height="13"></rect>
<path d="M7 11h5M7 15h8"></path>
</svg>
</span>
<span class="tab-label">Karten</span>
<span class="tab-badge mono-num" id="tab-count-cards" hidden>0</span>
</button>
<button class="tab" role="tab" data-route="erinnerungen" aria-selected="false">
<span class="count mono-num" id="tab-count-reminders">0</span>Termine
<span class="tab-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
<circle cx="12" cy="12" r="8.5"></circle>
<path d="M12 7v5.2l3.4 2"></path>
</svg>
</span>
<span class="tab-label">Termine</span>
<span class="tab-badge mono-num" id="tab-count-reminders" hidden>0</span>
</button>
<button class="tab" role="tab" data-route="mehr" aria-selected="false">
<span class="tab-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
<circle cx="12" cy="12" r="3.2"></circle>
<path d="M12 3v2.4M12 18.6V21M3 12h2.4M18.6 12H21M5.6 5.6l1.7 1.7M16.7 16.7l1.7 1.7M18.4 5.6l-1.7 1.7M7.3 16.7l-1.7 1.7"></path>
</svg>
</span>
<span class="tab-label">Mehr</span>
</button>
</nav>
</div>
@@ -125,10 +142,13 @@
<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>
<p class="label" id="busy-text">Karten werden freigestellt</p>
</div>
</div>
<!-- ---------------------------------------------------------- Bildbearbeitung -->
<div class="overlay" id="editor-overlay" hidden></div>
<div class="toast" id="toast" hidden></div>
<script src="/static/app.js"></script>

View File

@@ -1,14 +1,24 @@
/* Schwarzweiss, harte Kanten, keine Rundungen. Mobile first. */
/* Schwarzweiss mit Grau als Hierarchiestufe. Harte Kanten, keine Rundungen.
Mobile first.
Drei Ebenen, konsequent durchgehalten:
Flaeche weiss -> --fill (gruppiert) -> schwarz (das eine Wichtige)
Text --ink -> --ink-2 (erlaeuternd) -> --ink-3 (Beiwerk)
Linie 3px schwarz (traegt) -> 1px schwarz (gliedert) -> --rule (trennt) */
:root {
color-scheme: light;
--fg: #000;
--ink: #000;
--ink-2: #4a4a4a;
--ink-3: #8a8a8a;
--bg: #fff;
--muted: #6b6b6b;
--hair: #d5d5d5;
--wash: #f2f2f2;
--fill: #f4f4f4;
--page: #e4e4e4;
--rule: #d4d4d4;
--sans: -apple-system, BlinkMacSystemFont, "Helvetica Neue", Helvetica, Arial, sans-serif;
--shell: 3px;
--pad: 18px;
}
@@ -17,15 +27,25 @@
html, body {
margin: 0;
padding: 0;
background: var(--wash);
color: var(--fg);
background: var(--bg);
color: var(--ink);
font-family: var(--sans);
font-size: 15px;
line-height: 1.45;
-webkit-text-size-adjust: 100%;
}
body { overflow-x: hidden; }
body {
overflow-x: hidden;
overscroll-behavior-y: none;
-webkit-tap-highlight-color: transparent;
}
/* Bedienelemente verhalten sich wie in einer App, nicht wie Fliesstext. */
.btn, .chip, .tab, .row, .badge, .pill, .action, .label {
-webkit-user-select: none;
user-select: none;
}
button, input, textarea, select {
font: inherit;
@@ -42,124 +62,114 @@ a { color: inherit; }
.display {
font-weight: 800;
font-stretch: 85%;
letter-spacing: -0.02em;
letter-spacing: -0.025em;
text-transform: uppercase;
line-height: 0.95;
line-height: 0.94;
margin: 0;
}
.d-xl { font-size: 40px; }
.d-lg { font-size: 28px; }
.d-md { font-size: 20px; }
.d-xl { font-size: 42px; }
.d-lg { font-size: 30px; }
.d-md { font-size: 19px; letter-spacing: -0.01em; }
.label {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.16em;
letter-spacing: 0.18em;
text-transform: uppercase;
margin: 0;
}
.muted { color: var(--muted); }
.muted { color: var(--ink-2); }
.hint { font-size: 12.5px; line-height: 1.5; color: var(--ink-3); }
.mono-num { font-variant-numeric: tabular-nums; }
/* ---------------------------------------------------------------------- Gerüst */
/* ---------------------------------------------------------------------- Geruest */
.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 {
flex: 1;
padding: calc(var(--pad) + env(safe-area-inset-top)) var(--pad) 30px;
animation: rise 0.18s ease-out;
}
@keyframes rise {
from { opacity: 0; transform: translateY(7px); }
to { opacity: 1; transform: none; }
}
.screen[hidden] { display: none; }
.stack > * + * { margin-top: 14px; }
.stack > * + * { margin-top: 12px; }
.stack-tight > * + * { margin-top: 8px; }
/* ---------------------------------------------------------------------- Blöcke */
.block {
border: var(--shell) solid var(--fg);
padding: 16px;
}
/* ---------------------------------------------------------------------- Bloecke */
/* Oberste Ebene: das eine Wichtige pro Ansicht. Nur einmal verwenden. */
.block-invert {
background: var(--fg);
background: var(--ink);
color: var(--bg);
border: var(--shell) solid var(--fg);
padding: 20px 18px;
}
.block-invert .muted { color: #a9a9a9; }
.block-invert .label { color: #a9a9a9; }
/* Mittlere Ebene: gruppiert, tritt zurueck. */
.block {
background: var(--fill);
border: 1px solid var(--rule);
padding: 16px;
}
.block-invert .muted { color: #b4b4b4; }
/* Kopfband ueber einer Liste - ordnet zu, konkurriert aber nicht. */
.strip {
border: var(--shell) solid var(--fg);
border-top: none;
padding: 12px 16px;
display: flex;
align-items: baseline;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 11px 14px;
background: var(--fill);
border: 1px solid var(--ink);
border-bottom: none;
}
.strip .label { color: var(--ink-2); }
.section-label {
display: flex;
align-items: center;
gap: 10px;
margin: 26px 0 10px;
gap: 12px;
margin: 34px 0 12px;
}
.section-label .label { color: var(--ink); }
.section-label::after {
content: "";
flex: 1;
height: 1px;
background: var(--fg);
background: var(--rule);
}
.figures {
display: flex;
gap: 30px;
margin-top: 18px;
gap: 32px;
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid rgba(255, 255, 255, 0.28);
}
.figure { display: flex; flex-direction: column; }
.figure .label { color: inherit; opacity: 0.7; margin-bottom: 5px; }
.figure .label { margin-bottom: 6px; }
/* --------------------------------------------------------------------- Buttons */
@@ -169,9 +179,10 @@ main { flex: 1; display: flex; flex-direction: column; }
justify-content: space-between;
gap: 10px;
width: 100%;
min-height: 54px;
padding: 16px;
background: var(--bg);
border: var(--shell) solid var(--fg);
border: 2px solid var(--ink);
font-weight: 800;
font-size: 14px;
letter-spacing: 0.06em;
@@ -181,54 +192,99 @@ main { flex: 1; display: flex; flex-direction: column; }
text-align: left;
}
.btn-primary { background: var(--fg); color: var(--bg); }
/* Die eine Sache, die man auf der Startseite tut. Grosse Flaeche statt
Textzeile - auf dem Telefon zaehlt die Trefferflaeche, nicht die Kante. */
.action {
display: flex;
align-items: center;
gap: 16px;
padding: 22px 18px;
background: var(--ink);
color: var(--bg);
cursor: pointer;
}
.action:active { background: #1f1f1f; }
.action-icon { flex: 0 0 42px; color: var(--bg); }
.action-icon svg { width: 42px; height: 42px; display: block; }
.action-body { display: flex; flex-direction: column; gap: 7px; }
.action-hint { font-size: 12.5px; line-height: 1.45; color: #a9a9a9; }
/* Was man hier tun soll. */
.btn-primary { background: var(--ink); color: var(--bg); border-color: var(--ink); }
/* Vorhanden, aber leise. */
.btn-quiet {
border: 1px solid var(--rule);
color: var(--ink-2);
font-weight: 700;
}
.btn:disabled { opacity: 0.35; cursor: not-allowed; }
.btn .arrow { font-weight: 400; font-size: 17px; }
.btn-danger { border-color: var(--fg); color: var(--fg); }
.btn-quiet .arrow { color: var(--ink-3); }
/* --------------------------------------------------------------------- Formular */
.field { display: block; }
.field .label { color: var(--muted); margin-bottom: 5px; }
.field .label { color: var(--ink-3); margin-bottom: 6px; }
.field input,
.field textarea,
.field select {
.field textarea {
width: 100%;
padding: 11px 12px;
background: var(--bg);
border: 2px solid var(--fg);
border: 1px solid var(--ink);
}
.field input:focus,
.field textarea:focus,
.field select:focus { outline: 3px solid var(--fg); outline-offset: 1px; }
.field textarea:focus { outline: 2px solid var(--ink); outline-offset: 1px; }
.field textarea { min-height: 90px; resize: vertical; }
.field textarea { min-height: 88px; resize: vertical; }
.search {
display: flex;
border: var(--shell) solid var(--fg);
/* iOS rendert Datumsfelder eigenwillig: der Wert sitzt zentriert, die Hoehe
sackt ein, der Innenabstand kommt vom System. Das zieht sie auf die Form
der uebrigen Felder. */
.field input[type="datetime-local"] {
min-height: 46px;
line-height: 1.2;
text-align: left;
font-variant-numeric: tabular-nums;
}
.search input { flex: 1; border: none; padding: 14px; background: var(--bg); }
.field input[type="datetime-local"]::-webkit-date-and-time-value {
text-align: left;
margin: 0;
min-height: 20px;
}
.field input[type="datetime-local"]::-webkit-calendar-picker-indicator {
opacity: 0.5;
filter: grayscale(1);
}
.search { display: flex; border: 2px solid var(--ink); }
.search input { flex: 1; border: none; padding: 13px 14px; background: var(--bg); }
.search input:focus { outline: none; }
.search button {
border: none;
border-left: 2px solid var(--fg);
background: var(--bg);
border-left: 1px solid var(--rule);
background: var(--fill);
color: var(--ink-2);
padding: 0 16px;
cursor: pointer;
font-weight: 800;
font-weight: 700;
}
.chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
.chips { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 10px; }
/* Ungewaehlt zurueckgenommen, damit die Auswahl ueberhaupt auffaellt. */
.chip {
padding: 7px 11px;
border: 2px solid var(--fg);
padding: 9px 12px;
border: 1px solid var(--rule);
background: var(--bg);
color: var(--ink-3);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.12em;
@@ -236,11 +292,15 @@ main { flex: 1; display: flex; flex-direction: column; }
cursor: pointer;
}
.chip[aria-pressed="true"] { background: var(--fg); color: var(--bg); }
.chip[aria-pressed="true"] {
background: var(--ink);
border-color: var(--ink);
color: var(--bg);
}
/* --------------------------------------------------------------------- Tabellen */
/* --------------------------------------------------------------------- Listen */
.rows { border-top: 1px solid var(--fg); }
.rows { border-top: 1px solid var(--ink); border-bottom: 1px solid var(--ink); }
.row {
display: block;
@@ -248,30 +308,106 @@ main { flex: 1; display: flex; flex-direction: column; }
text-align: left;
background: var(--bg);
border: none;
border-bottom: 1px solid var(--hair);
padding: 14px 0;
border-bottom: 1px solid var(--rule);
padding: 14px;
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; }
.row:last-child { border-bottom: none; }
.row-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
}
.row-title { font-weight: 800; font-size: 17px; letter-spacing: -0.01em; }
.row-sub { color: var(--ink-2); font-size: 13.5px; margin-top: 3px; }
.row-meta {
font-size: 10px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ink-3);
white-space: nowrap;
}
.row-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 9px; }
.pill {
display: inline-block;
padding: 3px 7px;
border: 1px solid var(--rule);
background: var(--bg);
color: var(--ink-3);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.pill-solid { background: var(--ink); border-color: var(--ink); color: var(--bg); }
.badge {
display: inline-block;
padding: 5px 9px;
border: 1px solid var(--ink);
background: var(--bg);
color: var(--ink);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.12em;
text-transform: uppercase;
text-decoration: none;
cursor: pointer;
}
/* --------------------------------------------------------------------- Tabelle */
.data {
width: 100%;
border-collapse: collapse;
border-top: 1px solid var(--ink);
border-bottom: 1px solid var(--ink);
}
.data tr:nth-child(even) { background: var(--fill); }
.data th, .data td { padding: 10px 12px; text-align: left; vertical-align: top; }
.data th {
width: 36%;
font-weight: 600;
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ink-3);
padding-top: 13px;
}
.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; }
/* Anrufen, schreiben, Route starten - direkt aus der Tabelle. */
.data td a {
text-decoration: none;
border-bottom: 2px solid var(--ink);
padding-bottom: 1px;
}
.data td a::after {
content: " ↗";
font-weight: 400;
color: var(--ink-3);
}
/* ----------------------------------------------------------------------- Bilder */
.card-image {
display: block;
width: 100%;
border: var(--shell) solid var(--fg);
background: var(--wash);
border: 1px solid var(--ink);
background: var(--fill);
}
/* -------------------------------------------------------------------- Navigation */
@@ -282,52 +418,156 @@ main { flex: 1; display: flex; flex-direction: column; }
z-index: 20;
display: flex;
background: var(--bg);
border-top: var(--shell) solid var(--fg);
border-top: 3px solid var(--ink);
}
.tab {
position: relative;
flex: 1;
padding: 14px 4px calc(14px + env(safe-area-inset-bottom));
padding: 10px 4px calc(10px + env(safe-area-inset-bottom));
background: var(--bg);
border: none;
border-left: 1px solid var(--fg);
border-left: 1px solid var(--rule);
color: var(--ink-3);
font-size: 10px;
font-weight: 700;
letter-spacing: 0.12em;
letter-spacing: 0.1em;
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; }
.tab[aria-selected="true"] { background: var(--ink); color: var(--bg); }
/* ------------------------------------------------------------------- Zustände */
.tab-icon { display: block; width: 24px; height: 24px; margin: 0 auto 4px; }
.tab-icon svg { width: 24px; height: 24px; display: block; }
.tab-label { display: block; }
.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;
.tab-badge {
position: absolute;
top: 6px;
right: 50%;
transform: translateX(26px);
min-width: 18px;
padding: 1px 4px;
background: var(--ink);
color: var(--bg);
border: 1px solid var(--bg);
font-size: 10px;
font-weight: 800;
letter-spacing: 0;
}
.tab-badge[hidden] { display: none; }
.tab[aria-selected="true"] .tab-badge { background: var(--bg); color: var(--ink); border-color: var(--ink); }
/* ------------------------------------------------------------------- Zustaende */
.empty {
border: 1px dashed var(--rule);
background: var(--fill);
padding: 30px 18px;
text-align: center;
}
.empty .label { color: var(--ink-2); }
.overlay {
position: fixed;
inset: 0;
z-index: 60;
background: rgba(0, 0, 0, 0.82);
background: rgba(0, 0, 0, 0.86);
display: grid;
place-items: center;
padding: var(--pad);
}
.overlay[hidden] { display: none; }
.overlay .block { background: var(--bg); width: 100%; max-width: 380px; }
.overlay .block {
background: var(--bg);
border: 3px solid var(--ink);
width: 100%;
max-width: 380px;
}
/* ------------------------------------------------------------- Bildeditor */
.editor {
background: rgba(0, 0, 0, 0.92);
align-content: start;
overflow-y: auto;
}
.editor-panel {
width: 100%;
max-width: 460px;
background: var(--bg);
border: 3px solid var(--ink);
padding: 14px;
}
.editor-stage {
position: relative;
background: var(--fill);
touch-action: none;
user-select: none;
line-height: 0;
}
.editor-stage img { width: 100%; display: block; }
.crop-box {
position: absolute;
border: 2px solid var(--bg);
outline: 9999px solid rgba(0, 0, 0, 0.55);
box-sizing: border-box;
}
.crop-handle {
position: absolute;
width: 26px;
height: 26px;
background: var(--bg);
border: 2px solid var(--ink);
}
.crop-handle[data-corner="nw"] { left: -13px; top: -13px; }
.crop-handle[data-corner="ne"] { right: -13px; top: -13px; }
.crop-handle[data-corner="se"] { right: -13px; bottom: -13px; }
.crop-handle[data-corner="sw"] { left: -13px; bottom: -13px; }
.editor-row { display: flex; gap: 8px; margin-top: 12px; }
.editor-row .chip { flex: 1; text-align: center; }
.slider-row { display: flex; align-items: center; gap: 12px; margin-top: 14px; }
.slider-row .label { color: var(--ink-3); white-space: nowrap; }
.slider-row output { font-weight: 800; font-variant-numeric: tabular-nums; min-width: 42px; text-align: right; }
input[type="range"] {
flex: 1;
-webkit-appearance: none;
appearance: none;
height: 3px;
background: var(--ink);
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 26px;
height: 26px;
background: var(--ink);
border: 3px solid var(--bg);
box-shadow: 0 0 0 1px var(--ink);
}
input[type="range"]::-moz-range-thumb {
width: 22px;
height: 22px;
border-radius: 0;
background: var(--ink);
border: 3px solid var(--bg);
}
.spinner {
width: 26px;
@@ -347,7 +587,7 @@ main { flex: 1; display: flex; flex-direction: column; }
z-index: 70;
max-width: 90vw;
padding: 12px 16px;
background: var(--fg);
background: var(--ink);
color: var(--bg);
font-size: 13px;
font-weight: 600;
@@ -355,10 +595,11 @@ main { flex: 1; display: flex; flex-direction: column; }
.toast[hidden] { display: none; }
.hint { font-size: 12px; color: var(--muted); }
/* Nur am grossen Bildschirm ein Rahmen - auf dem Telefon fuellt die App
den Schirm, wie eine App das tut. */
@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; }
:root { --pad: 26px; }
body { background: var(--page); }
.app { border: 1px solid var(--ink); margin: 26px auto; min-height: calc(100vh - 52px); }
.d-xl { font-size: 52px; }
}

View File

@@ -3,16 +3,31 @@
Ohne ANTHROPIC_API_KEY laeuft die Extraktion in den Leerlauf - Freistellen,
Speichern, Suchen, Bearbeiten und Export werden trotzdem vollstaendig gepruft.
"""
import asyncio
import time
from datetime import datetime, timedelta, timezone
import pytest
from fastapi.testclient import TestClient
from app import db, mailer, reminders
from app import db, extract, mailer, reminders
from app.main import app
from tests.helpers import photo_bytes
def warte_bis_gelesen(client, card_ids, timeout=15.0):
"""Das Auslesen laeuft im Hintergrund - hier auf das Ende warten."""
frist = time.monotonic() + timeout
while time.monotonic() < frist:
stati = [
client.get(f"/api/cards/{i}").json()["extract_status"] for i in card_ids
]
if all(status != "offen" for status in stati):
return stati
time.sleep(0.1)
raise AssertionError("Karten wurden nicht fertig gelesen")
@pytest.fixture(scope="module")
def client():
with TestClient(app) as test_client:
@@ -25,7 +40,9 @@ def scanned(client):
"/api/scan", files={"file": ("tisch.jpg", photo_bytes(4), "image/jpeg")}
)
assert response.status_code == 200
return response.json()
data = response.json()
warte_bis_gelesen(client, [c["id"] for c in data["cards"]])
return data
def test_healthz(client):
@@ -58,10 +75,10 @@ def test_bearbeiten_und_suchen(client, scanned):
patched = client.patch(
f"/api/cards/{card_id}",
json={"full_name": "Anna Berg", "company": "Berg & Partner", "notes": "Messe"},
json={"first_name": "Anna", "last_name": "Berg", "company": "Berg & Partner", "notes": "Messe"},
)
assert patched.status_code == 200
assert patched.json()["full_name"] == "Anna Berg"
assert patched.json()["last_name"] == "Berg"
hits = client.get("/api/cards", params={"q": "berg"}).json()["cards"]
assert [c["id"] for c in hits] == [card_id]
@@ -80,7 +97,7 @@ def test_unbekannte_felder_werden_abgewiesen(client, scanned):
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"})
client.patch(f"/api/cards/{card_id}", json={"first_name": "Bea", "last_name": "Kurz"})
response = client.get(f"/vcf/{card_id}")
@@ -97,14 +114,6 @@ def test_filter_offen_blendet_exportierte_karten_aus(client, scanned):
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)
@@ -134,7 +143,7 @@ def test_erinnerung_ohne_datum_wird_abgewiesen(client, scanned):
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"})
client.patch(f"/api/cards/{card_id}", json={"first_name": "Carl", "last_name": "Spät"})
past = datetime.now(timezone.utc) - timedelta(minutes=5)
client.post(
f"/api/cards/{card_id}/reminders",
@@ -181,3 +190,169 @@ def test_pwa_dateien_werden_ausgeliefert(client):
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
def test_scan_antwortet_bevor_das_auslesen_fertig_ist(client, monkeypatch):
"""Der Kern der Hintergrundverarbeitung: die Antwort wartet nicht auf das
Modell. Ohne das haengt ein Zwanzigerstapel eine Minute im Upload."""
original = extract.extract_all
async def langsam(images):
await asyncio.sleep(1.5)
return await original(images)
monkeypatch.setattr(extract, "extract_all", langsam)
start = time.monotonic()
response = client.post(
"/api/scan", files={"file": ("einzel.jpg", photo_bytes(1), "image/jpeg")}
)
dauer = time.monotonic() - start
card_id = response.json()["cards"][0]["id"]
assert dauer < 1.2, f"Antwort brauchte {dauer:.2f}s, hat also gewartet"
assert client.get(f"/api/cards/{card_id}").json()["extract_status"] == "offen"
monkeypatch.undo()
warte_bis_gelesen(client, [card_id])
def test_offene_karten_zaehlen_nicht_als_unvollstaendig(client):
"""Waehrend des Lesens sind Felder naturgemaess leer - das ist kein Mangel."""
created = client.post(
"/api/scan", files={"file": ("einzel.jpg", photo_bytes(1), "image/jpeg")}
).json()
card_id = created["cards"][0]["id"]
warte_bis_gelesen(client, [card_id]) # sonst ueberschreibt der Hintergrundlauf
db.execute("UPDATE cards SET extract_status = 'offen' WHERE id = ?", (card_id,))
unvollstaendig = client.get(
"/api/cards", params={"filter": "unvollstaendig"}
).json()["cards"]
assert card_id not in [c["id"] for c in unvollstaendig]
assert client.get("/api/cards").json()["stats"]["laeuft"] >= 1
db.execute("UPDATE cards SET extract_status = 'ok' WHERE id = ?", (card_id,))
def test_sammelexport_ist_entfernt(client):
assert client.get("/vcf", params={"ids": "egal"}).status_code == 404
def test_handeintrag_ueberlebt_den_hintergrundlauf(client, monkeypatch):
"""Die Karte ist sichtbar, bevor sie gelesen ist. Wer in der Zwischenzeit
etwas eintraegt, darf es nicht wieder verlieren."""
original = extract.extract_all
async def langsam(images):
await asyncio.sleep(1.0)
return await original(images)
monkeypatch.setattr(extract, "extract_all", langsam)
card_id = client.post(
"/api/scan", files={"file": ("einzel.jpg", photo_bytes(1), "image/jpeg")}
).json()["cards"][0]["id"]
client.patch(f"/api/cards/{card_id}", json={"first_name": "Von", "last_name": "Hand", "notes": "Messe"})
monkeypatch.undo()
warte_bis_gelesen(client, [card_id])
card = client.get(f"/api/cards/{card_id}").json()
assert card["first_name"] == "Von"
assert card["last_name"] == "Hand"
assert card["notes"] == "Messe"
def test_anlass_wird_gespeichert_und_ist_durchsuchbar(client):
created = client.post(
"/api/scan",
files={"file": ("tisch.jpg", photo_bytes(2), "image/jpeg")},
data={"occasion": "Hannover Messe 2026"},
).json()
ids = [c["id"] for c in created["cards"]]
warte_bis_gelesen(client, ids)
assert all(c["occasion"] == "Hannover Messe 2026" for c in created["cards"])
gefunden = client.get("/api/cards", params={"q": "hannover messe"}).json()["cards"]
assert set(ids) <= {c["id"] for c in gefunden}
def test_anlass_ist_nachtraeglich_aenderbar(client):
card_id = client.post(
"/api/scan", files={"file": ("einzel.jpg", photo_bytes(1), "image/jpeg")}
).json()["cards"][0]["id"]
warte_bis_gelesen(client, [card_id])
patched = client.patch(f"/api/cards/{card_id}", json={"occasion": "Kundentermin"})
assert patched.json()["occasion"] == "Kundentermin"
def test_dubletten_stehen_in_der_detailansicht(client):
erste = client.post(
"/api/scan", files={"file": ("a.jpg", photo_bytes(1), "image/jpeg")}
).json()["cards"][0]["id"]
zweite = client.post(
"/api/scan", files={"file": ("b.jpg", photo_bytes(1), "image/jpeg")}
).json()["cards"][0]["id"]
warte_bis_gelesen(client, [erste, zweite])
for card_id in (erste, zweite):
client.patch(f"/api/cards/{card_id}", json={"email": "doppelt@firma.de"})
detail = client.get(f"/api/cards/{zweite}").json()
assert erste in [d["id"] for d in detail["duplicates"]]
assert detail["duplicates"][0]["reason"] == "gleiche E-Mail"
def test_rueckseite_nachtragen(client):
card_id = client.post(
"/api/scan", files={"file": ("vorne.jpg", photo_bytes(1), "image/jpeg")}
).json()["cards"][0]["id"]
warte_bis_gelesen(client, [card_id])
response = client.post(
f"/api/cards/{card_id}/back",
files={"file": ("hinten.jpg", photo_bytes(1), "image/jpeg")},
)
assert response.status_code == 200
assert response.json()["back_image_url"].endswith("side=back")
warte_bis_gelesen(client, [card_id])
bild = client.get(f"/api/cards/{card_id}/image", params={"side": "back"})
assert bild.status_code == 200
assert bild.headers["content-type"] == "image/jpeg"
def test_ohne_rueckseite_gibt_es_kein_rueckseitenbild(client):
card_id = client.post(
"/api/scan", files={"file": ("vorne.jpg", photo_bytes(1), "image/jpeg")}
).json()["cards"][0]["id"]
warte_bis_gelesen(client, [card_id])
assert client.get(
f"/api/cards/{card_id}/image", params={"side": "back"}
).status_code == 404
def test_loeschen_entfernt_auch_die_rueckseite(client):
from app import config
card_id = client.post(
"/api/scan", files={"file": ("vorne.jpg", photo_bytes(1), "image/jpeg")}
).json()["cards"][0]["id"]
warte_bis_gelesen(client, [card_id])
client.post(
f"/api/cards/{card_id}/back",
files={"file": ("hinten.jpg", photo_bytes(1), "image/jpeg")},
)
warte_bis_gelesen(client, [card_id])
row = db.query_one("SELECT * FROM cards WHERE id = ?", (card_id,))
client.delete(f"/api/cards/{card_id}")
assert not (config.IMAGE_DIR / row["image_file"]).exists()
assert not (config.IMAGE_DIR / row["back_image_file"]).exists()

View File

@@ -0,0 +1,89 @@
"""Zuschnitt aufhellen, Untergrund abschneiden, richtig herum drehen."""
import io
import numpy as np
from PIL import Image
from app import imaging, segment
from tests.helpers import photo_with_cards
def _jpeg(width: int, height: int, color=(255, 255, 255)) -> bytes:
buffer = io.BytesIO()
Image.new("RGB", (width, height), color).save(buffer, format="JPEG")
return buffer.getvalue()
def _size(raw: bytes) -> tuple:
return Image.open(io.BytesIO(raw)).size
def test_drehung_um_null_grad_laesst_die_datei_unangetastet():
raw = _jpeg(40, 20)
assert imaging.rotate_jpeg(raw, 0) is raw
assert imaging.rotate_jpeg(raw, 360) is raw
def test_drehung_um_180_grad_behaelt_das_format():
assert _size(imaging.rotate_jpeg(_jpeg(40, 20), 180)) == (40, 20)
def test_drehung_um_90_grad_tauscht_die_seiten():
assert _size(imaging.rotate_jpeg(_jpeg(40, 20), 90)) == (20, 40)
assert _size(imaging.rotate_jpeg(_jpeg(40, 20), 270)) == (20, 40)
def test_drehrichtung_ist_im_uhrzeigersinn():
# Oben links schwarz: nach 90 Grad im Uhrzeigersinn liegt es oben rechts.
image = np.full((20, 40, 3), 255, dtype=np.uint8)
image[:10, :20] = 0
buffer = io.BytesIO()
Image.fromarray(image).save(buffer, format="PNG")
gedreht = np.asarray(
Image.open(io.BytesIO(imaging.rotate_jpeg(buffer.getvalue(), 90)))
)
assert gedreht[:10, -10:].mean() < 60 # oben rechts jetzt dunkel
assert gedreht[:10, :10].mean() > 195 # oben links jetzt hell
def test_helle_karte_wird_aufgehellt():
# Karte mit Helligkeitsverlauf, wie ihn schraeges Licht erzeugt.
verlauf = np.linspace(150, 225, 400, dtype=np.float32)
karte = np.repeat(verlauf[None, :, None], 250, axis=0).repeat(3, axis=2)
karte = karte.astype(np.uint8)
aufgehellt = segment.flatten(karte)
assert aufgehellt.mean() > karte.mean() + 20
# Der Verlauf ist danach deutlich flacher als vorher.
vorher = float(karte[:, -1].mean() - karte[:, 0].mean())
nachher = float(aufgehellt[:, -1].mean() - aufgehellt[:, 0].mean())
assert nachher < vorher / 2
def test_dunkle_karte_bleibt_dunkel():
dunkel = np.full((250, 400, 3), 40, dtype=np.uint8)
assert np.array_equal(segment.flatten(dunkel), dunkel)
def test_zuschnitt_laesst_den_untergrund_draussen():
# Der Untergrund im Testfoto ist dunkel. Bleibt ein Streifen davon im
# Zuschnitt, waeren die Randspalten deutlich dunkler als die Mitte.
crops, _ = segment.segment(photo_with_cards(count=4))
for crop in crops:
rand = np.concatenate([crop[:, :3], crop[:, -3:]], axis=1)
assert rand.mean() > 170
def test_einzug_zieht_die_ecken_zur_mitte():
quad = np.array([[0, 0], [100, 0], [100, 100], [0, 100]], dtype="float32")
kleiner = segment._shrink(quad, factor=0.1)
assert kleiner[0][0] > quad[0][0]
assert kleiner[2][0] < quad[2][0]
assert np.allclose(kleiner.mean(axis=0), quad.mean(axis=0))

88
tests/test_duplicates.py Normal file
View File

@@ -0,0 +1,88 @@
"""Dublettenerkennung. Auf Messen trifft man Leute wieder."""
import uuid
import pytest
from app import db, duplicates
def _card(**felder):
columns = {
"id": uuid.uuid4().hex,
"created_at": "2026-09-01T10:00:00+00:00",
"extract_status": "ok",
**felder,
}
names = ", ".join(columns)
db.execute(
f"INSERT INTO cards ({names}) VALUES ({', '.join('?' for _ in columns)})",
list(columns.values()),
)
return db.query_one("SELECT * FROM cards WHERE id = ?", (columns["id"],))
@pytest.fixture(autouse=True)
def leere_tabelle():
db.execute("DELETE FROM cards")
yield
db.execute("DELETE FROM cards")
def test_gleiche_email_wird_gefunden():
_card(first_name="Anna", last_name="Berg", email="A.Berg@Firma.de")
neu = _card(first_name="A.", last_name="Berg", email="a.berg@firma.de")
treffer = duplicates.find(neu)
assert [t["reason"] for t in treffer] == ["gleiche E-Mail"]
def test_telefonnummer_in_anderer_schreibweise():
_card(first_name="Anna", last_name="Berg", phone="+49 511 1234560")
neu = _card(first_name="Anna", last_name="B.", mobile="0511/1234560")
treffer = duplicates.find(neu)
assert [t["reason"] for t in treffer] == ["gleiche Telefonnummer"]
def test_name_und_firma_zusammen():
_card(last_name="Berg", company="Berg & Partner GmbH")
neu = _card(last_name="berg", company="berg & partner gmbh")
assert [t["reason"] for t in duplicates.find(neu)] == ["gleicher Name und dieselbe Firma"]
def test_gleicher_nachname_allein_reicht_nicht():
_card(last_name="Berg", company="Andere GmbH")
neu = _card(last_name="Berg", company="Berg & Partner GmbH")
assert duplicates.find(neu) == []
def test_kurze_nummern_werden_nicht_verglichen():
# Eine dreistellige Durchwahl darf keine Dublette erzeugen.
_card(first_name="Anna", last_name="Berg", phone="123")
neu = _card(first_name="Bea", last_name="Kurz", phone="123")
assert duplicates.find(neu) == []
def test_leere_felder_erzeugen_keine_treffer():
_card(first_name="Anna", last_name="Berg")
neu = _card(first_name="Bea", last_name="Kurz")
assert duplicates.find(neu) == []
def test_die_karte_selbst_ist_keine_dublette():
karte = _card(first_name="Anna", last_name="Berg", email="a@b.de")
assert duplicates.find(karte) == []
def test_noch_nicht_gelesene_karten_bleiben_aussen_vor():
_card(first_name="Anna", last_name="Berg", email="a@b.de", extract_status="offen")
neu = _card(first_name="Anna", last_name="Berg", email="a@b.de")
assert duplicates.find(neu) == []

45
tests/test_extract.py Normal file
View File

@@ -0,0 +1,45 @@
"""Der Parametersatz haengt am Modell - das war ein echter Produktionsfehler:
claude-haiku-4-5 lehnt output_config.effort mit einem 400 ab.
"""
from app import extract
def test_effort_nur_fuer_modelle_die_es_koennen():
assert "claude-haiku-4-5".startswith(extract._NO_EFFORT)
assert "claude-sonnet-4-5".startswith(extract._NO_EFFORT)
assert not "claude-opus-5".startswith(extract._NO_EFFORT)
assert not "claude-sonnet-5".startswith(extract._NO_EFFORT)
def test_server_seitiger_fallback_nur_fuer_die_grossen():
assert "claude-opus-5".startswith(extract._WITH_FALLBACK)
assert "claude-fable-5-1".startswith(extract._WITH_FALLBACK)
assert not "claude-haiku-4-5".startswith(extract._WITH_FALLBACK)
assert not "claude-sonnet-5".startswith(extract._WITH_FALLBACK)
def test_output_config_ohne_effort_bleibt_gueltig():
ohne = extract._output_config(with_effort=False)
mit = extract._output_config(with_effort=True)
assert "effort" not in ohne
assert ohne["format"]["type"] == "json_schema"
assert mit["effort"] == "low"
assert mit["format"] == ohne["format"]
def test_schema_verlangt_alle_felder_und_verbietet_zusaetze():
assert extract.SCHEMA["additionalProperties"] is False
assert set(extract.SCHEMA["required"]) == {
"is_business_card",
"rotation",
*extract.FIELDS,
}
for field in extract.FIELDS:
assert extract.SCHEMA["properties"][field]["type"] == ["string", "null"]
def test_rotation_ist_auf_vier_werte_beschraenkt():
"""Das Modell sagt uns, wie herum die Karte gehoert - geometrisch ist das
nicht bestimmbar."""
assert extract.SCHEMA["properties"]["rotation"]["enum"] == [0, 90, 180, 270]

View File

@@ -17,7 +17,16 @@ def test_zuschnitte_haben_kartenformat():
for crop in crops:
height, width = crop.shape[:2]
assert (width, height) == (segment.OUT_WIDTH, segment.OUT_HEIGHT)
assert abs(width / height - segment.CARD_RATIO) < 0.01
assert segment.MIN_WIDTH <= width <= segment.OUT_WIDTH
def test_zuschnitt_wird_nicht_hochskaliert():
# Auf einem Stapelfoto ist eine Karte schmaler als OUT_WIDTH - der
# Zuschnitt darf die Vorlage nicht kuenstlich aufblasen.
crops, _ = segment.segment(photo_with_cards(count=12))
assert all(crop.shape[1] < segment.OUT_WIDTH for crop in crops)
def test_zuschnitt_enthaelt_die_karte_und_nicht_den_tisch():

View File

@@ -1,8 +1,7 @@
from app import vcard
BASE = {
"full_name": "Dr. Anna Berg",
"first_name": "Anna",
"first_name": "Dr. Anna",
"last_name": "Berg",
"job_title": "Geschäftsführerin",
"company": "Berg & Partner GmbH",
@@ -28,7 +27,7 @@ def test_enthaelt_die_pflichtzeilen():
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 "N:Berg;Dr. Anna;;;" in output
assert "TEL;TYPE=CELL:+49 170 1234567" in output
@@ -52,20 +51,20 @@ def test_leere_karte_bekommt_trotzdem_einen_namen():
assert "FN:Unbenannt" in output
def test_name_setzt_sich_aus_vor_und_nachname_zusammen():
output = vcard.build({"first_name": "Dr. Anna", "last_name": "Berg"})
assert "FN:Dr. Anna Berg" in output
assert "N:Berg;Dr. Anna;;;" 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"
assert vcard.filename({"first_name": "?!"}) == "kontakt.vcf"