Visitenkarten-Scanner: Stapelscan, Extraktion, Übersicht, vCard-Export
Selbst gehostete PWA, die Visitenkarten von einem Foto freistellt, ausliest und als Kontakt bereitstellt. - Stapelscan: OpenCV findet die Kartenrechtecke über mehrere Binärmasken, entzerrt sie perspektivisch und schneidet sie einzeln aus. Ohne Fund gilt das ganze Foto als eine Karte. - Extraktion: ein Aufruf je Zuschnitt an das Vision-Modell mit JSON-Schema. Kein vorgeschaltetes OCR - das würde Layout und Schriftgrößen wegwerfen, aus denen die Feldzuordnung entsteht. - Metadaten: Aufnahmezeit und GPS aus den EXIF-Daten des Fotos, Ortsname über Nominatim, Browserstandort nur als Rückfallebene. - Übersicht mit Volltextsuche und Filtern, Detailansicht mit Korrekturmaske. - Notizfeld je Karte, Erinnerungen per Mail inklusive Nachholen verpasster Termine nach einem Neustart. - vCard 3.0 einzeln und als Sammeldatei, Karte gilt danach als exportiert. - Anmeldung über ein Passwort, Sitzung als signiertes Cookie. - Deployment per Tag-Push nach den Konventionen in DEPLOY.md. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
10
.dockerignore
Normal file
10
.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
.git
|
||||||
|
.gitea
|
||||||
|
.venv
|
||||||
|
data
|
||||||
|
tests
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
.pytest_cache
|
||||||
33
.env.example
Normal file
33
.env.example
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Vorlage fuer das Gitea-Secret DOTENV (Repo -> Einstellungen -> Actions -> Secrets).
|
||||||
|
# Der komplette Inhalt dieser Datei kommt mehrzeilig in das Secret.
|
||||||
|
|
||||||
|
# --- Zugang -----------------------------------------------------------------
|
||||||
|
# Leer lassen schaltet die Anmeldung ab. Auf einer oeffentlich erreichbaren
|
||||||
|
# Domain muss hier ein Passwort stehen.
|
||||||
|
APP_PASSWORD=
|
||||||
|
# Zufaellig, mindestens 32 Zeichen: openssl rand -hex 32
|
||||||
|
SECRET_KEY=
|
||||||
|
|
||||||
|
# --- Auslesen der Karten ----------------------------------------------------
|
||||||
|
ANTHROPIC_API_KEY=
|
||||||
|
# Standard ist claude-opus-5. claude-haiku-4-5 ist deutlich guenstiger.
|
||||||
|
ANTHROPIC_MODEL=claude-opus-5
|
||||||
|
EXTRACT_CONCURRENCY=5
|
||||||
|
|
||||||
|
# --- Erinnerungsmails -------------------------------------------------------
|
||||||
|
SMTP_HOST=
|
||||||
|
SMTP_PORT=587
|
||||||
|
SMTP_USER=
|
||||||
|
SMTP_PASSWORD=
|
||||||
|
SMTP_FROM=
|
||||||
|
SMTP_STARTTLS=1
|
||||||
|
# Empfaenger aller Erinnerungen.
|
||||||
|
REMINDER_TO=
|
||||||
|
|
||||||
|
# --- Sonstiges --------------------------------------------------------------
|
||||||
|
DATA_DIR=/data
|
||||||
|
PORT=8080
|
||||||
|
# Fuer den Link in der Erinnerungsmail.
|
||||||
|
PUBLIC_BASE_URL=https://business-card-scanner.lucas-orth.de
|
||||||
|
# Ortsnamen ueber Nominatim aufloesen (0 schaltet es ab).
|
||||||
|
REVERSE_GEOCODE=1
|
||||||
3
.gitattributes
vendored
Normal file
3
.gitattributes
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# Entwicklung auf Windows, Ausfuehrung auf Linux: im Repo immer LF.
|
||||||
|
* text=auto eol=lf
|
||||||
|
*.png binary
|
||||||
50
.gitea/workflows/deploy.yml
Normal file
50
.gitea/workflows/deploy.yml
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
name: Build & Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
# Intern klonen statt ueber die oeffentliche Domain: Gitea laeuft auf
|
||||||
|
# demselben Host, der Weg ueber die oeffentliche IP findet nicht
|
||||||
|
# zurueck (NAT-Hairpin).
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
github-server-url: http://gitea:3000
|
||||||
|
|
||||||
|
- uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: '3.12'
|
||||||
|
|
||||||
|
- name: Abhaengigkeiten installieren
|
||||||
|
run: pip install -r requirements-dev.txt
|
||||||
|
|
||||||
|
- name: Tests
|
||||||
|
run: pytest -q
|
||||||
|
|
||||||
|
- name: Tag ermitteln
|
||||||
|
run: echo "IMAGE_TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
# Ein einziges Repo-Secret DOTENV mit dem kompletten .env-Inhalt.
|
||||||
|
# Ueber env: statt direkter Interpolation, damit Anfuehrungszeichen,
|
||||||
|
# Backticks und $ in den Werten unangetastet bleiben.
|
||||||
|
- name: .env aus Secret erzeugen
|
||||||
|
env:
|
||||||
|
DOTENV: ${{ secrets.DOTENV }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$DOTENV" ]; then
|
||||||
|
echo "Secret DOTENV ist leer oder nicht gesetzt."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
umask 077
|
||||||
|
echo "$DOTENV" > .env
|
||||||
|
|
||||||
|
- name: Deployen
|
||||||
|
run: docker compose up -d --build --remove-orphans --wait --wait-timeout 300
|
||||||
|
|
||||||
|
- name: Alte Layer aufraeumen
|
||||||
|
run: docker image prune -f
|
||||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.venv/
|
||||||
|
data/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache/
|
||||||
|
.env
|
||||||
|
.claude/
|
||||||
125
CLAUDE.md
Normal file
125
CLAUDE.md
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
Behavioral guidelines to reduce common LLM coding mistakes. Merge with project-specific instructions as needed.
|
||||||
|
|
||||||
|
**Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
|
||||||
|
|
||||||
|
## 1. Think Before Coding
|
||||||
|
|
||||||
|
**Don't assume. Don't hide confusion. Surface tradeoffs.**
|
||||||
|
|
||||||
|
Before implementing:
|
||||||
|
- State your assumptions explicitly. If uncertain, ask.
|
||||||
|
- If multiple interpretations exist, present them - don't pick silently.
|
||||||
|
- If a simpler approach exists, say so. Push back when warranted.
|
||||||
|
- If something is unclear, stop. Name what's confusing. Ask.
|
||||||
|
|
||||||
|
## 2. Simplicity First
|
||||||
|
|
||||||
|
**Minimum code that solves the problem. Nothing speculative.**
|
||||||
|
|
||||||
|
- No features beyond what was asked.
|
||||||
|
- No abstractions for single-use code.
|
||||||
|
- No "flexibility" or "configurability" that wasn't requested.
|
||||||
|
- No error handling for impossible scenarios.
|
||||||
|
- If you write 200 lines and it could be 50, rewrite it.
|
||||||
|
|
||||||
|
Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
|
||||||
|
|
||||||
|
## 3. Surgical Changes
|
||||||
|
|
||||||
|
**Touch only what you must. Clean up only your own mess.**
|
||||||
|
|
||||||
|
When editing existing code:
|
||||||
|
- Don't "improve" adjacent code, comments, or formatting.
|
||||||
|
- Don't refactor things that aren't broken.
|
||||||
|
- Match existing style, even if you'd do it differently.
|
||||||
|
- If you notice unrelated dead code, mention it - don't delete it.
|
||||||
|
|
||||||
|
When your changes create orphans:
|
||||||
|
- Remove imports/variables/functions that YOUR changes made unused.
|
||||||
|
- Don't remove pre-existing dead code unless asked.
|
||||||
|
|
||||||
|
The test: Every changed line should trace directly to the user's request.
|
||||||
|
|
||||||
|
## 4. Goal-Driven Execution
|
||||||
|
|
||||||
|
**Define success criteria. Loop until verified.**
|
||||||
|
|
||||||
|
Transform tasks into verifiable goals:
|
||||||
|
- "Add validation" → "Write tests for invalid inputs, then make them pass"
|
||||||
|
- "Fix the bug" → "Write a test that reproduces it, then make it pass"
|
||||||
|
- "Refactor X" → "Ensure tests pass before and after"
|
||||||
|
|
||||||
|
For multi-step tasks, state a brief plan:
|
||||||
|
```
|
||||||
|
1. [Step] → verify: [check]
|
||||||
|
2. [Step] → verify: [check]
|
||||||
|
3. [Step] → verify: [check]
|
||||||
|
```
|
||||||
|
|
||||||
|
Strong success criteria let you loop independently. Weak criteria ("make it work") require constant clarification.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Projekthinweise
|
||||||
|
|
||||||
|
## Sprache
|
||||||
|
|
||||||
|
Code-Kommentare, Docstrings, Oberflaeche und Commit-Nachrichten auf Deutsch.
|
||||||
|
Bezeichner im Code auf Englisch.
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Diese App wird per Tag-Push deployt, nicht manuell hochgeladen. Die
|
||||||
|
verbindlichen Konventionen stehen in **DEPLOY.md**. Vor Aenderungen an
|
||||||
|
`Dockerfile`, `docker-compose.yml` oder `.gitea/workflows/` dort nachsehen.
|
||||||
|
|
||||||
|
Kurzfassung der Regeln, die man leicht bricht:
|
||||||
|
|
||||||
|
- Kein `ports:` im Compose. Der Proxy erreicht den Container ueber das Netz
|
||||||
|
`nginx-proxy-manager_default` unter seinem `container_name`.
|
||||||
|
- `name:` im Compose ist Pflicht (fester Projektname), sonst legt der CI-Job
|
||||||
|
einen zweiten Stack an.
|
||||||
|
- Keine relativen Bind-Mounts. Compose laeuft im Job-Container, der Pfad zeigt
|
||||||
|
auf dem Host ins Leere. Persistente Daten in named volumes mit festem `name:`.
|
||||||
|
- Healthcheck ist Pflicht, der Deploy nutzt ihn als Gate. Hier zeigt er auf
|
||||||
|
`/healthz`. Abweichung von der Vorlage: `python -c` statt `node -e`, weil das
|
||||||
|
Image kein Node enthaelt.
|
||||||
|
- Secrets kommen aus dem Repo-Secret `DOTENV`, nie in die Compose-Datei.
|
||||||
|
- Tests laufen im Workflow **vor** dem Deploy-Schritt.
|
||||||
|
|
||||||
|
## Entscheidungen, die nicht aus dem Code hervorgehen
|
||||||
|
|
||||||
|
**Kein separater OCR-Schritt.** Das Kartenbild geht direkt an das
|
||||||
|
Vision-Modell. Tesseract vorzuschalten wuerde Layout und Schriftgroessen
|
||||||
|
wegwerfen - genau die Information, aus der die Feldzuordnung entsteht.
|
||||||
|
|
||||||
|
**OpenCV macht die Geometrie, das Modell den Inhalt.** Bounding Boxes vom
|
||||||
|
Modell zu erfragen waere naheliegend, ihre Koordinaten sind aber zu ungenau
|
||||||
|
fuer saubere Zuschnitte. Umgekehrt kann OpenCV nichts lesen.
|
||||||
|
|
||||||
|
**Das ganze Stapelfoto in einem Aufruf funktioniert nicht.** Die API skaliert
|
||||||
|
grosse Bilder herunter; jede Karte landet dann bei ein paar hundert Pixeln
|
||||||
|
Breite, und Telefonnummern in 7-Punkt-Schrift sind darin nicht mehr lesbar.
|
||||||
|
Deshalb ein Aufruf je Zuschnitt.
|
||||||
|
|
||||||
|
**EXIF schlaegt Browserstandort.** Der Zeitpunkt und Ort des Fotos sind
|
||||||
|
verlaesslicher als die des Uploads.
|
||||||
|
|
||||||
|
**Serverseitige Speicherung statt Offline-First.** Es gibt ohnehin einen
|
||||||
|
Server (fuer den API-Schluessel), damit ist SQLite plus Bilder auf der Platte
|
||||||
|
einfacher als IndexedDB mit Synchronisation.
|
||||||
|
|
||||||
|
**Die 180-Grad-Lage einer Karte ist geometrisch nicht bestimmbar.** Bleibt die
|
||||||
|
Extraktion leer, wird einmal gedreht nachgefasst.
|
||||||
|
|
||||||
|
## Modellwahl
|
||||||
|
|
||||||
|
Standard ist `claude-opus-5`, ueberschreibbar per `ANTHROPIC_MODEL`. Die
|
||||||
|
Extraktion laeuft mit `output_config.effort: "low"` - reines Ablesen, keine
|
||||||
|
Denkarbeit - und mit strukturiertem JSON-Schema statt freiem Text.
|
||||||
185
DEPLOY.md
Normal file
185
DEPLOY.md
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
# Deployment-Konventionen
|
||||||
|
|
||||||
|
Verbindlich fuer jede App, die auf dem VPS per Tag-Push deployt wird.
|
||||||
|
Abweichungen brauchen einen Grund, der hier nicht steht.
|
||||||
|
|
||||||
|
## Ablauf
|
||||||
|
|
||||||
|
```
|
||||||
|
git tag v1.2.3 && git push --tags
|
||||||
|
-> Gitea Actions (act_runner auf dem VPS)
|
||||||
|
-> npm ci / build / test <- rot bricht ab, nichts wird deployt
|
||||||
|
-> .env aus Repo-Secret DOTENV
|
||||||
|
-> docker compose up -d --build --wait
|
||||||
|
```
|
||||||
|
|
||||||
|
Kein SCP, kein manuelles Neubauen. Der Code landet nie im Dateisystem des
|
||||||
|
Hosts - der Job-Container klont, baut gegen den Docker-Daemon des Hosts und
|
||||||
|
startet den Container.
|
||||||
|
|
||||||
|
## Infrastruktur (existiert bereits, nicht neu anlegen)
|
||||||
|
|
||||||
|
| Ding | Wert |
|
||||||
|
|---|---|
|
||||||
|
| Git | Gitea, `https://gitea.lucas-orth.de`, Container `gitea`, intern `:3000` |
|
||||||
|
| Runner | `act_runner`, Container `gitea-runner`, Label `ubuntu-latest` |
|
||||||
|
| Netz CI | `gitea-ci` - Gitea + Runner + Job-Container |
|
||||||
|
| Netz Web | `nginx-proxy-manager_default` - Proxy + alle App-Container |
|
||||||
|
| Job-Image | `catthehacker/ubuntu:act-latest` (node + docker-cli + compose) |
|
||||||
|
|
||||||
|
Die beiden Netze sind bewusst getrennt: der Runner spricht mit Gitea, nicht
|
||||||
|
mit dem Proxy. App-Container haengen im Web-Netz, nicht im CI-Netz.
|
||||||
|
|
||||||
|
## docker-compose.yml
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# Fester Projektname. Ohne das leitet Compose ihn aus dem Verzeichnisnamen
|
||||||
|
# ab - und der ist im CI-Job ein anderer als auf dem Host.
|
||||||
|
name: business-card-scanner
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
# Versionstag ermoeglicht Rollback ohne Rebuild.
|
||||||
|
image: business-card-scanner:${IMAGE_TAG:-latest}
|
||||||
|
build: .
|
||||||
|
# Fester Name: das ist die Adresse, auf die der Proxy zeigt.
|
||||||
|
container_name: business-card-scanner
|
||||||
|
restart: unless-stopped
|
||||||
|
# Wird im Workflow aus dem Secret DOTENV erzeugt.
|
||||||
|
env_file: .env
|
||||||
|
networks:
|
||||||
|
- nginx-proxy-manager_default
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:8080/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
|
||||||
|
interval: 60s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
networks:
|
||||||
|
nginx-proxy-manager_default:
|
||||||
|
external: true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
business-card-scanner-data:
|
||||||
|
# Fester Name, damit das Volume nicht am Projektnamen haengt.
|
||||||
|
name: business-card-scanner-data
|
||||||
|
```
|
||||||
|
|
||||||
|
**Kein `ports:`.** Der Proxy erreicht den Container ueber das gemeinsame Netz
|
||||||
|
unter seinem Container-Namen. Ein Port-Mapping wuerde den Dienst zusaetzlich
|
||||||
|
offen ans Internet haengen.
|
||||||
|
|
||||||
|
**Healthcheck ist Pflicht.** Der Deploy-Schritt nutzt `--wait` und macht ihn
|
||||||
|
zum Gate. Ohne Healthcheck wird der Lauf gruen, auch wenn der Container in
|
||||||
|
einer Crash-Schleife haengt.
|
||||||
|
|
||||||
|
**Keine relativen Bind-Mounts** (`./data:/data`). Compose laeuft im
|
||||||
|
Job-Container, der Pfad wuerde auf dem Host ins Leere zeigen. Persistente
|
||||||
|
Daten gehoeren in ein named volume mit festem `name:`.
|
||||||
|
|
||||||
|
## .gitea/workflows/deploy.yml
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: Build & Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
# Intern klonen. Die oeffentliche Domain ist aus Containern heraus
|
||||||
|
# NICHT erreichbar: Gitea laeuft auf demselben Host, das Paket geht
|
||||||
|
# an die oeffentliche IP und findet nicht zurueck (NAT-Hairpin).
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
github-server-url: http://gitea:3000
|
||||||
|
|
||||||
|
# --- Build & Tests, projektspezifisch ---
|
||||||
|
|
||||||
|
- name: Tag ermitteln
|
||||||
|
run: echo "IMAGE_TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: .env aus Secret erzeugen
|
||||||
|
env:
|
||||||
|
DOTENV: ${{ secrets.DOTENV }}
|
||||||
|
run: |
|
||||||
|
if [ -z "$DOTENV" ]; then
|
||||||
|
echo "Secret DOTENV ist leer oder nicht gesetzt."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
umask 077
|
||||||
|
echo "$DOTENV" > .env
|
||||||
|
|
||||||
|
- name: Deployen
|
||||||
|
run: docker compose up -d --build --remove-orphans --wait --wait-timeout 180
|
||||||
|
|
||||||
|
- name: Alte Layer aufraeumen
|
||||||
|
run: docker image prune -f
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests gehoeren **vor** den Deploy-Schritt. Ein roter Lauf darf den Server
|
||||||
|
nicht anfassen.
|
||||||
|
|
||||||
|
## Dockerfile
|
||||||
|
|
||||||
|
- Multi-Stage: Build-Abhaengigkeiten landen nicht im finalen Image.
|
||||||
|
- `USER node` bzw. non-root im finalen Stage.
|
||||||
|
- `EXPOSE 8080` dokumentiert den internen Port (oeffnet nichts).
|
||||||
|
- Ein Health-Endpoint (`/healthz`), auf den der Healthcheck zeigt.
|
||||||
|
|
||||||
|
## Secrets
|
||||||
|
|
||||||
|
Ein einziges Repo-Secret **`DOTENV`** mit dem kompletten `.env`-Inhalt,
|
||||||
|
mehrzeilig. Anlegen unter Repo -> Einstellungen -> Actions -> Secrets.
|
||||||
|
|
||||||
|
Gitea speichert Secrets write-only: nach dem Anlegen nicht mehr lesbar, nur
|
||||||
|
ersetzbar. Die kanonische Fassung gehoert deshalb in den Passwortmanager,
|
||||||
|
Gitea haelt nur die Arbeitskopie fuer die Pipeline.
|
||||||
|
|
||||||
|
Der Wert wird ueber `env:` an die Shell gereicht, nie direkt interpoliert -
|
||||||
|
sonst zerlegt der Shell-Parser Werte mit `$`, Backticks oder Quotes.
|
||||||
|
|
||||||
|
## Nginx Proxy Manager
|
||||||
|
|
||||||
|
Neuer Proxy Host:
|
||||||
|
|
||||||
|
| Feld | Wert |
|
||||||
|
|---|---|
|
||||||
|
| Domain Names | `business-card-scanner.lucas-orth.de` |
|
||||||
|
| Scheme | `http` |
|
||||||
|
| Forward Hostname / IP | `business-card-scanner` (= `container_name`) |
|
||||||
|
| Forward Port | `8080` |
|
||||||
|
|
||||||
|
Danach Let's-Encrypt-Zertifikat ausstellen und "Force SSL" aktivieren.
|
||||||
|
|
||||||
|
Weil der Container-Name die Adresse ist: aendert sich `container_name`,
|
||||||
|
zeigt der Proxy-Host ins Leere.
|
||||||
|
|
||||||
|
## Bestehende App umstellen
|
||||||
|
|
||||||
|
Fuer dieses Projekt nicht relevant - es lief nie manuell auf dem Server.
|
||||||
|
|
||||||
|
## Erstes Deployment einer App
|
||||||
|
|
||||||
|
1. Repo in Gitea anlegen, pushen.
|
||||||
|
2. Secret `DOTENV` setzen.
|
||||||
|
3. Pruefen, ob schon ein Container mit dem Namen laeuft
|
||||||
|
(`docker ps -a --filter name=business-card-scanner`). Falls ja: im alten Verzeichnis
|
||||||
|
einmal `docker compose down`, sonst kollidiert der feste Container-Name.
|
||||||
|
4. Proxy Host im NPM anlegen.
|
||||||
|
5. `git tag v1.0.0 && git push origin v1.0.0`
|
||||||
|
|
||||||
|
## Fallstricke
|
||||||
|
|
||||||
|
| Symptom | Ursache |
|
||||||
|
|---|---|
|
||||||
|
| Job haengt beim Checkout | `github-server-url` fehlt, klont ueber die oeffentliche Domain |
|
||||||
|
| `network not found` | App-Container im CI-Netz statt im Web-Netz (oder umgekehrt) |
|
||||||
|
| Zweiter Container statt Update | `name:` im Compose fehlt, Projektname weicht ab |
|
||||||
|
| Lauf gruen, App tot | `--wait` fehlt oder kein Healthcheck definiert |
|
||||||
|
| Volume leer nach Umstellung | Projektname geaendert, altes Volume hiess anders |
|
||||||
|
| Proxy liefert 502 | `container_name` geaendert oder Container nicht im Web-Netz |
|
||||||
30
Dockerfile
Normal file
30
Dockerfile
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Abhaengigkeiten in ein venv bauen, damit der Compiler-Ballast nicht ins
|
||||||
|
# fertige Image wandert.
|
||||||
|
FROM python:3.12-slim AS build
|
||||||
|
RUN python -m venv /opt/venv
|
||||||
|
ENV PATH="/opt/venv/bin:$PATH"
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
FROM python:3.12-slim
|
||||||
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
|
PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PATH="/opt/venv/bin:$PATH" \
|
||||||
|
DATA_DIR=/data \
|
||||||
|
PORT=8080
|
||||||
|
|
||||||
|
# libglib2.0-0 wird von opencv-python-headless gebraucht.
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends libglib2.0-0 \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
|
&& useradd --uid 10001 --create-home app \
|
||||||
|
&& mkdir -p /data && chown app:app /data
|
||||||
|
|
||||||
|
COPY --from=build /opt/venv /opt/venv
|
||||||
|
WORKDIR /app
|
||||||
|
COPY app ./app
|
||||||
|
COPY static ./static
|
||||||
|
|
||||||
|
USER app
|
||||||
|
EXPOSE 8080
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8080"]
|
||||||
105
README.md
Normal file
105
README.md
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
# Business Card Scanner
|
||||||
|
|
||||||
|
Visitenkarten abfotografieren, automatisch freistellen, auslesen und als
|
||||||
|
Kontakt aufs iPhone holen. Selbst gehostete PWA, ein Container.
|
||||||
|
|
||||||
|
## Was die App macht
|
||||||
|
|
||||||
|
**Stapelscan.** Ein Foto von zwanzig Karten auf dem Tisch reicht. OpenCV findet
|
||||||
|
die Kartenrechtecke, entzerrt sie perspektivisch und schneidet sie einzeln aus –
|
||||||
|
ohne manuelle Nacharbeit. Jeder Zuschnitt geht als Bild an das Vision-Modell,
|
||||||
|
das Name, Firma, Position und Kontaktdaten als strukturiertes JSON zurückgibt.
|
||||||
|
Ein vorgeschalteter OCR-Schritt wäre kontraproduktiv: er würde Layout und
|
||||||
|
Schriftgrößen wegwerfen, also genau die Information, aus der die Feldzuordnung
|
||||||
|
entsteht.
|
||||||
|
|
||||||
|
**Metadaten.** Aufnahmezeit und GPS kommen aus den EXIF-Daten des Fotos, nicht
|
||||||
|
aus dem Browser – Karten werden oft erst abends am Schreibtisch abfotografiert,
|
||||||
|
der Browserstandort wäre dann das Wohnzimmer statt der Messe. Die Koordinaten
|
||||||
|
löst Nominatim in einen Ortsnamen auf. Fehlen EXIF-Daten, greift der
|
||||||
|
Browserstandort als Rückfallebene.
|
||||||
|
|
||||||
|
**Übersicht.** Alle Karten mit Volltextsuche über Name, Firma, Ort und Notiz.
|
||||||
|
Filter für „nicht exportiert“, „unvollständig“ und „mit Erinnerung“.
|
||||||
|
|
||||||
|
**Export.** Ein Tippen auf *In Kontakte speichern* liefert eine vCard 3.0, die
|
||||||
|
iOS direkt in die Kontakte-App übernimmt. Die Karte gilt danach als exportiert.
|
||||||
|
|
||||||
|
**Notizen und Erinnerungen.** Freitext pro Karte. Erinnerungen mit Termin gehen
|
||||||
|
als Mail raus; verpasste Termine holt der Scheduler beim nächsten Start nach.
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
Python 3.12, FastAPI, SQLite, OpenCV, Anthropic SDK. Frontend ohne Build-Schritt:
|
||||||
|
statisches HTML, CSS und JavaScript, als PWA installierbar.
|
||||||
|
|
||||||
|
## Lokal starten
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m venv .venv && .venv/bin/pip install -r requirements-dev.txt
|
||||||
|
cp .env.example .env # APP_PASSWORD leer lassen schaltet die Anmeldung ab
|
||||||
|
DATA_DIR=./data .venv/bin/uvicorn app.main:app --reload --port 8099
|
||||||
|
```
|
||||||
|
|
||||||
|
Ohne `ANTHROPIC_API_KEY` läuft alles außer der Extraktion: Karten werden
|
||||||
|
freigestellt und gespeichert, die Felder bleiben leer.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/pytest -q
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Tag-Push nach den Konventionen in [DEPLOY.md](DEPLOY.md). Vor dem ersten Tag:
|
||||||
|
|
||||||
|
1. **Secret `DOTENV`** anlegen (Repo → Einstellungen → Actions → Secrets), Inhalt
|
||||||
|
nach dem Muster in [.env.example](.env.example). `SECRET_KEY` mit
|
||||||
|
`openssl rand -hex 32` erzeugen.
|
||||||
|
2. **Proxy Host** im Nginx Proxy Manager:
|
||||||
|
`business-card-scanner.lucas-orth.de` → `business-card-scanner` : `8080`,
|
||||||
|
danach Let's-Encrypt-Zertifikat und „Force SSL“.
|
||||||
|
3. **Deployen:** `git tag v1.0.0 && git push origin v1.0.0`
|
||||||
|
|
||||||
|
### Zugang
|
||||||
|
|
||||||
|
Die App hängt hinter dem öffentlichen Proxy, nicht im Tailnet. Sie enthält
|
||||||
|
personenbezogene Daten Dritter, deshalb ist `APP_PASSWORD` auf dieser Domain
|
||||||
|
Pflicht – ohne Passwort ist sie für jeden erreichbar, der die Adresse kennt.
|
||||||
|
Wer zusätzlich absichern will, trägt im NPM unter *Access List* eine
|
||||||
|
IP-Beschränkung auf den Tailnet-Bereich ein.
|
||||||
|
|
||||||
|
### Kosten
|
||||||
|
|
||||||
|
Ein Kartenzuschnitt kostet ungefähr zwei Cent bei `claude-opus-5` (Standard).
|
||||||
|
Ein Stapel von zwanzig Karten liegt damit bei etwa 40 Cent.
|
||||||
|
`ANTHROPIC_MODEL=claude-haiku-4-5` in der `.env` drückt das auf einen Bruchteil,
|
||||||
|
zulasten der Trefferquote bei kleiner Schrift und unruhigen Layouts.
|
||||||
|
|
||||||
|
## Aufnahmebedingungen für den Stapelscan
|
||||||
|
|
||||||
|
Die Freistellung ist reine Geometrie und braucht Kontrast zum Untergrund:
|
||||||
|
|
||||||
|
- dunkler, matter, einfarbiger Untergrund
|
||||||
|
- Karten berühren sich nicht
|
||||||
|
- Kamera möglichst parallel zur Tischplatte
|
||||||
|
- volle Auflösung – deshalb die native Kamera, nicht der Livestream
|
||||||
|
|
||||||
|
Findet die Pipeline kein Kartenrechteck, behandelt sie das ganze Foto als eine
|
||||||
|
Karte und sagt das in der Oberfläche. Einzelne Fehlgriffe lassen sich pro Karte
|
||||||
|
über *Bearbeiten* korrigieren.
|
||||||
|
|
||||||
|
## Verzeichnisse
|
||||||
|
|
||||||
|
```
|
||||||
|
app/ FastAPI-Anwendung
|
||||||
|
segment.py OpenCV-Pipeline: Karten finden und entzerren
|
||||||
|
extract.py Vision-Modell, strukturiertes JSON
|
||||||
|
imaging.py EXIF-Auswertung, Bildkonvertierung
|
||||||
|
vcard.py vCard 3.0
|
||||||
|
reminders.py Scheduler für die Erinnerungsmails
|
||||||
|
static/ PWA: HTML, CSS, JavaScript, Service Worker
|
||||||
|
tests/ pytest, inklusive synthetischer Stapelfotos
|
||||||
|
```
|
||||||
|
|
||||||
|
Persistente Daten liegen im Volume `business-card-scanner-data` unter `/data`:
|
||||||
|
`cards.db` und die Kartenbilder in `images/`.
|
||||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
55
app/config.py
Normal file
55
app/config.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"""Konfiguration. Alle Werte kommen aus der Umgebung (im Betrieb aus .env)."""
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _s(name: str, default: str = "") -> str:
|
||||||
|
return os.environ.get(name, default).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _i(name: str, default: int) -> int:
|
||||||
|
try:
|
||||||
|
return int(_s(name) or default)
|
||||||
|
except ValueError:
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
def _b(name: str, default: bool) -> bool:
|
||||||
|
v = _s(name).lower()
|
||||||
|
if not v:
|
||||||
|
return default
|
||||||
|
return v in ("1", "true", "yes", "on")
|
||||||
|
|
||||||
|
|
||||||
|
DATA_DIR = Path(_s("DATA_DIR", "./data"))
|
||||||
|
IMAGE_DIR = DATA_DIR / "images"
|
||||||
|
DB_PATH = DATA_DIR / "cards.db"
|
||||||
|
|
||||||
|
PORT = _i("PORT", 8080)
|
||||||
|
|
||||||
|
# Leeres Passwort schaltet die Anmeldung ab - nur fuer die lokale Entwicklung.
|
||||||
|
APP_PASSWORD = _s("APP_PASSWORD")
|
||||||
|
SECRET_KEY = _s("SECRET_KEY", "dev-only-insecure-secret")
|
||||||
|
SESSION_DAYS = _i("SESSION_DAYS", 30)
|
||||||
|
|
||||||
|
ANTHROPIC_API_KEY = _s("ANTHROPIC_API_KEY")
|
||||||
|
ANTHROPIC_MODEL = _s("ANTHROPIC_MODEL", "claude-opus-5")
|
||||||
|
EXTRACT_CONCURRENCY = _i("EXTRACT_CONCURRENCY", 5)
|
||||||
|
|
||||||
|
SMTP_HOST = _s("SMTP_HOST")
|
||||||
|
SMTP_PORT = _i("SMTP_PORT", 587)
|
||||||
|
SMTP_USER = _s("SMTP_USER")
|
||||||
|
SMTP_PASSWORD = _s("SMTP_PASSWORD")
|
||||||
|
SMTP_FROM = _s("SMTP_FROM") or SMTP_USER
|
||||||
|
SMTP_STARTTLS = _b("SMTP_STARTTLS", True)
|
||||||
|
REMINDER_TO = _s("REMINDER_TO")
|
||||||
|
|
||||||
|
REVERSE_GEOCODE = _b("REVERSE_GEOCODE", True)
|
||||||
|
NOMINATIM_URL = _s("NOMINATIM_URL", "https://nominatim.openstreetmap.org/reverse")
|
||||||
|
USER_AGENT = _s("USER_AGENT", "business-card-scanner/1.0 (self-hosted)")
|
||||||
|
|
||||||
|
PUBLIC_BASE_URL = _s("PUBLIC_BASE_URL").rstrip("/")
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_dirs() -> None:
|
||||||
|
IMAGE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
100
app/db.py
Normal file
100
app/db.py
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
"""SQLite-Zugriff. Eine Verbindung, Schreibzugriffe unter einem Lock."""
|
||||||
|
import sqlite3
|
||||||
|
import threading
|
||||||
|
from typing import Any, Iterable
|
||||||
|
|
||||||
|
from . import config
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_conn: sqlite3.Connection | None = None
|
||||||
|
|
||||||
|
SCHEMA = """
|
||||||
|
CREATE TABLE IF NOT EXISTS cards (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
captured_at TEXT,
|
||||||
|
batch_id TEXT,
|
||||||
|
image_file TEXT,
|
||||||
|
source TEXT NOT NULL DEFAULT 'batch',
|
||||||
|
full_name TEXT,
|
||||||
|
first_name TEXT,
|
||||||
|
last_name TEXT,
|
||||||
|
job_title TEXT,
|
||||||
|
company TEXT,
|
||||||
|
department TEXT,
|
||||||
|
email TEXT,
|
||||||
|
phone TEXT,
|
||||||
|
mobile TEXT,
|
||||||
|
fax TEXT,
|
||||||
|
website TEXT,
|
||||||
|
street TEXT,
|
||||||
|
postal_code TEXT,
|
||||||
|
city TEXT,
|
||||||
|
country TEXT,
|
||||||
|
notes TEXT NOT NULL DEFAULT '',
|
||||||
|
lat REAL,
|
||||||
|
lon REAL,
|
||||||
|
place TEXT,
|
||||||
|
geo_source TEXT,
|
||||||
|
extract_status TEXT NOT NULL DEFAULT 'ok',
|
||||||
|
extract_error TEXT,
|
||||||
|
exported_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS reminders (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
card_id TEXT NOT NULL REFERENCES cards(id) ON DELETE CASCADE,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
due_at TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
sent_at TEXT,
|
||||||
|
send_error TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cards_created ON cards(created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reminders_due ON reminders(due_at) WHERE sent_at IS NULL;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS geocache (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
place TEXT,
|
||||||
|
fetched_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def connect() -> sqlite3.Connection:
|
||||||
|
global _conn
|
||||||
|
if _conn is None:
|
||||||
|
config.ensure_dirs()
|
||||||
|
_conn = sqlite3.connect(config.DB_PATH, check_same_thread=False)
|
||||||
|
_conn.row_factory = sqlite3.Row
|
||||||
|
_conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
_conn.execute("PRAGMA foreign_keys=ON")
|
||||||
|
_conn.executescript(SCHEMA)
|
||||||
|
_conn.commit()
|
||||||
|
return _conn
|
||||||
|
|
||||||
|
|
||||||
|
def query(sql: str, params: Iterable[Any] = ()) -> list[sqlite3.Row]:
|
||||||
|
with _lock:
|
||||||
|
return connect().execute(sql, tuple(params)).fetchall()
|
||||||
|
|
||||||
|
|
||||||
|
def query_one(sql: str, params: Iterable[Any] = ()) -> sqlite3.Row | None:
|
||||||
|
rows = query(sql, params)
|
||||||
|
return rows[0] if rows else None
|
||||||
|
|
||||||
|
|
||||||
|
def execute(sql: str, params: Iterable[Any] = ()) -> None:
|
||||||
|
with _lock:
|
||||||
|
conn = connect()
|
||||||
|
conn.execute(sql, tuple(params))
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def execute_many(statements: list[tuple[str, Iterable[Any]]]) -> None:
|
||||||
|
with _lock:
|
||||||
|
conn = connect()
|
||||||
|
for sql, params in statements:
|
||||||
|
conn.execute(sql, tuple(params))
|
||||||
|
conn.commit()
|
||||||
159
app/extract.py
Normal file
159
app/extract.py
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
"""Kontaktdaten aus einem Kartenbild lesen.
|
||||||
|
|
||||||
|
Das Bild geht direkt an das Vision-Modell: Layout, Schriftgroesse und
|
||||||
|
Gruppierung sind genau die Information, die die Feldzuordnung moeglich macht
|
||||||
|
- ein vorgeschalteter OCR-Schritt wuerde sie wegwerfen.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import anthropic
|
||||||
|
|
||||||
|
from . import config
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
FIELDS = [
|
||||||
|
"full_name", "first_name", "last_name", "job_title", "company", "department",
|
||||||
|
"email", "phone", "mobile", "fax", "website",
|
||||||
|
"street", "postal_code", "city", "country",
|
||||||
|
]
|
||||||
|
|
||||||
|
SCHEMA = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"is_business_card": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "False, wenn der Ausschnitt keine Visitenkarte zeigt.",
|
||||||
|
},
|
||||||
|
**{name: {"type": ["string", "null"]} for name in FIELDS},
|
||||||
|
},
|
||||||
|
"required": ["is_business_card", *FIELDS],
|
||||||
|
"additionalProperties": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
SYSTEM = """Du liest Visitenkarten aus und gibst die Kontaktdaten strukturiert zurueck.
|
||||||
|
|
||||||
|
Regeln:
|
||||||
|
- Uebernimm nur, was auf der Karte steht. Nichts ergaenzen, nichts raten.
|
||||||
|
- Fehlt ein Feld auf der Karte, gib null zurueck.
|
||||||
|
- Telefonnummern im internationalen Format, wenn die Vorwahl erkennbar ist
|
||||||
|
(z. B. +49 511 123456), sonst unveraendert.
|
||||||
|
- phone ist die Festnetznummer, mobile die Mobilnummer. Ist nur eine Nummer
|
||||||
|
ohne Kennzeichnung angegeben, gehoert sie in phone.
|
||||||
|
- company ist der Firmenname, job_title die Funktion der Person.
|
||||||
|
- full_name ist der vollstaendige Name inklusive Titeln wie "Dr.",
|
||||||
|
first_name und last_name ohne Titel.
|
||||||
|
- website ohne Protokollpraefix.
|
||||||
|
- Zeigt der Ausschnitt keine Visitenkarte (Tischkante, Notizzettel, leere
|
||||||
|
Flaeche), setze is_business_card auf false und alle Felder auf null."""
|
||||||
|
|
||||||
|
PROMPT = "Lies diese Visitenkarte aus."
|
||||||
|
|
||||||
|
_client = None
|
||||||
|
|
||||||
|
|
||||||
|
def available() -> bool:
|
||||||
|
return bool(config.ANTHROPIC_API_KEY)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_client() -> anthropic.AsyncAnthropic:
|
||||||
|
global _client
|
||||||
|
if _client is None:
|
||||||
|
_client = anthropic.AsyncAnthropic(api_key=config.ANTHROPIC_API_KEY)
|
||||||
|
return _client
|
||||||
|
|
||||||
|
|
||||||
|
def _empty(reason: str) -> dict:
|
||||||
|
return {"is_business_card": False, "_error": reason, **{f: None for f in FIELDS}}
|
||||||
|
|
||||||
|
|
||||||
|
def _has_content(result: dict) -> bool:
|
||||||
|
return any(result.get(field) for field in FIELDS)
|
||||||
|
|
||||||
|
|
||||||
|
async def _ask(image_jpeg: bytes) -> dict:
|
||||||
|
client = _get_client()
|
||||||
|
messages = [
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": [
|
||||||
|
{
|
||||||
|
"type": "image",
|
||||||
|
"source": {
|
||||||
|
"type": "base64",
|
||||||
|
"media_type": "image/jpeg",
|
||||||
|
"data": base64.standard_b64encode(image_jpeg).decode(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{"type": "text", "text": PROMPT},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
]
|
||||||
|
common = dict(
|
||||||
|
model=config.ANTHROPIC_MODEL,
|
||||||
|
max_tokens=2000,
|
||||||
|
system=SYSTEM,
|
||||||
|
messages=messages,
|
||||||
|
output_config={
|
||||||
|
"format": {"type": "json_schema", "schema": SCHEMA},
|
||||||
|
"effort": "low", # reines Ablesen, keine Denkarbeit
|
||||||
|
},
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
response = await client.beta.messages.create(
|
||||||
|
betas=["server-side-fallback-2026-07-01"],
|
||||||
|
fallbacks="default",
|
||||||
|
**common,
|
||||||
|
)
|
||||||
|
except anthropic.BadRequestError:
|
||||||
|
# Der Fallback-Parameter haengt am Modell. Ohne ihn laeuft die Anfrage
|
||||||
|
# weiter, sie bricht bei einer Ablehnung nur ohne Ersatzmodell ab.
|
||||||
|
log.warning("Server-side fallback abgelehnt, Anfrage ohne Fallback")
|
||||||
|
response = await client.messages.create(**common)
|
||||||
|
|
||||||
|
if response.stop_reason == "refusal":
|
||||||
|
return _empty("Vom Modell abgelehnt")
|
||||||
|
|
||||||
|
text = next((b.text for b in response.content if b.type == "text"), None)
|
||||||
|
if not text:
|
||||||
|
return _empty("Leere Antwort")
|
||||||
|
return json.loads(text)
|
||||||
|
|
||||||
|
|
||||||
|
async def extract(image_jpeg: bytes, rotated_jpeg: bytes = None) -> dict:
|
||||||
|
"""Ein Kartenbild auslesen.
|
||||||
|
|
||||||
|
Bleibt das Ergebnis leer, wird einmal um 180 Grad gedreht nachgefasst:
|
||||||
|
wie herum die Karte auf dem Tisch lag, ist geometrisch nicht bestimmbar.
|
||||||
|
"""
|
||||||
|
if not available():
|
||||||
|
return _empty("Kein ANTHROPIC_API_KEY gesetzt")
|
||||||
|
try:
|
||||||
|
result = await _ask(image_jpeg)
|
||||||
|
except Exception as exc: # Netzfehler, Rate Limit, ungueltiges JSON
|
||||||
|
log.exception("Extraktion fehlgeschlagen")
|
||||||
|
return _empty(f"{type(exc).__name__}: {exc}")
|
||||||
|
|
||||||
|
if not _has_content(result) and rotated_jpeg is not None:
|
||||||
|
try:
|
||||||
|
retry = await _ask(rotated_jpeg)
|
||||||
|
if _has_content(retry):
|
||||||
|
retry["_rotated"] = True
|
||||||
|
return retry
|
||||||
|
except Exception:
|
||||||
|
log.exception("Zweiter Versuch (gedreht) fehlgeschlagen")
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
async def extract_all(pairs: list) -> list:
|
||||||
|
"""Alle Karten eines Fotos parallel auslesen."""
|
||||||
|
limit = asyncio.Semaphore(max(1, config.EXTRACT_CONCURRENCY))
|
||||||
|
|
||||||
|
async def one(upright: bytes, rotated: bytes) -> dict:
|
||||||
|
async with limit:
|
||||||
|
return await extract(upright, rotated)
|
||||||
|
|
||||||
|
return await asyncio.gather(*(one(u, r) for u, r in pairs))
|
||||||
52
app/geocode.py
Normal file
52
app/geocode.py
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"""Reverse-Geocoding ueber Nominatim, Ergebnisse werden dauerhaft gecacht."""
|
||||||
|
import json
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from . import config, db
|
||||||
|
|
||||||
|
|
||||||
|
def _label(address: dict) -> str:
|
||||||
|
city = (
|
||||||
|
address.get("city")
|
||||||
|
or address.get("town")
|
||||||
|
or address.get("village")
|
||||||
|
or address.get("municipality")
|
||||||
|
or address.get("county")
|
||||||
|
)
|
||||||
|
detail = address.get("suburb") or address.get("neighbourhood") or address.get("road")
|
||||||
|
parts = [p for p in (city, detail) if p]
|
||||||
|
if not parts:
|
||||||
|
return address.get("state") or address.get("country") or ""
|
||||||
|
return ", ".join(parts[:2])
|
||||||
|
|
||||||
|
|
||||||
|
def reverse(lat: float | None, lon: float | None) -> str | None:
|
||||||
|
if lat is None or lon is None or not config.REVERSE_GEOCODE:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Auf ~100 m runden: benachbarte Scans teilen sich denselben Cache-Eintrag.
|
||||||
|
key = f"{lat:.3f},{lon:.3f}"
|
||||||
|
cached = db.query_one("SELECT place FROM geocache WHERE key = ?", (key,))
|
||||||
|
if cached is not None:
|
||||||
|
return cached["place"]
|
||||||
|
|
||||||
|
url = config.NOMINATIM_URL + "?" + urllib.parse.urlencode(
|
||||||
|
{"lat": lat, "lon": lon, "format": "jsonv2", "zoom": "16", "accept-language": "de"}
|
||||||
|
)
|
||||||
|
place = None
|
||||||
|
try:
|
||||||
|
request = urllib.request.Request(url, headers={"User-Agent": config.USER_AGENT})
|
||||||
|
with urllib.request.urlopen(request, timeout=6) as response:
|
||||||
|
data = json.loads(response.read().decode("utf-8"))
|
||||||
|
place = _label(data.get("address") or {}) or data.get("display_name")
|
||||||
|
except Exception:
|
||||||
|
# Ohne Ortsnamen bleiben die Koordinaten - kein Grund, den Scan abzubrechen.
|
||||||
|
return None
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"INSERT OR REPLACE INTO geocache (key, place, fetched_at) VALUES (?, ?, ?)",
|
||||||
|
(key, place, datetime.now(timezone.utc).isoformat()),
|
||||||
|
)
|
||||||
|
return place
|
||||||
91
app/imaging.py
Normal file
91
app/imaging.py
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
"""EXIF-Auswertung und Bild-Hilfsfunktionen."""
|
||||||
|
import io
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image, ImageOps
|
||||||
|
|
||||||
|
# EXIF-Tags, die uns interessieren (Nummern statt Namen: stabil ueber Pillow-Versionen).
|
||||||
|
_TAG_DATETIME_ORIGINAL = 0x9003
|
||||||
|
_TAG_OFFSET_ORIGINAL = 0x9011
|
||||||
|
_TAG_GPS_IFD = 0x8825
|
||||||
|
_GPS_LAT_REF, _GPS_LAT = 1, 2
|
||||||
|
_GPS_LON_REF, _GPS_LON = 3, 4
|
||||||
|
|
||||||
|
|
||||||
|
def _to_degrees(value) -> float | None:
|
||||||
|
try:
|
||||||
|
d, m, s = (float(x) for x in value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
return d + m / 60.0 + s / 3600.0
|
||||||
|
|
||||||
|
|
||||||
|
def read_metadata(raw: bytes) -> dict:
|
||||||
|
"""Aufnahmezeitpunkt und GPS aus den EXIF-Daten des Originalfotos.
|
||||||
|
|
||||||
|
Der Zeitpunkt des Fotos ist verlaesslicher als der des Uploads: Karten
|
||||||
|
werden oft erst Stunden spaeter am Schreibtisch abfotografiert.
|
||||||
|
"""
|
||||||
|
out: dict = {"captured_at": None, "lat": None, "lon": None}
|
||||||
|
try:
|
||||||
|
exif = Image.open(io.BytesIO(raw)).getexif()
|
||||||
|
except Exception:
|
||||||
|
return out
|
||||||
|
if not exif:
|
||||||
|
return out
|
||||||
|
|
||||||
|
taken = exif.get(_TAG_DATETIME_ORIGINAL)
|
||||||
|
if not taken:
|
||||||
|
ifd = exif.get_ifd(0x8769) # Exif-IFD
|
||||||
|
taken = ifd.get(_TAG_DATETIME_ORIGINAL) if ifd else None
|
||||||
|
offset = ifd.get(_TAG_OFFSET_ORIGINAL) if ifd else None
|
||||||
|
else:
|
||||||
|
offset = exif.get(_TAG_OFFSET_ORIGINAL)
|
||||||
|
if taken:
|
||||||
|
try:
|
||||||
|
dt = datetime.strptime(str(taken).strip(), "%Y:%m:%d %H:%M:%S")
|
||||||
|
iso = f"{dt.isoformat()}{offset}" if offset else dt.isoformat()
|
||||||
|
parsed = datetime.fromisoformat(iso)
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||||
|
out["captured_at"] = parsed.astimezone(timezone.utc).isoformat()
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
gps = exif.get_ifd(_TAG_GPS_IFD)
|
||||||
|
if gps:
|
||||||
|
lat = _to_degrees(gps.get(_GPS_LAT))
|
||||||
|
lon = _to_degrees(gps.get(_GPS_LON))
|
||||||
|
if lat is not None and lon is not None:
|
||||||
|
if str(gps.get(_GPS_LAT_REF, "N")).upper().startswith("S"):
|
||||||
|
lat = -lat
|
||||||
|
if str(gps.get(_GPS_LON_REF, "E")).upper().startswith("W"):
|
||||||
|
lon = -lon
|
||||||
|
out["lat"], out["lon"] = round(lat, 6), round(lon, 6)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def decode(raw: bytes) -> np.ndarray:
|
||||||
|
"""Bytes zu einem BGR-Array, EXIF-Drehung bereits angewandt."""
|
||||||
|
image = Image.open(io.BytesIO(raw))
|
||||||
|
image = ImageOps.exif_transpose(image).convert("RGB")
|
||||||
|
return np.asarray(image)[:, :, ::-1].copy()
|
||||||
|
|
||||||
|
|
||||||
|
def encode_jpeg(rgb_or_bgr: np.ndarray, quality: int = 88, bgr: bool = True) -> bytes:
|
||||||
|
array = rgb_or_bgr[:, :, ::-1] if bgr else rgb_or_bgr
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
Image.fromarray(array).save(buffer, format="JPEG", quality=quality, optimize=True)
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def fit_within(image: np.ndarray, max_edge: int) -> np.ndarray:
|
||||||
|
height, width = image.shape[:2]
|
||||||
|
longest = max(height, width)
|
||||||
|
if longest <= max_edge:
|
||||||
|
return image
|
||||||
|
scale = max_edge / longest
|
||||||
|
pil = Image.fromarray(image[:, :, ::-1])
|
||||||
|
pil = pil.resize((round(width * scale), round(height * scale)), Image.LANCZOS)
|
||||||
|
return np.asarray(pil)[:, :, ::-1].copy()
|
||||||
33
app/mailer.py
Normal file
33
app/mailer.py
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
"""Mailversand ueber das eigene Postfach (SMTP)."""
|
||||||
|
import logging
|
||||||
|
import smtplib
|
||||||
|
from email.message import EmailMessage
|
||||||
|
|
||||||
|
from . import config
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def configured() -> bool:
|
||||||
|
return bool(config.SMTP_HOST and config.SMTP_FROM and config.REMINDER_TO)
|
||||||
|
|
||||||
|
|
||||||
|
def send(subject: str, body: str) -> None:
|
||||||
|
"""Verschickt eine Mail. Wirft bei Fehlern - der Aufrufer entscheidet,
|
||||||
|
ob nachher erneut versucht wird."""
|
||||||
|
if not configured():
|
||||||
|
raise RuntimeError("SMTP ist nicht konfiguriert (SMTP_HOST/SMTP_FROM/REMINDER_TO)")
|
||||||
|
|
||||||
|
message = EmailMessage()
|
||||||
|
message["Subject"] = subject
|
||||||
|
message["From"] = config.SMTP_FROM
|
||||||
|
message["To"] = config.REMINDER_TO
|
||||||
|
message.set_content(body)
|
||||||
|
|
||||||
|
with smtplib.SMTP(config.SMTP_HOST, config.SMTP_PORT, timeout=20) as server:
|
||||||
|
if config.SMTP_STARTTLS:
|
||||||
|
server.starttls()
|
||||||
|
if config.SMTP_USER:
|
||||||
|
server.login(config.SMTP_USER, config.SMTP_PASSWORD)
|
||||||
|
server.send_message(message)
|
||||||
|
log.info("Mail verschickt: %s", subject)
|
||||||
394
app/main.py
Normal file
394
app/main.py
Normal file
@@ -0,0 +1,394 @@
|
|||||||
|
"""HTTP-Schnittstelle und Auslieferung der PWA."""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
from fastapi import Depends, FastAPI, Form, HTTPException, Request, Response, UploadFile
|
||||||
|
from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from . import config, db, extract, geocode, imaging, reminders, security, segment, vcard
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
|
||||||
|
log = logging.getLogger("bcs")
|
||||||
|
|
||||||
|
STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
|
||||||
|
MAX_UPLOAD_BYTES = 30 * 1024 * 1024
|
||||||
|
|
||||||
|
EDITABLE = [
|
||||||
|
"full_name", "first_name", "last_name", "job_title", "company", "department",
|
||||||
|
"email", "phone", "mobile", "fax", "website",
|
||||||
|
"street", "postal_code", "city", "country", "notes",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
config.ensure_dirs()
|
||||||
|
db.connect()
|
||||||
|
if not security.enabled():
|
||||||
|
log.warning("APP_PASSWORD ist leer - die App ist ohne Anmeldung erreichbar.")
|
||||||
|
if not extract.available():
|
||||||
|
log.warning("ANTHROPIC_API_KEY fehlt - Karten werden nur zugeschnitten, nicht ausgelesen.")
|
||||||
|
task = asyncio.create_task(reminders.run_forever())
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
task.cancel()
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(title="Business Card Scanner", lifespan=lifespan, docs_url=None, redoc_url=None)
|
||||||
|
auth = Depends(security.require_auth)
|
||||||
|
|
||||||
|
|
||||||
|
def now_iso() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def card_dict(row) -> dict:
|
||||||
|
card = dict(row)
|
||||||
|
card["image_url"] = f"/api/cards/{card['id']}/image" if card.get("image_file") else None
|
||||||
|
card["has_contact"] = any(
|
||||||
|
card.get(f) for f in ("full_name", "company", "email", "phone", "mobile")
|
||||||
|
)
|
||||||
|
return card
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- Sitzung
|
||||||
|
|
||||||
|
@app.get("/healthz")
|
||||||
|
def healthz() -> dict:
|
||||||
|
db.query_one("SELECT 1 AS ok")
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/session")
|
||||||
|
def session(request: Request) -> dict:
|
||||||
|
return {
|
||||||
|
"auth_required": security.enabled(),
|
||||||
|
"authenticated": not security.enabled()
|
||||||
|
or security.valid_token(request.cookies.get(security.COOKIE)),
|
||||||
|
"extraction_available": extract.available(),
|
||||||
|
"mail_configured": bool(config.SMTP_HOST and config.REMINDER_TO),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/login")
|
||||||
|
def login(response: Response, password: str = Form(...)) -> dict:
|
||||||
|
if not security.enabled():
|
||||||
|
return {"ok": True}
|
||||||
|
if not security.check_password(password):
|
||||||
|
raise HTTPException(status_code=401, detail="Passwort stimmt nicht")
|
||||||
|
response.set_cookie(
|
||||||
|
security.COOKIE,
|
||||||
|
security.issue_token(),
|
||||||
|
max_age=config.SESSION_DAYS * 86400,
|
||||||
|
httponly=True,
|
||||||
|
samesite="lax",
|
||||||
|
secure=True,
|
||||||
|
path="/",
|
||||||
|
)
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/logout")
|
||||||
|
def logout(response: Response) -> dict:
|
||||||
|
response.delete_cookie(security.COOKIE, path="/")
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------------------ Scan
|
||||||
|
|
||||||
|
def _process_photo(raw: bytes) -> tuple:
|
||||||
|
"""Rechenlastiger Teil: dekodieren, freistellen, als JPEG kodieren."""
|
||||||
|
meta = imaging.read_metadata(raw)
|
||||||
|
image = imaging.decode(raw)
|
||||||
|
crops, fallback = segment.segment(image)
|
||||||
|
encoded = [
|
||||||
|
(
|
||||||
|
imaging.encode_jpeg(crop),
|
||||||
|
imaging.encode_jpeg(cv2.rotate(crop, cv2.ROTATE_180)),
|
||||||
|
)
|
||||||
|
for crop in crops
|
||||||
|
]
|
||||||
|
return meta, encoded, fallback
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/scan", dependencies=[auth])
|
||||||
|
async def scan(
|
||||||
|
file: UploadFile,
|
||||||
|
lat: float = Form(None),
|
||||||
|
lon: float = Form(None),
|
||||||
|
) -> JSONResponse:
|
||||||
|
raw = await file.read()
|
||||||
|
if not raw:
|
||||||
|
raise HTTPException(status_code=400, detail="Leere Datei")
|
||||||
|
if len(raw) > MAX_UPLOAD_BYTES:
|
||||||
|
raise HTTPException(status_code=413, detail="Foto ist groesser als 30 MB")
|
||||||
|
|
||||||
|
try:
|
||||||
|
meta, encoded, fallback = await asyncio.to_thread(_process_photo, raw)
|
||||||
|
except Exception as exc:
|
||||||
|
log.exception("Foto konnte nicht verarbeitet werden")
|
||||||
|
raise HTTPException(status_code=400, detail=f"Bild nicht lesbar: {exc}") from exc
|
||||||
|
|
||||||
|
# Der Ort des Fotos schlaegt den des Uploads: Karten werden oft erst
|
||||||
|
# abends am Schreibtisch abfotografiert, nicht auf der Messe.
|
||||||
|
if meta["lat"] is not None:
|
||||||
|
geo_source = "exif"
|
||||||
|
elif lat is not None and lon is not None:
|
||||||
|
meta["lat"], meta["lon"], geo_source = lat, lon, "browser"
|
||||||
|
else:
|
||||||
|
geo_source = None
|
||||||
|
place = await asyncio.to_thread(geocode.reverse, meta["lat"], meta["lon"])
|
||||||
|
|
||||||
|
results = await extract.extract_all(encoded)
|
||||||
|
|
||||||
|
batch_id = uuid.uuid4().hex
|
||||||
|
created = now_iso()
|
||||||
|
source = "einzel" if fallback else "stapel"
|
||||||
|
cards = []
|
||||||
|
for (upright, _), result in zip(encoded, results):
|
||||||
|
card_id = uuid.uuid4().hex
|
||||||
|
image_file = f"{card_id}.jpg"
|
||||||
|
(config.IMAGE_DIR / image_file).write_bytes(upright)
|
||||||
|
columns = {
|
||||||
|
"id": card_id,
|
||||||
|
"created_at": created,
|
||||||
|
"captured_at": meta["captured_at"] or created,
|
||||||
|
"batch_id": batch_id,
|
||||||
|
"image_file": image_file,
|
||||||
|
"source": source,
|
||||||
|
"notes": "",
|
||||||
|
"lat": meta["lat"],
|
||||||
|
"lon": meta["lon"],
|
||||||
|
"place": place,
|
||||||
|
"geo_source": geo_source,
|
||||||
|
"extract_status": "fehler" if result.get("_error") else "ok",
|
||||||
|
"extract_error": result.get("_error"),
|
||||||
|
}
|
||||||
|
for field in extract.FIELDS:
|
||||||
|
columns[field] = (result.get(field) or None)
|
||||||
|
names = ", ".join(columns)
|
||||||
|
placeholders = ", ".join("?" for _ in columns)
|
||||||
|
db.execute(
|
||||||
|
f"INSERT INTO cards ({names}) VALUES ({placeholders})", list(columns.values())
|
||||||
|
)
|
||||||
|
cards.append(card_dict(db.query_one("SELECT * FROM cards WHERE id = ?", (card_id,))))
|
||||||
|
|
||||||
|
log.info("Scan %s: %d Karte(n), Fallback=%s", batch_id, len(cards), fallback)
|
||||||
|
return JSONResponse(
|
||||||
|
{"batch_id": batch_id, "count": len(cards), "fallback": fallback, "cards": cards}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------- Karten
|
||||||
|
|
||||||
|
@app.get("/api/cards", dependencies=[auth])
|
||||||
|
def list_cards(q: str = "", filter: str = "alle") -> dict:
|
||||||
|
sql = "SELECT * FROM cards"
|
||||||
|
where, params = [], []
|
||||||
|
if q.strip():
|
||||||
|
needle = f"%{q.strip()}%"
|
||||||
|
searchable = [
|
||||||
|
"full_name", "company", "job_title", "email", "phone", "mobile",
|
||||||
|
"website", "city", "place", "notes", "department", "street",
|
||||||
|
]
|
||||||
|
where.append("(" + " OR ".join(f"{c} LIKE ?" for c in searchable) + ")")
|
||||||
|
params += [needle] * len(searchable)
|
||||||
|
if filter == "offen":
|
||||||
|
where.append("exported_at IS NULL")
|
||||||
|
elif filter == "unvollstaendig":
|
||||||
|
where.append(
|
||||||
|
"(extract_status <> 'ok' OR (full_name IS NULL AND company IS NULL))"
|
||||||
|
)
|
||||||
|
elif filter == "erinnerung":
|
||||||
|
where.append(
|
||||||
|
"id IN (SELECT card_id FROM reminders WHERE sent_at IS NULL)"
|
||||||
|
)
|
||||||
|
if where:
|
||||||
|
sql += " WHERE " + " AND ".join(where)
|
||||||
|
sql += " ORDER BY COALESCE(captured_at, created_at) DESC, rowid DESC"
|
||||||
|
|
||||||
|
rows = [card_dict(r) for r in db.query(sql, params)]
|
||||||
|
totals = db.query_one(
|
||||||
|
"""
|
||||||
|
SELECT COUNT(*) AS total,
|
||||||
|
SUM(CASE WHEN exported_at IS NULL THEN 1 ELSE 0 END) AS offen
|
||||||
|
FROM cards
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
pending = db.query_one(
|
||||||
|
"SELECT COUNT(*) AS n FROM reminders WHERE sent_at IS NULL"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"cards": rows,
|
||||||
|
"stats": {
|
||||||
|
"total": totals["total"] or 0,
|
||||||
|
"offen": totals["offen"] or 0,
|
||||||
|
"erinnerungen": pending["n"] or 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _card_or_404(card_id: str):
|
||||||
|
row = db.query_one("SELECT * FROM cards WHERE id = ?", (card_id,))
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Karte nicht gefunden")
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/cards/{card_id}", dependencies=[auth])
|
||||||
|
def get_card(card_id: str) -> dict:
|
||||||
|
card = card_dict(_card_or_404(card_id))
|
||||||
|
card["reminders"] = [
|
||||||
|
dict(r)
|
||||||
|
for r in db.query(
|
||||||
|
"SELECT * FROM reminders WHERE card_id = ? ORDER BY due_at", (card_id,)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
return card
|
||||||
|
|
||||||
|
|
||||||
|
@app.patch("/api/cards/{card_id}", dependencies=[auth])
|
||||||
|
async def update_card(card_id: str, request: Request) -> dict:
|
||||||
|
_card_or_404(card_id)
|
||||||
|
payload = await request.json()
|
||||||
|
changes = {k: (payload[k] or None) for k in EDITABLE if k in payload}
|
||||||
|
if "notes" in changes:
|
||||||
|
changes["notes"] = changes["notes"] or ""
|
||||||
|
if not changes:
|
||||||
|
raise HTTPException(status_code=400, detail="Keine bekannten Felder im Aufruf")
|
||||||
|
assignments = ", ".join(f"{k} = ?" for k in changes)
|
||||||
|
db.execute(
|
||||||
|
f"UPDATE cards SET {assignments} WHERE id = ?", [*changes.values(), card_id]
|
||||||
|
)
|
||||||
|
return card_dict(_card_or_404(card_id))
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/cards/{card_id}", dependencies=[auth])
|
||||||
|
def delete_card(card_id: str) -> dict:
|
||||||
|
row = _card_or_404(card_id)
|
||||||
|
if row["image_file"]:
|
||||||
|
(config.IMAGE_DIR / row["image_file"]).unlink(missing_ok=True)
|
||||||
|
db.execute("DELETE FROM cards WHERE id = ?", (card_id,))
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/api/cards/{card_id}/image", dependencies=[auth])
|
||||||
|
def card_image(card_id: str) -> FileResponse:
|
||||||
|
row = _card_or_404(card_id)
|
||||||
|
path = config.IMAGE_DIR / (row["image_file"] or "")
|
||||||
|
if not row["image_file"] or not path.exists():
|
||||||
|
raise HTTPException(status_code=404, detail="Kein Bild gespeichert")
|
||||||
|
return FileResponse(path, media_type="image/jpeg")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------- Export
|
||||||
|
|
||||||
|
def _mark_exported(ids: list) -> None:
|
||||||
|
stamp = now_iso()
|
||||||
|
db.execute_many(
|
||||||
|
[("UPDATE cards SET exported_at = ? WHERE id = ?", (stamp, i)) for i in ids]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/vcf/{card_id}", dependencies=[auth])
|
||||||
|
def download_vcard(card_id: str) -> Response:
|
||||||
|
card = dict(_card_or_404(card_id))
|
||||||
|
_mark_exported([card_id])
|
||||||
|
return Response(
|
||||||
|
content=vcard.build(card),
|
||||||
|
media_type="text/vcard; charset=utf-8",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{vcard.filename(card)}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/vcf", dependencies=[auth])
|
||||||
|
def download_vcards(ids: str = "") -> Response:
|
||||||
|
wanted = [i for i in ids.split(",") if i.strip()]
|
||||||
|
if not wanted:
|
||||||
|
raise HTTPException(status_code=400, detail="Keine Karten ausgewaehlt")
|
||||||
|
placeholders = ", ".join("?" for _ in wanted)
|
||||||
|
rows = db.query(f"SELECT * FROM cards WHERE id IN ({placeholders})", wanted)
|
||||||
|
if not rows:
|
||||||
|
raise HTTPException(status_code=404, detail="Karten nicht gefunden")
|
||||||
|
_mark_exported([r["id"] for r in rows])
|
||||||
|
return Response(
|
||||||
|
content=vcard.build_many([dict(r) for r in rows]),
|
||||||
|
media_type="text/vcard; charset=utf-8",
|
||||||
|
headers={"Content-Disposition": 'attachment; filename="kontakte.vcf"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------- Erinnerungen
|
||||||
|
|
||||||
|
@app.get("/api/reminders", dependencies=[auth])
|
||||||
|
def list_reminders() -> dict:
|
||||||
|
rows = db.query(
|
||||||
|
"""
|
||||||
|
SELECT r.*, c.full_name, c.company
|
||||||
|
FROM reminders r JOIN cards c ON c.id = r.card_id
|
||||||
|
ORDER BY r.sent_at IS NOT NULL, r.due_at
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
return {"reminders": [dict(r) for r in rows], "mail_configured": bool(config.SMTP_HOST)}
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/cards/{card_id}/reminders", dependencies=[auth])
|
||||||
|
async def create_reminder(card_id: str, request: Request) -> dict:
|
||||||
|
_card_or_404(card_id)
|
||||||
|
payload = await request.json()
|
||||||
|
text = (payload.get("text") or "").strip()
|
||||||
|
due_at = (payload.get("due_at") or "").strip()
|
||||||
|
if not text:
|
||||||
|
raise HTTPException(status_code=400, detail="Erinnerungstext fehlt")
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(due_at.replace("Z", "+00:00"))
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=400, detail="Datum nicht lesbar") from exc
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
reminder_id = uuid.uuid4().hex
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO reminders (id, card_id, text, due_at, created_at) VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(reminder_id, card_id, text, parsed.astimezone(timezone.utc).isoformat(), now_iso()),
|
||||||
|
)
|
||||||
|
return dict(db.query_one("SELECT * FROM reminders WHERE id = ?", (reminder_id,)))
|
||||||
|
|
||||||
|
|
||||||
|
@app.delete("/api/reminders/{reminder_id}", dependencies=[auth])
|
||||||
|
def delete_reminder(reminder_id: str) -> dict:
|
||||||
|
db.execute("DELETE FROM reminders WHERE id = ?", (reminder_id,))
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- Statisch
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
def index() -> FileResponse:
|
||||||
|
return FileResponse(STATIC_DIR / "index.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/sw.js")
|
||||||
|
def service_worker() -> FileResponse:
|
||||||
|
# Muss von der Wurzel kommen, sonst gilt der Service Worker nur fuer /static.
|
||||||
|
return FileResponse(STATIC_DIR / "sw.js", media_type="application/javascript")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/manifest.webmanifest")
|
||||||
|
def manifest() -> FileResponse:
|
||||||
|
return FileResponse(STATIC_DIR / "manifest.webmanifest", media_type="application/manifest+json")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/robots.txt")
|
||||||
|
def robots() -> PlainTextResponse:
|
||||||
|
return PlainTextResponse("User-agent: *\nDisallow: /\n")
|
||||||
|
|
||||||
|
|
||||||
|
app.mount("/static", StaticFiles(directory=STATIC_DIR), name="static")
|
||||||
90
app/reminders.py
Normal file
90
app/reminders.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
"""Faellige Erinnerungen als Mail verschicken.
|
||||||
|
|
||||||
|
Der Scheduler fragt die Datenbank ab, statt Timer im Speicher zu halten:
|
||||||
|
laeuft der Server zum Zeitpunkt einer Erinnerung nicht, wird sie beim
|
||||||
|
naechsten Start nachgeholt.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from . import config, db, mailer
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
INTERVAL_SECONDS = 60
|
||||||
|
|
||||||
|
|
||||||
|
def _now() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _body(row) -> str:
|
||||||
|
who = row["full_name"] or row["company"] or "Unbekannter Kontakt"
|
||||||
|
lines = [row["text"], "", "-" * 40, f"Kontakt: {who}"]
|
||||||
|
if row["company"] and row["full_name"]:
|
||||||
|
lines.append(f"Firma: {row['company']}")
|
||||||
|
if row["email"]:
|
||||||
|
lines.append(f"E-Mail: {row['email']}")
|
||||||
|
if row["phone"]:
|
||||||
|
lines.append(f"Telefon: {row['phone']}")
|
||||||
|
if row["mobile"]:
|
||||||
|
lines.append(f"Mobil: {row['mobile']}")
|
||||||
|
if row["place"]:
|
||||||
|
lines.append(f"Kennengelernt in: {row['place']}")
|
||||||
|
if config.PUBLIC_BASE_URL:
|
||||||
|
lines += ["", f"{config.PUBLIC_BASE_URL}/#/karte/{row['card_id']}"]
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def due_reminders() -> list:
|
||||||
|
return db.query(
|
||||||
|
"""
|
||||||
|
SELECT r.id, r.card_id, r.text, r.due_at,
|
||||||
|
c.full_name, c.company, c.email, c.phone, c.mobile, c.place
|
||||||
|
FROM reminders r
|
||||||
|
JOIN cards c ON c.id = r.card_id
|
||||||
|
WHERE r.sent_at IS NULL AND r.due_at <= ?
|
||||||
|
ORDER BY r.due_at
|
||||||
|
""",
|
||||||
|
(_now(),),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def process_due() -> int:
|
||||||
|
"""Alle faelligen Erinnerungen abarbeiten, Anzahl der Versendeten zurueck."""
|
||||||
|
rows = due_reminders()
|
||||||
|
if not rows:
|
||||||
|
return 0
|
||||||
|
if not mailer.configured():
|
||||||
|
log.warning("%d faellige Erinnerung(en), aber kein SMTP konfiguriert", len(rows))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
sent = 0
|
||||||
|
for row in rows:
|
||||||
|
who = row["full_name"] or row["company"] or "Kontakt"
|
||||||
|
try:
|
||||||
|
mailer.send(f"Erinnerung: {who}", _body(row))
|
||||||
|
except Exception as exc:
|
||||||
|
log.exception("Erinnerung %s konnte nicht verschickt werden", row["id"])
|
||||||
|
db.execute(
|
||||||
|
"UPDATE reminders SET send_error = ? WHERE id = ?",
|
||||||
|
(f"{type(exc).__name__}: {exc}", row["id"]),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
db.execute(
|
||||||
|
"UPDATE reminders SET sent_at = ?, send_error = NULL WHERE id = ?",
|
||||||
|
(_now(), row["id"]),
|
||||||
|
)
|
||||||
|
sent += 1
|
||||||
|
return sent
|
||||||
|
|
||||||
|
|
||||||
|
async def run_forever() -> None:
|
||||||
|
log.info("Erinnerungs-Scheduler gestartet (Intervall %ds)", INTERVAL_SECONDS)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await asyncio.to_thread(process_due)
|
||||||
|
except Exception:
|
||||||
|
log.exception("Scheduler-Durchlauf fehlgeschlagen")
|
||||||
|
await asyncio.sleep(INTERVAL_SECONDS)
|
||||||
49
app/security.py
Normal file
49
app/security.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
"""Anmeldung ueber ein einzelnes Passwort, Sitzung als signiertes Cookie."""
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import time
|
||||||
|
|
||||||
|
from fastapi import HTTPException, Request
|
||||||
|
|
||||||
|
from . import config
|
||||||
|
|
||||||
|
COOKIE = "bcs_session"
|
||||||
|
|
||||||
|
|
||||||
|
def enabled() -> bool:
|
||||||
|
return bool(config.APP_PASSWORD)
|
||||||
|
|
||||||
|
|
||||||
|
def _sign(payload: str) -> str:
|
||||||
|
return hmac.new(
|
||||||
|
config.SECRET_KEY.encode(), payload.encode(), hashlib.sha256
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def issue_token() -> str:
|
||||||
|
expires = str(int(time.time()) + config.SESSION_DAYS * 86400)
|
||||||
|
return f"{expires}.{_sign(expires)}"
|
||||||
|
|
||||||
|
|
||||||
|
def valid_token(token: str | None) -> bool:
|
||||||
|
if not token or "." not in token:
|
||||||
|
return False
|
||||||
|
expires, signature = token.split(".", 1)
|
||||||
|
if not hmac.compare_digest(signature, _sign(expires)):
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
return int(expires) > time.time()
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def check_password(candidate: str) -> bool:
|
||||||
|
return hmac.compare_digest(candidate, config.APP_PASSWORD)
|
||||||
|
|
||||||
|
|
||||||
|
def require_auth(request: Request) -> None:
|
||||||
|
"""FastAPI-Abhaengigkeit fuer alle geschuetzten Endpunkte."""
|
||||||
|
if not enabled():
|
||||||
|
return
|
||||||
|
if not valid_token(request.cookies.get(COOKIE)):
|
||||||
|
raise HTTPException(status_code=401, detail="Nicht angemeldet")
|
||||||
154
app/segment.py
Normal file
154
app/segment.py
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
"""Visitenkarten aus einem Foto freistellen.
|
||||||
|
|
||||||
|
Reine Geometrie, kein Modell: Kanten finden, Rechtecke mit Kartenformat
|
||||||
|
behalten, perspektivisch entzerren. Der Inhalt der Karte spielt hier keine
|
||||||
|
Rolle - den liest spaeter das Vision-Modell aus dem Zuschnitt.
|
||||||
|
"""
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
# ISO 7810 ID-1: 85,6 x 54 mm. Toleranz nach unten fuer abweichende Formate.
|
||||||
|
CARD_RATIO = 85.6 / 54.0
|
||||||
|
RATIO_MIN, RATIO_MAX = 1.25, 2.05
|
||||||
|
|
||||||
|
OUT_WIDTH = 1400
|
||||||
|
OUT_HEIGHT = round(OUT_WIDTH / CARD_RATIO)
|
||||||
|
|
||||||
|
DETECT_EDGE = 1600 # Aufloesung, auf der gesucht wird
|
||||||
|
MIN_AREA_FRACTION = 0.004 # kleiner ist Rauschen, kein Kartenfund
|
||||||
|
MAX_AREA_FRACTION = 0.60 # groesser ist der Tisch, nicht die Karte
|
||||||
|
MIN_FILL = 0.80 # Kontur muss ihr eigenes Rechteck fuellen
|
||||||
|
|
||||||
|
|
||||||
|
def _masks(gray: np.ndarray) -> list[np.ndarray]:
|
||||||
|
"""Mehrere Binaerbilder, weil je nach Untergrund ein anderes traegt."""
|
||||||
|
blurred = cv2.GaussianBlur(gray, (5, 5), 0)
|
||||||
|
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))
|
||||||
|
|
||||||
|
edges = cv2.dilate(cv2.Canny(blurred, 40, 120), kernel, iterations=2)
|
||||||
|
edges = cv2.morphologyEx(edges, cv2.MORPH_CLOSE, kernel, iterations=2)
|
||||||
|
|
||||||
|
adaptive = cv2.adaptiveThreshold(
|
||||||
|
blurred, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY, 51, 10
|
||||||
|
)
|
||||||
|
adaptive = cv2.morphologyEx(adaptive, cv2.MORPH_CLOSE, kernel, iterations=2)
|
||||||
|
|
||||||
|
_, otsu = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
|
||||||
|
|
||||||
|
return [edges, adaptive, otsu, cv2.bitwise_not(otsu)]
|
||||||
|
|
||||||
|
|
||||||
|
def _candidates(mask: np.ndarray, image_area: float) -> list[tuple]:
|
||||||
|
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
found = []
|
||||||
|
for contour in contours:
|
||||||
|
rect = cv2.minAreaRect(contour)
|
||||||
|
(_, _), (width, height), _ = rect
|
||||||
|
if width < 24 or height < 24:
|
||||||
|
continue
|
||||||
|
long_side, short_side = max(width, height), min(width, height)
|
||||||
|
if not RATIO_MIN <= long_side / short_side <= RATIO_MAX:
|
||||||
|
continue
|
||||||
|
rect_area = width * height
|
||||||
|
if not MIN_AREA_FRACTION * image_area <= rect_area <= MAX_AREA_FRACTION * image_area:
|
||||||
|
continue
|
||||||
|
fill = cv2.contourArea(contour) / rect_area
|
||||||
|
if fill < MIN_FILL:
|
||||||
|
continue
|
||||||
|
found.append((rect, fill))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def _deduplicate(candidates: list[tuple]) -> list[tuple]:
|
||||||
|
"""Dieselbe Karte wird von mehreren Masken gefunden - besten Treffer behalten."""
|
||||||
|
kept: list[tuple] = []
|
||||||
|
for rect, fill in sorted(candidates, key=lambda c: -c[1]):
|
||||||
|
(cx, cy), (width, height), _ = rect
|
||||||
|
radius = min(width, height) * 0.5
|
||||||
|
duplicate = False
|
||||||
|
for other_rect, _ in kept:
|
||||||
|
(ox, oy), _, _ = other_rect
|
||||||
|
if np.hypot(cx - ox, cy - oy) < radius:
|
||||||
|
duplicate = True
|
||||||
|
break
|
||||||
|
if not duplicate:
|
||||||
|
kept.append((rect, fill))
|
||||||
|
return kept
|
||||||
|
|
||||||
|
|
||||||
|
def _order_quad(points: np.ndarray) -> np.ndarray:
|
||||||
|
"""Ecken als oben-links, oben-rechts, unten-rechts, unten-links."""
|
||||||
|
total = points.sum(axis=1)
|
||||||
|
diff = points[:, 1] - points[:, 0]
|
||||||
|
ordered = np.array(
|
||||||
|
[
|
||||||
|
points[np.argmin(total)], # oben links
|
||||||
|
points[np.argmin(diff)], # oben rechts
|
||||||
|
points[np.argmax(total)], # unten rechts
|
||||||
|
points[np.argmax(diff)], # unten links
|
||||||
|
],
|
||||||
|
dtype="float32",
|
||||||
|
)
|
||||||
|
# Hochkant liegende Karte um 90 Grad drehen, damit der Zuschnitt quer ist.
|
||||||
|
width = np.linalg.norm(ordered[1] - ordered[0])
|
||||||
|
height = np.linalg.norm(ordered[3] - ordered[0])
|
||||||
|
if height > width:
|
||||||
|
ordered = np.roll(ordered, -1, axis=0)
|
||||||
|
return ordered
|
||||||
|
|
||||||
|
|
||||||
|
def _reading_order(rects: list) -> list:
|
||||||
|
"""Zeilenweise sortieren, damit die Reihenfolge dem Tisch entspricht."""
|
||||||
|
if not rects:
|
||||||
|
return rects
|
||||||
|
row_height = np.median([min(r[1]) for r in rects]) * 0.7
|
||||||
|
return sorted(rects, key=lambda r: (round(r[0][1] / row_height), r[0][0]))
|
||||||
|
|
||||||
|
|
||||||
|
def segment(image: np.ndarray) -> tuple[list[np.ndarray], bool]:
|
||||||
|
"""Liefert die entzerrten Kartenbilder und ob auf das Gesamtbild
|
||||||
|
zurueckgefallen wurde (kein Kartenrechteck gefunden)."""
|
||||||
|
height, width = image.shape[:2]
|
||||||
|
scale = DETECT_EDGE / max(height, width) if max(height, width) > DETECT_EDGE else 1.0
|
||||||
|
small = (
|
||||||
|
cv2.resize(image, None, fx=scale, fy=scale, interpolation=cv2.INTER_AREA)
|
||||||
|
if scale < 1.0
|
||||||
|
else image
|
||||||
|
)
|
||||||
|
gray = cv2.cvtColor(small, cv2.COLOR_BGR2GRAY)
|
||||||
|
image_area = float(small.shape[0] * small.shape[1])
|
||||||
|
|
||||||
|
candidates: list[tuple] = []
|
||||||
|
for mask in _masks(gray):
|
||||||
|
candidates.extend(_candidates(mask, image_area))
|
||||||
|
|
||||||
|
rects = [rect for rect, _ in _deduplicate(candidates)]
|
||||||
|
if not rects:
|
||||||
|
return [_fit_whole(image)], True
|
||||||
|
|
||||||
|
crops = []
|
||||||
|
for rect in _reading_order(rects):
|
||||||
|
quad = _order_quad(cv2.boxPoints(rect)) / scale # zurueck auf volle Aufloesung
|
||||||
|
target = np.array(
|
||||||
|
[[0, 0], [OUT_WIDTH, 0], [OUT_WIDTH, OUT_HEIGHT], [0, OUT_HEIGHT]],
|
||||||
|
dtype="float32",
|
||||||
|
)
|
||||||
|
matrix = cv2.getPerspectiveTransform(quad, target)
|
||||||
|
crops.append(
|
||||||
|
cv2.warpPerspective(
|
||||||
|
image, matrix, (OUT_WIDTH, OUT_HEIGHT), flags=cv2.INTER_CUBIC
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return crops, False
|
||||||
|
|
||||||
|
|
||||||
|
def _fit_whole(image: np.ndarray) -> np.ndarray:
|
||||||
|
"""Ohne Fund: das ganze Bild als eine Karte behandeln."""
|
||||||
|
height, width = image.shape[:2]
|
||||||
|
if width < height: # Hochformat drehen
|
||||||
|
image = cv2.rotate(image, cv2.ROTATE_90_CLOCKWISE)
|
||||||
|
height, width = image.shape[:2]
|
||||||
|
scale = min(OUT_WIDTH / width, OUT_HEIGHT / height, 1.0)
|
||||||
|
if scale < 1.0:
|
||||||
|
image = cv2.resize(image, None, fx=scale, fy=scale, interpolation=cv2.INTER_AREA)
|
||||||
|
return image
|
||||||
90
app/vcard.py
Normal file
90
app/vcard.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
"""vCard 3.0 erzeugen - das Format, das iOS beim Antippen zuverlaessig oeffnet."""
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
_ESCAPES = (("\\", "\\\\"), (";", "\\;"), (",", "\\,"), ("\n", "\\n"))
|
||||||
|
|
||||||
|
|
||||||
|
def _escape(value) -> str:
|
||||||
|
text = str(value)
|
||||||
|
for char, replacement in _ESCAPES:
|
||||||
|
text = text.replace(char, replacement)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _line(name: str, value) -> list:
|
||||||
|
return [name + ":" + _escape(value)] if value else []
|
||||||
|
|
||||||
|
|
||||||
|
def _display_name(card: dict) -> str:
|
||||||
|
if card.get("full_name"):
|
||||||
|
return card["full_name"]
|
||||||
|
joined = " ".join(filter(None, (card.get("first_name"), card.get("last_name"))))
|
||||||
|
return joined or card.get("company") or "Unbenannt"
|
||||||
|
|
||||||
|
|
||||||
|
def _note(card: dict) -> str:
|
||||||
|
parts = []
|
||||||
|
if card.get("notes"):
|
||||||
|
parts.append(card["notes"])
|
||||||
|
|
||||||
|
stamp = card.get("captured_at") or card.get("created_at")
|
||||||
|
scanned = ""
|
||||||
|
if stamp:
|
||||||
|
try:
|
||||||
|
when = datetime.fromisoformat(stamp).strftime("%d.%m.%Y um %H:%M")
|
||||||
|
scanned = "Gescannt am " + when
|
||||||
|
except ValueError:
|
||||||
|
scanned = "Gescannt am " + str(stamp)
|
||||||
|
if card.get("place"):
|
||||||
|
scanned = (scanned + " in " if scanned else "Gescannt in ") + card["place"]
|
||||||
|
if scanned:
|
||||||
|
parts.append(scanned)
|
||||||
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
def build(card: dict) -> str:
|
||||||
|
lines = ["BEGIN:VCARD", "VERSION:3.0"]
|
||||||
|
lines.append(
|
||||||
|
"N:{};{};;;".format(
|
||||||
|
_escape(card.get("last_name") or ""), _escape(card.get("first_name") or "")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
lines.append("FN:" + _escape(_display_name(card)))
|
||||||
|
lines += _line("ORG", card.get("company"))
|
||||||
|
lines += _line("TITLE", card.get("job_title"))
|
||||||
|
if card.get("email"):
|
||||||
|
lines.append("EMAIL;TYPE=INTERNET,WORK:" + _escape(card["email"]))
|
||||||
|
if card.get("phone"):
|
||||||
|
lines.append("TEL;TYPE=WORK,VOICE:" + _escape(card["phone"]))
|
||||||
|
if card.get("mobile"):
|
||||||
|
lines.append("TEL;TYPE=CELL:" + _escape(card["mobile"]))
|
||||||
|
if card.get("fax"):
|
||||||
|
lines.append("TEL;TYPE=WORK,FAX:" + _escape(card["fax"]))
|
||||||
|
if card.get("website"):
|
||||||
|
lines.append("URL:" + _escape(card["website"]))
|
||||||
|
if any(card.get(k) for k in ("street", "postal_code", "city", "country")):
|
||||||
|
lines.append(
|
||||||
|
"ADR;TYPE=WORK:;;{};{};;{};{}".format(
|
||||||
|
_escape(card.get("street") or ""),
|
||||||
|
_escape(card.get("city") or ""),
|
||||||
|
_escape(card.get("postal_code") or ""),
|
||||||
|
_escape(card.get("country") or ""),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if card.get("lat") is not None and card.get("lon") is not None:
|
||||||
|
lines.append("GEO:{};{}".format(card["lat"], card["lon"]))
|
||||||
|
note = _note(card)
|
||||||
|
if note:
|
||||||
|
lines.append("NOTE:" + _escape(note))
|
||||||
|
lines.append("END:VCARD")
|
||||||
|
return "\r\n".join(lines) + "\r\n"
|
||||||
|
|
||||||
|
|
||||||
|
def build_many(cards: list) -> str:
|
||||||
|
return "".join(build(card) for card in cards)
|
||||||
|
|
||||||
|
|
||||||
|
def filename(card: dict) -> str:
|
||||||
|
base = _display_name(card).replace(" ", "-")
|
||||||
|
safe = "".join(c for c in base if c.isalnum() or c in "-_")
|
||||||
|
return (safe or "kontakt") + ".vcf"
|
||||||
39
docker-compose.yml
Normal file
39
docker-compose.yml
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Fester Projektname. Ohne das leitet Compose ihn aus dem Verzeichnisnamen
|
||||||
|
# ab - und der ist im CI-Job ein anderer als auf dem Host.
|
||||||
|
name: business-card-scanner
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
# Versionstag ermoeglicht Rollback ohne Rebuild.
|
||||||
|
image: business-card-scanner:${IMAGE_TAG:-latest}
|
||||||
|
build: .
|
||||||
|
# Fester Name: das ist die Adresse, auf die der Proxy zeigt.
|
||||||
|
container_name: business-card-scanner
|
||||||
|
restart: unless-stopped
|
||||||
|
# Wird im Workflow aus dem Secret DOTENV erzeugt.
|
||||||
|
env_file: .env
|
||||||
|
volumes:
|
||||||
|
- business-card-scanner-data:/data
|
||||||
|
networks:
|
||||||
|
- nginx-proxy-manager_default
|
||||||
|
# Pflicht: der Deploy-Schritt nutzt --wait und macht das zum Gate.
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
- CMD
|
||||||
|
- python
|
||||||
|
- -c
|
||||||
|
- import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8080/healthz', timeout=4).status == 200 else 1)
|
||||||
|
interval: 60s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
# Kein ports-Mapping: Der Proxy erreicht den Container ueber das gemeinsame
|
||||||
|
# Netz unter seinem Container-Namen.
|
||||||
|
networks:
|
||||||
|
nginx-proxy-manager_default:
|
||||||
|
external: true
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
business-card-scanner-data:
|
||||||
|
# Fester Name, damit das Volume nicht am Projektnamen haengt.
|
||||||
|
name: business-card-scanner-data
|
||||||
3
requirements-dev.txt
Normal file
3
requirements-dev.txt
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
-r requirements.txt
|
||||||
|
pytest==9.0.1
|
||||||
|
httpx==0.28.1
|
||||||
7
requirements.txt
Normal file
7
requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
fastapi==0.141.1
|
||||||
|
uvicorn[standard]==0.41.0
|
||||||
|
python-multipart==0.0.20
|
||||||
|
anthropic==1.4.0
|
||||||
|
opencv-python-headless==4.14.0.94
|
||||||
|
numpy==2.2.6
|
||||||
|
pillow==12.1.0
|
||||||
576
static/app.js
Normal file
576
static/app.js
Normal file
@@ -0,0 +1,576 @@
|
|||||||
|
/* Visitenkarten-Scanner: Routing, Ansichten, API-Aufrufe. */
|
||||||
|
(() => {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const FIELD_LABELS = {
|
||||||
|
full_name: "Name",
|
||||||
|
first_name: "Vorname",
|
||||||
|
last_name: "Nachname",
|
||||||
|
job_title: "Position",
|
||||||
|
company: "Firma",
|
||||||
|
department: "Abteilung",
|
||||||
|
email: "E-Mail",
|
||||||
|
phone: "Telefon",
|
||||||
|
mobile: "Mobil",
|
||||||
|
fax: "Fax",
|
||||||
|
website: "Web",
|
||||||
|
street: "Straße",
|
||||||
|
postal_code: "PLZ",
|
||||||
|
city: "Ort",
|
||||||
|
country: "Land",
|
||||||
|
};
|
||||||
|
const FIELD_ORDER = Object.keys(FIELD_LABELS);
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
route: "scan",
|
||||||
|
cardId: null,
|
||||||
|
query: "",
|
||||||
|
filter: "alle",
|
||||||
|
cards: [],
|
||||||
|
stats: { total: 0, offen: 0, erinnerungen: 0 },
|
||||||
|
editing: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const screens = {
|
||||||
|
scan: $("screen-scan"),
|
||||||
|
karten: $("screen-cards"),
|
||||||
|
karte: $("screen-detail"),
|
||||||
|
erinnerungen: $("screen-reminders"),
|
||||||
|
};
|
||||||
|
|
||||||
|
const esc = (value) =>
|
||||||
|
String(value ?? "").replace(/[&<>"']/g, (c) => ({
|
||||||
|
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||||
|
})[c]);
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ Helfer
|
||||||
|
|
||||||
|
function toast(message) {
|
||||||
|
const node = $("toast");
|
||||||
|
node.textContent = message;
|
||||||
|
node.hidden = false;
|
||||||
|
clearTimeout(toast.timer);
|
||||||
|
toast.timer = setTimeout(() => { node.hidden = true; }, 3200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function busy(on, text) {
|
||||||
|
$("busy-text").textContent = text || "Karten werden gelesen";
|
||||||
|
$("busy-overlay").hidden = !on;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(path, options = {}) {
|
||||||
|
const response = await fetch(path, { credentials: "same-origin", ...options });
|
||||||
|
if (response.status === 401) {
|
||||||
|
$("login-overlay").hidden = false;
|
||||||
|
throw new Error("Nicht angemeldet");
|
||||||
|
}
|
||||||
|
if (!response.ok) {
|
||||||
|
let detail = `Fehler ${response.status}`;
|
||||||
|
try { detail = (await response.json()).detail || detail; } catch (_) {}
|
||||||
|
throw new Error(detail);
|
||||||
|
}
|
||||||
|
return response.status === 204 ? null : response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (iso) => {
|
||||||
|
if (!iso) return "–";
|
||||||
|
const date = new Date(iso);
|
||||||
|
return Number.isNaN(date.getTime())
|
||||||
|
? "–"
|
||||||
|
: date.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatDateTime = (iso) => {
|
||||||
|
if (!iso) return "–";
|
||||||
|
const date = new Date(iso);
|
||||||
|
return Number.isNaN(date.getTime())
|
||||||
|
? "–"
|
||||||
|
: date.toLocaleString("de-DE", {
|
||||||
|
day: "2-digit", month: "2-digit", year: "numeric",
|
||||||
|
hour: "2-digit", minute: "2-digit",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const displayName = (card) =>
|
||||||
|
card.full_name ||
|
||||||
|
[card.first_name, card.last_name].filter(Boolean).join(" ") ||
|
||||||
|
card.company ||
|
||||||
|
"Ohne Namen";
|
||||||
|
|
||||||
|
/** Position des Browsers als Rückfallebene. Die EXIF-Daten des Fotos sind
|
||||||
|
* genauer, deshalb blockiert das hier nichts und scheitert leise. */
|
||||||
|
function browserPosition() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
if (!navigator.geolocation) return resolve(null);
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(position) => resolve(position.coords),
|
||||||
|
() => resolve(null),
|
||||||
|
{ timeout: 6000, maximumAge: 300000 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------- Routing
|
||||||
|
|
||||||
|
function go(route, cardId) {
|
||||||
|
location.hash = cardId ? `#/${route}/${cardId}` : `#/${route}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyRoute() {
|
||||||
|
const parts = location.hash.replace(/^#\/?/, "").split("/");
|
||||||
|
const route = screens[parts[0]] ? parts[0] : "scan";
|
||||||
|
state.route = route;
|
||||||
|
state.cardId = route === "karte" ? parts[1] : null;
|
||||||
|
|
||||||
|
Object.entries(screens).forEach(([name, node]) => { node.hidden = name !== route; });
|
||||||
|
document.querySelectorAll(".tab").forEach((tab) => {
|
||||||
|
const active = tab.dataset.route === route || (route === "karte" && tab.dataset.route === "karten");
|
||||||
|
tab.setAttribute("aria-selected", active ? "true" : "false");
|
||||||
|
});
|
||||||
|
window.scrollTo(0, 0);
|
||||||
|
|
||||||
|
if (route === "karten") loadCards();
|
||||||
|
if (route === "karte") loadCard(state.cardId);
|
||||||
|
if (route === "erinnerungen") loadReminders();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------- Scan
|
||||||
|
|
||||||
|
async function runScan(file) {
|
||||||
|
if (!file) return;
|
||||||
|
busy(true, "Karten werden freigestellt");
|
||||||
|
const body = new FormData();
|
||||||
|
body.append("file", file);
|
||||||
|
const coords = await browserPosition();
|
||||||
|
if (coords) {
|
||||||
|
body.append("lat", coords.latitude);
|
||||||
|
body.append("lon", coords.longitude);
|
||||||
|
}
|
||||||
|
busy(true, "Karten werden gelesen");
|
||||||
|
try {
|
||||||
|
const result = await api("/api/scan", { method: "POST", body });
|
||||||
|
renderScanResult(result);
|
||||||
|
await refreshStats();
|
||||||
|
toast(`${result.count} Karte${result.count === 1 ? "" : "n"} erfasst`);
|
||||||
|
} catch (error) {
|
||||||
|
toast(error.message);
|
||||||
|
} finally {
|
||||||
|
busy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderScanResult(result) {
|
||||||
|
const rows = result.cards
|
||||||
|
.map(
|
||||||
|
(card) => `
|
||||||
|
<button class="row" data-card="${esc(card.id)}">
|
||||||
|
<span class="row-head">
|
||||||
|
<span class="row-title">${esc(displayName(card))}</span>
|
||||||
|
<span class="row-meta">${card.has_contact ? "OK" : "Leer"}</span>
|
||||||
|
</span>
|
||||||
|
<span class="row-sub">${esc(card.company || card.email || "Keine Daten erkannt")}</span>
|
||||||
|
</button>`
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
$("scan-result").innerHTML = `
|
||||||
|
<div class="section-label"><span class="label">Ergebnis</span></div>
|
||||||
|
<div class="strip" style="border-top:3px solid #000">
|
||||||
|
<span class="label">Gefundene Karten</span>
|
||||||
|
<span class="display d-lg mono-num">${result.count}</span>
|
||||||
|
</div>
|
||||||
|
${result.fallback
|
||||||
|
? `<p class="hint" style="margin-top:10px">Kein Kartenrechteck erkannt – das ganze Foto
|
||||||
|
wurde als eine Karte behandelt. Für einen Stapel hilft ein dunkler, einfarbiger
|
||||||
|
Untergrund mit Abstand zwischen den Karten.</p>`
|
||||||
|
: ""}
|
||||||
|
<div class="rows" style="margin-top:14px">${rows}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- Kartenliste
|
||||||
|
|
||||||
|
async function loadCards() {
|
||||||
|
const params = new URLSearchParams({ q: state.query, filter: state.filter });
|
||||||
|
try {
|
||||||
|
const data = await api(`/api/cards?${params}`);
|
||||||
|
state.cards = data.cards;
|
||||||
|
state.stats = data.stats;
|
||||||
|
updateCounters();
|
||||||
|
renderCards();
|
||||||
|
} catch (error) {
|
||||||
|
toast(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCards() {
|
||||||
|
const list = $("cards-list");
|
||||||
|
if (!state.cards.length) {
|
||||||
|
list.innerHTML = `<div class="empty">
|
||||||
|
<p class="label">Nichts gefunden</p>
|
||||||
|
<p class="hint" style="margin:8px 0 0">
|
||||||
|
${state.query || state.filter !== "alle"
|
||||||
|
? "Andere Suche oder anderer Filter."
|
||||||
|
: "Noch keine Karten erfasst."}
|
||||||
|
</p></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectable = state.cards.map((c) => c.id).join(",");
|
||||||
|
list.innerHTML = `
|
||||||
|
<div class="strip" style="border-top:3px solid #000">
|
||||||
|
<span class="label">${state.cards.length} Karte${state.cards.length === 1 ? "" : "n"}</span>
|
||||||
|
<a class="badge" href="/vcf?ids=${esc(selectable)}" download>Alle als vCard</a>
|
||||||
|
</div>
|
||||||
|
<div class="rows">
|
||||||
|
${state.cards
|
||||||
|
.map(
|
||||||
|
(card) => `
|
||||||
|
<button class="row" data-card="${esc(card.id)}">
|
||||||
|
<span class="row-head">
|
||||||
|
<span class="row-title">${esc(displayName(card))}</span>
|
||||||
|
<span class="row-meta">${esc(formatDate(card.captured_at || card.created_at))}</span>
|
||||||
|
</span>
|
||||||
|
<span class="row-sub">
|
||||||
|
${esc([card.job_title, card.company].filter(Boolean).join(" · ") || "Keine Firma erkannt")}
|
||||||
|
</span>
|
||||||
|
<span class="row-sub">${esc(
|
||||||
|
[
|
||||||
|
card.place,
|
||||||
|
card.exported_at ? "exportiert" : "nicht exportiert",
|
||||||
|
card.extract_status !== "ok" ? "Lesefehler" : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ")
|
||||||
|
)}</span>
|
||||||
|
</button>`
|
||||||
|
)
|
||||||
|
.join("")}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------- Kartendetail
|
||||||
|
|
||||||
|
async function loadCard(cardId) {
|
||||||
|
try {
|
||||||
|
const card = await api(`/api/cards/${cardId}`);
|
||||||
|
renderCard(card);
|
||||||
|
} catch (error) {
|
||||||
|
screens.karte.innerHTML = `<div class="empty"><p class="label">${esc(error.message)}</p></div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dataTable(card) {
|
||||||
|
const rows = FIELD_ORDER.filter((field) => card[field])
|
||||||
|
.map(
|
||||||
|
(field) =>
|
||||||
|
`<tr><th>${FIELD_LABELS[field]}</th><td>${esc(card[field])}</td></tr>`
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
return rows
|
||||||
|
? `<table class="data">${rows}</table>`
|
||||||
|
: `<p class="hint">Keine Felder erkannt. Über „Bearbeiten“ nachtragen.</p>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function editForm(card) {
|
||||||
|
return `<div class="stack-tight">${FIELD_ORDER.map(
|
||||||
|
(field) => `
|
||||||
|
<label class="field">
|
||||||
|
<span class="label">${FIELD_LABELS[field]}</span>
|
||||||
|
<input data-field="${field}" value="${esc(card[field] || "")}">
|
||||||
|
</label>`
|
||||||
|
).join("")}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCard(card) {
|
||||||
|
const meta = [];
|
||||||
|
if (card.place) meta.push(esc(card.place));
|
||||||
|
if (card.lat != null) meta.push(`${card.lat.toFixed(4)}, ${card.lon.toFixed(4)}`);
|
||||||
|
|
||||||
|
const reminders = card.reminders.length
|
||||||
|
? `<div class="rows">${card.reminders
|
||||||
|
.map(
|
||||||
|
(reminder) => `
|
||||||
|
<div class="row" style="cursor:default">
|
||||||
|
<span class="row-head">
|
||||||
|
<span class="row-title" style="font-size:14px">${esc(reminder.text)}</span>
|
||||||
|
<button class="badge" data-delete-reminder="${esc(reminder.id)}">Löschen</button>
|
||||||
|
</span>
|
||||||
|
<span class="row-sub">
|
||||||
|
${esc(formatDateTime(reminder.due_at))}
|
||||||
|
${reminder.sent_at ? " · verschickt" : ""}
|
||||||
|
${reminder.send_error ? " · Versand fehlgeschlagen" : ""}
|
||||||
|
</span>
|
||||||
|
</div>`
|
||||||
|
)
|
||||||
|
.join("")}</div>`
|
||||||
|
: `<p class="hint">Keine Erinnerung gesetzt.</p>`;
|
||||||
|
|
||||||
|
screens.karte.innerHTML = `
|
||||||
|
<button class="chip" data-back>← Zurück</button>
|
||||||
|
|
||||||
|
<div class="block-invert">
|
||||||
|
<p class="label">${card.source === "stapel" ? "Aus Stapelscan" : "Einzelscan"}</p>
|
||||||
|
<h1 class="display d-lg" style="margin-top:8px">${esc(displayName(card))}</h1>
|
||||||
|
<p class="muted" style="margin:6px 0 0">
|
||||||
|
${esc([card.job_title, card.company].filter(Boolean).join(" · ") || "Keine Firma erkannt")}
|
||||||
|
</p>
|
||||||
|
<div class="figures">
|
||||||
|
<span class="figure">
|
||||||
|
<span class="label">Erfasst</span>
|
||||||
|
<span class="display d-md">${esc(formatDate(card.captured_at || card.created_at))}</span>
|
||||||
|
</span>
|
||||||
|
<span class="figure">
|
||||||
|
<span class="label">Ort</span>
|
||||||
|
<span class="display d-md">${meta.length ? esc(meta[0]) : "Unbekannt"}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
${card.image_url
|
||||||
|
? `<img class="card-image" src="${esc(card.image_url)}" alt="Visitenkarte">`
|
||||||
|
: ""}
|
||||||
|
|
||||||
|
<a class="btn btn-primary" href="/vcf/${esc(card.id)}" download>
|
||||||
|
In Kontakte speichern <span class="arrow">→</span>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
${card.extract_status !== "ok"
|
||||||
|
? `<p class="hint">Lesefehler: ${esc(card.extract_error || "unbekannt")}</p>`
|
||||||
|
: ""}
|
||||||
|
|
||||||
|
<div class="section-label">
|
||||||
|
<span class="label">Daten</span>
|
||||||
|
</div>
|
||||||
|
<button class="chip" data-toggle-edit style="margin-bottom:10px">
|
||||||
|
${state.editing ? "Fertig" : "Bearbeiten"}
|
||||||
|
</button>
|
||||||
|
${state.editing ? editForm(card) : dataTable(card)}
|
||||||
|
|
||||||
|
<div class="section-label"><span class="label">Notiz</span></div>
|
||||||
|
<label class="field">
|
||||||
|
<textarea data-field="notes" placeholder="Worum ging es im Gespräch?">${esc(card.notes || "")}</textarea>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="section-label"><span class="label">Erinnerung</span></div>
|
||||||
|
${reminders}
|
||||||
|
<form class="stack-tight" id="reminder-form" style="margin-top:14px">
|
||||||
|
<label class="field">
|
||||||
|
<span class="label">Text</span>
|
||||||
|
<input name="text" placeholder="Angebot nachfassen" required>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span class="label">Termin</span>
|
||||||
|
<input name="due" type="datetime-local" required>
|
||||||
|
</label>
|
||||||
|
<button class="btn" type="submit">Erinnerung anlegen <span class="arrow">→</span></button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="section-label"><span class="label">Karte entfernen</span></div>
|
||||||
|
<button class="btn btn-danger" data-delete-card="${esc(card.id)}">
|
||||||
|
Endgültig löschen <span class="arrow">×</span>
|
||||||
|
</button>`;
|
||||||
|
|
||||||
|
screens.karte.querySelectorAll("[data-field]").forEach((input) => {
|
||||||
|
input.addEventListener("change", async () => {
|
||||||
|
try {
|
||||||
|
await api(`/api/cards/${card.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ [input.dataset.field]: input.value }),
|
||||||
|
});
|
||||||
|
toast("Gespeichert");
|
||||||
|
} catch (error) {
|
||||||
|
toast(error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$("reminder-form").addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const form = event.currentTarget;
|
||||||
|
const due = form.elements.due.value;
|
||||||
|
if (!due) return;
|
||||||
|
try {
|
||||||
|
await api(`/api/cards/${card.id}/reminders`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
text: form.elements.text.value,
|
||||||
|
due_at: new Date(due).toISOString(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
toast("Erinnerung angelegt");
|
||||||
|
loadCard(card.id);
|
||||||
|
refreshStats();
|
||||||
|
} catch (error) {
|
||||||
|
toast(error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------ Erinnerungen
|
||||||
|
|
||||||
|
async function loadReminders() {
|
||||||
|
try {
|
||||||
|
const data = await api("/api/reminders");
|
||||||
|
const list = $("reminders-list");
|
||||||
|
if (!data.reminders.length) {
|
||||||
|
list.innerHTML = `<div class="empty"><p class="label">Keine Erinnerungen</p>
|
||||||
|
<p class="hint" style="margin:8px 0 0">Erinnerungen legst du auf einer Karte an.</p></div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.innerHTML = `
|
||||||
|
${data.mail_configured ? "" : `<p class="hint">Kein SMTP konfiguriert – es wird nichts verschickt.</p>`}
|
||||||
|
<div class="rows">${data.reminders
|
||||||
|
.map(
|
||||||
|
(reminder) => `
|
||||||
|
<button class="row" data-card="${esc(reminder.card_id)}">
|
||||||
|
<span class="row-head">
|
||||||
|
<span class="row-title" style="font-size:15px">${esc(reminder.text)}</span>
|
||||||
|
<span class="row-meta">${reminder.sent_at ? "verschickt" : "offen"}</span>
|
||||||
|
</span>
|
||||||
|
<span class="row-sub">${esc(formatDateTime(reminder.due_at))}
|
||||||
|
· ${esc(reminder.full_name || reminder.company || "Kontakt")}</span>
|
||||||
|
</button>`
|
||||||
|
)
|
||||||
|
.join("")}</div>`;
|
||||||
|
} catch (error) {
|
||||||
|
toast(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------- Zähler
|
||||||
|
|
||||||
|
function updateCounters() {
|
||||||
|
$("figure-total").textContent = state.stats.total;
|
||||||
|
$("figure-open").textContent = state.stats.offen;
|
||||||
|
$("tab-count-cards").textContent = state.stats.total;
|
||||||
|
$("tab-count-reminders").textContent = state.stats.erinnerungen;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshStats() {
|
||||||
|
try {
|
||||||
|
const data = await api("/api/cards?filter=alle");
|
||||||
|
state.stats = data.stats;
|
||||||
|
updateCounters();
|
||||||
|
} catch (_) { /* Zähler sind nicht kritisch */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------- Ereignisse
|
||||||
|
|
||||||
|
document.addEventListener("click", (event) => {
|
||||||
|
const cardButton = event.target.closest("[data-card]");
|
||||||
|
if (cardButton) return go("karte", cardButton.dataset.card);
|
||||||
|
|
||||||
|
if (event.target.closest("[data-back]")) {
|
||||||
|
state.editing = false;
|
||||||
|
return go("karten");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.target.closest("[data-toggle-edit]")) {
|
||||||
|
state.editing = !state.editing;
|
||||||
|
return loadCard(state.cardId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tab = event.target.closest(".tab");
|
||||||
|
if (tab) return go(tab.dataset.route);
|
||||||
|
|
||||||
|
const chip = event.target.closest("#filter-chips .chip");
|
||||||
|
if (chip) {
|
||||||
|
state.filter = chip.dataset.filter;
|
||||||
|
document.querySelectorAll("#filter-chips .chip").forEach((c) =>
|
||||||
|
c.setAttribute("aria-pressed", c === chip ? "true" : "false")
|
||||||
|
);
|
||||||
|
return loadCards();
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeReminder = event.target.closest("[data-delete-reminder]");
|
||||||
|
if (removeReminder) {
|
||||||
|
event.preventDefault();
|
||||||
|
return api(`/api/reminders/${removeReminder.dataset.deleteReminder}`, { method: "DELETE" })
|
||||||
|
.then(() => { loadCard(state.cardId); refreshStats(); })
|
||||||
|
.catch((error) => toast(error.message));
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeCard = event.target.closest("[data-delete-card]");
|
||||||
|
if (removeCard) {
|
||||||
|
if (!confirm("Karte und Bild endgültig löschen?")) return;
|
||||||
|
return api(`/api/cards/${removeCard.dataset.deleteCard}`, { method: "DELETE" })
|
||||||
|
.then(() => { toast("Karte gelöscht"); go("karten"); })
|
||||||
|
.catch((error) => toast(error.message));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let searchTimer;
|
||||||
|
$("search-input").addEventListener("input", (event) => {
|
||||||
|
state.query = event.target.value;
|
||||||
|
clearTimeout(searchTimer);
|
||||||
|
searchTimer = setTimeout(loadCards, 220);
|
||||||
|
});
|
||||||
|
|
||||||
|
$("search-clear").addEventListener("click", () => {
|
||||||
|
$("search-input").value = "";
|
||||||
|
state.query = "";
|
||||||
|
loadCards();
|
||||||
|
});
|
||||||
|
|
||||||
|
["input-camera", "input-library"].forEach((id) => {
|
||||||
|
$(id).addEventListener("change", (event) => {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
event.target.value = "";
|
||||||
|
runScan(file);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
$("login-form").addEventListener("submit", async (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const body = new FormData();
|
||||||
|
body.append("password", $("login-password").value);
|
||||||
|
try {
|
||||||
|
await fetch("/api/login", { method: "POST", body, credentials: "same-origin" })
|
||||||
|
.then((response) => {
|
||||||
|
if (!response.ok) throw new Error("Passwort stimmt nicht");
|
||||||
|
});
|
||||||
|
$("login-overlay").hidden = true;
|
||||||
|
$("login-error").hidden = true;
|
||||||
|
boot();
|
||||||
|
} catch (error) {
|
||||||
|
$("login-error").textContent = error.message;
|
||||||
|
$("login-error").hidden = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$("logout-button").addEventListener("click", async () => {
|
||||||
|
await api("/api/logout", { method: "POST" }).catch(() => {});
|
||||||
|
location.reload();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener("hashchange", applyRoute);
|
||||||
|
|
||||||
|
// --------------------------------------------------------------- Start
|
||||||
|
|
||||||
|
async function boot() {
|
||||||
|
try {
|
||||||
|
const session = await fetch("/api/session", { credentials: "same-origin" }).then((r) => r.json());
|
||||||
|
if (session.auth_required && !session.authenticated) {
|
||||||
|
$("login-overlay").hidden = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$("logout-button").hidden = !session.auth_required;
|
||||||
|
if (!session.extraction_available) {
|
||||||
|
toast("Kein API-Schlüssel gesetzt – Karten werden nur zugeschnitten.");
|
||||||
|
}
|
||||||
|
await refreshStats();
|
||||||
|
applyRoute();
|
||||||
|
} catch (error) {
|
||||||
|
toast("Server nicht erreichbar");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ("serviceWorker" in navigator) {
|
||||||
|
navigator.serviceWorker.register("/sw.js", { updateViaCache: "none" }).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
boot();
|
||||||
|
})();
|
||||||
BIN
static/icons/icon-192.png
Normal file
BIN
static/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 353 B |
BIN
static/icons/icon-512.png
Normal file
BIN
static/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
136
static/index.html
Normal file
136
static/index.html
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<meta name="theme-color" content="#000000">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||||
|
<meta name="apple-mobile-web-app-title" content="Karten">
|
||||||
|
<title>Visitenkarten Scanner</title>
|
||||||
|
<link rel="manifest" href="/manifest.webmanifest">
|
||||||
|
<link rel="apple-touch-icon" href="/static/icons/icon-192.png">
|
||||||
|
<link rel="icon" href="/static/icons/icon-192.png">
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="app">
|
||||||
|
<header class="topbar">
|
||||||
|
<span class="mark" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.2">
|
||||||
|
<rect x="2" y="5" width="20" height="14"></rect>
|
||||||
|
<path d="M6 10h5M6 14h8"></path>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="wordmark">
|
||||||
|
<span class="display d-md">Visitenkarten</span>
|
||||||
|
<span class="label">Scanner</span>
|
||||||
|
</span>
|
||||||
|
<span class="spacer"></span>
|
||||||
|
<button class="chip" id="logout-button" hidden>Abmelden</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<!-- ------------------------------------------------------------- Scan -->
|
||||||
|
<section class="screen stack" id="screen-scan">
|
||||||
|
<div class="block-invert">
|
||||||
|
<p class="label">Stapel oder Einzelkarte</p>
|
||||||
|
<h1 class="display d-xl" style="margin-top:8px">Karten<br>erfassen</h1>
|
||||||
|
<p class="muted" style="margin:12px 0 0">
|
||||||
|
Karten nebeneinander auf einen dunklen, einfarbigen Untergrund legen,
|
||||||
|
Handy parallel darüber halten. Ein Foto genügt für den ganzen Tisch.
|
||||||
|
</p>
|
||||||
|
<div class="figures">
|
||||||
|
<span class="figure">
|
||||||
|
<span class="label">Erfasst</span>
|
||||||
|
<span class="display d-lg mono-num" id="figure-total">0</span>
|
||||||
|
</span>
|
||||||
|
<span class="figure">
|
||||||
|
<span class="label">Nicht exportiert</span>
|
||||||
|
<span class="display d-lg mono-num" id="figure-open">0</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="btn btn-primary" for="input-camera">
|
||||||
|
Foto aufnehmen <span class="arrow">→</span>
|
||||||
|
<input id="input-camera" type="file" accept="image/*" capture="environment" hidden>
|
||||||
|
</label>
|
||||||
|
<label class="btn" for="input-library">
|
||||||
|
Aus Fotos wählen <span class="arrow">↗</span>
|
||||||
|
<input id="input-library" type="file" accept="image/*" hidden>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p class="hint">
|
||||||
|
Die Kamera liefert die volle Auflösung – bei zwanzig Karten auf einem Foto
|
||||||
|
ist das der Unterschied zwischen lesbar und nicht lesbar.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div id="scan-result"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ------------------------------------------------------------ Karten -->
|
||||||
|
<section class="screen stack" id="screen-cards" hidden>
|
||||||
|
<div class="search">
|
||||||
|
<input id="search-input" type="search" placeholder="Name, Firma, Ort, Notiz …"
|
||||||
|
autocomplete="off" enterkeyhint="search">
|
||||||
|
<button id="search-clear" type="button" aria-label="Suche leeren">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="chips" id="filter-chips">
|
||||||
|
<button class="chip" data-filter="alle" aria-pressed="true">Alle</button>
|
||||||
|
<button class="chip" data-filter="offen" aria-pressed="false">Nicht exportiert</button>
|
||||||
|
<button class="chip" data-filter="unvollstaendig" aria-pressed="false">Unvollständig</button>
|
||||||
|
<button class="chip" data-filter="erinnerung" aria-pressed="false">Mit Erinnerung</button>
|
||||||
|
</div>
|
||||||
|
<div id="cards-list"></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ------------------------------------------------------------ Detail -->
|
||||||
|
<section class="screen stack" id="screen-detail" hidden></section>
|
||||||
|
|
||||||
|
<!-- ------------------------------------------------------ Erinnerungen -->
|
||||||
|
<section class="screen stack" id="screen-reminders" hidden>
|
||||||
|
<div class="section-label"><span class="label">Erinnerungen</span></div>
|
||||||
|
<div id="reminders-list"></div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<nav class="tabs" role="tablist">
|
||||||
|
<button class="tab" role="tab" data-route="scan" aria-selected="true">Scan</button>
|
||||||
|
<button class="tab" role="tab" data-route="karten" aria-selected="false">
|
||||||
|
<span class="count mono-num" id="tab-count-cards">0</span>Karten
|
||||||
|
</button>
|
||||||
|
<button class="tab" role="tab" data-route="erinnerungen" aria-selected="false">
|
||||||
|
<span class="count mono-num" id="tab-count-reminders">0</span>Termine
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ------------------------------------------------------------------ Login -->
|
||||||
|
<div class="overlay" id="login-overlay" hidden>
|
||||||
|
<form class="block stack" id="login-form">
|
||||||
|
<p class="label muted">Zugang</p>
|
||||||
|
<h2 class="display d-lg">Anmelden</h2>
|
||||||
|
<label class="field">
|
||||||
|
<span class="label">Passwort</span>
|
||||||
|
<input type="password" id="login-password" autocomplete="current-password" required>
|
||||||
|
</label>
|
||||||
|
<button class="btn btn-primary" type="submit">Weiter <span class="arrow">→</span></button>
|
||||||
|
<p class="hint" id="login-error" hidden></p>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- -------------------------------------------------------------- Fortschritt -->
|
||||||
|
<div class="overlay" id="busy-overlay" hidden>
|
||||||
|
<div class="block-invert stack" style="text-align:center">
|
||||||
|
<div class="spinner" style="margin:0 auto"></div>
|
||||||
|
<p class="label" id="busy-text">Karten werden gelesen</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toast" id="toast" hidden></div>
|
||||||
|
|
||||||
|
<script src="/static/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
16
static/manifest.webmanifest
Normal file
16
static/manifest.webmanifest
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "Visitenkarten Scanner",
|
||||||
|
"short_name": "Karten",
|
||||||
|
"description": "Visitenkarten fotografieren, auslesen und als Kontakt speichern.",
|
||||||
|
"start_url": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"theme_color": "#000000",
|
||||||
|
"icons": [
|
||||||
|
{ "src": "/static/icons/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||||
|
{ "src": "/static/icons/icon-512.png", "sizes": "512x512", "type": "image/png" },
|
||||||
|
{ "src": "/static/icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||||
|
]
|
||||||
|
}
|
||||||
381
static/style.css
Normal file
381
static/style.css
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
/* Schwarzweiss, harte Kanten, keine Rundungen. Mobile first. */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
--fg: #000;
|
||||||
|
--bg: #fff;
|
||||||
|
--muted: #6b6b6b;
|
||||||
|
--hair: #d5d5d5;
|
||||||
|
--wash: #f2f2f2;
|
||||||
|
--sans: -apple-system, BlinkMacSystemFont, "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||||
|
--shell: 3px;
|
||||||
|
--pad: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--wash);
|
||||||
|
color: var(--fg);
|
||||||
|
font-family: var(--sans);
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1.45;
|
||||||
|
-webkit-text-size-adjust: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body { overflow-x: hidden; }
|
||||||
|
|
||||||
|
button, input, textarea, select {
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
border-radius: 0;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a { color: inherit; }
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ Typografie */
|
||||||
|
|
||||||
|
.display {
|
||||||
|
font-weight: 800;
|
||||||
|
font-stretch: 85%;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
line-height: 0.95;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.d-xl { font-size: 40px; }
|
||||||
|
.d-lg { font-size: 28px; }
|
||||||
|
.d-md { font-size: 20px; }
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.16em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted { color: var(--muted); }
|
||||||
|
.mono-num { font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------- Gerüst */
|
||||||
|
|
||||||
|
.app {
|
||||||
|
max-width: 560px;
|
||||||
|
margin: 0 auto;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--bg);
|
||||||
|
border-left: 1px solid var(--fg);
|
||||||
|
border-right: 1px solid var(--fg);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px var(--pad);
|
||||||
|
background: var(--bg);
|
||||||
|
border-bottom: var(--shell) solid var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark {
|
||||||
|
width: 34px;
|
||||||
|
height: 34px;
|
||||||
|
flex: 0 0 34px;
|
||||||
|
background: var(--fg);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark svg { width: 20px; height: 20px; }
|
||||||
|
|
||||||
|
.wordmark { line-height: 1; }
|
||||||
|
.wordmark .d-md { display: block; }
|
||||||
|
.wordmark .label { color: var(--muted); margin-top: 3px; }
|
||||||
|
|
||||||
|
.topbar .spacer { margin-left: auto; }
|
||||||
|
|
||||||
|
main { flex: 1; display: flex; flex-direction: column; }
|
||||||
|
.screen { flex: 1; padding: var(--pad) var(--pad) 28px; }
|
||||||
|
.screen[hidden] { display: none; }
|
||||||
|
|
||||||
|
.stack > * + * { margin-top: 14px; }
|
||||||
|
.stack-tight > * + * { margin-top: 8px; }
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------------- Blöcke */
|
||||||
|
|
||||||
|
.block {
|
||||||
|
border: var(--shell) solid var(--fg);
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-invert {
|
||||||
|
background: var(--fg);
|
||||||
|
color: var(--bg);
|
||||||
|
border: var(--shell) solid var(--fg);
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-invert .muted { color: #b4b4b4; }
|
||||||
|
|
||||||
|
.strip {
|
||||||
|
border: var(--shell) solid var(--fg);
|
||||||
|
border-top: none;
|
||||||
|
padding: 12px 16px;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 26px 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-label::after {
|
||||||
|
content: "";
|
||||||
|
flex: 1;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.figures {
|
||||||
|
display: flex;
|
||||||
|
gap: 30px;
|
||||||
|
margin-top: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.figure { display: flex; flex-direction: column; }
|
||||||
|
.figure .label { color: inherit; opacity: 0.7; margin-bottom: 5px; }
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------- Buttons */
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 16px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: var(--shell) solid var(--fg);
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 14px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary { background: var(--fg); color: var(--bg); }
|
||||||
|
.btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||||
|
.btn .arrow { font-weight: 400; font-size: 17px; }
|
||||||
|
|
||||||
|
.btn-row { display: flex; gap: 10px; }
|
||||||
|
.btn-row .btn { flex: 1; }
|
||||||
|
|
||||||
|
.btn-small {
|
||||||
|
padding: 9px 12px;
|
||||||
|
font-size: 11px;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
border-width: 2px;
|
||||||
|
width: auto;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-danger { border-color: var(--fg); color: var(--fg); }
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------- Formular */
|
||||||
|
|
||||||
|
.field { display: block; }
|
||||||
|
.field .label { color: var(--muted); margin-bottom: 5px; }
|
||||||
|
|
||||||
|
.field input,
|
||||||
|
.field textarea,
|
||||||
|
.field select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 11px 12px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 2px solid var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input:focus,
|
||||||
|
.field textarea:focus,
|
||||||
|
.field select:focus { outline: 3px solid var(--fg); outline-offset: 1px; }
|
||||||
|
|
||||||
|
.field textarea { min-height: 90px; resize: vertical; }
|
||||||
|
|
||||||
|
.search {
|
||||||
|
display: flex;
|
||||||
|
border: var(--shell) solid var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search input { flex: 1; border: none; padding: 14px; background: var(--bg); }
|
||||||
|
.search input:focus { outline: none; }
|
||||||
|
.search button {
|
||||||
|
border: none;
|
||||||
|
border-left: 2px solid var(--fg);
|
||||||
|
background: var(--bg);
|
||||||
|
padding: 0 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
|
||||||
|
|
||||||
|
.chip {
|
||||||
|
padding: 7px 11px;
|
||||||
|
border: 2px solid var(--fg);
|
||||||
|
background: var(--bg);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip[aria-pressed="true"] { background: var(--fg); color: var(--bg); }
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------- Tabellen */
|
||||||
|
|
||||||
|
.rows { border-top: 1px solid var(--fg); }
|
||||||
|
|
||||||
|
.row {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
background: var(--bg);
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--hair);
|
||||||
|
padding: 14px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row:last-child { border-bottom: 1px solid var(--fg); }
|
||||||
|
.row-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
|
||||||
|
.row-title { font-weight: 800; font-size: 16px; }
|
||||||
|
.row-sub { color: var(--muted); font-size: 13px; margin-top: 2px; }
|
||||||
|
.row-meta { font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--muted); white-space: nowrap; }
|
||||||
|
|
||||||
|
.data { width: 100%; border-collapse: collapse; }
|
||||||
|
.data th, .data td { padding: 11px 0; border-bottom: 1px solid var(--hair); text-align: left; vertical-align: top; }
|
||||||
|
.data th { width: 38%; font-weight: 400; color: var(--muted); font-size: 13px; }
|
||||||
|
.data td { font-weight: 600; word-break: break-word; }
|
||||||
|
.data tr:last-child th, .data tr:last-child td { border-bottom: none; }
|
||||||
|
|
||||||
|
/* ----------------------------------------------------------------------- Bilder */
|
||||||
|
|
||||||
|
.card-image {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
border: var(--shell) solid var(--fg);
|
||||||
|
background: var(--wash);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumbs { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
|
||||||
|
.thumbs img { width: 100%; border: 2px solid var(--fg); display: block; }
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------- Navigation */
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
z-index: 20;
|
||||||
|
display: flex;
|
||||||
|
background: var(--bg);
|
||||||
|
border-top: var(--shell) solid var(--fg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
flex: 1;
|
||||||
|
padding: 14px 4px calc(14px + env(safe-area-inset-bottom));
|
||||||
|
background: var(--bg);
|
||||||
|
border: none;
|
||||||
|
border-left: 1px solid var(--fg);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:first-child { border-left: none; }
|
||||||
|
.tab[aria-selected="true"] { background: var(--fg); color: var(--bg); }
|
||||||
|
.tab .count { display: block; font-size: 16px; font-weight: 800; margin-bottom: 2px; }
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------- Zustände */
|
||||||
|
|
||||||
|
.empty { border: 2px dashed var(--fg); padding: 28px 18px; text-align: center; }
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 3px 7px;
|
||||||
|
border: 2px solid var(--fg);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge-solid { background: var(--fg); color: var(--bg); }
|
||||||
|
|
||||||
|
.overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 60;
|
||||||
|
background: rgba(0, 0, 0, 0.82);
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: var(--pad);
|
||||||
|
}
|
||||||
|
|
||||||
|
.overlay[hidden] { display: none; }
|
||||||
|
.overlay .block { background: var(--bg); width: 100%; max-width: 380px; }
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
border: 3px solid var(--bg);
|
||||||
|
border-top-color: transparent;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
position: fixed;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 96px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 70;
|
||||||
|
max-width: 90vw;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--fg);
|
||||||
|
color: var(--bg);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast[hidden] { display: none; }
|
||||||
|
|
||||||
|
.hint { font-size: 12px; color: var(--muted); }
|
||||||
|
|
||||||
|
@media (min-width: 600px) {
|
||||||
|
:root { --pad: 24px; }
|
||||||
|
.app { border: 1px solid var(--fg); margin: 24px auto; min-height: calc(100vh - 48px); }
|
||||||
|
.d-xl { font-size: 48px; }
|
||||||
|
}
|
||||||
39
static/sw.js
Normal file
39
static/sw.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
/* Nur die Programmhuelle wird gecacht. Kartendaten und Bilder kommen immer
|
||||||
|
frisch vom Server - eine veraltete Liste waere schlimmer als keine. */
|
||||||
|
const CACHE = "bcs-shell-v1";
|
||||||
|
const SHELL = ["/", "/static/style.css", "/static/app.js", "/manifest.webmanifest"];
|
||||||
|
|
||||||
|
self.addEventListener("install", (event) => {
|
||||||
|
event.waitUntil(caches.open(CACHE).then((cache) => cache.addAll(SHELL)));
|
||||||
|
self.skipWaiting();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("activate", (event) => {
|
||||||
|
event.waitUntil(
|
||||||
|
caches.keys().then((keys) =>
|
||||||
|
Promise.all(keys.filter((key) => key !== CACHE).map((key) => caches.delete(key)))
|
||||||
|
)
|
||||||
|
);
|
||||||
|
self.clients.claim();
|
||||||
|
});
|
||||||
|
|
||||||
|
self.addEventListener("fetch", (event) => {
|
||||||
|
const url = new URL(event.request.url);
|
||||||
|
if (event.request.method !== "GET" || url.origin !== self.location.origin) return;
|
||||||
|
if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/vcf")) return;
|
||||||
|
|
||||||
|
// Fuer die Huelle den HTTP-Cache umgehen: sonst liefert ein Deploy noch
|
||||||
|
// tagelang die alte CSS- und JS-Datei aus.
|
||||||
|
const shell = SHELL.includes(url.pathname);
|
||||||
|
event.respondWith(
|
||||||
|
fetch(event.request, shell ? { cache: "no-store" } : undefined)
|
||||||
|
.then((response) => {
|
||||||
|
if (shell) {
|
||||||
|
const copy = response.clone();
|
||||||
|
caches.open(CACHE).then((cache) => cache.put(event.request, copy));
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
})
|
||||||
|
.catch(() => caches.match(event.request).then((hit) => hit || caches.match("/")))
|
||||||
|
);
|
||||||
|
});
|
||||||
16
tests/conftest.py
Normal file
16
tests/conftest.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
"""Testumgebung: eigenes Datenverzeichnis, keine Anmeldung, kein Netzverkehr.
|
||||||
|
|
||||||
|
Muss vor dem Import von app.* laufen - config liest die Umgebung beim Import.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
os.environ["DATA_DIR"] = tempfile.mkdtemp(prefix="bcs-test-")
|
||||||
|
os.environ["APP_PASSWORD"] = ""
|
||||||
|
os.environ["ANTHROPIC_API_KEY"] = ""
|
||||||
|
os.environ["REVERSE_GEOCODE"] = "0"
|
||||||
|
os.environ["SMTP_HOST"] = ""
|
||||||
59
tests/helpers.py
Normal file
59
tests/helpers.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"""Erzeugt Testfotos: mehrere Visitenkarten auf einem dunklen Untergrund."""
|
||||||
|
import io
|
||||||
|
import random
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image, ImageDraw
|
||||||
|
|
||||||
|
|
||||||
|
CARD_RATIO = 85.6 / 54.0
|
||||||
|
|
||||||
|
|
||||||
|
def _card(width: int = 420, seed: int = 0) -> Image.Image:
|
||||||
|
"""Eine weisse Karte mit ein paar dunklen Textzeilen."""
|
||||||
|
height = round(width / CARD_RATIO)
|
||||||
|
card = Image.new("RGB", (width, height), "white")
|
||||||
|
draw = ImageDraw.Draw(card)
|
||||||
|
rng = random.Random(seed)
|
||||||
|
draw.line([(0.08 * width, 0.30 * height), (0.62 * width, 0.30 * height)], fill="black", width=6)
|
||||||
|
draw.line([(0.08 * width, 0.44 * height), (0.48 * width, 0.44 * height)], fill=(70, 70, 70), width=3)
|
||||||
|
for index in range(3):
|
||||||
|
y = (0.62 + index * 0.10) * height
|
||||||
|
draw.line(
|
||||||
|
[(0.08 * width, y), ((0.30 + rng.random() * 0.35) * width, y)],
|
||||||
|
fill=(90, 90, 90),
|
||||||
|
width=2,
|
||||||
|
)
|
||||||
|
return card
|
||||||
|
|
||||||
|
|
||||||
|
def photo_with_cards(count: int = 6, size=(2400, 1800), angle_range: int = 8) -> np.ndarray:
|
||||||
|
"""Foto im BGR-Format mit `count` Karten in einem Raster."""
|
||||||
|
background = Image.new("RGB", size, (38, 40, 44))
|
||||||
|
rng = random.Random(count)
|
||||||
|
columns = 2 if count <= 6 else 3
|
||||||
|
rows = (count + columns - 1) // columns
|
||||||
|
cell_width = size[0] // columns
|
||||||
|
cell_height = size[1] // rows
|
||||||
|
card_width = int(min(cell_width * 0.72, cell_height * 0.72 * CARD_RATIO))
|
||||||
|
|
||||||
|
for index in range(count):
|
||||||
|
column, row = index % columns, index // columns
|
||||||
|
card = _card(card_width, seed=index)
|
||||||
|
angle = rng.uniform(-angle_range, angle_range)
|
||||||
|
rotated = card.rotate(angle, expand=True, resample=Image.BICUBIC)
|
||||||
|
mask = Image.new("L", card.size, 255).rotate(
|
||||||
|
angle, expand=True, resample=Image.BICUBIC
|
||||||
|
)
|
||||||
|
x = column * cell_width + (cell_width - rotated.width) // 2
|
||||||
|
y = row * cell_height + (cell_height - rotated.height) // 2
|
||||||
|
background.paste(rotated, (x, y), mask)
|
||||||
|
|
||||||
|
return np.asarray(background)[:, :, ::-1].copy()
|
||||||
|
|
||||||
|
|
||||||
|
def photo_bytes(count: int = 6) -> bytes:
|
||||||
|
array = photo_with_cards(count)
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
Image.fromarray(array[:, :, ::-1]).save(buffer, format="JPEG", quality=92)
|
||||||
|
return buffer.getvalue()
|
||||||
183
tests/test_api.py
Normal file
183
tests/test_api.py
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
"""Durchstich durch die HTTP-Schnittstelle.
|
||||||
|
|
||||||
|
Ohne ANTHROPIC_API_KEY laeuft die Extraktion in den Leerlauf - Freistellen,
|
||||||
|
Speichern, Suchen, Bearbeiten und Export werden trotzdem vollstaendig gepruft.
|
||||||
|
"""
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app import db, mailer, reminders
|
||||||
|
from app.main import app
|
||||||
|
from tests.helpers import photo_bytes
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def client():
|
||||||
|
with TestClient(app) as test_client:
|
||||||
|
yield test_client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def scanned(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/scan", files={"file": ("tisch.jpg", photo_bytes(4), "image/jpeg")}
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def test_healthz(client):
|
||||||
|
assert client.get("/healthz").json() == {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_legt_je_karte_einen_datensatz_an(scanned):
|
||||||
|
assert scanned["count"] == 4
|
||||||
|
assert scanned["fallback"] is False
|
||||||
|
assert len({card["id"] for card in scanned["cards"]}) == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_jede_karte_hat_ein_gespeichertes_bild(client, scanned):
|
||||||
|
for card in scanned["cards"]:
|
||||||
|
image = client.get(card["image_url"])
|
||||||
|
assert image.status_code == 200
|
||||||
|
assert image.headers["content-type"] == "image/jpeg"
|
||||||
|
assert len(image.content) > 1000
|
||||||
|
|
||||||
|
|
||||||
|
def test_liste_zaehlt_und_liefert_die_karten(client, scanned):
|
||||||
|
data = client.get("/api/cards").json()
|
||||||
|
|
||||||
|
assert data["stats"]["total"] >= 4
|
||||||
|
assert data["stats"]["offen"] >= 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_bearbeiten_und_suchen(client, scanned):
|
||||||
|
card_id = scanned["cards"][0]["id"]
|
||||||
|
|
||||||
|
patched = client.patch(
|
||||||
|
f"/api/cards/{card_id}",
|
||||||
|
json={"full_name": "Anna Berg", "company": "Berg & Partner", "notes": "Messe"},
|
||||||
|
)
|
||||||
|
assert patched.status_code == 200
|
||||||
|
assert patched.json()["full_name"] == "Anna Berg"
|
||||||
|
|
||||||
|
hits = client.get("/api/cards", params={"q": "berg"}).json()["cards"]
|
||||||
|
assert [c["id"] for c in hits] == [card_id]
|
||||||
|
|
||||||
|
by_note = client.get("/api/cards", params={"q": "Messe"}).json()["cards"]
|
||||||
|
assert card_id in [c["id"] for c in by_note]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unbekannte_felder_werden_abgewiesen(client, scanned):
|
||||||
|
response = client.patch(
|
||||||
|
f"/api/cards/{scanned['cards'][0]['id']}", json={"extract_status": "ok"}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_vcard_download_markiert_die_karte_als_exportiert(client, scanned):
|
||||||
|
card_id = scanned["cards"][1]["id"]
|
||||||
|
client.patch(f"/api/cards/{card_id}", json={"full_name": "Bea Kurz"})
|
||||||
|
|
||||||
|
response = client.get(f"/vcf/{card_id}")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.headers["content-type"].startswith("text/vcard")
|
||||||
|
assert "Bea-Kurz.vcf" in response.headers["content-disposition"]
|
||||||
|
assert "FN:Bea Kurz" in response.text
|
||||||
|
assert client.get(f"/api/cards/{card_id}").json()["exported_at"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_offen_blendet_exportierte_karten_aus(client, scanned):
|
||||||
|
offen = client.get("/api/cards", params={"filter": "offen"}).json()["cards"]
|
||||||
|
|
||||||
|
assert all(card["exported_at"] is None for card in offen)
|
||||||
|
|
||||||
|
|
||||||
|
def test_sammelexport_mehrerer_karten(client, scanned):
|
||||||
|
ids = ",".join(card["id"] for card in scanned["cards"][:3])
|
||||||
|
|
||||||
|
response = client.get("/vcf", params={"ids": ids})
|
||||||
|
|
||||||
|
assert response.text.count("BEGIN:VCARD") == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_erinnerung_anlegen_und_filtern(client, scanned):
|
||||||
|
card_id = scanned["cards"][2]["id"]
|
||||||
|
due = datetime.now(timezone.utc) + timedelta(days=3)
|
||||||
|
|
||||||
|
created = client.post(
|
||||||
|
f"/api/cards/{card_id}/reminders",
|
||||||
|
json={"text": "Angebot nachfassen", "due_at": due.isoformat()},
|
||||||
|
)
|
||||||
|
assert created.status_code == 200
|
||||||
|
|
||||||
|
detail = client.get(f"/api/cards/{card_id}").json()
|
||||||
|
assert len(detail["reminders"]) == 1
|
||||||
|
|
||||||
|
mit_erinnerung = client.get("/api/cards", params={"filter": "erinnerung"}).json()
|
||||||
|
assert card_id in [c["id"] for c in mit_erinnerung["cards"]]
|
||||||
|
assert mit_erinnerung["stats"]["erinnerungen"] >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_erinnerung_ohne_datum_wird_abgewiesen(client, scanned):
|
||||||
|
response = client.post(
|
||||||
|
f"/api/cards/{scanned['cards'][2]['id']}/reminders",
|
||||||
|
json={"text": "Ohne Termin", "due_at": "morgen"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_faellige_erinnerung_wird_verschickt(client, scanned, monkeypatch):
|
||||||
|
card_id = scanned["cards"][3]["id"]
|
||||||
|
client.patch(f"/api/cards/{card_id}", json={"full_name": "Carl Spät"})
|
||||||
|
past = datetime.now(timezone.utc) - timedelta(minutes=5)
|
||||||
|
client.post(
|
||||||
|
f"/api/cards/{card_id}/reminders",
|
||||||
|
json={"text": "Rueckruf", "due_at": past.isoformat()},
|
||||||
|
)
|
||||||
|
|
||||||
|
verschickt = []
|
||||||
|
monkeypatch.setattr(mailer, "configured", lambda: True)
|
||||||
|
monkeypatch.setattr(mailer, "send", lambda subject, body: verschickt.append((subject, body)))
|
||||||
|
|
||||||
|
assert reminders.process_due() == 1
|
||||||
|
assert "Carl Spät" in verschickt[0][0]
|
||||||
|
assert "Rueckruf" in verschickt[0][1]
|
||||||
|
|
||||||
|
# Zweiter Durchlauf darf nicht erneut verschicken.
|
||||||
|
assert reminders.process_due() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_loeschen_entfernt_karte_und_bild(client):
|
||||||
|
created = client.post(
|
||||||
|
"/api/scan", files={"file": ("einzel.jpg", photo_bytes(1), "image/jpeg")}
|
||||||
|
).json()
|
||||||
|
card_id = created["cards"][0]["id"]
|
||||||
|
image_file = db.query_one("SELECT image_file FROM cards WHERE id = ?", (card_id,))["image_file"]
|
||||||
|
|
||||||
|
from app import config
|
||||||
|
|
||||||
|
assert (config.IMAGE_DIR / image_file).exists()
|
||||||
|
assert client.delete(f"/api/cards/{card_id}").status_code == 200
|
||||||
|
assert client.get(f"/api/cards/{card_id}").status_code == 404
|
||||||
|
assert not (config.IMAGE_DIR / image_file).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_kaputter_upload_gibt_400(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/scan", files={"file": ("kaputt.jpg", b"kein bild", "image/jpeg")}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_pwa_dateien_werden_ausgeliefert(client):
|
||||||
|
assert client.get("/").status_code == 200
|
||||||
|
assert client.get("/sw.js").status_code == 200
|
||||||
|
assert client.get("/manifest.webmanifest").status_code == 200
|
||||||
|
assert client.get("/static/app.js").status_code == 200
|
||||||
72
tests/test_imaging.py
Normal file
72
tests/test_imaging.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import io
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from app import imaging
|
||||||
|
|
||||||
|
|
||||||
|
def _jpeg_with_exif(tags: dict) -> bytes:
|
||||||
|
image = Image.new("RGB", (64, 40), "white")
|
||||||
|
exif = Image.Exif()
|
||||||
|
for tag, value in tags.items():
|
||||||
|
exif[tag] = value
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
image.save(buffer, format="JPEG", exif=exif.tobytes())
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def test_aufnahmezeit_wird_gelesen():
|
||||||
|
raw = _jpeg_with_exif({0x9003: "2026:09:01 14:23:05"})
|
||||||
|
|
||||||
|
meta = imaging.read_metadata(raw)
|
||||||
|
|
||||||
|
assert meta["captured_at"].startswith("2026-09-01T14:23:05")
|
||||||
|
|
||||||
|
|
||||||
|
def test_gps_wird_in_dezimalgrad_umgerechnet():
|
||||||
|
raw = _jpeg_with_exif(
|
||||||
|
{0x8825: {1: "N", 2: (52.0, 19.0, 18.0), 3: "E", 4: (9.0, 48.0, 28.0)}}
|
||||||
|
)
|
||||||
|
|
||||||
|
meta = imaging.read_metadata(raw)
|
||||||
|
|
||||||
|
assert meta["lat"] == 52.321667
|
||||||
|
assert meta["lon"] == 9.807778
|
||||||
|
|
||||||
|
|
||||||
|
def test_suedliche_und_westliche_koordinaten_werden_negativ():
|
||||||
|
raw = _jpeg_with_exif(
|
||||||
|
{0x8825: {1: "S", 2: (33.0, 55.0, 0.0), 3: "W", 4: (18.0, 25.0, 0.0)}}
|
||||||
|
)
|
||||||
|
|
||||||
|
meta = imaging.read_metadata(raw)
|
||||||
|
|
||||||
|
assert meta["lat"] < 0
|
||||||
|
assert meta["lon"] < 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_foto_ohne_exif_liefert_leere_metadaten():
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
Image.new("RGB", (32, 32), "white").save(buffer, format="JPEG")
|
||||||
|
|
||||||
|
meta = imaging.read_metadata(buffer.getvalue())
|
||||||
|
|
||||||
|
assert meta == {"captured_at": None, "lat": None, "lon": None}
|
||||||
|
|
||||||
|
|
||||||
|
def test_kaputte_datei_wirft_nicht():
|
||||||
|
assert imaging.read_metadata(b"kein bild") == {
|
||||||
|
"captured_at": None,
|
||||||
|
"lat": None,
|
||||||
|
"lon": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_dekodieren_liefert_bgr_array():
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
Image.new("RGB", (20, 10), (255, 0, 0)).save(buffer, format="PNG")
|
||||||
|
|
||||||
|
array = imaging.decode(buffer.getvalue())
|
||||||
|
|
||||||
|
assert array.shape == (10, 20, 3)
|
||||||
|
assert tuple(array[0, 0]) == (0, 0, 255) # Rot liegt in BGR hinten
|
||||||
45
tests/test_segment.py
Normal file
45
tests/test_segment.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from app import segment
|
||||||
|
from tests.helpers import photo_with_cards
|
||||||
|
|
||||||
|
|
||||||
|
def test_findet_alle_karten_im_stapel():
|
||||||
|
photo = photo_with_cards(count=6)
|
||||||
|
crops, fallback = segment.segment(photo)
|
||||||
|
|
||||||
|
assert fallback is False
|
||||||
|
assert len(crops) == 6
|
||||||
|
|
||||||
|
|
||||||
|
def test_zuschnitte_haben_kartenformat():
|
||||||
|
crops, _ = segment.segment(photo_with_cards(count=4))
|
||||||
|
|
||||||
|
for crop in crops:
|
||||||
|
height, width = crop.shape[:2]
|
||||||
|
assert (width, height) == (segment.OUT_WIDTH, segment.OUT_HEIGHT)
|
||||||
|
|
||||||
|
|
||||||
|
def test_zuschnitt_enthaelt_die_karte_und_nicht_den_tisch():
|
||||||
|
crops, _ = segment.segment(photo_with_cards(count=4))
|
||||||
|
|
||||||
|
for crop in crops:
|
||||||
|
# Die Karte ist weiss, der Untergrund dunkel. Ein Zuschnitt, der
|
||||||
|
# danebenliegt, waere im Mittel dunkel.
|
||||||
|
assert crop.mean() > 170
|
||||||
|
|
||||||
|
|
||||||
|
def test_grosser_stapel():
|
||||||
|
crops, fallback = segment.segment(photo_with_cards(count=12))
|
||||||
|
|
||||||
|
assert fallback is False
|
||||||
|
assert len(crops) == 12
|
||||||
|
|
||||||
|
|
||||||
|
def test_einzelne_formatfuellende_karte_faellt_auf_gesamtbild_zurueck():
|
||||||
|
# Randlose Karte: es gibt keine Kante zum Untergrund, also keinen Fund.
|
||||||
|
card = np.full((1080, 1712, 3), 245, dtype=np.uint8)
|
||||||
|
crops, fallback = segment.segment(card)
|
||||||
|
|
||||||
|
assert fallback is True
|
||||||
|
assert len(crops) == 1
|
||||||
71
tests/test_vcard.py
Normal file
71
tests/test_vcard.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
from app import vcard
|
||||||
|
|
||||||
|
BASE = {
|
||||||
|
"full_name": "Dr. Anna Berg",
|
||||||
|
"first_name": "Anna",
|
||||||
|
"last_name": "Berg",
|
||||||
|
"job_title": "Geschäftsführerin",
|
||||||
|
"company": "Berg & Partner GmbH",
|
||||||
|
"email": "a.berg@berg-partner.de",
|
||||||
|
"phone": "+49 511 1234560",
|
||||||
|
"mobile": "+49 170 1234567",
|
||||||
|
"website": "berg-partner.de",
|
||||||
|
"street": "Musterweg 3",
|
||||||
|
"postal_code": "30159",
|
||||||
|
"city": "Hannover",
|
||||||
|
"country": "Deutschland",
|
||||||
|
"notes": "Sucht ein neues CRM.",
|
||||||
|
"captured_at": "2026-09-01T10:15:00+00:00",
|
||||||
|
"place": "Hannover, Messegelände",
|
||||||
|
"lat": 52.3216,
|
||||||
|
"lon": 9.8078,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_enthaelt_die_pflichtzeilen():
|
||||||
|
output = vcard.build(BASE)
|
||||||
|
|
||||||
|
assert output.startswith("BEGIN:VCARD\r\nVERSION:3.0\r\n")
|
||||||
|
assert output.endswith("END:VCARD\r\n")
|
||||||
|
assert "FN:Dr. Anna Berg" in output
|
||||||
|
assert "N:Berg;Anna;;;" in output
|
||||||
|
assert "TEL;TYPE=CELL:+49 170 1234567" in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_kommas_und_semikolons_werden_maskiert():
|
||||||
|
output = vcard.build({**BASE, "company": "Berg, Partner; Co"})
|
||||||
|
|
||||||
|
assert "ORG:Berg\\, Partner\\; Co" in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_notiz_traegt_zeit_und_ort():
|
||||||
|
note = [line for line in vcard.build(BASE).splitlines() if line.startswith("NOTE:")][0]
|
||||||
|
|
||||||
|
assert "Sucht ein neues CRM." in note
|
||||||
|
assert "01.09.2026" in note
|
||||||
|
assert "Messegelände" in note
|
||||||
|
|
||||||
|
|
||||||
|
def test_leere_karte_bekommt_trotzdem_einen_namen():
|
||||||
|
output = vcard.build({"notes": ""})
|
||||||
|
|
||||||
|
assert "FN:Unbenannt" in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_firma_ersetzt_den_namen_wenn_keiner_erkannt_wurde():
|
||||||
|
output = vcard.build({"company": "Berg & Partner GmbH"})
|
||||||
|
|
||||||
|
assert "FN:Berg & Partner GmbH" in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_mehrere_karten_in_einer_datei():
|
||||||
|
output = vcard.build_many([BASE, {"full_name": "Bea Kurz"}])
|
||||||
|
|
||||||
|
assert output.count("BEGIN:VCARD") == 2
|
||||||
|
assert output.count("END:VCARD") == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_dateiname_ist_dateisystemtauglich():
|
||||||
|
assert vcard.filename(BASE) == "Dr-Anna-Berg.vcf"
|
||||||
|
assert vcard.filename({}) == "Unbenannt.vcf"
|
||||||
|
assert vcard.filename({"full_name": "?!"}) == "kontakt.vcf"
|
||||||
Reference in New Issue
Block a user