Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e8918e0cd | ||
|
|
bf0a397597 | ||
|
|
a7fbc15792 | ||
|
|
1f29d5eb27 | ||
|
|
bc1981f1bd |
@@ -149,7 +149,7 @@ Neuer Proxy Host:
|
|||||||
|
|
||||||
| Feld | Wert |
|
| Feld | Wert |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Domain Names | `business-card-scanner.lucas-orth.de` |
|
| Domain Names | `scanner.lucas-orth.de` |
|
||||||
| Scheme | `http` |
|
| Scheme | `http` |
|
||||||
| Forward Hostname / IP | `business-card-scanner` (= `container_name`) |
|
| Forward Hostname / IP | `business-card-scanner` (= `container_name`) |
|
||||||
| Forward Port | `8080` |
|
| Forward Port | `8080` |
|
||||||
|
|||||||
62
README.md
62
README.md
@@ -7,11 +7,23 @@ Kontakt aufs iPhone holen. Selbst gehostete PWA, ein Container.
|
|||||||
|
|
||||||
**Stapelscan.** Ein Foto von zwanzig Karten auf dem Tisch reicht. OpenCV findet
|
**Stapelscan.** Ein Foto von zwanzig Karten auf dem Tisch reicht. OpenCV findet
|
||||||
die Kartenrechtecke, entzerrt sie perspektivisch und schneidet sie einzeln aus –
|
die Kartenrechtecke, entzerrt sie perspektivisch und schneidet sie einzeln aus –
|
||||||
ohne manuelle Nacharbeit. Jeder Zuschnitt geht als Bild an das Vision-Modell,
|
ohne manuelle Nacharbeit. Der Zuschnitt wird dabei leicht enger gesetzt als die
|
||||||
das Name, Firma, Position und Kontaktdaten als strukturiertes JSON zurückgibt.
|
erkannte Kante, damit kein Untergrund stehen bleibt, und die Beleuchtung wird
|
||||||
Ein vorgeschalteter OCR-Schritt wäre kontraproduktiv: er würde Layout und
|
ausgeglichen, damit das Papier weiß wird statt grau.
|
||||||
Schriftgrößen wegwerfen, also genau die Information, aus der die Feldzuordnung
|
|
||||||
entsteht.
|
Jeder Zuschnitt geht als Bild an das Vision-Modell, das Name, Firma, Position
|
||||||
|
und Kontaktdaten als strukturiertes JSON zurückgibt. Ein vorgeschalteter
|
||||||
|
OCR-Schritt wäre kontraproduktiv: er würde Layout und Schriftgrößen wegwerfen,
|
||||||
|
also genau die Information, aus der die Feldzuordnung entsteht. Das Modell sagt
|
||||||
|
außerdem, wie herum die Karte gehört – geometrisch ist das nicht bestimmbar –
|
||||||
|
und das gespeicherte Bild wird entsprechend gedreht.
|
||||||
|
|
||||||
|
**Die Antwort wartet nicht auf das Modell.** Der Scan liefert die freigestellten
|
||||||
|
Karten sofort zurück, das Auslesen läuft danach im Hintergrund weiter; die
|
||||||
|
Oberfläche lädt nach. Ein Zwanzigerstapel blockiert damit keine Minute im
|
||||||
|
Upload. Wer eine Karte in der Zwischenzeit von Hand ergänzt, verliert die
|
||||||
|
Eingabe nicht – das Modell füllt nur leere Felder. Bricht der Server mitten im
|
||||||
|
Lauf ab, holt er die offenen Karten beim nächsten Start nach.
|
||||||
|
|
||||||
**Metadaten.** Aufnahmezeit und GPS kommen aus den EXIF-Daten des Fotos, nicht
|
**Metadaten.** Aufnahmezeit und GPS kommen aus den EXIF-Daten des Fotos, nicht
|
||||||
aus dem Browser – Karten werden oft erst abends am Schreibtisch abfotografiert,
|
aus dem Browser – Karten werden oft erst abends am Schreibtisch abfotografiert,
|
||||||
@@ -19,14 +31,41 @@ der Browserstandort wäre dann das Wohnzimmer statt der Messe. Die Koordinaten
|
|||||||
löst Nominatim in einen Ortsnamen auf. Fehlen EXIF-Daten, greift der
|
löst Nominatim in einen Ortsnamen auf. Fehlen EXIF-Daten, greift der
|
||||||
Browserstandort als Rückfallebene.
|
Browserstandort als Rückfallebene.
|
||||||
|
|
||||||
**Übersicht.** Alle Karten mit Volltextsuche über Name, Firma, Ort und Notiz.
|
**Anlass.** Ein Feld auf der Startseite („Hannover Messe 2026“) gilt für alle
|
||||||
Filter für „nicht exportiert“, „unvollständig“ und „mit Erinnerung“.
|
folgenden Scans und wird im Browser gemerkt. Verlässlicher als der Ortsname aus
|
||||||
|
den Koordinaten und die eigentliche Ordnung über den Bestand.
|
||||||
|
|
||||||
**Export.** Ein Tippen auf *In Kontakte speichern* liefert eine vCard 3.0, die
|
**Dubletten.** Auf Messen trifft man Leute wieder. Die Detailansicht zeigt
|
||||||
iOS direkt in die Kontakte-App übernimmt. Die Karte gilt danach als exportiert.
|
Karten, die dieselbe Person meinen könnten – gleiche E-Mail, gleiche
|
||||||
|
Telefonnummer (Schreibweise egal, verglichen werden die letzten acht Ziffern)
|
||||||
|
oder gleicher Nachname bei gleicher Firma.
|
||||||
|
|
||||||
|
**Rückseite.** Nachträglich ein zweites Bild zur Karte aufnehmen. Es wird
|
||||||
|
ausgelesen, füllt aber nur Felder, die die Vorderseite offen gelassen hat –
|
||||||
|
dort steht oft nur die Mobilnummer.
|
||||||
|
|
||||||
|
**Übersicht.** Alle Karten mit Volltextsuche über Name, Firma, Anlass, Ort und
|
||||||
|
Notiz. Filter für „nicht exportiert“, „unvollständig“ und „mit Erinnerung“.
|
||||||
|
|
||||||
|
**Bild nachbearbeiten.** Drehen in 90-Grad-Schritten, Ausrichten über einen
|
||||||
|
Schieberegler und Zuschneiden per Rahmen. Jede Korrektur rechnet der Server vom
|
||||||
|
unbearbeiteten Original, das beim ersten Eingriff daneben abgelegt wird – so
|
||||||
|
summieren sich keine Verluste und *Zurücksetzen* führt zurück zum Scan.
|
||||||
|
|
||||||
|
**Klickbare Felder.** E-Mail, Web, Telefon und Mobil sind Handlungen, keine
|
||||||
|
Zeichenketten. Die Adresse öffnet die Navigations-App, die unter *Mehr*
|
||||||
|
eingestellt ist (Apple Karten, Google Maps oder OpenStreetMap).
|
||||||
|
|
||||||
|
**Export und Teilen.** *In Kontakte speichern* liefert eine vCard 3.0, die iOS
|
||||||
|
direkt in die Kontakte-App übernimmt. *Als vCard teilen* geht über das
|
||||||
|
System-Teilenblatt – per AirDrop, Mail oder Nachricht, ohne Umweg über den
|
||||||
|
Download. Bewusst einzeln: ein Sammelexport schiebt zwanzig Kontakte ungeprüft
|
||||||
|
ins Adressbuch.
|
||||||
|
|
||||||
**Notizen und Erinnerungen.** Freitext pro Karte. Erinnerungen mit Termin gehen
|
**Notizen und Erinnerungen.** Freitext pro Karte. Erinnerungen mit Termin gehen
|
||||||
als Mail raus; verpasste Termine holt der Scheduler beim nächsten Start nach.
|
als Mail raus; verpasste Termine holt der Scheduler beim nächsten Start nach.
|
||||||
|
Schnellwahl für die üblichen Abstände, damit das Datumsfeld selten gebraucht
|
||||||
|
wird.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
@@ -56,7 +95,7 @@ Tag-Push nach den Konventionen in [DEPLOY.md](DEPLOY.md). Vor dem ersten Tag:
|
|||||||
nach dem Muster in [.env.example](.env.example). `SECRET_KEY` mit
|
nach dem Muster in [.env.example](.env.example). `SECRET_KEY` mit
|
||||||
`openssl rand -hex 32` erzeugen.
|
`openssl rand -hex 32` erzeugen.
|
||||||
2. **Proxy Host** im Nginx Proxy Manager:
|
2. **Proxy Host** im Nginx Proxy Manager:
|
||||||
`business-card-scanner.lucas-orth.de` → `business-card-scanner` : `8080`,
|
`scanner.lucas-orth.de` → `business-card-scanner` : `8080`,
|
||||||
danach Let's-Encrypt-Zertifikat und „Force SSL“.
|
danach Let's-Encrypt-Zertifikat und „Force SSL“.
|
||||||
3. **Deployen:** `git tag v1.0.0 && git push origin v1.0.0`
|
3. **Deployen:** `git tag v1.0.0 && git push origin v1.0.0`
|
||||||
|
|
||||||
@@ -94,7 +133,8 @@ Karte und sagt das in der Oberfläche. Einzelne Fehlgriffe lassen sich pro Karte
|
|||||||
app/ FastAPI-Anwendung
|
app/ FastAPI-Anwendung
|
||||||
segment.py OpenCV-Pipeline: Karten finden und entzerren
|
segment.py OpenCV-Pipeline: Karten finden und entzerren
|
||||||
extract.py Vision-Modell, strukturiertes JSON
|
extract.py Vision-Modell, strukturiertes JSON
|
||||||
imaging.py EXIF-Auswertung, Bildkonvertierung
|
duplicates.py Ähnliche Karten finden
|
||||||
|
imaging.py EXIF, Drehen, Zuschneiden
|
||||||
vcard.py vCard 3.0
|
vcard.py vCard 3.0
|
||||||
reminders.py Scheduler für die Erinnerungsmails
|
reminders.py Scheduler für die Erinnerungsmails
|
||||||
static/ PWA: HTML, CSS, JavaScript, Service Worker
|
static/ PWA: HTML, CSS, JavaScript, Service Worker
|
||||||
|
|||||||
44
app/db.py
44
app/db.py
@@ -15,7 +15,9 @@ CREATE TABLE IF NOT EXISTS cards (
|
|||||||
captured_at TEXT,
|
captured_at TEXT,
|
||||||
batch_id TEXT,
|
batch_id TEXT,
|
||||||
image_file TEXT,
|
image_file TEXT,
|
||||||
|
back_image_file TEXT,
|
||||||
source TEXT NOT NULL DEFAULT 'batch',
|
source TEXT NOT NULL DEFAULT 'batch',
|
||||||
|
occasion TEXT,
|
||||||
full_name TEXT,
|
full_name TEXT,
|
||||||
first_name TEXT,
|
first_name TEXT,
|
||||||
last_name TEXT,
|
last_name TEXT,
|
||||||
@@ -71,10 +73,45 @@ def connect() -> sqlite3.Connection:
|
|||||||
_conn.execute("PRAGMA journal_mode=WAL")
|
_conn.execute("PRAGMA journal_mode=WAL")
|
||||||
_conn.execute("PRAGMA foreign_keys=ON")
|
_conn.execute("PRAGMA foreign_keys=ON")
|
||||||
_conn.executescript(SCHEMA)
|
_conn.executescript(SCHEMA)
|
||||||
|
_migrate(_conn)
|
||||||
_conn.commit()
|
_conn.commit()
|
||||||
return _conn
|
return _conn
|
||||||
|
|
||||||
|
|
||||||
|
# Spalten, die nach dem ersten Deploy dazugekommen sind. CREATE TABLE IF NOT
|
||||||
|
# EXISTS ruehrt eine bestehende Tabelle nicht an - ohne das hier liefe die
|
||||||
|
# App auf dem Server mit dem alten Schema weiter.
|
||||||
|
LATER_COLUMNS = {
|
||||||
|
"back_image_file": "TEXT",
|
||||||
|
"occasion": "TEXT",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _migrate(conn: sqlite3.Connection) -> None:
|
||||||
|
present = {row["name"] for row in conn.execute("PRAGMA table_info(cards)")}
|
||||||
|
for name, kind in LATER_COLUMNS.items():
|
||||||
|
if name not in present:
|
||||||
|
conn.execute(f"ALTER TABLE cards ADD COLUMN {name} {kind}")
|
||||||
|
_split_legacy_names(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def _split_legacy_names(conn: sqlite3.Connection) -> None:
|
||||||
|
"""full_name ist als Feld entfallen. Aeltere Datensaetze haben nur diesen
|
||||||
|
einen Namen - am letzten Leerzeichen trennen, damit sie nicht namenlos
|
||||||
|
dastehen. Die Spalte bleibt: SQLite baut zum Loeschen die Tabelle neu,
|
||||||
|
und der Gewinn waere ein paar Byte."""
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id, full_name FROM cards "
|
||||||
|
"WHERE full_name IS NOT NULL AND first_name IS NULL AND last_name IS NULL"
|
||||||
|
).fetchall()
|
||||||
|
for row in rows:
|
||||||
|
first, _, last = row["full_name"].strip().rpartition(" ")
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE cards SET first_name = ?, last_name = ? WHERE id = ?",
|
||||||
|
(first or None, last or None, row["id"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def query(sql: str, params: Iterable[Any] = ()) -> list[sqlite3.Row]:
|
def query(sql: str, params: Iterable[Any] = ()) -> list[sqlite3.Row]:
|
||||||
with _lock:
|
with _lock:
|
||||||
return connect().execute(sql, tuple(params)).fetchall()
|
return connect().execute(sql, tuple(params)).fetchall()
|
||||||
@@ -91,10 +128,3 @@ def execute(sql: str, params: Iterable[Any] = ()) -> None:
|
|||||||
conn.execute(sql, tuple(params))
|
conn.execute(sql, tuple(params))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
def execute_many(statements: list[tuple[str, Iterable[Any]]]) -> None:
|
|
||||||
with _lock:
|
|
||||||
conn = connect()
|
|
||||||
for sql, params in statements:
|
|
||||||
conn.execute(sql, tuple(params))
|
|
||||||
conn.commit()
|
|
||||||
|
|||||||
55
app/duplicates.py
Normal file
55
app/duplicates.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"""Karten finden, die dieselbe Person meinen.
|
||||||
|
|
||||||
|
Auf Messen trifft man Leute wieder, und dieselbe Karte landet ein zweites Mal
|
||||||
|
im Stapel. Verglichen wird nicht der ganze Datensatz, sondern das, was eine
|
||||||
|
Person eindeutig macht - in dieser Reihenfolge, weil die Aussagekraft faellt.
|
||||||
|
"""
|
||||||
|
from . import db
|
||||||
|
|
||||||
|
|
||||||
|
def _phone_key(value) -> str:
|
||||||
|
"""Nur die letzten acht Ziffern. Damit passen +49 511 123456,
|
||||||
|
0049511123456 und 0511/123456 aufeinander."""
|
||||||
|
digits = "".join(character for character in (value or "") if character.isdigit())
|
||||||
|
return digits[-8:] if len(digits) >= 8 else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _phone_keys(row) -> set:
|
||||||
|
return {_phone_key(row["phone"]), _phone_key(row["mobile"])} - {""}
|
||||||
|
|
||||||
|
|
||||||
|
def _clean(value) -> str:
|
||||||
|
return (value or "").strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _match(card, other) -> str:
|
||||||
|
email = _clean(card["email"])
|
||||||
|
if email and _clean(other["email"]) == email:
|
||||||
|
return "gleiche E-Mail"
|
||||||
|
if _phone_keys(card) & _phone_keys(other):
|
||||||
|
return "gleiche Telefonnummer"
|
||||||
|
last_name, company = _clean(card["last_name"]), _clean(card["company"])
|
||||||
|
if last_name and company:
|
||||||
|
if _clean(other["last_name"]) == last_name and _clean(other["company"]) == company:
|
||||||
|
return "gleicher Name und dieselbe Firma"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def find(card) -> list:
|
||||||
|
"""Aehnliche Karten, neueste zuerst."""
|
||||||
|
others = db.query(
|
||||||
|
"""
|
||||||
|
SELECT id, first_name, last_name, company, email, phone, mobile,
|
||||||
|
occasion, captured_at, created_at
|
||||||
|
FROM cards
|
||||||
|
WHERE id <> ? AND extract_status <> 'offen'
|
||||||
|
ORDER BY COALESCE(captured_at, created_at) DESC
|
||||||
|
""",
|
||||||
|
(card["id"],),
|
||||||
|
)
|
||||||
|
hits = []
|
||||||
|
for other in others:
|
||||||
|
reason = _match(card, other)
|
||||||
|
if reason:
|
||||||
|
hits.append({**dict(other), "reason": reason})
|
||||||
|
return hits
|
||||||
@@ -11,12 +11,12 @@ import logging
|
|||||||
|
|
||||||
import anthropic
|
import anthropic
|
||||||
|
|
||||||
from . import config
|
from . import config, imaging
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
FIELDS = [
|
FIELDS = [
|
||||||
"full_name", "first_name", "last_name", "job_title", "company", "department",
|
"first_name", "last_name", "job_title", "company", "department",
|
||||||
"email", "phone", "mobile", "fax", "website",
|
"email", "phone", "mobile", "fax", "website",
|
||||||
"street", "postal_code", "city", "country",
|
"street", "postal_code", "city", "country",
|
||||||
]
|
]
|
||||||
@@ -28,9 +28,17 @@ SCHEMA = {
|
|||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"description": "False, wenn der Ausschnitt keine Visitenkarte zeigt.",
|
"description": "False, wenn der Ausschnitt keine Visitenkarte zeigt.",
|
||||||
},
|
},
|
||||||
|
"rotation": {
|
||||||
|
"type": "integer",
|
||||||
|
"enum": [0, 90, 180, 270],
|
||||||
|
"description": (
|
||||||
|
"Grad im Uhrzeigersinn, um die das Bild gedreht werden muss, "
|
||||||
|
"damit die Schrift aufrecht steht."
|
||||||
|
),
|
||||||
|
},
|
||||||
**{name: {"type": ["string", "null"]} for name in FIELDS},
|
**{name: {"type": ["string", "null"]} for name in FIELDS},
|
||||||
},
|
},
|
||||||
"required": ["is_business_card", *FIELDS],
|
"required": ["is_business_card", "rotation", *FIELDS],
|
||||||
"additionalProperties": False,
|
"additionalProperties": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,17 +52,33 @@ Regeln:
|
|||||||
- phone ist die Festnetznummer, mobile die Mobilnummer. Ist nur eine Nummer
|
- phone ist die Festnetznummer, mobile die Mobilnummer. Ist nur eine Nummer
|
||||||
ohne Kennzeichnung angegeben, gehoert sie in phone.
|
ohne Kennzeichnung angegeben, gehoert sie in phone.
|
||||||
- company ist der Firmenname, job_title die Funktion der Person.
|
- company ist der Firmenname, job_title die Funktion der Person.
|
||||||
- full_name ist der vollstaendige Name inklusive Titeln wie "Dr.",
|
- first_name ist der Vorname einschliesslich akademischer Titel ("Dr. Anna"),
|
||||||
first_name und last_name ohne Titel.
|
last_name der Nachname.
|
||||||
- website ohne Protokollpraefix.
|
- website ohne Protokollpraefix.
|
||||||
- Zeigt der Ausschnitt keine Visitenkarte (Tischkante, Notizzettel, leere
|
- Zeigt der Ausschnitt keine Visitenkarte (Tischkante, Notizzettel, leere
|
||||||
Flaeche), setze is_business_card auf false und alle Felder auf null."""
|
Flaeche), setze is_business_card auf false und alle Felder auf null.
|
||||||
|
- rotation sagt, um wie viel Grad im Uhrzeigersinn das Bild gedreht werden
|
||||||
|
muss, damit die Schrift aufrecht steht. Steht sie schon richtig, ist der
|
||||||
|
Wert 0. Steht die Karte auf dem Kopf, ist er 180."""
|
||||||
|
|
||||||
PROMPT = "Lies diese Visitenkarte aus."
|
PROMPT = "Lies diese Visitenkarte aus."
|
||||||
|
|
||||||
|
# Nicht jedes Modell nimmt jeden Parameter an: die kleinen Modelle kennen
|
||||||
|
# output_config.effort nicht (400), der server-seitige Fallback gilt nur fuer
|
||||||
|
# die groessten.
|
||||||
|
_NO_EFFORT = ("claude-haiku-", "claude-sonnet-4-5")
|
||||||
|
_WITH_FALLBACK = ("claude-opus-5", "claude-fable-")
|
||||||
|
|
||||||
_client = None
|
_client = None
|
||||||
|
|
||||||
|
|
||||||
|
def _output_config(with_effort: bool) -> dict:
|
||||||
|
settings = {"format": {"type": "json_schema", "schema": SCHEMA}}
|
||||||
|
if with_effort:
|
||||||
|
settings["effort"] = "low" # reines Ablesen, keine Denkarbeit
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
def available() -> bool:
|
def available() -> bool:
|
||||||
return bool(config.ANTHROPIC_API_KEY)
|
return bool(config.ANTHROPIC_API_KEY)
|
||||||
|
|
||||||
@@ -67,7 +91,8 @@ def _get_client() -> anthropic.AsyncAnthropic:
|
|||||||
|
|
||||||
|
|
||||||
def _empty(reason: str) -> dict:
|
def _empty(reason: str) -> dict:
|
||||||
return {"is_business_card": False, "_error": reason, **{f: None for f in FIELDS}}
|
return {"is_business_card": False, "rotation": 0, "_error": reason,
|
||||||
|
**{f: None for f in FIELDS}}
|
||||||
|
|
||||||
|
|
||||||
def _has_content(result: dict) -> bool:
|
def _has_content(result: dict) -> bool:
|
||||||
@@ -92,27 +117,28 @@ async def _ask(image_jpeg: bytes) -> dict:
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
common = dict(
|
model = config.ANTHROPIC_MODEL
|
||||||
model=config.ANTHROPIC_MODEL,
|
common = dict(model=model, max_tokens=2000, system=SYSTEM, messages=messages)
|
||||||
max_tokens=2000,
|
effort = not model.startswith(_NO_EFFORT)
|
||||||
system=SYSTEM,
|
|
||||||
messages=messages,
|
|
||||||
output_config={
|
|
||||||
"format": {"type": "json_schema", "schema": SCHEMA},
|
|
||||||
"effort": "low", # reines Ablesen, keine Denkarbeit
|
|
||||||
},
|
|
||||||
)
|
|
||||||
try:
|
try:
|
||||||
response = await client.beta.messages.create(
|
if model.startswith(_WITH_FALLBACK):
|
||||||
betas=["server-side-fallback-2026-07-01"],
|
response = await client.beta.messages.create(
|
||||||
fallbacks="default",
|
betas=["server-side-fallback-2026-07-01"],
|
||||||
**common,
|
fallbacks="default",
|
||||||
|
output_config=_output_config(effort),
|
||||||
|
**common,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
response = await client.messages.create(
|
||||||
|
output_config=_output_config(effort), **common
|
||||||
|
)
|
||||||
|
except anthropic.BadRequestError as exc:
|
||||||
|
# Ein Modell mit engerem Parametersatz, als die Listen oben kennen.
|
||||||
|
# Einmal ohne Zusatzparameter nachfassen, statt die Karte zu verlieren.
|
||||||
|
log.warning("Anfrage abgelehnt (%s) - Wiederholung ohne Zusatzparameter", exc)
|
||||||
|
response = await client.messages.create(
|
||||||
|
output_config=_output_config(with_effort=False), **common
|
||||||
)
|
)
|
||||||
except anthropic.BadRequestError:
|
|
||||||
# Der Fallback-Parameter haengt am Modell. Ohne ihn laeuft die Anfrage
|
|
||||||
# weiter, sie bricht bei einer Ablehnung nur ohne Ersatzmodell ab.
|
|
||||||
log.warning("Server-side fallback abgelehnt, Anfrage ohne Fallback")
|
|
||||||
response = await client.messages.create(**common)
|
|
||||||
|
|
||||||
if response.stop_reason == "refusal":
|
if response.stop_reason == "refusal":
|
||||||
return _empty("Vom Modell abgelehnt")
|
return _empty("Vom Modell abgelehnt")
|
||||||
@@ -123,11 +149,12 @@ async def _ask(image_jpeg: bytes) -> dict:
|
|||||||
return json.loads(text)
|
return json.loads(text)
|
||||||
|
|
||||||
|
|
||||||
async def extract(image_jpeg: bytes, rotated_jpeg: bytes = None) -> dict:
|
async def extract(image_jpeg: bytes) -> dict:
|
||||||
"""Ein Kartenbild auslesen.
|
"""Ein Kartenbild auslesen.
|
||||||
|
|
||||||
Bleibt das Ergebnis leer, wird einmal um 180 Grad gedreht nachgefasst:
|
Bleibt das Ergebnis leer, wird einmal um 180 Grad gedreht nachgefasst:
|
||||||
wie herum die Karte auf dem Tisch lag, ist geometrisch nicht bestimmbar.
|
wie herum die Karte auf dem Tisch lag, ist geometrisch nicht bestimmbar.
|
||||||
|
Die gedrehte Fassung entsteht erst hier, nicht auf Vorrat fuer jede Karte.
|
||||||
"""
|
"""
|
||||||
if not available():
|
if not available():
|
||||||
return _empty("Kein ANTHROPIC_API_KEY gesetzt")
|
return _empty("Kein ANTHROPIC_API_KEY gesetzt")
|
||||||
@@ -137,23 +164,26 @@ async def extract(image_jpeg: bytes, rotated_jpeg: bytes = None) -> dict:
|
|||||||
log.exception("Extraktion fehlgeschlagen")
|
log.exception("Extraktion fehlgeschlagen")
|
||||||
return _empty(f"{type(exc).__name__}: {exc}")
|
return _empty(f"{type(exc).__name__}: {exc}")
|
||||||
|
|
||||||
if not _has_content(result) and rotated_jpeg is not None:
|
if not _has_content(result):
|
||||||
try:
|
try:
|
||||||
retry = await _ask(rotated_jpeg)
|
rotated = await asyncio.to_thread(imaging.rotate_jpeg, image_jpeg, 180)
|
||||||
|
retry = await _ask(rotated)
|
||||||
if _has_content(retry):
|
if _has_content(retry):
|
||||||
retry["_rotated"] = True
|
# Der zweite Anlauf sah das Bild gedreht - dann stimmt auch
|
||||||
|
# seine Rotationsangabe relativ zum gedrehten Bild.
|
||||||
|
retry["rotation"] = (int(retry.get("rotation") or 0) + 180) % 360
|
||||||
return retry
|
return retry
|
||||||
except Exception:
|
except Exception:
|
||||||
log.exception("Zweiter Versuch (gedreht) fehlgeschlagen")
|
log.exception("Zweiter Versuch (gedreht) fehlgeschlagen")
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
async def extract_all(pairs: list) -> list:
|
async def extract_all(images: list) -> list:
|
||||||
"""Alle Karten eines Fotos parallel auslesen."""
|
"""Alle Karten eines Fotos parallel auslesen."""
|
||||||
limit = asyncio.Semaphore(max(1, config.EXTRACT_CONCURRENCY))
|
limit = asyncio.Semaphore(max(1, config.EXTRACT_CONCURRENCY))
|
||||||
|
|
||||||
async def one(upright: bytes, rotated: bytes) -> dict:
|
async def one(image_jpeg: bytes) -> dict:
|
||||||
async with limit:
|
async with limit:
|
||||||
return await extract(upright, rotated)
|
return await extract(image_jpeg)
|
||||||
|
|
||||||
return await asyncio.gather(*(one(u, r) for u, r in pairs))
|
return await asyncio.gather(*(one(image) for image in images))
|
||||||
|
|||||||
@@ -79,3 +79,43 @@ def encode_jpeg(rgb_or_bgr: np.ndarray, quality: int = 88, bgr: bool = True) ->
|
|||||||
Image.fromarray(array).save(buffer, format="JPEG", quality=quality, optimize=True)
|
Image.fromarray(array).save(buffer, format="JPEG", quality=quality, optimize=True)
|
||||||
return buffer.getvalue()
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def edit_jpeg(raw: bytes, rotate: int = 0, angle: float = 0.0, crop: dict = None) -> bytes:
|
||||||
|
"""Drehen, ausrichten, zuschneiden - in dieser Reihenfolge.
|
||||||
|
|
||||||
|
Wird immer auf das unbearbeitete Original angewandt, nie auf ein schon
|
||||||
|
bearbeitetes Bild: sonst summieren sich Verluste bei jeder Korrektur.
|
||||||
|
"""
|
||||||
|
image = Image.open(io.BytesIO(raw))
|
||||||
|
if rotate % 360:
|
||||||
|
image = image.rotate(-rotate, expand=True)
|
||||||
|
if angle:
|
||||||
|
# Ohne expand: der Rahmen bleibt, die Ecken laufen weiss aus. Was
|
||||||
|
# dabei ueber den Rand geht, schneidet der Nutzer anschliessend weg.
|
||||||
|
image = image.rotate(
|
||||||
|
-angle, expand=False, resample=Image.BICUBIC, fillcolor=(255, 255, 255)
|
||||||
|
)
|
||||||
|
if crop:
|
||||||
|
width, height = image.size
|
||||||
|
left = max(0, round(crop["x"] * width))
|
||||||
|
top = max(0, round(crop["y"] * height))
|
||||||
|
right = min(width, round((crop["x"] + crop["w"]) * width))
|
||||||
|
bottom = min(height, round((crop["y"] + crop["h"]) * height))
|
||||||
|
if right - left > 10 and bottom - top > 10:
|
||||||
|
image = image.crop((left, top, right, bottom))
|
||||||
|
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
image.convert("RGB").save(buffer, format="JPEG", quality=90, optimize=True)
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def rotate_jpeg(raw: bytes, degrees: int) -> bytes:
|
||||||
|
"""Im Uhrzeigersinn drehen. Arbeitet auf dem fertigen JPEG, nicht auf dem
|
||||||
|
grossen Rohbild - gebraucht wird das nur fuer einzelne Karten."""
|
||||||
|
if degrees % 360 == 0:
|
||||||
|
return raw
|
||||||
|
image = Image.open(io.BytesIO(raw)).rotate(-degrees, expand=True)
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
image.save(buffer, format="JPEG", quality=88, optimize=True)
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|||||||
227
app/main.py
227
app/main.py
@@ -6,12 +6,14 @@ from contextlib import asynccontextmanager
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import cv2
|
|
||||||
from fastapi import Depends, FastAPI, Form, HTTPException, Request, Response, UploadFile
|
from fastapi import Depends, FastAPI, Form, HTTPException, Request, Response, UploadFile
|
||||||
from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse
|
from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
from . import config, db, extract, geocode, imaging, reminders, security, segment, vcard
|
from . import (
|
||||||
|
config, db, duplicates, extract, geocode, imaging, reminders, security,
|
||||||
|
segment, vcard,
|
||||||
|
)
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
|
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
|
||||||
log = logging.getLogger("bcs")
|
log = logging.getLogger("bcs")
|
||||||
@@ -20,9 +22,9 @@ STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
|
|||||||
MAX_UPLOAD_BYTES = 30 * 1024 * 1024
|
MAX_UPLOAD_BYTES = 30 * 1024 * 1024
|
||||||
|
|
||||||
EDITABLE = [
|
EDITABLE = [
|
||||||
"full_name", "first_name", "last_name", "job_title", "company", "department",
|
"first_name", "last_name", "job_title", "company", "department",
|
||||||
"email", "phone", "mobile", "fax", "website",
|
"email", "phone", "mobile", "fax", "website",
|
||||||
"street", "postal_code", "city", "country", "notes",
|
"street", "postal_code", "city", "country", "notes", "occasion", "place",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -34,6 +36,14 @@ async def lifespan(app: FastAPI):
|
|||||||
log.warning("APP_PASSWORD ist leer - die App ist ohne Anmeldung erreichbar.")
|
log.warning("APP_PASSWORD ist leer - die App ist ohne Anmeldung erreichbar.")
|
||||||
if not extract.available():
|
if not extract.available():
|
||||||
log.warning("ANTHROPIC_API_KEY fehlt - Karten werden nur zugeschnitten, nicht ausgelesen.")
|
log.warning("ANTHROPIC_API_KEY fehlt - Karten werden nur zugeschnitten, nicht ausgelesen.")
|
||||||
|
|
||||||
|
# Karten, die beim letzten Herunterfahren noch in der Warteschlange
|
||||||
|
# standen, wuerden sonst dauerhaft auf "wird gelesen" stehen bleiben.
|
||||||
|
unfinished = db.query("SELECT id FROM cards WHERE extract_status = 'offen'")
|
||||||
|
if unfinished:
|
||||||
|
log.info("%d Karte(n) aus dem letzten Lauf werden nachgeholt", len(unfinished))
|
||||||
|
start_reading([row["id"] for row in unfinished])
|
||||||
|
|
||||||
task = asyncio.create_task(reminders.run_forever())
|
task = asyncio.create_task(reminders.run_forever())
|
||||||
try:
|
try:
|
||||||
yield
|
yield
|
||||||
@@ -52,8 +62,11 @@ def now_iso() -> str:
|
|||||||
def card_dict(row) -> dict:
|
def card_dict(row) -> dict:
|
||||||
card = dict(row)
|
card = dict(row)
|
||||||
card["image_url"] = f"/api/cards/{card['id']}/image" if card.get("image_file") else None
|
card["image_url"] = f"/api/cards/{card['id']}/image" if card.get("image_file") else None
|
||||||
|
card["back_image_url"] = (
|
||||||
|
f"/api/cards/{card['id']}/image?side=back" if card.get("back_image_file") else None
|
||||||
|
)
|
||||||
card["has_contact"] = any(
|
card["has_contact"] = any(
|
||||||
card.get(f) for f in ("full_name", "company", "email", "phone", "mobile")
|
card.get(f) for f in ("last_name", "company", "email", "phone", "mobile")
|
||||||
)
|
)
|
||||||
return card
|
return card
|
||||||
|
|
||||||
@@ -104,25 +117,73 @@ def logout(response: Response) -> dict:
|
|||||||
# ------------------------------------------------------------------------------ Scan
|
# ------------------------------------------------------------------------------ Scan
|
||||||
|
|
||||||
def _process_photo(raw: bytes) -> tuple:
|
def _process_photo(raw: bytes) -> tuple:
|
||||||
"""Rechenlastiger Teil: dekodieren, freistellen, als JPEG kodieren."""
|
"""Rechenlastiger Teil: dekodieren, freistellen, als JPEG kodieren.
|
||||||
|
|
||||||
|
Die Zuschnitte werden einzeln kodiert und sofort freigegeben - bei zwanzig
|
||||||
|
Karten liegen sonst rund 70 MB Rohbild gleichzeitig im Speicher.
|
||||||
|
"""
|
||||||
meta = imaging.read_metadata(raw)
|
meta = imaging.read_metadata(raw)
|
||||||
image = imaging.decode(raw)
|
crops, fallback = segment.segment(imaging.decode(raw))
|
||||||
crops, fallback = segment.segment(image)
|
encoded = []
|
||||||
encoded = [
|
while crops:
|
||||||
(
|
encoded.append(imaging.encode_jpeg(crops.pop(0)))
|
||||||
imaging.encode_jpeg(crop),
|
|
||||||
imaging.encode_jpeg(cv2.rotate(crop, cv2.ROTATE_180)),
|
|
||||||
)
|
|
||||||
for crop in crops
|
|
||||||
]
|
|
||||||
return meta, encoded, fallback
|
return meta, encoded, fallback
|
||||||
|
|
||||||
|
|
||||||
|
# Laufende Lesevorgaenge festhalten, sonst raeumt die Garbage Collection sie
|
||||||
|
# mitten im Lauf weg.
|
||||||
|
_reading: set = set()
|
||||||
|
|
||||||
|
|
||||||
|
def start_reading(card_ids: list, side: str = "front") -> None:
|
||||||
|
task = asyncio.create_task(_read_cards(card_ids, side))
|
||||||
|
_reading.add(task)
|
||||||
|
task.add_done_callback(_reading.discard)
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_cards(card_ids: list, side: str = "front") -> None:
|
||||||
|
"""Das Auslesen laeuft nach der Antwort weiter - der Nutzer sieht seine
|
||||||
|
Karten sofort, die Felder tropfen nach."""
|
||||||
|
column = "back_image_file" if side == "back" else "image_file"
|
||||||
|
rows = [db.query_one("SELECT * FROM cards WHERE id = ?", (i,)) for i in card_ids]
|
||||||
|
rows = [row for row in rows if row is not None and row[column]]
|
||||||
|
images = [(config.IMAGE_DIR / row[column]).read_bytes() for row in rows]
|
||||||
|
|
||||||
|
results = await extract.extract_all(images)
|
||||||
|
|
||||||
|
for row, result in zip(rows, results):
|
||||||
|
rotation = int(result.get("rotation") or 0) % 360
|
||||||
|
if rotation:
|
||||||
|
# Die Lage der Karte auf dem Tisch ist geometrisch nicht
|
||||||
|
# bestimmbar - das Modell sagt uns, wie herum sie gehoert.
|
||||||
|
path = config.IMAGE_DIR / row[column]
|
||||||
|
path.write_bytes(
|
||||||
|
await asyncio.to_thread(imaging.rotate_jpeg, path.read_bytes(), rotation)
|
||||||
|
)
|
||||||
|
fields = {f: (result.get(f) or None) for f in extract.FIELDS}
|
||||||
|
# COALESCE, weil das Auslesen laeuft, waehrend die Karte schon
|
||||||
|
# sichtbar ist: was in der Zwischenzeit von Hand eingetragen wurde,
|
||||||
|
# darf das Modell nicht ueberschreiben.
|
||||||
|
assignments = ", ".join(f"{name} = COALESCE({name}, ?)" for name in fields)
|
||||||
|
db.execute(
|
||||||
|
f"UPDATE cards SET {assignments}, extract_status = ?, extract_error = ? "
|
||||||
|
"WHERE id = ? AND extract_status = 'offen'",
|
||||||
|
[
|
||||||
|
*fields.values(),
|
||||||
|
"fehler" if result.get("_error") else "ok",
|
||||||
|
result.get("_error"),
|
||||||
|
row["id"],
|
||||||
|
],
|
||||||
|
)
|
||||||
|
log.info("%d Karte(n) ausgelesen", len(rows))
|
||||||
|
|
||||||
|
|
||||||
@app.post("/api/scan", dependencies=[auth])
|
@app.post("/api/scan", dependencies=[auth])
|
||||||
async def scan(
|
async def scan(
|
||||||
file: UploadFile,
|
file: UploadFile,
|
||||||
lat: float = Form(None),
|
lat: float = Form(None),
|
||||||
lon: float = Form(None),
|
lon: float = Form(None),
|
||||||
|
occasion: str = Form(""),
|
||||||
) -> JSONResponse:
|
) -> JSONResponse:
|
||||||
raw = await file.read()
|
raw = await file.read()
|
||||||
if not raw:
|
if not raw:
|
||||||
@@ -146,13 +207,11 @@ async def scan(
|
|||||||
geo_source = None
|
geo_source = None
|
||||||
place = await asyncio.to_thread(geocode.reverse, meta["lat"], meta["lon"])
|
place = await asyncio.to_thread(geocode.reverse, meta["lat"], meta["lon"])
|
||||||
|
|
||||||
results = await extract.extract_all(encoded)
|
|
||||||
|
|
||||||
batch_id = uuid.uuid4().hex
|
batch_id = uuid.uuid4().hex
|
||||||
created = now_iso()
|
created = now_iso()
|
||||||
source = "einzel" if fallback else "stapel"
|
source = "einzel" if fallback else "stapel"
|
||||||
cards = []
|
cards = []
|
||||||
for (upright, _), result in zip(encoded, results):
|
for upright in encoded:
|
||||||
card_id = uuid.uuid4().hex
|
card_id = uuid.uuid4().hex
|
||||||
image_file = f"{card_id}.jpg"
|
image_file = f"{card_id}.jpg"
|
||||||
(config.IMAGE_DIR / image_file).write_bytes(upright)
|
(config.IMAGE_DIR / image_file).write_bytes(upright)
|
||||||
@@ -168,11 +227,9 @@ async def scan(
|
|||||||
"lon": meta["lon"],
|
"lon": meta["lon"],
|
||||||
"place": place,
|
"place": place,
|
||||||
"geo_source": geo_source,
|
"geo_source": geo_source,
|
||||||
"extract_status": "fehler" if result.get("_error") else "ok",
|
"occasion": occasion.strip() or None,
|
||||||
"extract_error": result.get("_error"),
|
"extract_status": "offen",
|
||||||
}
|
}
|
||||||
for field in extract.FIELDS:
|
|
||||||
columns[field] = (result.get(field) or None)
|
|
||||||
names = ", ".join(columns)
|
names = ", ".join(columns)
|
||||||
placeholders = ", ".join("?" for _ in columns)
|
placeholders = ", ".join("?" for _ in columns)
|
||||||
db.execute(
|
db.execute(
|
||||||
@@ -180,6 +237,9 @@ async def scan(
|
|||||||
)
|
)
|
||||||
cards.append(card_dict(db.query_one("SELECT * FROM cards WHERE id = ?", (card_id,))))
|
cards.append(card_dict(db.query_one("SELECT * FROM cards WHERE id = ?", (card_id,))))
|
||||||
|
|
||||||
|
# Antwort geht sofort raus, das Auslesen laeuft danach weiter.
|
||||||
|
start_reading([card["id"] for card in cards])
|
||||||
|
|
||||||
log.info("Scan %s: %d Karte(n), Fallback=%s", batch_id, len(cards), fallback)
|
log.info("Scan %s: %d Karte(n), Fallback=%s", batch_id, len(cards), fallback)
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
{"batch_id": batch_id, "count": len(cards), "fallback": fallback, "cards": cards}
|
{"batch_id": batch_id, "count": len(cards), "fallback": fallback, "cards": cards}
|
||||||
@@ -195,8 +255,8 @@ def list_cards(q: str = "", filter: str = "alle") -> dict:
|
|||||||
if q.strip():
|
if q.strip():
|
||||||
needle = f"%{q.strip()}%"
|
needle = f"%{q.strip()}%"
|
||||||
searchable = [
|
searchable = [
|
||||||
"full_name", "company", "job_title", "email", "phone", "mobile",
|
"first_name", "last_name", "company", "job_title", "email", "phone", "mobile",
|
||||||
"website", "city", "place", "notes", "department", "street",
|
"website", "city", "place", "notes", "department", "street", "occasion",
|
||||||
]
|
]
|
||||||
where.append("(" + " OR ".join(f"{c} LIKE ?" for c in searchable) + ")")
|
where.append("(" + " OR ".join(f"{c} LIKE ?" for c in searchable) + ")")
|
||||||
params += [needle] * len(searchable)
|
params += [needle] * len(searchable)
|
||||||
@@ -204,7 +264,8 @@ def list_cards(q: str = "", filter: str = "alle") -> dict:
|
|||||||
where.append("exported_at IS NULL")
|
where.append("exported_at IS NULL")
|
||||||
elif filter == "unvollstaendig":
|
elif filter == "unvollstaendig":
|
||||||
where.append(
|
where.append(
|
||||||
"(extract_status <> 'ok' OR (full_name IS NULL AND company IS NULL))"
|
"(extract_status = 'fehler' "
|
||||||
|
"OR (extract_status = 'ok' AND last_name IS NULL AND company IS NULL))"
|
||||||
)
|
)
|
||||||
elif filter == "erinnerung":
|
elif filter == "erinnerung":
|
||||||
where.append(
|
where.append(
|
||||||
@@ -218,7 +279,8 @@ def list_cards(q: str = "", filter: str = "alle") -> dict:
|
|||||||
totals = db.query_one(
|
totals = db.query_one(
|
||||||
"""
|
"""
|
||||||
SELECT COUNT(*) AS total,
|
SELECT COUNT(*) AS total,
|
||||||
SUM(CASE WHEN exported_at IS NULL THEN 1 ELSE 0 END) AS offen
|
SUM(CASE WHEN exported_at IS NULL THEN 1 ELSE 0 END) AS offen,
|
||||||
|
SUM(CASE WHEN extract_status = 'offen' THEN 1 ELSE 0 END) AS laeuft
|
||||||
FROM cards
|
FROM cards
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
@@ -231,6 +293,7 @@ def list_cards(q: str = "", filter: str = "alle") -> dict:
|
|||||||
"total": totals["total"] or 0,
|
"total": totals["total"] or 0,
|
||||||
"offen": totals["offen"] or 0,
|
"offen": totals["offen"] or 0,
|
||||||
"erinnerungen": pending["n"] or 0,
|
"erinnerungen": pending["n"] or 0,
|
||||||
|
"laeuft": totals["laeuft"] or 0,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,6 +314,7 @@ def get_card(card_id: str) -> dict:
|
|||||||
"SELECT * FROM reminders WHERE card_id = ? ORDER BY due_at", (card_id,)
|
"SELECT * FROM reminders WHERE card_id = ? ORDER BY due_at", (card_id,)
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
card["duplicates"] = duplicates.find(card)
|
||||||
return card
|
return card
|
||||||
|
|
||||||
|
|
||||||
@@ -273,34 +337,104 @@ async def update_card(card_id: str, request: Request) -> dict:
|
|||||||
@app.delete("/api/cards/{card_id}", dependencies=[auth])
|
@app.delete("/api/cards/{card_id}", dependencies=[auth])
|
||||||
def delete_card(card_id: str) -> dict:
|
def delete_card(card_id: str) -> dict:
|
||||||
row = _card_or_404(card_id)
|
row = _card_or_404(card_id)
|
||||||
if row["image_file"]:
|
for name in (row["image_file"], row["back_image_file"]):
|
||||||
(config.IMAGE_DIR / row["image_file"]).unlink(missing_ok=True)
|
if name:
|
||||||
|
(config.IMAGE_DIR / name).unlink(missing_ok=True)
|
||||||
|
_original_of(name).unlink(missing_ok=True)
|
||||||
db.execute("DELETE FROM cards WHERE id = ?", (card_id,))
|
db.execute("DELETE FROM cards WHERE id = ?", (card_id,))
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/cards/{card_id}/image", dependencies=[auth])
|
@app.get("/api/cards/{card_id}/image", dependencies=[auth])
|
||||||
def card_image(card_id: str) -> FileResponse:
|
def card_image(card_id: str, side: str = "front") -> FileResponse:
|
||||||
row = _card_or_404(card_id)
|
row = _card_or_404(card_id)
|
||||||
path = config.IMAGE_DIR / (row["image_file"] or "")
|
name = row["back_image_file"] if side == "back" else row["image_file"]
|
||||||
if not row["image_file"] or not path.exists():
|
path = config.IMAGE_DIR / (name or "")
|
||||||
|
if not name or not path.exists():
|
||||||
raise HTTPException(status_code=404, detail="Kein Bild gespeichert")
|
raise HTTPException(status_code=404, detail="Kein Bild gespeichert")
|
||||||
return FileResponse(path, media_type="image/jpeg")
|
return FileResponse(path, media_type="image/jpeg")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------- Export
|
def _original_of(name: str) -> Path:
|
||||||
|
"""Unbearbeitete Fassung neben dem Bild. Damit bleibt jede Korrektur
|
||||||
|
zuruecknehmbar und wird nicht auf eine schon bearbeitete Datei gerechnet."""
|
||||||
|
return config.IMAGE_DIR / (name[:-4] + "-orig.jpg")
|
||||||
|
|
||||||
def _mark_exported(ids: list) -> None:
|
|
||||||
stamp = now_iso()
|
@app.post("/api/cards/{card_id}/edit", dependencies=[auth])
|
||||||
db.execute_many(
|
async def edit_image(card_id: str, request: Request) -> dict:
|
||||||
[("UPDATE cards SET exported_at = ? WHERE id = ?", (stamp, i)) for i in ids]
|
row = _card_or_404(card_id)
|
||||||
|
payload = await request.json()
|
||||||
|
side = payload.get("side", "front")
|
||||||
|
name = row["back_image_file"] if side == "back" else row["image_file"]
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(status_code=404, detail="Kein Bild gespeichert")
|
||||||
|
|
||||||
|
current = config.IMAGE_DIR / name
|
||||||
|
original = _original_of(name)
|
||||||
|
if not original.exists():
|
||||||
|
original.write_bytes(current.read_bytes())
|
||||||
|
|
||||||
|
if payload.get("reset"):
|
||||||
|
current.write_bytes(original.read_bytes())
|
||||||
|
return card_dict(_card_or_404(card_id))
|
||||||
|
|
||||||
|
rotate = int(payload.get("rotate") or 0)
|
||||||
|
if rotate % 90:
|
||||||
|
raise HTTPException(status_code=400, detail="Drehung nur in 90-Grad-Schritten")
|
||||||
|
angle = max(-20.0, min(20.0, float(payload.get("angle") or 0)))
|
||||||
|
|
||||||
|
crop = payload.get("crop")
|
||||||
|
if crop is not None:
|
||||||
|
try:
|
||||||
|
crop = {key: float(crop[key]) for key in ("x", "y", "w", "h")}
|
||||||
|
except (KeyError, TypeError, ValueError) as exc:
|
||||||
|
raise HTTPException(status_code=400, detail="Zuschnitt unvollstaendig") from exc
|
||||||
|
if crop["w"] <= 0.02 or crop["h"] <= 0.02:
|
||||||
|
raise HTTPException(status_code=400, detail="Zuschnitt zu klein")
|
||||||
|
|
||||||
|
edited = await asyncio.to_thread(
|
||||||
|
imaging.edit_jpeg, original.read_bytes(), rotate, angle, crop
|
||||||
)
|
)
|
||||||
|
current.write_bytes(edited)
|
||||||
|
return card_dict(_card_or_404(card_id))
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/cards/{card_id}/back", dependencies=[auth])
|
||||||
|
async def add_back(card_id: str, file: UploadFile) -> dict:
|
||||||
|
"""Rueckseite nachtragen. Sie wird ausgelesen, fuellt aber nur Felder, die
|
||||||
|
die Vorderseite offen gelassen hat - dort steht oft nur die Mobilnummer."""
|
||||||
|
row = _card_or_404(card_id)
|
||||||
|
raw = await file.read()
|
||||||
|
if not raw:
|
||||||
|
raise HTTPException(status_code=400, detail="Leere Datei")
|
||||||
|
if len(raw) > MAX_UPLOAD_BYTES:
|
||||||
|
raise HTTPException(status_code=413, detail="Foto ist groesser als 30 MB")
|
||||||
|
|
||||||
|
try:
|
||||||
|
_, encoded, _ = await asyncio.to_thread(_process_photo, raw)
|
||||||
|
except Exception as exc:
|
||||||
|
log.exception("Rueckseite konnte nicht verarbeitet werden")
|
||||||
|
raise HTTPException(status_code=400, detail=f"Bild nicht lesbar: {exc}") from exc
|
||||||
|
if not encoded:
|
||||||
|
raise HTTPException(status_code=400, detail="Keine Karte im Bild gefunden")
|
||||||
|
|
||||||
|
name = f"{card_id}-back.jpg"
|
||||||
|
(config.IMAGE_DIR / name).write_bytes(encoded[0])
|
||||||
|
db.execute(
|
||||||
|
"UPDATE cards SET back_image_file = ?, extract_status = 'offen' WHERE id = ?",
|
||||||
|
(name, card_id),
|
||||||
|
)
|
||||||
|
start_reading([card_id], side="back")
|
||||||
|
return card_dict(_card_or_404(card_id))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------- Export
|
||||||
|
|
||||||
@app.get("/vcf/{card_id}", dependencies=[auth])
|
@app.get("/vcf/{card_id}", dependencies=[auth])
|
||||||
def download_vcard(card_id: str) -> Response:
|
def download_vcard(card_id: str) -> Response:
|
||||||
card = dict(_card_or_404(card_id))
|
card = dict(_card_or_404(card_id))
|
||||||
_mark_exported([card_id])
|
db.execute("UPDATE cards SET exported_at = ? WHERE id = ?", (now_iso(), card_id))
|
||||||
return Response(
|
return Response(
|
||||||
content=vcard.build(card),
|
content=vcard.build(card),
|
||||||
media_type="text/vcard; charset=utf-8",
|
media_type="text/vcard; charset=utf-8",
|
||||||
@@ -308,30 +442,13 @@ def download_vcard(card_id: str) -> Response:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/vcf", dependencies=[auth])
|
|
||||||
def download_vcards(ids: str = "") -> Response:
|
|
||||||
wanted = [i for i in ids.split(",") if i.strip()]
|
|
||||||
if not wanted:
|
|
||||||
raise HTTPException(status_code=400, detail="Keine Karten ausgewaehlt")
|
|
||||||
placeholders = ", ".join("?" for _ in wanted)
|
|
||||||
rows = db.query(f"SELECT * FROM cards WHERE id IN ({placeholders})", wanted)
|
|
||||||
if not rows:
|
|
||||||
raise HTTPException(status_code=404, detail="Karten nicht gefunden")
|
|
||||||
_mark_exported([r["id"] for r in rows])
|
|
||||||
return Response(
|
|
||||||
content=vcard.build_many([dict(r) for r in rows]),
|
|
||||||
media_type="text/vcard; charset=utf-8",
|
|
||||||
headers={"Content-Disposition": 'attachment; filename="kontakte.vcf"'},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------------- Erinnerungen
|
# ----------------------------------------------------------------------- Erinnerungen
|
||||||
|
|
||||||
@app.get("/api/reminders", dependencies=[auth])
|
@app.get("/api/reminders", dependencies=[auth])
|
||||||
def list_reminders() -> dict:
|
def list_reminders() -> dict:
|
||||||
rows = db.query(
|
rows = db.query(
|
||||||
"""
|
"""
|
||||||
SELECT r.*, c.full_name, c.company
|
SELECT r.*, c.first_name, c.last_name, c.company
|
||||||
FROM reminders r JOIN cards c ON c.id = r.card_id
|
FROM reminders r JOIN cards c ON c.id = r.card_id
|
||||||
ORDER BY r.sent_at IS NOT NULL, r.due_at
|
ORDER BY r.sent_at IS NOT NULL, r.due_at
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -19,10 +19,14 @@ def _now() -> str:
|
|||||||
return datetime.now(timezone.utc).isoformat()
|
return datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def _name(row) -> str:
|
||||||
|
return " ".join(filter(None, (row["first_name"], row["last_name"])))
|
||||||
|
|
||||||
|
|
||||||
def _body(row) -> str:
|
def _body(row) -> str:
|
||||||
who = row["full_name"] or row["company"] or "Unbekannter Kontakt"
|
who = _name(row) or row["company"] or "Unbekannter Kontakt"
|
||||||
lines = [row["text"], "", "-" * 40, f"Kontakt: {who}"]
|
lines = [row["text"], "", "-" * 40, f"Kontakt: {who}"]
|
||||||
if row["company"] and row["full_name"]:
|
if row["company"] and _name(row):
|
||||||
lines.append(f"Firma: {row['company']}")
|
lines.append(f"Firma: {row['company']}")
|
||||||
if row["email"]:
|
if row["email"]:
|
||||||
lines.append(f"E-Mail: {row['email']}")
|
lines.append(f"E-Mail: {row['email']}")
|
||||||
@@ -41,7 +45,7 @@ def due_reminders() -> list:
|
|||||||
return db.query(
|
return db.query(
|
||||||
"""
|
"""
|
||||||
SELECT r.id, r.card_id, r.text, r.due_at,
|
SELECT r.id, r.card_id, r.text, r.due_at,
|
||||||
c.full_name, c.company, c.email, c.phone, c.mobile, c.place
|
c.first_name, c.last_name, c.company, c.email, c.phone, c.mobile, c.place
|
||||||
FROM reminders r
|
FROM reminders r
|
||||||
JOIN cards c ON c.id = r.card_id
|
JOIN cards c ON c.id = r.card_id
|
||||||
WHERE r.sent_at IS NULL AND r.due_at <= ?
|
WHERE r.sent_at IS NULL AND r.due_at <= ?
|
||||||
@@ -62,7 +66,7 @@ def process_due() -> int:
|
|||||||
|
|
||||||
sent = 0
|
sent = 0
|
||||||
for row in rows:
|
for row in rows:
|
||||||
who = row["full_name"] or row["company"] or "Kontakt"
|
who = _name(row) or row["company"] or "Kontakt"
|
||||||
try:
|
try:
|
||||||
mailer.send(f"Erinnerung: {who}", _body(row))
|
mailer.send(f"Erinnerung: {who}", _body(row))
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
|||||||
@@ -11,9 +11,14 @@ import numpy as np
|
|||||||
CARD_RATIO = 85.6 / 54.0
|
CARD_RATIO = 85.6 / 54.0
|
||||||
RATIO_MIN, RATIO_MAX = 1.25, 2.05
|
RATIO_MIN, RATIO_MAX = 1.25, 2.05
|
||||||
|
|
||||||
OUT_WIDTH = 1400
|
OUT_WIDTH = 1400 # Obergrenze; kleinere Karten werden nicht hochskaliert
|
||||||
|
MIN_WIDTH = 600
|
||||||
OUT_HEIGHT = round(OUT_WIDTH / CARD_RATIO)
|
OUT_HEIGHT = round(OUT_WIDTH / CARD_RATIO)
|
||||||
|
|
||||||
|
# Das gefundene Rechteck liegt auf der Kartenkante. Ein schmaler Streifen
|
||||||
|
# davon ist noch Tisch - der faellt weg, bevor zugeschnitten wird.
|
||||||
|
INSET = 0.015
|
||||||
|
|
||||||
DETECT_EDGE = 1600 # Aufloesung, auf der gesucht wird
|
DETECT_EDGE = 1600 # Aufloesung, auf der gesucht wird
|
||||||
MIN_AREA_FRACTION = 0.004 # kleiner ist Rauschen, kein Kartenfund
|
MIN_AREA_FRACTION = 0.004 # kleiner ist Rauschen, kein Kartenfund
|
||||||
MAX_AREA_FRACTION = 0.60 # groesser ist der Tisch, nicht die Karte
|
MAX_AREA_FRACTION = 0.60 # groesser ist der Tisch, nicht die Karte
|
||||||
@@ -129,19 +134,56 @@ def segment(image: np.ndarray) -> tuple[list[np.ndarray], bool]:
|
|||||||
crops = []
|
crops = []
|
||||||
for rect in _reading_order(rects):
|
for rect in _reading_order(rects):
|
||||||
quad = _order_quad(cv2.boxPoints(rect)) / scale # zurueck auf volle Aufloesung
|
quad = _order_quad(cv2.boxPoints(rect)) / scale # zurueck auf volle Aufloesung
|
||||||
|
quad = _shrink(quad)
|
||||||
|
width, height = _target_size(quad)
|
||||||
target = np.array(
|
target = np.array(
|
||||||
[[0, 0], [OUT_WIDTH, 0], [OUT_WIDTH, OUT_HEIGHT], [0, OUT_HEIGHT]],
|
[[0, 0], [width, 0], [width, height], [0, height]], dtype="float32"
|
||||||
dtype="float32",
|
|
||||||
)
|
)
|
||||||
matrix = cv2.getPerspectiveTransform(quad, target)
|
matrix = cv2.getPerspectiveTransform(quad, target)
|
||||||
crops.append(
|
crop = cv2.warpPerspective(image, matrix, (width, height), flags=cv2.INTER_CUBIC)
|
||||||
cv2.warpPerspective(
|
crops.append(flatten(crop))
|
||||||
image, matrix, (OUT_WIDTH, OUT_HEIGHT), flags=cv2.INTER_CUBIC
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return crops, False
|
return crops, False
|
||||||
|
|
||||||
|
|
||||||
|
def _shrink(quad: np.ndarray, factor: float = INSET) -> np.ndarray:
|
||||||
|
"""Ecken zur Mitte ziehen, damit kein Untergrund im Zuschnitt bleibt."""
|
||||||
|
center = quad.mean(axis=0)
|
||||||
|
return (center + (quad - center) * (1.0 - factor)).astype("float32")
|
||||||
|
|
||||||
|
|
||||||
|
def flatten(card: np.ndarray) -> np.ndarray:
|
||||||
|
"""Beleuchtung ausgleichen, damit das Papier weiss wird statt grau.
|
||||||
|
|
||||||
|
Geteilt wird durch eine stark weichgezeichnete Fassung des Bildes - das
|
||||||
|
ist die Beleuchtung. Uebrig bleibt der Aufdruck. Dunkle Karten bleiben
|
||||||
|
unangetastet: bei ihnen ist das Dunkle das Papier, kein Schatten.
|
||||||
|
"""
|
||||||
|
gray = cv2.cvtColor(card, cv2.COLOR_BGR2GRAY)
|
||||||
|
if np.median(gray) < 120:
|
||||||
|
return card
|
||||||
|
|
||||||
|
sigma = max(card.shape[1] / 16.0, 3.0)
|
||||||
|
illumination = cv2.GaussianBlur(gray, (0, 0), sigma).astype(np.float32)
|
||||||
|
gain = np.clip(250.0 / np.maximum(illumination, 1.0), 1.0, 2.2)
|
||||||
|
lifted = card.astype(np.float32) * gain[:, :, None]
|
||||||
|
return np.clip(lifted, 0, 255).astype(np.uint8)
|
||||||
|
|
||||||
|
|
||||||
|
def _target_size(quad: np.ndarray) -> tuple:
|
||||||
|
"""Zuschnitt so gross wie die Vorlage, hoechstens OUT_WIDTH.
|
||||||
|
|
||||||
|
Auf einem Stapelfoto ist eine Karte nur ein paar hundert Pixel breit.
|
||||||
|
Sie auf 1400 hochzurechnen erzeugt vier Mal so viele Pixel ohne ein
|
||||||
|
Quentchen mehr Information - und kostet Speicher, Bildgroesse und
|
||||||
|
Bildtokens beim Modellaufruf.
|
||||||
|
"""
|
||||||
|
long_edge = max(
|
||||||
|
np.linalg.norm(quad[1] - quad[0]), np.linalg.norm(quad[2] - quad[3])
|
||||||
|
)
|
||||||
|
width = int(min(OUT_WIDTH, max(MIN_WIDTH, round(long_edge))))
|
||||||
|
return width, round(width / CARD_RATIO)
|
||||||
|
|
||||||
|
|
||||||
def _fit_whole(image: np.ndarray) -> np.ndarray:
|
def _fit_whole(image: np.ndarray) -> np.ndarray:
|
||||||
"""Ohne Fund: das ganze Bild als eine Karte behandeln."""
|
"""Ohne Fund: das ganze Bild als eine Karte behandeln."""
|
||||||
height, width = image.shape[:2]
|
height, width = image.shape[:2]
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ def _line(name: str, value) -> list:
|
|||||||
|
|
||||||
|
|
||||||
def _display_name(card: dict) -> str:
|
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"))))
|
joined = " ".join(filter(None, (card.get("first_name"), card.get("last_name"))))
|
||||||
return joined or card.get("company") or "Unbenannt"
|
return joined or card.get("company") or "Unbenannt"
|
||||||
|
|
||||||
@@ -80,10 +78,6 @@ def build(card: dict) -> str:
|
|||||||
return "\r\n".join(lines) + "\r\n"
|
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:
|
def filename(card: dict) -> str:
|
||||||
base = _display_name(card).replace(" ", "-")
|
base = _display_name(card).replace(" ", "-")
|
||||||
safe = "".join(c for c in base if c.isalnum() or c in "-_")
|
safe = "".join(c for c in base if c.isalnum() or c in "-_")
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# Wird im Workflow aus dem Secret DOTENV erzeugt.
|
# Wird im Workflow aus dem Secret DOTENV erzeugt.
|
||||||
env_file: .env
|
env_file: .env
|
||||||
|
# Deckel gegen Ausreisser: ein Stapelscan braucht im Normalfall deutlich
|
||||||
|
# unter 500 MB. Laeuft er aus dem Ruder, stirbt der Container - nicht der
|
||||||
|
# Host, auf dem auch Jitsi und die Datenbank liegen.
|
||||||
|
mem_limit: 1g
|
||||||
|
cpus: 1.5
|
||||||
volumes:
|
volumes:
|
||||||
- business-card-scanner-data:/data
|
- business-card-scanner-data:/data
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
675
static/app.js
675
static/app.js
@@ -3,7 +3,6 @@
|
|||||||
"use strict";
|
"use strict";
|
||||||
|
|
||||||
const FIELD_LABELS = {
|
const FIELD_LABELS = {
|
||||||
full_name: "Name",
|
|
||||||
first_name: "Vorname",
|
first_name: "Vorname",
|
||||||
last_name: "Nachname",
|
last_name: "Nachname",
|
||||||
job_title: "Position",
|
job_title: "Position",
|
||||||
@@ -18,6 +17,8 @@
|
|||||||
postal_code: "PLZ",
|
postal_code: "PLZ",
|
||||||
city: "Ort",
|
city: "Ort",
|
||||||
country: "Land",
|
country: "Land",
|
||||||
|
occasion: "Anlass",
|
||||||
|
place: "Scan-Ort",
|
||||||
};
|
};
|
||||||
const FIELD_ORDER = Object.keys(FIELD_LABELS);
|
const FIELD_ORDER = Object.keys(FIELD_LABELS);
|
||||||
|
|
||||||
@@ -27,7 +28,7 @@
|
|||||||
query: "",
|
query: "",
|
||||||
filter: "alle",
|
filter: "alle",
|
||||||
cards: [],
|
cards: [],
|
||||||
stats: { total: 0, offen: 0, erinnerungen: 0 },
|
stats: { total: 0, offen: 0, erinnerungen: 0, laeuft: 0 },
|
||||||
editing: false,
|
editing: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -37,6 +38,7 @@
|
|||||||
karten: $("screen-cards"),
|
karten: $("screen-cards"),
|
||||||
karte: $("screen-detail"),
|
karte: $("screen-detail"),
|
||||||
erinnerungen: $("screen-reminders"),
|
erinnerungen: $("screen-reminders"),
|
||||||
|
mehr: $("screen-mehr"),
|
||||||
};
|
};
|
||||||
|
|
||||||
const esc = (value) =>
|
const esc = (value) =>
|
||||||
@@ -44,6 +46,15 @@
|
|||||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||||
})[c]);
|
})[c]);
|
||||||
|
|
||||||
|
const store = {
|
||||||
|
get(key) {
|
||||||
|
try { return localStorage.getItem(key) || ""; } catch (_) { return ""; }
|
||||||
|
},
|
||||||
|
set(key, value) {
|
||||||
|
try { localStorage.setItem(key, value); } catch (_) { /* privater Modus */ }
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// ------------------------------------------------------------------ Helfer
|
// ------------------------------------------------------------------ Helfer
|
||||||
|
|
||||||
function toast(message) {
|
function toast(message) {
|
||||||
@@ -55,7 +66,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function busy(on, text) {
|
function busy(on, text) {
|
||||||
$("busy-text").textContent = text || "Karten werden gelesen";
|
$("busy-text").textContent = text || "Karten werden freigestellt";
|
||||||
$("busy-overlay").hidden = !on;
|
$("busy-overlay").hidden = !on;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,24 +104,84 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const displayName = (card) =>
|
const displayName = (card) =>
|
||||||
card.full_name ||
|
|
||||||
[card.first_name, card.last_name].filter(Boolean).join(" ") ||
|
[card.first_name, card.last_name].filter(Boolean).join(" ") ||
|
||||||
card.company ||
|
card.company ||
|
||||||
"Ohne Namen";
|
"Ohne Namen";
|
||||||
|
|
||||||
|
const NAV_APPS = {
|
||||||
|
apple: {
|
||||||
|
label: "Apple Karten",
|
||||||
|
url: (query) => `https://maps.apple.com/?q=${encodeURIComponent(query)}`,
|
||||||
|
},
|
||||||
|
google: {
|
||||||
|
label: "Google Maps",
|
||||||
|
url: (query) =>
|
||||||
|
`https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`,
|
||||||
|
},
|
||||||
|
osm: {
|
||||||
|
label: "OpenStreetMap",
|
||||||
|
url: (query) =>
|
||||||
|
`https://www.openstreetmap.org/search?query=${encodeURIComponent(query)}`,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const navApp = () => (NAV_APPS[store.get("navApp")] ? store.get("navApp") : "apple");
|
||||||
|
|
||||||
|
const address = (card) =>
|
||||||
|
[card.street, [card.postal_code, card.city].filter(Boolean).join(" "), card.country]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
const link = (href, text) =>
|
||||||
|
`<a href="${esc(href)}" rel="noopener">${esc(text)}</a>`;
|
||||||
|
|
||||||
|
const subtitle = (card) =>
|
||||||
|
[card.job_title, card.company].filter(Boolean).join(" · ") || "Keine Firma erkannt";
|
||||||
|
|
||||||
/** Position des Browsers als Rückfallebene. Die EXIF-Daten des Fotos sind
|
/** Position des Browsers als Rückfallebene. Die EXIF-Daten des Fotos sind
|
||||||
* genauer, deshalb blockiert das hier nichts und scheitert leise. */
|
* genauer, deshalb blockiert das hier nichts und scheitert leise. */
|
||||||
function browserPosition() {
|
function browserPosition() {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
if (!navigator.geolocation) return resolve(null);
|
if (!navigator.geolocation) return resolve({ error: "Gerät liefert keinen Standort" });
|
||||||
navigator.geolocation.getCurrentPosition(
|
navigator.geolocation.getCurrentPosition(
|
||||||
(position) => resolve(position.coords),
|
(position) => resolve({ coords: position.coords }),
|
||||||
() => resolve(null),
|
(error) =>
|
||||||
{ timeout: 6000, maximumAge: 300000 }
|
resolve({
|
||||||
|
error:
|
||||||
|
error.code === error.PERMISSION_DENIED
|
||||||
|
? "Standortfreigabe fehlt"
|
||||||
|
: "Standort nicht ermittelbar",
|
||||||
|
}),
|
||||||
|
{ enableHighAccuracy: false, timeout: 8000, maximumAge: 300000 }
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Zustand einer Karte als Pillen. Ob sie schon exportiert wurde, steht
|
||||||
|
* bewusst nicht dabei - dafür gibt es den Filter. */
|
||||||
|
function cardTags(card) {
|
||||||
|
const tags = [];
|
||||||
|
if (card.occasion) tags.push(["pill-solid", card.occasion]);
|
||||||
|
else if (card.place) tags.push(["", card.place]);
|
||||||
|
if (card.extract_status === "offen") tags.push(["", "wird gelesen"]);
|
||||||
|
if (card.extract_status === "fehler") tags.push(["", "Lesefehler"]);
|
||||||
|
return tags
|
||||||
|
.map(([extra, text]) => `<span class="pill ${extra}">${esc(text)}</span>`)
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function cardRow(card) {
|
||||||
|
return `
|
||||||
|
<button class="row" data-card="${esc(card.id)}">
|
||||||
|
<span class="row-head">
|
||||||
|
<span class="row-title">${esc(displayName(card))}</span>
|
||||||
|
<span class="row-meta">${esc(formatDate(card.captured_at || card.created_at))}</span>
|
||||||
|
</span>
|
||||||
|
<span class="row-sub">${esc(subtitle(card))}</span>
|
||||||
|
<span class="row-tags">${cardTags(card)}</span>
|
||||||
|
</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
// ----------------------------------------------------------------- Routing
|
// ----------------------------------------------------------------- Routing
|
||||||
|
|
||||||
function go(route, cardId) {
|
function go(route, cardId) {
|
||||||
@@ -125,14 +196,17 @@
|
|||||||
|
|
||||||
Object.entries(screens).forEach(([name, node]) => { node.hidden = name !== route; });
|
Object.entries(screens).forEach(([name, node]) => { node.hidden = name !== route; });
|
||||||
document.querySelectorAll(".tab").forEach((tab) => {
|
document.querySelectorAll(".tab").forEach((tab) => {
|
||||||
const active = tab.dataset.route === route || (route === "karte" && tab.dataset.route === "karten");
|
const active =
|
||||||
|
tab.dataset.route === route || (route === "karte" && tab.dataset.route === "karten");
|
||||||
tab.setAttribute("aria-selected", active ? "true" : "false");
|
tab.setAttribute("aria-selected", active ? "true" : "false");
|
||||||
});
|
});
|
||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0);
|
||||||
|
|
||||||
|
if (route === "scan") loadRecent();
|
||||||
if (route === "karten") loadCards();
|
if (route === "karten") loadCards();
|
||||||
if (route === "karte") loadCard(state.cardId);
|
if (route === "karte") loadCard(state.cardId);
|
||||||
if (route === "erinnerungen") loadReminders();
|
if (route === "erinnerungen") loadReminders();
|
||||||
|
if (route === "mehr") renderSettings(state.session || {});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------- Scan
|
// ------------------------------------------------------------------- Scan
|
||||||
@@ -142,17 +216,22 @@
|
|||||||
busy(true, "Karten werden freigestellt");
|
busy(true, "Karten werden freigestellt");
|
||||||
const body = new FormData();
|
const body = new FormData();
|
||||||
body.append("file", file);
|
body.append("file", file);
|
||||||
const coords = await browserPosition();
|
body.append("occasion", $("occasion-input").value.trim());
|
||||||
if (coords) {
|
const position = await browserPosition();
|
||||||
body.append("lat", coords.latitude);
|
if (position.coords) {
|
||||||
body.append("lon", coords.longitude);
|
body.append("lat", position.coords.latitude);
|
||||||
|
body.append("lon", position.coords.longitude);
|
||||||
}
|
}
|
||||||
busy(true, "Karten werden gelesen");
|
|
||||||
try {
|
try {
|
||||||
const result = await api("/api/scan", { method: "POST", body });
|
const result = await api("/api/scan", { method: "POST", body });
|
||||||
renderScanResult(result);
|
renderScanResult(result);
|
||||||
await refreshStats();
|
await refreshStats();
|
||||||
toast(`${result.count} Karte${result.count === 1 ? "" : "n"} erfasst`);
|
loadRecent();
|
||||||
|
const ohneOrt = result.cards.every((card) => !card.place);
|
||||||
|
toast(
|
||||||
|
`${result.count} Karte${result.count === 1 ? "" : "n"} erfasst` +
|
||||||
|
(ohneOrt && position.error ? ` · ohne Ort (${position.error})` : ", wird gelesen")
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast(error.message);
|
toast(error.message);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -161,31 +240,43 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderScanResult(result) {
|
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 = `
|
$("scan-result").innerHTML = `
|
||||||
<div class="section-label"><span class="label">Ergebnis</span></div>
|
<div class="section-label"><span class="label">Ergebnis</span></div>
|
||||||
<div class="strip" style="border-top:3px solid #000">
|
<div class="strip">
|
||||||
<span class="label">Gefundene Karten</span>
|
<span class="label">${result.count} Karte${result.count === 1 ? "" : "n"} gefunden</span>
|
||||||
<span class="display d-lg mono-num">${result.count}</span>
|
<span class="pill">wird gelesen</span>
|
||||||
</div>
|
</div>
|
||||||
${result.fallback
|
${result.fallback
|
||||||
? `<p class="hint" style="margin-top:10px">Kein Kartenrechteck erkannt – das ganze Foto
|
? `<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
|
wurde als eine Karte behandelt. Für einen Stapel hilft ein dunkler, einfarbiger
|
||||||
Untergrund mit Abstand zwischen den Karten.</p>`
|
Untergrund mit Abstand zwischen den Karten.</p>`
|
||||||
: ""}
|
: ""}
|
||||||
<div class="rows" style="margin-top:14px">${rows}</div>`;
|
<div class="rows" style="margin-top:12px">
|
||||||
|
${result.cards.map(cardRow).join("")}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRecent() {
|
||||||
|
try {
|
||||||
|
const data = await api("/api/cards?filter=alle");
|
||||||
|
state.stats = data.stats;
|
||||||
|
updateCounters();
|
||||||
|
const recent = data.cards.slice(0, 3);
|
||||||
|
$("recent-list").innerHTML = recent.length
|
||||||
|
? `<div class="rows">${recent.map(cardRow).join("")}</div>
|
||||||
|
${data.cards.length > 3
|
||||||
|
? `<button class="btn btn-quiet" data-goto="karten" style="margin-top:12px">
|
||||||
|
Alle ${data.stats.total} Karten <span class="arrow">→</span>
|
||||||
|
</button>`
|
||||||
|
: ""}`
|
||||||
|
: `<div class="empty">
|
||||||
|
<p class="label">Noch nichts erfasst</p>
|
||||||
|
<p class="hint" style="margin:8px 0 0">Mach oben ein Foto – der Rest passiert von allein.</p>
|
||||||
|
</div>`;
|
||||||
|
schedulePoll();
|
||||||
|
} catch (error) {
|
||||||
|
toast(error.message);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------- Kartenliste
|
// ------------------------------------------------------------- Kartenliste
|
||||||
@@ -198,11 +289,25 @@
|
|||||||
state.stats = data.stats;
|
state.stats = data.stats;
|
||||||
updateCounters();
|
updateCounters();
|
||||||
renderCards();
|
renderCards();
|
||||||
|
schedulePoll();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast(error.message);
|
toast(error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Das Auslesen läuft auf dem Server weiter, nachdem der Scan geantwortet
|
||||||
|
* hat. Solange noch etwas offen ist, holen wir den Stand nach. */
|
||||||
|
function schedulePoll() {
|
||||||
|
clearTimeout(schedulePoll.timer);
|
||||||
|
if (!state.stats.laeuft) return;
|
||||||
|
schedulePoll.timer = setTimeout(() => {
|
||||||
|
if (state.route === "karten") loadCards();
|
||||||
|
else if (state.route === "karte") loadCard(state.cardId);
|
||||||
|
else if (state.route === "scan") loadRecent();
|
||||||
|
else refreshStats();
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
function renderCards() {
|
function renderCards() {
|
||||||
const list = $("cards-list");
|
const list = $("cards-list");
|
||||||
if (!state.cards.length) {
|
if (!state.cards.length) {
|
||||||
@@ -216,37 +321,12 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectable = state.cards.map((c) => c.id).join(",");
|
|
||||||
list.innerHTML = `
|
list.innerHTML = `
|
||||||
<div class="strip" style="border-top:3px solid #000">
|
<div class="strip">
|
||||||
<span class="label">${state.cards.length} Karte${state.cards.length === 1 ? "" : "n"}</span>
|
<span class="label">${state.cards.length} Karte${state.cards.length === 1 ? "" : "n"}</span>
|
||||||
<a class="badge" href="/vcf?ids=${esc(selectable)}" download>Alle als vCard</a>
|
${state.stats.laeuft ? `<span class="pill">${state.stats.laeuft} werden gelesen</span>` : ""}
|
||||||
</div>
|
</div>
|
||||||
<div class="rows">
|
<div class="rows">${state.cards.map(cardRow).join("")}</div>`;
|
||||||
${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
|
// ----------------------------------------------------------- Kartendetail
|
||||||
@@ -255,20 +335,47 @@
|
|||||||
try {
|
try {
|
||||||
const card = await api(`/api/cards/${cardId}`);
|
const card = await api(`/api/cards/${cardId}`);
|
||||||
renderCard(card);
|
renderCard(card);
|
||||||
|
if (card.extract_status === "offen") {
|
||||||
|
state.stats = { ...state.stats, laeuft: Math.max(1, state.stats.laeuft) };
|
||||||
|
schedulePoll();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
screens.karte.innerHTML = `<div class="empty"><p class="label">${esc(error.message)}</p></div>`;
|
screens.karte.innerHTML = `<div class="empty"><p class="label">${esc(error.message)}</p></div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** E-Mail, Web, Nummern und Adresse sind Handlungen, keine Zeichenketten. */
|
||||||
|
function fieldCell(card, field) {
|
||||||
|
const value = card[field];
|
||||||
|
if (field === "email") return link(`mailto:${value}`, value);
|
||||||
|
if (field === "website") {
|
||||||
|
return link(/^https?:\/\//i.test(value) ? value : `https://${value}`, value);
|
||||||
|
}
|
||||||
|
if (["phone", "mobile", "fax"].includes(field)) {
|
||||||
|
return link(`tel:${value.replace(/[^\d+]/g, "")}`, value);
|
||||||
|
}
|
||||||
|
return esc(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Straße, PLZ, Ort und Land stehen in der Tabelle als eine Adresszeile.
|
||||||
|
const DISPLAY_ORDER = [
|
||||||
|
"first_name", "last_name", "job_title", "company", "department",
|
||||||
|
"email", "phone", "mobile", "fax", "website", "address", "occasion", "place",
|
||||||
|
];
|
||||||
|
|
||||||
function dataTable(card) {
|
function dataTable(card) {
|
||||||
const rows = FIELD_ORDER.filter((field) => card[field])
|
const postal = address(card);
|
||||||
.map(
|
const rows = DISPLAY_ORDER.map((field) => {
|
||||||
(field) =>
|
if (field === "address") {
|
||||||
`<tr><th>${FIELD_LABELS[field]}</th><td>${esc(card[field])}</td></tr>`
|
if (!postal) return "";
|
||||||
)
|
return `<tr><th>Adresse</th><td>${link(NAV_APPS[navApp()].url(postal), postal)}</td></tr>`;
|
||||||
.join("");
|
}
|
||||||
return rows
|
if (!card[field]) return "";
|
||||||
? `<table class="data">${rows}</table>`
|
return `<tr><th>${FIELD_LABELS[field]}</th><td>${fieldCell(card, field)}</td></tr>`;
|
||||||
|
}).filter(Boolean);
|
||||||
|
|
||||||
|
return rows.length
|
||||||
|
? `<table class="data">${rows.join("")}</table>`
|
||||||
: `<p class="hint">Keine Felder erkannt. Über „Bearbeiten“ nachtragen.</p>`;
|
: `<p class="hint">Keine Felder erkannt. Über „Bearbeiten“ nachtragen.</p>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,18 +389,49 @@
|
|||||||
).join("")}</div>`;
|
).join("")}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCard(card) {
|
function duplicateBlock(card) {
|
||||||
const meta = [];
|
if (!card.duplicates || !card.duplicates.length) return "";
|
||||||
if (card.place) meta.push(esc(card.place));
|
return `
|
||||||
if (card.lat != null) meta.push(`${card.lat.toFixed(4)}, ${card.lon.toFixed(4)}`);
|
<div class="section-label"><span class="label">Schon einmal erfasst?</span></div>
|
||||||
|
<div class="rows">
|
||||||
|
${card.duplicates
|
||||||
|
.map(
|
||||||
|
(other) => `
|
||||||
|
<button class="row" data-card="${esc(other.id)}">
|
||||||
|
<span class="row-head">
|
||||||
|
<span class="row-title">${esc(displayName(other))}</span>
|
||||||
|
<span class="row-meta">${esc(formatDate(other.captured_at || other.created_at))}</span>
|
||||||
|
</span>
|
||||||
|
<span class="row-sub">${esc(other.reason)}${other.occasion ? " · " + esc(other.occasion) : ""}</span>
|
||||||
|
</button>`
|
||||||
|
)
|
||||||
|
.join("")}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GEO_SOURCE = { exif: "aus dem Foto", browser: "vom Gerät" };
|
||||||
|
|
||||||
|
const QUICK_DATES = [
|
||||||
|
["In 3 Tagen", 3],
|
||||||
|
["In 1 Woche", 7],
|
||||||
|
["In 2 Wochen", 14],
|
||||||
|
["In 1 Monat", 30],
|
||||||
|
];
|
||||||
|
|
||||||
|
function localInputValue(date) {
|
||||||
|
const pad = (n) => String(n).padStart(2, "0");
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
|
||||||
|
`T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCard(card) {
|
||||||
const reminders = card.reminders.length
|
const reminders = card.reminders.length
|
||||||
? `<div class="rows">${card.reminders
|
? `<div class="rows">${card.reminders
|
||||||
.map(
|
.map(
|
||||||
(reminder) => `
|
(reminder) => `
|
||||||
<div class="row" style="cursor:default">
|
<div class="row" style="cursor:default">
|
||||||
<span class="row-head">
|
<span class="row-head">
|
||||||
<span class="row-title" style="font-size:14px">${esc(reminder.text)}</span>
|
<span class="row-title" style="font-size:15px">${esc(reminder.text)}</span>
|
||||||
<button class="badge" data-delete-reminder="${esc(reminder.id)}">Löschen</button>
|
<button class="badge" data-delete-reminder="${esc(reminder.id)}">Löschen</button>
|
||||||
</span>
|
</span>
|
||||||
<span class="row-sub">
|
<span class="row-sub">
|
||||||
@@ -307,41 +445,57 @@
|
|||||||
: `<p class="hint">Keine Erinnerung gesetzt.</p>`;
|
: `<p class="hint">Keine Erinnerung gesetzt.</p>`;
|
||||||
|
|
||||||
screens.karte.innerHTML = `
|
screens.karte.innerHTML = `
|
||||||
<button class="chip" data-back>← Zurück</button>
|
<button class="chip" data-back>← Karten</button>
|
||||||
|
|
||||||
<div class="block-invert">
|
<div class="block-invert">
|
||||||
<p class="label">${card.source === "stapel" ? "Aus Stapelscan" : "Einzelscan"}</p>
|
<p class="label">${esc(card.occasion || (card.source === "stapel" ? "Aus Stapelscan" : "Einzelscan"))}</p>
|
||||||
<h1 class="display d-lg" style="margin-top:8px">${esc(displayName(card))}</h1>
|
<h1 class="display d-lg" style="margin-top:8px">${esc(displayName(card))}</h1>
|
||||||
<p class="muted" style="margin:6px 0 0">
|
<p class="muted" style="margin:6px 0 0">${esc(subtitle(card))}</p>
|
||||||
${esc([card.job_title, card.company].filter(Boolean).join(" · ") || "Keine Firma erkannt")}
|
|
||||||
</p>
|
|
||||||
<div class="figures">
|
<div class="figures">
|
||||||
<span class="figure">
|
<span class="figure">
|
||||||
<span class="label">Erfasst</span>
|
<span class="label">Erfasst</span>
|
||||||
<span class="display d-md">${esc(formatDate(card.captured_at || card.created_at))}</span>
|
<span class="display d-md">${esc(formatDate(card.captured_at || card.created_at))}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="figure">
|
<span class="figure">
|
||||||
<span class="label">Ort</span>
|
<span class="label">Ort${card.geo_source ? " · " + GEO_SOURCE[card.geo_source] : ""}</span>
|
||||||
<span class="display d-md">${meta.length ? esc(meta[0]) : "Unbekannt"}</span>
|
<span class="display d-md">${esc(card.place || "Unbekannt")}</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
${card.image_url
|
${card.image_url
|
||||||
? `<img class="card-image" src="${esc(card.image_url)}" alt="Visitenkarte">`
|
? `<img class="card-image" src="${esc(card.image_url)}" alt="Vorderseite">
|
||||||
|
<button class="btn btn-quiet" data-edit-image="front">
|
||||||
|
Bild bearbeiten <span class="arrow">✎</span>
|
||||||
|
</button>`
|
||||||
: ""}
|
: ""}
|
||||||
|
${card.back_image_url
|
||||||
|
? `<img class="card-image" src="${esc(card.back_image_url)}" alt="Rückseite">
|
||||||
|
<button class="btn btn-quiet" data-edit-image="back">
|
||||||
|
Rückseite bearbeiten <span class="arrow">✎</span>
|
||||||
|
</button>`
|
||||||
|
: `<label class="btn btn-quiet" for="input-back">
|
||||||
|
Rückseite hinzufügen <span class="arrow">+</span>
|
||||||
|
<input id="input-back" type="file" accept="image/*" capture="environment" hidden>
|
||||||
|
</label>`}
|
||||||
|
|
||||||
<a class="btn btn-primary" href="/vcf/${esc(card.id)}" download>
|
<a class="btn btn-primary" href="/vcf/${esc(card.id)}" download>
|
||||||
In Kontakte speichern <span class="arrow">→</span>
|
In Kontakte speichern <span class="arrow">→</span>
|
||||||
</a>
|
</a>
|
||||||
|
<button class="btn" data-share="${esc(card.id)}">
|
||||||
|
Als vCard teilen <span class="arrow">↗</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
${card.extract_status !== "ok"
|
${card.extract_status === "offen"
|
||||||
|
? `<p class="hint">Die Karte wird gerade ausgelesen – die Felder erscheinen gleich.</p>`
|
||||||
|
: ""}
|
||||||
|
${card.extract_status === "fehler"
|
||||||
? `<p class="hint">Lesefehler: ${esc(card.extract_error || "unbekannt")}</p>`
|
? `<p class="hint">Lesefehler: ${esc(card.extract_error || "unbekannt")}</p>`
|
||||||
: ""}
|
: ""}
|
||||||
|
|
||||||
<div class="section-label">
|
${duplicateBlock(card)}
|
||||||
<span class="label">Daten</span>
|
|
||||||
</div>
|
<div class="section-label"><span class="label">Daten</span></div>
|
||||||
<button class="chip" data-toggle-edit style="margin-bottom:10px">
|
<button class="chip" data-toggle-edit style="margin-bottom:10px">
|
||||||
${state.editing ? "Fertig" : "Bearbeiten"}
|
${state.editing ? "Fertig" : "Bearbeiten"}
|
||||||
</button>
|
</button>
|
||||||
@@ -359,7 +513,12 @@
|
|||||||
<span class="label">Text</span>
|
<span class="label">Text</span>
|
||||||
<input name="text" placeholder="Angebot nachfassen" required>
|
<input name="text" placeholder="Angebot nachfassen" required>
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<div class="chips" style="margin-top:12px">
|
||||||
|
${QUICK_DATES.map(
|
||||||
|
([text, days]) => `<button class="chip" type="button" data-quick="${days}">${text}</button>`
|
||||||
|
).join("")}
|
||||||
|
</div>
|
||||||
|
<label class="field" style="margin-top:12px">
|
||||||
<span class="label">Termin</span>
|
<span class="label">Termin</span>
|
||||||
<input name="due" type="datetime-local" required>
|
<input name="due" type="datetime-local" required>
|
||||||
</label>
|
</label>
|
||||||
@@ -367,7 +526,7 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="section-label"><span class="label">Karte entfernen</span></div>
|
<div class="section-label"><span class="label">Karte entfernen</span></div>
|
||||||
<button class="btn btn-danger" data-delete-card="${esc(card.id)}">
|
<button class="btn btn-quiet" data-delete-card="${esc(card.id)}">
|
||||||
Endgültig löschen <span class="arrow">×</span>
|
Endgültig löschen <span class="arrow">×</span>
|
||||||
</button>`;
|
</button>`;
|
||||||
|
|
||||||
@@ -386,9 +545,39 @@
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
$("reminder-form").addEventListener("submit", async (event) => {
|
const backInput = $("input-back");
|
||||||
|
if (backInput) {
|
||||||
|
backInput.addEventListener("change", async (event) => {
|
||||||
|
const file = event.target.files[0];
|
||||||
|
event.target.value = "";
|
||||||
|
if (!file) return;
|
||||||
|
busy(true, "Rückseite wird gelesen");
|
||||||
|
try {
|
||||||
|
const body = new FormData();
|
||||||
|
body.append("file", file);
|
||||||
|
await api(`/api/cards/${card.id}/back`, { method: "POST", body });
|
||||||
|
toast("Rückseite hinzugefügt");
|
||||||
|
loadCard(card.id);
|
||||||
|
} catch (error) {
|
||||||
|
toast(error.message);
|
||||||
|
} finally {
|
||||||
|
busy(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const form = $("reminder-form");
|
||||||
|
form.querySelectorAll("[data-quick]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
const date = new Date();
|
||||||
|
date.setDate(date.getDate() + Number(button.dataset.quick));
|
||||||
|
date.setHours(9, 0, 0, 0);
|
||||||
|
form.elements.due.value = localInputValue(date);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
form.addEventListener("submit", async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const form = event.currentTarget;
|
|
||||||
const due = form.elements.due.value;
|
const due = form.elements.due.value;
|
||||||
if (!due) return;
|
if (!due) return;
|
||||||
try {
|
try {
|
||||||
@@ -409,6 +598,76 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Teilen über das System-Teilenblatt. Damit landet die vCard per AirDrop,
|
||||||
|
* Mail oder Nachricht beim Gegenüber, ohne Umweg über den Download. */
|
||||||
|
async function shareCard(cardId) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/vcf/${cardId}`, { credentials: "same-origin" });
|
||||||
|
if (!response.ok) throw new Error("vCard konnte nicht geladen werden");
|
||||||
|
const disposition = response.headers.get("content-disposition") || "";
|
||||||
|
const match = disposition.match(/filename="([^"]+)"/);
|
||||||
|
const blob = await response.blob();
|
||||||
|
const file = new File([blob], match ? match[1] : "kontakt.vcf", {
|
||||||
|
type: "text/vcard",
|
||||||
|
});
|
||||||
|
if (navigator.canShare && navigator.canShare({ files: [file] })) {
|
||||||
|
await navigator.share({ files: [file] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Kein Teilenblatt (Desktop, ältere Browser): dann eben herunterladen.
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.href = url;
|
||||||
|
link.download = file.name;
|
||||||
|
link.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.name !== "AbortError") toast(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------- Einstellungen
|
||||||
|
|
||||||
|
function renderSettings(session) {
|
||||||
|
const current = navApp();
|
||||||
|
screens.mehr.innerHTML = `
|
||||||
|
<div class="section-label"><span class="label">Navigation</span></div>
|
||||||
|
<p class="hint">Womit Adressen geöffnet werden.</p>
|
||||||
|
<div class="chips" id="nav-app-chips">
|
||||||
|
${Object.entries(NAV_APPS)
|
||||||
|
.map(
|
||||||
|
([key, app]) =>
|
||||||
|
`<button class="chip" data-nav-app="${key}" aria-pressed="${key === current}">
|
||||||
|
${app.label}
|
||||||
|
</button>`
|
||||||
|
)
|
||||||
|
.join("")}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section-label"><span class="label">Status</span></div>
|
||||||
|
<table class="data">
|
||||||
|
<tr><th>Auslesen</th><td>${session.extraction_available ? "aktiv" : "kein API-Schlüssel"}</td></tr>
|
||||||
|
<tr><th>Erinnerungsmails</th><td>${session.mail_configured ? "aktiv" : "kein SMTP"}</td></tr>
|
||||||
|
<tr><th>Anmeldung</th><td>${session.auth_required ? "mit Passwort" : "offen"}</td></tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
${session.auth_required
|
||||||
|
? `<div class="section-label"><span class="label">Zugang</span></div>
|
||||||
|
<button class="btn btn-quiet" id="logout-button">
|
||||||
|
Abmelden <span class="arrow">→</span>
|
||||||
|
</button>`
|
||||||
|
: `<div class="section-label"><span class="label">Zugang</span></div>
|
||||||
|
<p class="hint">Ohne Passwort erreichbar – APP_PASSWORD in der .env setzen.</p>`}`;
|
||||||
|
|
||||||
|
const logout = $("logout-button");
|
||||||
|
if (logout) {
|
||||||
|
logout.addEventListener("click", async () => {
|
||||||
|
await api("/api/logout", { method: "POST" }).catch(() => {});
|
||||||
|
location.reload();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------ Erinnerungen
|
// ------------------------------------------------------------ Erinnerungen
|
||||||
|
|
||||||
async function loadReminders() {
|
async function loadReminders() {
|
||||||
@@ -442,11 +701,15 @@
|
|||||||
|
|
||||||
// -------------------------------------------------------------- Zähler
|
// -------------------------------------------------------------- Zähler
|
||||||
|
|
||||||
|
function badge(id, value) {
|
||||||
|
const node = $(id);
|
||||||
|
node.textContent = value;
|
||||||
|
node.hidden = !value;
|
||||||
|
}
|
||||||
|
|
||||||
function updateCounters() {
|
function updateCounters() {
|
||||||
$("figure-total").textContent = state.stats.total;
|
badge("tab-count-cards", state.stats.total);
|
||||||
$("figure-open").textContent = state.stats.offen;
|
badge("tab-count-reminders", state.stats.erinnerungen);
|
||||||
$("tab-count-cards").textContent = state.stats.total;
|
|
||||||
$("tab-count-reminders").textContent = state.stats.erinnerungen;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshStats() {
|
async function refreshStats() {
|
||||||
@@ -454,15 +717,210 @@
|
|||||||
const data = await api("/api/cards?filter=alle");
|
const data = await api("/api/cards?filter=alle");
|
||||||
state.stats = data.stats;
|
state.stats = data.stats;
|
||||||
updateCounters();
|
updateCounters();
|
||||||
|
schedulePoll();
|
||||||
} catch (_) { /* Zähler sind nicht kritisch */ }
|
} catch (_) { /* Zähler sind nicht kritisch */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------- Bildbearbeitung
|
||||||
|
|
||||||
|
/** Zwei Zuschnitte hintereinander: der zweite bezieht sich auf das Ergebnis
|
||||||
|
* des ersten, der Server rechnet aber immer vom Original. Also verrechnen. */
|
||||||
|
function composeCrop(previous, next) {
|
||||||
|
if (!previous) return next;
|
||||||
|
return {
|
||||||
|
x: previous.x + next.x * previous.w,
|
||||||
|
y: previous.y + next.y * previous.h,
|
||||||
|
w: next.w * previous.w,
|
||||||
|
h: next.h * previous.h,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drehen, ausrichten, zuschneiden. Jede Korrektur wird auf dem Server vom
|
||||||
|
* unbearbeiteten Original gerechnet - deshalb hält der Editor den ganzen
|
||||||
|
* Parametersatz und schickt ihn jedes Mal komplett mit. */
|
||||||
|
function openEditor(cardId, side) {
|
||||||
|
const overlay = $("editor-overlay");
|
||||||
|
const source = `/api/cards/${cardId}/image${side === "back" ? "?side=back" : ""}`;
|
||||||
|
const params = { rotate: 0, angle: 0, crop: null };
|
||||||
|
let box = null;
|
||||||
|
let drag = null;
|
||||||
|
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="editor-panel">
|
||||||
|
<p class="label" style="margin-bottom:12px">
|
||||||
|
${side === "back" ? "Rückseite" : "Vorderseite"} bearbeiten
|
||||||
|
</p>
|
||||||
|
<div class="editor-stage" id="editor-stage"><img id="editor-image" alt=""></div>
|
||||||
|
<div class="editor-row">
|
||||||
|
<button class="chip" data-rotate="-90" type="button">↺ 90°</button>
|
||||||
|
<button class="chip" data-rotate="90" type="button">↻ 90°</button>
|
||||||
|
<button class="chip" id="editor-crop" type="button" disabled>Zuschneiden</button>
|
||||||
|
</div>
|
||||||
|
<div class="slider-row">
|
||||||
|
<span class="label">Ausrichten</span>
|
||||||
|
<input type="range" id="editor-angle" min="-20" max="20" step="0.5" value="0">
|
||||||
|
<output id="editor-angle-out">0°</output>
|
||||||
|
</div>
|
||||||
|
<p class="hint" style="margin-top:12px">
|
||||||
|
Zum Zuschneiden über dem Bild einen Rahmen aufziehen, Ecken zum Feinjustieren anfassen.
|
||||||
|
</p>
|
||||||
|
<div class="editor-row">
|
||||||
|
<button class="chip" id="editor-reset" type="button">Zurücksetzen</button>
|
||||||
|
<button class="chip" id="editor-done" type="button" aria-pressed="true">Fertig</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
overlay.hidden = false;
|
||||||
|
|
||||||
|
const stage = $("editor-stage");
|
||||||
|
const image = $("editor-image");
|
||||||
|
const cropButton = $("editor-crop");
|
||||||
|
|
||||||
|
const reload = () => {
|
||||||
|
image.src = `${source}${source.includes("?") ? "&" : "?"}t=${Date.now()}`;
|
||||||
|
};
|
||||||
|
reload();
|
||||||
|
|
||||||
|
function drawBox() {
|
||||||
|
const existing = stage.querySelector(".crop-box");
|
||||||
|
if (existing) existing.remove();
|
||||||
|
cropButton.disabled = !box;
|
||||||
|
if (!box) return;
|
||||||
|
const node = document.createElement("div");
|
||||||
|
node.className = "crop-box";
|
||||||
|
node.style.left = `${box.x * 100}%`;
|
||||||
|
node.style.top = `${box.y * 100}%`;
|
||||||
|
node.style.width = `${box.w * 100}%`;
|
||||||
|
node.style.height = `${box.h * 100}%`;
|
||||||
|
node.innerHTML = ["nw", "ne", "se", "sw"]
|
||||||
|
.map((corner) => `<span class="crop-handle" data-corner="${corner}"></span>`)
|
||||||
|
.join("");
|
||||||
|
stage.appendChild(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
function relative(event) {
|
||||||
|
const bounds = stage.getBoundingClientRect();
|
||||||
|
return {
|
||||||
|
x: Math.min(1, Math.max(0, (event.clientX - bounds.left) / bounds.width)),
|
||||||
|
y: Math.min(1, Math.max(0, (event.clientY - bounds.top) / bounds.height)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
stage.addEventListener("pointerdown", (event) => {
|
||||||
|
const handle = event.target.closest(".crop-handle");
|
||||||
|
const point = relative(event);
|
||||||
|
stage.setPointerCapture(event.pointerId);
|
||||||
|
if (handle) {
|
||||||
|
drag = { corner: handle.dataset.corner };
|
||||||
|
} else {
|
||||||
|
drag = { corner: null, origin: point };
|
||||||
|
box = { x: point.x, y: point.y, w: 0, h: 0 };
|
||||||
|
}
|
||||||
|
event.preventDefault();
|
||||||
|
});
|
||||||
|
|
||||||
|
stage.addEventListener("pointermove", (event) => {
|
||||||
|
if (!drag || !box) return;
|
||||||
|
const point = relative(event);
|
||||||
|
if (drag.corner) {
|
||||||
|
const right = box.x + box.w;
|
||||||
|
const bottom = box.y + box.h;
|
||||||
|
if (drag.corner.includes("w")) { box.w = right - point.x; box.x = point.x; }
|
||||||
|
else { box.w = point.x - box.x; }
|
||||||
|
if (drag.corner.includes("n")) { box.h = bottom - point.y; box.y = point.y; }
|
||||||
|
else { box.h = point.y - box.y; }
|
||||||
|
if (box.w < 0) { box.x += box.w; box.w = -box.w; }
|
||||||
|
if (box.h < 0) { box.y += box.h; box.h = -box.h; }
|
||||||
|
} else {
|
||||||
|
box.x = Math.min(drag.origin.x, point.x);
|
||||||
|
box.y = Math.min(drag.origin.y, point.y);
|
||||||
|
box.w = Math.abs(point.x - drag.origin.x);
|
||||||
|
box.h = Math.abs(point.y - drag.origin.y);
|
||||||
|
}
|
||||||
|
drawBox();
|
||||||
|
});
|
||||||
|
|
||||||
|
const endDrag = () => {
|
||||||
|
drag = null;
|
||||||
|
if (box && (box.w < 0.03 || box.h < 0.03)) box = null;
|
||||||
|
drawBox();
|
||||||
|
};
|
||||||
|
stage.addEventListener("pointerup", endDrag);
|
||||||
|
stage.addEventListener("pointercancel", endDrag);
|
||||||
|
|
||||||
|
async function apply(extra = {}) {
|
||||||
|
try {
|
||||||
|
await api(`/api/cards/${cardId}/edit`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ side, ...params, ...extra }),
|
||||||
|
});
|
||||||
|
reload();
|
||||||
|
} catch (error) {
|
||||||
|
toast(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
overlay.querySelectorAll("[data-rotate]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
params.rotate = (params.rotate + Number(button.dataset.rotate) + 360) % 360;
|
||||||
|
// Der Zuschnitt bezog sich auf die alte Lage und gilt nicht mehr.
|
||||||
|
params.crop = null;
|
||||||
|
box = null;
|
||||||
|
drawBox();
|
||||||
|
apply();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
let angleTimer;
|
||||||
|
const angleInput = $("editor-angle");
|
||||||
|
angleInput.addEventListener("input", () => {
|
||||||
|
params.angle = Number(angleInput.value);
|
||||||
|
$("editor-angle-out").textContent = `${params.angle}°`;
|
||||||
|
clearTimeout(angleTimer);
|
||||||
|
angleTimer = setTimeout(apply, 350);
|
||||||
|
});
|
||||||
|
|
||||||
|
cropButton.addEventListener("click", () => {
|
||||||
|
if (!box) return;
|
||||||
|
params.crop = composeCrop(params.crop, box);
|
||||||
|
box = null;
|
||||||
|
drawBox();
|
||||||
|
apply();
|
||||||
|
});
|
||||||
|
|
||||||
|
$("editor-reset").addEventListener("click", () => {
|
||||||
|
params.rotate = 0;
|
||||||
|
params.angle = 0;
|
||||||
|
params.crop = null;
|
||||||
|
box = null;
|
||||||
|
angleInput.value = 0;
|
||||||
|
$("editor-angle-out").textContent = "0°";
|
||||||
|
drawBox();
|
||||||
|
apply({ reset: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
$("editor-done").addEventListener("click", () => {
|
||||||
|
overlay.hidden = true;
|
||||||
|
overlay.innerHTML = "";
|
||||||
|
loadCard(cardId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------- Ereignisse
|
// ------------------------------------------------------------- Ereignisse
|
||||||
|
|
||||||
document.addEventListener("click", (event) => {
|
document.addEventListener("click", (event) => {
|
||||||
|
const shareButton = event.target.closest("[data-share]");
|
||||||
|
if (shareButton) return shareCard(shareButton.dataset.share);
|
||||||
|
|
||||||
|
const editImage = event.target.closest("[data-edit-image]");
|
||||||
|
if (editImage) return openEditor(state.cardId, editImage.dataset.editImage);
|
||||||
|
|
||||||
const cardButton = event.target.closest("[data-card]");
|
const cardButton = event.target.closest("[data-card]");
|
||||||
if (cardButton) return go("karte", cardButton.dataset.card);
|
if (cardButton) return go("karte", cardButton.dataset.card);
|
||||||
|
|
||||||
|
const goto = event.target.closest("[data-goto]");
|
||||||
|
if (goto) return go(goto.dataset.goto);
|
||||||
|
|
||||||
if (event.target.closest("[data-back]")) {
|
if (event.target.closest("[data-back]")) {
|
||||||
state.editing = false;
|
state.editing = false;
|
||||||
return go("karten");
|
return go("karten");
|
||||||
@@ -476,6 +934,15 @@
|
|||||||
const tab = event.target.closest(".tab");
|
const tab = event.target.closest(".tab");
|
||||||
if (tab) return go(tab.dataset.route);
|
if (tab) return go(tab.dataset.route);
|
||||||
|
|
||||||
|
const navChip = event.target.closest("[data-nav-app]");
|
||||||
|
if (navChip) {
|
||||||
|
store.set("navApp", navChip.dataset.navApp);
|
||||||
|
document.querySelectorAll("[data-nav-app]").forEach((c) =>
|
||||||
|
c.setAttribute("aria-pressed", c === navChip ? "true" : "false")
|
||||||
|
);
|
||||||
|
return toast(`Adressen öffnen in ${NAV_APPS[navChip.dataset.navApp].label}`);
|
||||||
|
}
|
||||||
|
|
||||||
const chip = event.target.closest("#filter-chips .chip");
|
const chip = event.target.closest("#filter-chips .chip");
|
||||||
if (chip) {
|
if (chip) {
|
||||||
state.filter = chip.dataset.filter;
|
state.filter = chip.dataset.filter;
|
||||||
@@ -515,6 +982,12 @@
|
|||||||
loadCards();
|
loadCards();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Der Anlass gilt für eine ganze Messe, nicht für einen Scan.
|
||||||
|
$("occasion-input").value = store.get("occasion");
|
||||||
|
$("occasion-input").addEventListener("change", (event) => {
|
||||||
|
store.set("occasion", event.target.value.trim());
|
||||||
|
});
|
||||||
|
|
||||||
["input-camera", "input-library"].forEach((id) => {
|
["input-camera", "input-library"].forEach((id) => {
|
||||||
$(id).addEventListener("change", (event) => {
|
$(id).addEventListener("change", (event) => {
|
||||||
const file = event.target.files[0];
|
const file = event.target.files[0];
|
||||||
@@ -528,10 +1001,10 @@
|
|||||||
const body = new FormData();
|
const body = new FormData();
|
||||||
body.append("password", $("login-password").value);
|
body.append("password", $("login-password").value);
|
||||||
try {
|
try {
|
||||||
await fetch("/api/login", { method: "POST", body, credentials: "same-origin" })
|
const response = await fetch("/api/login", {
|
||||||
.then((response) => {
|
method: "POST", body, credentials: "same-origin",
|
||||||
if (!response.ok) throw new Error("Passwort stimmt nicht");
|
});
|
||||||
});
|
if (!response.ok) throw new Error("Passwort stimmt nicht");
|
||||||
$("login-overlay").hidden = true;
|
$("login-overlay").hidden = true;
|
||||||
$("login-error").hidden = true;
|
$("login-error").hidden = true;
|
||||||
boot();
|
boot();
|
||||||
@@ -541,11 +1014,6 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$("logout-button").addEventListener("click", async () => {
|
|
||||||
await api("/api/logout", { method: "POST" }).catch(() => {});
|
|
||||||
location.reload();
|
|
||||||
});
|
|
||||||
|
|
||||||
window.addEventListener("hashchange", applyRoute);
|
window.addEventListener("hashchange", applyRoute);
|
||||||
|
|
||||||
// --------------------------------------------------------------- Start
|
// --------------------------------------------------------------- Start
|
||||||
@@ -557,11 +1025,10 @@
|
|||||||
$("login-overlay").hidden = false;
|
$("login-overlay").hidden = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$("logout-button").hidden = !session.auth_required;
|
state.session = session;
|
||||||
if (!session.extraction_available) {
|
if (!session.extraction_available) {
|
||||||
toast("Kein API-Schlüssel gesetzt – Karten werden nur zugeschnitten.");
|
toast("Kein API-Schlüssel gesetzt – Karten werden nur zugeschnitten.");
|
||||||
}
|
}
|
||||||
await refreshStats();
|
|
||||||
applyRoute();
|
applyRoute();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast("Server nicht erreichbar");
|
toast("Server nicht erreichbar");
|
||||||
|
|||||||
@@ -16,64 +16,47 @@
|
|||||||
<body>
|
<body>
|
||||||
|
|
||||||
<div class="app">
|
<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>
|
<main>
|
||||||
<!-- ------------------------------------------------------------- Scan -->
|
<!-- ------------------------------------------------------------- Scan -->
|
||||||
<section class="screen stack" id="screen-scan">
|
<section class="screen stack" id="screen-scan">
|
||||||
<div class="block-invert">
|
<label class="field">
|
||||||
<p class="label">Stapel oder Einzelkarte</p>
|
<span class="label">Anlass · gilt für die nächsten Scans</span>
|
||||||
<h1 class="display d-xl" style="margin-top:8px">Karten<br>erfassen</h1>
|
<input id="occasion-input" placeholder="z. B. Hannover Messe 2026"
|
||||||
<p class="muted" style="margin:12px 0 0">
|
autocomplete="off" enterkeyhint="done">
|
||||||
Karten nebeneinander auf einen dunklen, einfarbigen Untergrund legen,
|
</label>
|
||||||
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">
|
<label class="action" for="input-camera">
|
||||||
Foto aufnehmen <span class="arrow">→</span>
|
<span class="action-icon" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
|
||||||
|
<path d="M3 7h3l1.5-2h9L18 7h3v12H3z"></path>
|
||||||
|
<circle cx="12" cy="13" r="3.6"></circle>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="action-body">
|
||||||
|
<span class="display d-lg">Foto aufnehmen</span>
|
||||||
|
<span class="action-hint">
|
||||||
|
Karten nebeneinander auf einen dunklen Untergrund legen.
|
||||||
|
Ein Foto reicht für den ganzen Tisch.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
<input id="input-camera" type="file" accept="image/*" capture="environment" hidden>
|
<input id="input-camera" type="file" accept="image/*" capture="environment" hidden>
|
||||||
</label>
|
</label>
|
||||||
<label class="btn" for="input-library">
|
|
||||||
|
<label class="btn btn-quiet" for="input-library">
|
||||||
Aus Fotos wählen <span class="arrow">↗</span>
|
Aus Fotos wählen <span class="arrow">↗</span>
|
||||||
<input id="input-library" type="file" accept="image/*" hidden>
|
<input id="input-library" type="file" accept="image/*" hidden>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<p class="hint">
|
|
||||||
Die Kamera liefert die volle Auflösung – bei zwanzig Karten auf einem Foto
|
|
||||||
ist das der Unterschied zwischen lesbar und nicht lesbar.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div id="scan-result"></div>
|
<div id="scan-result"></div>
|
||||||
|
|
||||||
|
<div class="section-label"><span class="label">Zuletzt erfasst</span></div>
|
||||||
|
<div id="recent-list"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- ------------------------------------------------------------ Karten -->
|
<!-- ------------------------------------------------------------ Karten -->
|
||||||
<section class="screen stack" id="screen-cards" hidden>
|
<section class="screen stack" id="screen-cards" hidden>
|
||||||
<div class="search">
|
<div class="search">
|
||||||
<input id="search-input" type="search" placeholder="Name, Firma, Ort, Notiz …"
|
<input id="search-input" type="search" placeholder="Name, Firma, Anlass, Notiz …"
|
||||||
autocomplete="off" enterkeyhint="search">
|
autocomplete="off" enterkeyhint="search">
|
||||||
<button id="search-clear" type="button" aria-label="Suche leeren">×</button>
|
<button id="search-clear" type="button" aria-label="Suche leeren">×</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -89,6 +72,9 @@
|
|||||||
<!-- ------------------------------------------------------------ Detail -->
|
<!-- ------------------------------------------------------------ Detail -->
|
||||||
<section class="screen stack" id="screen-detail" hidden></section>
|
<section class="screen stack" id="screen-detail" hidden></section>
|
||||||
|
|
||||||
|
<!-- ------------------------------------------------------ Einstellungen -->
|
||||||
|
<section class="screen stack" id="screen-mehr" hidden></section>
|
||||||
|
|
||||||
<!-- ------------------------------------------------------ Erinnerungen -->
|
<!-- ------------------------------------------------------ Erinnerungen -->
|
||||||
<section class="screen stack" id="screen-reminders" hidden>
|
<section class="screen stack" id="screen-reminders" hidden>
|
||||||
<div class="section-label"><span class="label">Erinnerungen</span></div>
|
<div class="section-label"><span class="label">Erinnerungen</span></div>
|
||||||
@@ -97,12 +83,43 @@
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<nav class="tabs" role="tablist">
|
<nav class="tabs" role="tablist">
|
||||||
<button class="tab" role="tab" data-route="scan" aria-selected="true">Scan</button>
|
<button class="tab" role="tab" data-route="scan" aria-selected="true">
|
||||||
|
<span class="tab-icon" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
|
||||||
|
<path d="M3 7h3l1.5-2h9L18 7h3v12H3z"></path>
|
||||||
|
<circle cx="12" cy="13" r="3.4"></circle>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="tab-label">Scannen</span>
|
||||||
|
</button>
|
||||||
<button class="tab" role="tab" data-route="karten" aria-selected="false">
|
<button class="tab" role="tab" data-route="karten" aria-selected="false">
|
||||||
<span class="count mono-num" id="tab-count-cards">0</span>Karten
|
<span class="tab-icon" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
|
||||||
|
<rect x="3" y="6" width="18" height="13"></rect>
|
||||||
|
<path d="M7 11h5M7 15h8"></path>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="tab-label">Karten</span>
|
||||||
|
<span class="tab-badge mono-num" id="tab-count-cards" hidden>0</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="tab" role="tab" data-route="erinnerungen" aria-selected="false">
|
<button class="tab" role="tab" data-route="erinnerungen" aria-selected="false">
|
||||||
<span class="count mono-num" id="tab-count-reminders">0</span>Termine
|
<span class="tab-icon" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
|
||||||
|
<circle cx="12" cy="12" r="8.5"></circle>
|
||||||
|
<path d="M12 7v5.2l3.4 2"></path>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="tab-label">Termine</span>
|
||||||
|
<span class="tab-badge mono-num" id="tab-count-reminders" hidden>0</span>
|
||||||
|
</button>
|
||||||
|
<button class="tab" role="tab" data-route="mehr" aria-selected="false">
|
||||||
|
<span class="tab-icon" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
|
||||||
|
<circle cx="12" cy="12" r="3.2"></circle>
|
||||||
|
<path d="M12 3v2.4M12 18.6V21M3 12h2.4M18.6 12H21M5.6 5.6l1.7 1.7M16.7 16.7l1.7 1.7M18.4 5.6l-1.7 1.7M7.3 16.7l-1.7 1.7"></path>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<span class="tab-label">Mehr</span>
|
||||||
</button>
|
</button>
|
||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
@@ -125,10 +142,13 @@
|
|||||||
<div class="overlay" id="busy-overlay" hidden>
|
<div class="overlay" id="busy-overlay" hidden>
|
||||||
<div class="block-invert stack" style="text-align:center">
|
<div class="block-invert stack" style="text-align:center">
|
||||||
<div class="spinner" style="margin:0 auto"></div>
|
<div class="spinner" style="margin:0 auto"></div>
|
||||||
<p class="label" id="busy-text">Karten werden gelesen</p>
|
<p class="label" id="busy-text">Karten werden freigestellt</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ---------------------------------------------------------- Bildbearbeitung -->
|
||||||
|
<div class="overlay" id="editor-overlay" hidden></div>
|
||||||
|
|
||||||
<div class="toast" id="toast" hidden></div>
|
<div class="toast" id="toast" hidden></div>
|
||||||
|
|
||||||
<script src="/static/app.js"></script>
|
<script src="/static/app.js"></script>
|
||||||
|
|||||||
505
static/style.css
505
static/style.css
@@ -1,14 +1,24 @@
|
|||||||
/* Schwarzweiss, harte Kanten, keine Rundungen. Mobile first. */
|
/* Schwarzweiss mit Grau als Hierarchiestufe. Harte Kanten, keine Rundungen.
|
||||||
|
Mobile first.
|
||||||
|
|
||||||
|
Drei Ebenen, konsequent durchgehalten:
|
||||||
|
Flaeche weiss -> --fill (gruppiert) -> schwarz (das eine Wichtige)
|
||||||
|
Text --ink -> --ink-2 (erlaeuternd) -> --ink-3 (Beiwerk)
|
||||||
|
Linie 3px schwarz (traegt) -> 1px schwarz (gliedert) -> --rule (trennt) */
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light;
|
color-scheme: light;
|
||||||
--fg: #000;
|
|
||||||
|
--ink: #000;
|
||||||
|
--ink-2: #4a4a4a;
|
||||||
|
--ink-3: #8a8a8a;
|
||||||
|
|
||||||
--bg: #fff;
|
--bg: #fff;
|
||||||
--muted: #6b6b6b;
|
--fill: #f4f4f4;
|
||||||
--hair: #d5d5d5;
|
--page: #e4e4e4;
|
||||||
--wash: #f2f2f2;
|
--rule: #d4d4d4;
|
||||||
|
|
||||||
--sans: -apple-system, BlinkMacSystemFont, "Helvetica Neue", Helvetica, Arial, sans-serif;
|
--sans: -apple-system, BlinkMacSystemFont, "Helvetica Neue", Helvetica, Arial, sans-serif;
|
||||||
--shell: 3px;
|
|
||||||
--pad: 18px;
|
--pad: 18px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17,15 +27,25 @@
|
|||||||
html, body {
|
html, body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
background: var(--wash);
|
background: var(--bg);
|
||||||
color: var(--fg);
|
color: var(--ink);
|
||||||
font-family: var(--sans);
|
font-family: var(--sans);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
-webkit-text-size-adjust: 100%;
|
-webkit-text-size-adjust: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
body { overflow-x: hidden; }
|
body {
|
||||||
|
overflow-x: hidden;
|
||||||
|
overscroll-behavior-y: none;
|
||||||
|
-webkit-tap-highlight-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bedienelemente verhalten sich wie in einer App, nicht wie Fliesstext. */
|
||||||
|
.btn, .chip, .tab, .row, .badge, .pill, .action, .label {
|
||||||
|
-webkit-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
button, input, textarea, select {
|
button, input, textarea, select {
|
||||||
font: inherit;
|
font: inherit;
|
||||||
@@ -42,124 +62,114 @@ a { color: inherit; }
|
|||||||
.display {
|
.display {
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
font-stretch: 85%;
|
font-stretch: 85%;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.025em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
line-height: 0.95;
|
line-height: 0.94;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.d-xl { font-size: 40px; }
|
.d-xl { font-size: 42px; }
|
||||||
.d-lg { font-size: 28px; }
|
.d-lg { font-size: 30px; }
|
||||||
.d-md { font-size: 20px; }
|
.d-md { font-size: 19px; letter-spacing: -0.01em; }
|
||||||
|
|
||||||
.label {
|
.label {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.16em;
|
letter-spacing: 0.18em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.muted { color: var(--muted); }
|
.muted { color: var(--ink-2); }
|
||||||
|
.hint { font-size: 12.5px; line-height: 1.5; color: var(--ink-3); }
|
||||||
.mono-num { font-variant-numeric: tabular-nums; }
|
.mono-num { font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------- Gerüst */
|
/* ---------------------------------------------------------------------- Geruest */
|
||||||
|
|
||||||
.app {
|
.app {
|
||||||
max-width: 560px;
|
max-width: 560px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border-left: 1px solid var(--fg);
|
|
||||||
border-right: 1px solid var(--fg);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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; }
|
main { flex: 1; display: flex; flex-direction: column; }
|
||||||
.screen { flex: 1; padding: var(--pad) var(--pad) 28px; }
|
.screen {
|
||||||
|
flex: 1;
|
||||||
|
padding: calc(var(--pad) + env(safe-area-inset-top)) var(--pad) 30px;
|
||||||
|
animation: rise 0.18s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rise {
|
||||||
|
from { opacity: 0; transform: translateY(7px); }
|
||||||
|
to { opacity: 1; transform: none; }
|
||||||
|
}
|
||||||
.screen[hidden] { display: none; }
|
.screen[hidden] { display: none; }
|
||||||
|
|
||||||
.stack > * + * { margin-top: 14px; }
|
.stack > * + * { margin-top: 12px; }
|
||||||
.stack-tight > * + * { margin-top: 8px; }
|
.stack-tight > * + * { margin-top: 8px; }
|
||||||
|
|
||||||
/* ---------------------------------------------------------------------- Blöcke */
|
/* ---------------------------------------------------------------------- Bloecke */
|
||||||
|
|
||||||
.block {
|
|
||||||
border: var(--shell) solid var(--fg);
|
|
||||||
padding: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/* Oberste Ebene: das eine Wichtige pro Ansicht. Nur einmal verwenden. */
|
||||||
.block-invert {
|
.block-invert {
|
||||||
background: var(--fg);
|
background: var(--ink);
|
||||||
color: var(--bg);
|
color: var(--bg);
|
||||||
border: var(--shell) solid var(--fg);
|
padding: 20px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-invert .muted { color: #a9a9a9; }
|
||||||
|
.block-invert .label { color: #a9a9a9; }
|
||||||
|
|
||||||
|
/* Mittlere Ebene: gruppiert, tritt zurueck. */
|
||||||
|
.block {
|
||||||
|
background: var(--fill);
|
||||||
|
border: 1px solid var(--rule);
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.block-invert .muted { color: #b4b4b4; }
|
/* Kopfband ueber einer Liste - ordnet zu, konkurriert aber nicht. */
|
||||||
|
|
||||||
.strip {
|
.strip {
|
||||||
border: var(--shell) solid var(--fg);
|
|
||||||
border-top: none;
|
|
||||||
padding: 12px 16px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
|
padding: 11px 14px;
|
||||||
|
background: var(--fill);
|
||||||
|
border: 1px solid var(--ink);
|
||||||
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.strip .label { color: var(--ink-2); }
|
||||||
|
|
||||||
.section-label {
|
.section-label {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 12px;
|
||||||
margin: 26px 0 10px;
|
margin: 34px 0 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.section-label .label { color: var(--ink); }
|
||||||
|
|
||||||
.section-label::after {
|
.section-label::after {
|
||||||
content: "";
|
content: "";
|
||||||
flex: 1;
|
flex: 1;
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: var(--fg);
|
background: var(--rule);
|
||||||
}
|
}
|
||||||
|
|
||||||
.figures {
|
.figures {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 30px;
|
gap: 32px;
|
||||||
margin-top: 18px;
|
margin-top: 20px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.28);
|
||||||
}
|
}
|
||||||
|
|
||||||
.figure { display: flex; flex-direction: column; }
|
.figure { display: flex; flex-direction: column; }
|
||||||
.figure .label { color: inherit; opacity: 0.7; margin-bottom: 5px; }
|
.figure .label { margin-bottom: 6px; }
|
||||||
|
|
||||||
/* --------------------------------------------------------------------- Buttons */
|
/* --------------------------------------------------------------------- Buttons */
|
||||||
|
|
||||||
@@ -169,9 +179,10 @@ main { flex: 1; display: flex; flex-direction: column; }
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
min-height: 54px;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border: var(--shell) solid var(--fg);
|
border: 2px solid var(--ink);
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
letter-spacing: 0.06em;
|
letter-spacing: 0.06em;
|
||||||
@@ -181,54 +192,99 @@ main { flex: 1; display: flex; flex-direction: column; }
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary { background: var(--fg); color: var(--bg); }
|
/* Die eine Sache, die man auf der Startseite tut. Grosse Flaeche statt
|
||||||
|
Textzeile - auf dem Telefon zaehlt die Trefferflaeche, nicht die Kante. */
|
||||||
|
.action {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 22px 18px;
|
||||||
|
background: var(--ink);
|
||||||
|
color: var(--bg);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action:active { background: #1f1f1f; }
|
||||||
|
.action-icon { flex: 0 0 42px; color: var(--bg); }
|
||||||
|
.action-icon svg { width: 42px; height: 42px; display: block; }
|
||||||
|
.action-body { display: flex; flex-direction: column; gap: 7px; }
|
||||||
|
.action-hint { font-size: 12.5px; line-height: 1.45; color: #a9a9a9; }
|
||||||
|
|
||||||
|
/* Was man hier tun soll. */
|
||||||
|
.btn-primary { background: var(--ink); color: var(--bg); border-color: var(--ink); }
|
||||||
|
|
||||||
|
/* Vorhanden, aber leise. */
|
||||||
|
.btn-quiet {
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
color: var(--ink-2);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
.btn:disabled { opacity: 0.35; cursor: not-allowed; }
|
||||||
.btn .arrow { font-weight: 400; font-size: 17px; }
|
.btn .arrow { font-weight: 400; font-size: 17px; }
|
||||||
|
.btn-quiet .arrow { color: var(--ink-3); }
|
||||||
.btn-danger { border-color: var(--fg); color: var(--fg); }
|
|
||||||
|
|
||||||
/* --------------------------------------------------------------------- Formular */
|
/* --------------------------------------------------------------------- Formular */
|
||||||
|
|
||||||
.field { display: block; }
|
.field { display: block; }
|
||||||
.field .label { color: var(--muted); margin-bottom: 5px; }
|
.field .label { color: var(--ink-3); margin-bottom: 6px; }
|
||||||
|
|
||||||
.field input,
|
.field input,
|
||||||
.field textarea,
|
.field textarea {
|
||||||
.field select {
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 11px 12px;
|
padding: 11px 12px;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border: 2px solid var(--fg);
|
border: 1px solid var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
.field input:focus,
|
.field input:focus,
|
||||||
.field textarea:focus,
|
.field textarea:focus { outline: 2px solid var(--ink); outline-offset: 1px; }
|
||||||
.field select:focus { outline: 3px solid var(--fg); outline-offset: 1px; }
|
|
||||||
|
|
||||||
.field textarea { min-height: 90px; resize: vertical; }
|
.field textarea { min-height: 88px; resize: vertical; }
|
||||||
|
|
||||||
.search {
|
/* iOS rendert Datumsfelder eigenwillig: der Wert sitzt zentriert, die Hoehe
|
||||||
display: flex;
|
sackt ein, der Innenabstand kommt vom System. Das zieht sie auf die Form
|
||||||
border: var(--shell) solid var(--fg);
|
der uebrigen Felder. */
|
||||||
|
.field input[type="datetime-local"] {
|
||||||
|
min-height: 46px;
|
||||||
|
line-height: 1.2;
|
||||||
|
text-align: left;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search input { flex: 1; border: none; padding: 14px; background: var(--bg); }
|
.field input[type="datetime-local"]::-webkit-date-and-time-value {
|
||||||
|
text-align: left;
|
||||||
|
margin: 0;
|
||||||
|
min-height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input[type="datetime-local"]::-webkit-calendar-picker-indicator {
|
||||||
|
opacity: 0.5;
|
||||||
|
filter: grayscale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.search { display: flex; border: 2px solid var(--ink); }
|
||||||
|
.search input { flex: 1; border: none; padding: 13px 14px; background: var(--bg); }
|
||||||
.search input:focus { outline: none; }
|
.search input:focus { outline: none; }
|
||||||
|
|
||||||
.search button {
|
.search button {
|
||||||
border: none;
|
border: none;
|
||||||
border-left: 2px solid var(--fg);
|
border-left: 1px solid var(--rule);
|
||||||
background: var(--bg);
|
background: var(--fill);
|
||||||
|
color: var(--ink-2);
|
||||||
padding: 0 16px;
|
padding: 0 16px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 800;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
|
.chips { display: flex; flex-wrap: wrap; gap: 7px; margin-top: 10px; }
|
||||||
|
|
||||||
|
/* Ungewaehlt zurueckgenommen, damit die Auswahl ueberhaupt auffaellt. */
|
||||||
.chip {
|
.chip {
|
||||||
padding: 7px 11px;
|
padding: 9px 12px;
|
||||||
border: 2px solid var(--fg);
|
border: 1px solid var(--rule);
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
|
color: var(--ink-3);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.12em;
|
letter-spacing: 0.12em;
|
||||||
@@ -236,11 +292,15 @@ main { flex: 1; display: flex; flex-direction: column; }
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chip[aria-pressed="true"] { background: var(--fg); color: var(--bg); }
|
.chip[aria-pressed="true"] {
|
||||||
|
background: var(--ink);
|
||||||
|
border-color: var(--ink);
|
||||||
|
color: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
/* --------------------------------------------------------------------- Tabellen */
|
/* --------------------------------------------------------------------- Listen */
|
||||||
|
|
||||||
.rows { border-top: 1px solid var(--fg); }
|
.rows { border-top: 1px solid var(--ink); border-bottom: 1px solid var(--ink); }
|
||||||
|
|
||||||
.row {
|
.row {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -248,30 +308,106 @@ main { flex: 1; display: flex; flex-direction: column; }
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border: none;
|
border: none;
|
||||||
border-bottom: 1px solid var(--hair);
|
border-bottom: 1px solid var(--rule);
|
||||||
padding: 14px 0;
|
padding: 14px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.row:last-child { border-bottom: 1px solid var(--fg); }
|
.row:last-child { border-bottom: none; }
|
||||||
.row-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
|
|
||||||
.row-title { font-weight: 800; font-size: 16px; }
|
.row-head {
|
||||||
.row-sub { color: var(--muted); font-size: 13px; margin-top: 2px; }
|
display: flex;
|
||||||
.row-meta { font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--muted); white-space: nowrap; }
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-title { font-weight: 800; font-size: 17px; letter-spacing: -0.01em; }
|
||||||
|
.row-sub { color: var(--ink-2); font-size: 13.5px; margin-top: 3px; }
|
||||||
|
|
||||||
|
.row-meta {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-3);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 9px; }
|
||||||
|
|
||||||
|
.pill {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 3px 7px;
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink-3);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pill-solid { background: var(--ink); border-color: var(--ink); color: var(--bg); }
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 5px 9px;
|
||||||
|
border: 1px solid var(--ink);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------- Tabelle */
|
||||||
|
|
||||||
|
.data {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
border-top: 1px solid var(--ink);
|
||||||
|
border-bottom: 1px solid var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.data tr:nth-child(even) { background: var(--fill); }
|
||||||
|
.data th, .data td { padding: 10px 12px; text-align: left; vertical-align: top; }
|
||||||
|
|
||||||
|
.data th {
|
||||||
|
width: 36%;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--ink-3);
|
||||||
|
padding-top: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
.data { width: 100%; border-collapse: collapse; }
|
|
||||||
.data th, .data td { padding: 11px 0; border-bottom: 1px solid var(--hair); text-align: left; vertical-align: top; }
|
|
||||||
.data th { width: 38%; font-weight: 400; color: var(--muted); font-size: 13px; }
|
|
||||||
.data td { font-weight: 600; word-break: break-word; }
|
.data td { font-weight: 600; word-break: break-word; }
|
||||||
.data tr:last-child th, .data tr:last-child td { border-bottom: none; }
|
|
||||||
|
/* Anrufen, schreiben, Route starten - direkt aus der Tabelle. */
|
||||||
|
.data td a {
|
||||||
|
text-decoration: none;
|
||||||
|
border-bottom: 2px solid var(--ink);
|
||||||
|
padding-bottom: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.data td a::after {
|
||||||
|
content: " ↗";
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--ink-3);
|
||||||
|
}
|
||||||
|
|
||||||
/* ----------------------------------------------------------------------- Bilder */
|
/* ----------------------------------------------------------------------- Bilder */
|
||||||
|
|
||||||
.card-image {
|
.card-image {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border: var(--shell) solid var(--fg);
|
border: 1px solid var(--ink);
|
||||||
background: var(--wash);
|
background: var(--fill);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -------------------------------------------------------------------- Navigation */
|
/* -------------------------------------------------------------------- Navigation */
|
||||||
@@ -282,52 +418,156 @@ main { flex: 1; display: flex; flex-direction: column; }
|
|||||||
z-index: 20;
|
z-index: 20;
|
||||||
display: flex;
|
display: flex;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border-top: var(--shell) solid var(--fg);
|
border-top: 3px solid var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab {
|
.tab {
|
||||||
|
position: relative;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 14px 4px calc(14px + env(safe-area-inset-bottom));
|
padding: 10px 4px calc(10px + env(safe-area-inset-bottom));
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border: none;
|
border: none;
|
||||||
border-left: 1px solid var(--fg);
|
border-left: 1px solid var(--rule);
|
||||||
|
color: var(--ink-3);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
letter-spacing: 0.12em;
|
letter-spacing: 0.1em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab:first-child { border-left: none; }
|
.tab:first-child { border-left: none; }
|
||||||
.tab[aria-selected="true"] { background: var(--fg); color: var(--bg); }
|
.tab[aria-selected="true"] { background: var(--ink); color: var(--bg); }
|
||||||
.tab .count { display: block; font-size: 16px; font-weight: 800; margin-bottom: 2px; }
|
|
||||||
|
|
||||||
/* ------------------------------------------------------------------- Zustände */
|
.tab-icon { display: block; width: 24px; height: 24px; margin: 0 auto 4px; }
|
||||||
|
.tab-icon svg { width: 24px; height: 24px; display: block; }
|
||||||
|
.tab-label { display: block; }
|
||||||
|
|
||||||
.empty { border: 2px dashed var(--fg); padding: 28px 18px; text-align: center; }
|
.tab-badge {
|
||||||
|
position: absolute;
|
||||||
.badge {
|
top: 6px;
|
||||||
display: inline-block;
|
right: 50%;
|
||||||
padding: 3px 7px;
|
transform: translateX(26px);
|
||||||
border: 2px solid var(--fg);
|
min-width: 18px;
|
||||||
font-size: 9px;
|
padding: 1px 4px;
|
||||||
font-weight: 700;
|
background: var(--ink);
|
||||||
letter-spacing: 0.12em;
|
color: var(--bg);
|
||||||
text-transform: uppercase;
|
border: 1px solid var(--bg);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tab-badge[hidden] { display: none; }
|
||||||
|
.tab[aria-selected="true"] .tab-badge { background: var(--bg); color: var(--ink); border-color: var(--ink); }
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------- Zustaende */
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
border: 1px dashed var(--rule);
|
||||||
|
background: var(--fill);
|
||||||
|
padding: 30px 18px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty .label { color: var(--ink-2); }
|
||||||
|
|
||||||
.overlay {
|
.overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
z-index: 60;
|
z-index: 60;
|
||||||
background: rgba(0, 0, 0, 0.82);
|
background: rgba(0, 0, 0, 0.86);
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
padding: var(--pad);
|
padding: var(--pad);
|
||||||
}
|
}
|
||||||
|
|
||||||
.overlay[hidden] { display: none; }
|
.overlay[hidden] { display: none; }
|
||||||
.overlay .block { background: var(--bg); width: 100%; max-width: 380px; }
|
|
||||||
|
.overlay .block {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 3px solid var(--ink);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 380px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------- Bildeditor */
|
||||||
|
|
||||||
|
.editor {
|
||||||
|
background: rgba(0, 0, 0, 0.92);
|
||||||
|
align-content: start;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-panel {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 460px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 3px solid var(--ink);
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-stage {
|
||||||
|
position: relative;
|
||||||
|
background: var(--fill);
|
||||||
|
touch-action: none;
|
||||||
|
user-select: none;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-stage img { width: 100%; display: block; }
|
||||||
|
|
||||||
|
.crop-box {
|
||||||
|
position: absolute;
|
||||||
|
border: 2px solid var(--bg);
|
||||||
|
outline: 9999px solid rgba(0, 0, 0, 0.55);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-handle {
|
||||||
|
position: absolute;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 2px solid var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-handle[data-corner="nw"] { left: -13px; top: -13px; }
|
||||||
|
.crop-handle[data-corner="ne"] { right: -13px; top: -13px; }
|
||||||
|
.crop-handle[data-corner="se"] { right: -13px; bottom: -13px; }
|
||||||
|
.crop-handle[data-corner="sw"] { left: -13px; bottom: -13px; }
|
||||||
|
|
||||||
|
.editor-row { display: flex; gap: 8px; margin-top: 12px; }
|
||||||
|
.editor-row .chip { flex: 1; text-align: center; }
|
||||||
|
|
||||||
|
.slider-row { display: flex; align-items: center; gap: 12px; margin-top: 14px; }
|
||||||
|
.slider-row .label { color: var(--ink-3); white-space: nowrap; }
|
||||||
|
.slider-row output { font-weight: 800; font-variant-numeric: tabular-nums; min-width: 42px; text-align: right; }
|
||||||
|
|
||||||
|
input[type="range"] {
|
||||||
|
flex: 1;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
height: 3px;
|
||||||
|
background: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
width: 26px;
|
||||||
|
height: 26px;
|
||||||
|
background: var(--ink);
|
||||||
|
border: 3px solid var(--bg);
|
||||||
|
box-shadow: 0 0 0 1px var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"]::-moz-range-thumb {
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 0;
|
||||||
|
background: var(--ink);
|
||||||
|
border: 3px solid var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
.spinner {
|
.spinner {
|
||||||
width: 26px;
|
width: 26px;
|
||||||
@@ -347,7 +587,7 @@ main { flex: 1; display: flex; flex-direction: column; }
|
|||||||
z-index: 70;
|
z-index: 70;
|
||||||
max-width: 90vw;
|
max-width: 90vw;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
background: var(--fg);
|
background: var(--ink);
|
||||||
color: var(--bg);
|
color: var(--bg);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -355,10 +595,11 @@ main { flex: 1; display: flex; flex-direction: column; }
|
|||||||
|
|
||||||
.toast[hidden] { display: none; }
|
.toast[hidden] { display: none; }
|
||||||
|
|
||||||
.hint { font-size: 12px; color: var(--muted); }
|
/* Nur am grossen Bildschirm ein Rahmen - auf dem Telefon fuellt die App
|
||||||
|
den Schirm, wie eine App das tut. */
|
||||||
@media (min-width: 600px) {
|
@media (min-width: 600px) {
|
||||||
:root { --pad: 24px; }
|
:root { --pad: 26px; }
|
||||||
.app { border: 1px solid var(--fg); margin: 24px auto; min-height: calc(100vh - 48px); }
|
body { background: var(--page); }
|
||||||
.d-xl { font-size: 48px; }
|
.app { border: 1px solid var(--ink); margin: 26px auto; min-height: calc(100vh - 52px); }
|
||||||
|
.d-xl { font-size: 52px; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,31 @@
|
|||||||
Ohne ANTHROPIC_API_KEY laeuft die Extraktion in den Leerlauf - Freistellen,
|
Ohne ANTHROPIC_API_KEY laeuft die Extraktion in den Leerlauf - Freistellen,
|
||||||
Speichern, Suchen, Bearbeiten und Export werden trotzdem vollstaendig gepruft.
|
Speichern, Suchen, Bearbeiten und Export werden trotzdem vollstaendig gepruft.
|
||||||
"""
|
"""
|
||||||
|
import asyncio
|
||||||
|
import time
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from app import db, mailer, reminders
|
from app import db, extract, mailer, reminders
|
||||||
from app.main import app
|
from app.main import app
|
||||||
from tests.helpers import photo_bytes
|
from tests.helpers import photo_bytes
|
||||||
|
|
||||||
|
|
||||||
|
def warte_bis_gelesen(client, card_ids, timeout=15.0):
|
||||||
|
"""Das Auslesen laeuft im Hintergrund - hier auf das Ende warten."""
|
||||||
|
frist = time.monotonic() + timeout
|
||||||
|
while time.monotonic() < frist:
|
||||||
|
stati = [
|
||||||
|
client.get(f"/api/cards/{i}").json()["extract_status"] for i in card_ids
|
||||||
|
]
|
||||||
|
if all(status != "offen" for status in stati):
|
||||||
|
return stati
|
||||||
|
time.sleep(0.1)
|
||||||
|
raise AssertionError("Karten wurden nicht fertig gelesen")
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(scope="module")
|
@pytest.fixture(scope="module")
|
||||||
def client():
|
def client():
|
||||||
with TestClient(app) as test_client:
|
with TestClient(app) as test_client:
|
||||||
@@ -25,7 +40,9 @@ def scanned(client):
|
|||||||
"/api/scan", files={"file": ("tisch.jpg", photo_bytes(4), "image/jpeg")}
|
"/api/scan", files={"file": ("tisch.jpg", photo_bytes(4), "image/jpeg")}
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
return response.json()
|
data = response.json()
|
||||||
|
warte_bis_gelesen(client, [c["id"] for c in data["cards"]])
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
def test_healthz(client):
|
def test_healthz(client):
|
||||||
@@ -58,10 +75,10 @@ def test_bearbeiten_und_suchen(client, scanned):
|
|||||||
|
|
||||||
patched = client.patch(
|
patched = client.patch(
|
||||||
f"/api/cards/{card_id}",
|
f"/api/cards/{card_id}",
|
||||||
json={"full_name": "Anna Berg", "company": "Berg & Partner", "notes": "Messe"},
|
json={"first_name": "Anna", "last_name": "Berg", "company": "Berg & Partner", "notes": "Messe"},
|
||||||
)
|
)
|
||||||
assert patched.status_code == 200
|
assert patched.status_code == 200
|
||||||
assert patched.json()["full_name"] == "Anna Berg"
|
assert patched.json()["last_name"] == "Berg"
|
||||||
|
|
||||||
hits = client.get("/api/cards", params={"q": "berg"}).json()["cards"]
|
hits = client.get("/api/cards", params={"q": "berg"}).json()["cards"]
|
||||||
assert [c["id"] for c in hits] == [card_id]
|
assert [c["id"] for c in hits] == [card_id]
|
||||||
@@ -80,7 +97,7 @@ def test_unbekannte_felder_werden_abgewiesen(client, scanned):
|
|||||||
|
|
||||||
def test_vcard_download_markiert_die_karte_als_exportiert(client, scanned):
|
def test_vcard_download_markiert_die_karte_als_exportiert(client, scanned):
|
||||||
card_id = scanned["cards"][1]["id"]
|
card_id = scanned["cards"][1]["id"]
|
||||||
client.patch(f"/api/cards/{card_id}", json={"full_name": "Bea Kurz"})
|
client.patch(f"/api/cards/{card_id}", json={"first_name": "Bea", "last_name": "Kurz"})
|
||||||
|
|
||||||
response = client.get(f"/vcf/{card_id}")
|
response = client.get(f"/vcf/{card_id}")
|
||||||
|
|
||||||
@@ -97,14 +114,6 @@ def test_filter_offen_blendet_exportierte_karten_aus(client, scanned):
|
|||||||
assert all(card["exported_at"] is None for card in offen)
|
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):
|
def test_erinnerung_anlegen_und_filtern(client, scanned):
|
||||||
card_id = scanned["cards"][2]["id"]
|
card_id = scanned["cards"][2]["id"]
|
||||||
due = datetime.now(timezone.utc) + timedelta(days=3)
|
due = datetime.now(timezone.utc) + timedelta(days=3)
|
||||||
@@ -134,7 +143,7 @@ def test_erinnerung_ohne_datum_wird_abgewiesen(client, scanned):
|
|||||||
|
|
||||||
def test_faellige_erinnerung_wird_verschickt(client, scanned, monkeypatch):
|
def test_faellige_erinnerung_wird_verschickt(client, scanned, monkeypatch):
|
||||||
card_id = scanned["cards"][3]["id"]
|
card_id = scanned["cards"][3]["id"]
|
||||||
client.patch(f"/api/cards/{card_id}", json={"full_name": "Carl Spät"})
|
client.patch(f"/api/cards/{card_id}", json={"first_name": "Carl", "last_name": "Spät"})
|
||||||
past = datetime.now(timezone.utc) - timedelta(minutes=5)
|
past = datetime.now(timezone.utc) - timedelta(minutes=5)
|
||||||
client.post(
|
client.post(
|
||||||
f"/api/cards/{card_id}/reminders",
|
f"/api/cards/{card_id}/reminders",
|
||||||
@@ -181,3 +190,169 @@ def test_pwa_dateien_werden_ausgeliefert(client):
|
|||||||
assert client.get("/sw.js").status_code == 200
|
assert client.get("/sw.js").status_code == 200
|
||||||
assert client.get("/manifest.webmanifest").status_code == 200
|
assert client.get("/manifest.webmanifest").status_code == 200
|
||||||
assert client.get("/static/app.js").status_code == 200
|
assert client.get("/static/app.js").status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_scan_antwortet_bevor_das_auslesen_fertig_ist(client, monkeypatch):
|
||||||
|
"""Der Kern der Hintergrundverarbeitung: die Antwort wartet nicht auf das
|
||||||
|
Modell. Ohne das haengt ein Zwanzigerstapel eine Minute im Upload."""
|
||||||
|
original = extract.extract_all
|
||||||
|
|
||||||
|
async def langsam(images):
|
||||||
|
await asyncio.sleep(1.5)
|
||||||
|
return await original(images)
|
||||||
|
|
||||||
|
monkeypatch.setattr(extract, "extract_all", langsam)
|
||||||
|
|
||||||
|
start = time.monotonic()
|
||||||
|
response = client.post(
|
||||||
|
"/api/scan", files={"file": ("einzel.jpg", photo_bytes(1), "image/jpeg")}
|
||||||
|
)
|
||||||
|
dauer = time.monotonic() - start
|
||||||
|
card_id = response.json()["cards"][0]["id"]
|
||||||
|
|
||||||
|
assert dauer < 1.2, f"Antwort brauchte {dauer:.2f}s, hat also gewartet"
|
||||||
|
assert client.get(f"/api/cards/{card_id}").json()["extract_status"] == "offen"
|
||||||
|
|
||||||
|
monkeypatch.undo()
|
||||||
|
warte_bis_gelesen(client, [card_id])
|
||||||
|
|
||||||
|
|
||||||
|
def test_offene_karten_zaehlen_nicht_als_unvollstaendig(client):
|
||||||
|
"""Waehrend des Lesens sind Felder naturgemaess leer - das ist kein Mangel."""
|
||||||
|
created = client.post(
|
||||||
|
"/api/scan", files={"file": ("einzel.jpg", photo_bytes(1), "image/jpeg")}
|
||||||
|
).json()
|
||||||
|
card_id = created["cards"][0]["id"]
|
||||||
|
warte_bis_gelesen(client, [card_id]) # sonst ueberschreibt der Hintergrundlauf
|
||||||
|
db.execute("UPDATE cards SET extract_status = 'offen' WHERE id = ?", (card_id,))
|
||||||
|
|
||||||
|
unvollstaendig = client.get(
|
||||||
|
"/api/cards", params={"filter": "unvollstaendig"}
|
||||||
|
).json()["cards"]
|
||||||
|
|
||||||
|
assert card_id not in [c["id"] for c in unvollstaendig]
|
||||||
|
assert client.get("/api/cards").json()["stats"]["laeuft"] >= 1
|
||||||
|
|
||||||
|
db.execute("UPDATE cards SET extract_status = 'ok' WHERE id = ?", (card_id,))
|
||||||
|
|
||||||
|
|
||||||
|
def test_sammelexport_ist_entfernt(client):
|
||||||
|
assert client.get("/vcf", params={"ids": "egal"}).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_handeintrag_ueberlebt_den_hintergrundlauf(client, monkeypatch):
|
||||||
|
"""Die Karte ist sichtbar, bevor sie gelesen ist. Wer in der Zwischenzeit
|
||||||
|
etwas eintraegt, darf es nicht wieder verlieren."""
|
||||||
|
original = extract.extract_all
|
||||||
|
|
||||||
|
async def langsam(images):
|
||||||
|
await asyncio.sleep(1.0)
|
||||||
|
return await original(images)
|
||||||
|
|
||||||
|
monkeypatch.setattr(extract, "extract_all", langsam)
|
||||||
|
card_id = client.post(
|
||||||
|
"/api/scan", files={"file": ("einzel.jpg", photo_bytes(1), "image/jpeg")}
|
||||||
|
).json()["cards"][0]["id"]
|
||||||
|
|
||||||
|
client.patch(f"/api/cards/{card_id}", json={"first_name": "Von", "last_name": "Hand", "notes": "Messe"})
|
||||||
|
|
||||||
|
monkeypatch.undo()
|
||||||
|
warte_bis_gelesen(client, [card_id])
|
||||||
|
|
||||||
|
card = client.get(f"/api/cards/{card_id}").json()
|
||||||
|
assert card["first_name"] == "Von"
|
||||||
|
assert card["last_name"] == "Hand"
|
||||||
|
assert card["notes"] == "Messe"
|
||||||
|
|
||||||
|
|
||||||
|
def test_anlass_wird_gespeichert_und_ist_durchsuchbar(client):
|
||||||
|
created = client.post(
|
||||||
|
"/api/scan",
|
||||||
|
files={"file": ("tisch.jpg", photo_bytes(2), "image/jpeg")},
|
||||||
|
data={"occasion": "Hannover Messe 2026"},
|
||||||
|
).json()
|
||||||
|
ids = [c["id"] for c in created["cards"]]
|
||||||
|
warte_bis_gelesen(client, ids)
|
||||||
|
|
||||||
|
assert all(c["occasion"] == "Hannover Messe 2026" for c in created["cards"])
|
||||||
|
|
||||||
|
gefunden = client.get("/api/cards", params={"q": "hannover messe"}).json()["cards"]
|
||||||
|
assert set(ids) <= {c["id"] for c in gefunden}
|
||||||
|
|
||||||
|
|
||||||
|
def test_anlass_ist_nachtraeglich_aenderbar(client):
|
||||||
|
card_id = client.post(
|
||||||
|
"/api/scan", files={"file": ("einzel.jpg", photo_bytes(1), "image/jpeg")}
|
||||||
|
).json()["cards"][0]["id"]
|
||||||
|
warte_bis_gelesen(client, [card_id])
|
||||||
|
|
||||||
|
patched = client.patch(f"/api/cards/{card_id}", json={"occasion": "Kundentermin"})
|
||||||
|
|
||||||
|
assert patched.json()["occasion"] == "Kundentermin"
|
||||||
|
|
||||||
|
|
||||||
|
def test_dubletten_stehen_in_der_detailansicht(client):
|
||||||
|
erste = client.post(
|
||||||
|
"/api/scan", files={"file": ("a.jpg", photo_bytes(1), "image/jpeg")}
|
||||||
|
).json()["cards"][0]["id"]
|
||||||
|
zweite = client.post(
|
||||||
|
"/api/scan", files={"file": ("b.jpg", photo_bytes(1), "image/jpeg")}
|
||||||
|
).json()["cards"][0]["id"]
|
||||||
|
warte_bis_gelesen(client, [erste, zweite])
|
||||||
|
for card_id in (erste, zweite):
|
||||||
|
client.patch(f"/api/cards/{card_id}", json={"email": "doppelt@firma.de"})
|
||||||
|
|
||||||
|
detail = client.get(f"/api/cards/{zweite}").json()
|
||||||
|
|
||||||
|
assert erste in [d["id"] for d in detail["duplicates"]]
|
||||||
|
assert detail["duplicates"][0]["reason"] == "gleiche E-Mail"
|
||||||
|
|
||||||
|
|
||||||
|
def test_rueckseite_nachtragen(client):
|
||||||
|
card_id = client.post(
|
||||||
|
"/api/scan", files={"file": ("vorne.jpg", photo_bytes(1), "image/jpeg")}
|
||||||
|
).json()["cards"][0]["id"]
|
||||||
|
warte_bis_gelesen(client, [card_id])
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
f"/api/cards/{card_id}/back",
|
||||||
|
files={"file": ("hinten.jpg", photo_bytes(1), "image/jpeg")},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json()["back_image_url"].endswith("side=back")
|
||||||
|
warte_bis_gelesen(client, [card_id])
|
||||||
|
|
||||||
|
bild = client.get(f"/api/cards/{card_id}/image", params={"side": "back"})
|
||||||
|
assert bild.status_code == 200
|
||||||
|
assert bild.headers["content-type"] == "image/jpeg"
|
||||||
|
|
||||||
|
|
||||||
|
def test_ohne_rueckseite_gibt_es_kein_rueckseitenbild(client):
|
||||||
|
card_id = client.post(
|
||||||
|
"/api/scan", files={"file": ("vorne.jpg", photo_bytes(1), "image/jpeg")}
|
||||||
|
).json()["cards"][0]["id"]
|
||||||
|
warte_bis_gelesen(client, [card_id])
|
||||||
|
|
||||||
|
assert client.get(
|
||||||
|
f"/api/cards/{card_id}/image", params={"side": "back"}
|
||||||
|
).status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_loeschen_entfernt_auch_die_rueckseite(client):
|
||||||
|
from app import config
|
||||||
|
|
||||||
|
card_id = client.post(
|
||||||
|
"/api/scan", files={"file": ("vorne.jpg", photo_bytes(1), "image/jpeg")}
|
||||||
|
).json()["cards"][0]["id"]
|
||||||
|
warte_bis_gelesen(client, [card_id])
|
||||||
|
client.post(
|
||||||
|
f"/api/cards/{card_id}/back",
|
||||||
|
files={"file": ("hinten.jpg", photo_bytes(1), "image/jpeg")},
|
||||||
|
)
|
||||||
|
warte_bis_gelesen(client, [card_id])
|
||||||
|
row = db.query_one("SELECT * FROM cards WHERE id = ?", (card_id,))
|
||||||
|
|
||||||
|
client.delete(f"/api/cards/{card_id}")
|
||||||
|
|
||||||
|
assert not (config.IMAGE_DIR / row["image_file"]).exists()
|
||||||
|
assert not (config.IMAGE_DIR / row["back_image_file"]).exists()
|
||||||
|
|||||||
89
tests/test_bildaufbereitung.py
Normal file
89
tests/test_bildaufbereitung.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
"""Zuschnitt aufhellen, Untergrund abschneiden, richtig herum drehen."""
|
||||||
|
import io
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from app import imaging, segment
|
||||||
|
from tests.helpers import photo_with_cards
|
||||||
|
|
||||||
|
|
||||||
|
def _jpeg(width: int, height: int, color=(255, 255, 255)) -> bytes:
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
Image.new("RGB", (width, height), color).save(buffer, format="JPEG")
|
||||||
|
return buffer.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _size(raw: bytes) -> tuple:
|
||||||
|
return Image.open(io.BytesIO(raw)).size
|
||||||
|
|
||||||
|
|
||||||
|
def test_drehung_um_null_grad_laesst_die_datei_unangetastet():
|
||||||
|
raw = _jpeg(40, 20)
|
||||||
|
|
||||||
|
assert imaging.rotate_jpeg(raw, 0) is raw
|
||||||
|
assert imaging.rotate_jpeg(raw, 360) is raw
|
||||||
|
|
||||||
|
|
||||||
|
def test_drehung_um_180_grad_behaelt_das_format():
|
||||||
|
assert _size(imaging.rotate_jpeg(_jpeg(40, 20), 180)) == (40, 20)
|
||||||
|
|
||||||
|
|
||||||
|
def test_drehung_um_90_grad_tauscht_die_seiten():
|
||||||
|
assert _size(imaging.rotate_jpeg(_jpeg(40, 20), 90)) == (20, 40)
|
||||||
|
assert _size(imaging.rotate_jpeg(_jpeg(40, 20), 270)) == (20, 40)
|
||||||
|
|
||||||
|
|
||||||
|
def test_drehrichtung_ist_im_uhrzeigersinn():
|
||||||
|
# Oben links schwarz: nach 90 Grad im Uhrzeigersinn liegt es oben rechts.
|
||||||
|
image = np.full((20, 40, 3), 255, dtype=np.uint8)
|
||||||
|
image[:10, :20] = 0
|
||||||
|
buffer = io.BytesIO()
|
||||||
|
Image.fromarray(image).save(buffer, format="PNG")
|
||||||
|
gedreht = np.asarray(
|
||||||
|
Image.open(io.BytesIO(imaging.rotate_jpeg(buffer.getvalue(), 90)))
|
||||||
|
)
|
||||||
|
|
||||||
|
assert gedreht[:10, -10:].mean() < 60 # oben rechts jetzt dunkel
|
||||||
|
assert gedreht[:10, :10].mean() > 195 # oben links jetzt hell
|
||||||
|
|
||||||
|
|
||||||
|
def test_helle_karte_wird_aufgehellt():
|
||||||
|
# Karte mit Helligkeitsverlauf, wie ihn schraeges Licht erzeugt.
|
||||||
|
verlauf = np.linspace(150, 225, 400, dtype=np.float32)
|
||||||
|
karte = np.repeat(verlauf[None, :, None], 250, axis=0).repeat(3, axis=2)
|
||||||
|
karte = karte.astype(np.uint8)
|
||||||
|
|
||||||
|
aufgehellt = segment.flatten(karte)
|
||||||
|
|
||||||
|
assert aufgehellt.mean() > karte.mean() + 20
|
||||||
|
# Der Verlauf ist danach deutlich flacher als vorher.
|
||||||
|
vorher = float(karte[:, -1].mean() - karte[:, 0].mean())
|
||||||
|
nachher = float(aufgehellt[:, -1].mean() - aufgehellt[:, 0].mean())
|
||||||
|
assert nachher < vorher / 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_dunkle_karte_bleibt_dunkel():
|
||||||
|
dunkel = np.full((250, 400, 3), 40, dtype=np.uint8)
|
||||||
|
|
||||||
|
assert np.array_equal(segment.flatten(dunkel), dunkel)
|
||||||
|
|
||||||
|
|
||||||
|
def test_zuschnitt_laesst_den_untergrund_draussen():
|
||||||
|
# Der Untergrund im Testfoto ist dunkel. Bleibt ein Streifen davon im
|
||||||
|
# Zuschnitt, waeren die Randspalten deutlich dunkler als die Mitte.
|
||||||
|
crops, _ = segment.segment(photo_with_cards(count=4))
|
||||||
|
|
||||||
|
for crop in crops:
|
||||||
|
rand = np.concatenate([crop[:, :3], crop[:, -3:]], axis=1)
|
||||||
|
assert rand.mean() > 170
|
||||||
|
|
||||||
|
|
||||||
|
def test_einzug_zieht_die_ecken_zur_mitte():
|
||||||
|
quad = np.array([[0, 0], [100, 0], [100, 100], [0, 100]], dtype="float32")
|
||||||
|
|
||||||
|
kleiner = segment._shrink(quad, factor=0.1)
|
||||||
|
|
||||||
|
assert kleiner[0][0] > quad[0][0]
|
||||||
|
assert kleiner[2][0] < quad[2][0]
|
||||||
|
assert np.allclose(kleiner.mean(axis=0), quad.mean(axis=0))
|
||||||
88
tests/test_duplicates.py
Normal file
88
tests/test_duplicates.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
"""Dublettenerkennung. Auf Messen trifft man Leute wieder."""
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app import db, duplicates
|
||||||
|
|
||||||
|
|
||||||
|
def _card(**felder):
|
||||||
|
columns = {
|
||||||
|
"id": uuid.uuid4().hex,
|
||||||
|
"created_at": "2026-09-01T10:00:00+00:00",
|
||||||
|
"extract_status": "ok",
|
||||||
|
**felder,
|
||||||
|
}
|
||||||
|
names = ", ".join(columns)
|
||||||
|
db.execute(
|
||||||
|
f"INSERT INTO cards ({names}) VALUES ({', '.join('?' for _ in columns)})",
|
||||||
|
list(columns.values()),
|
||||||
|
)
|
||||||
|
return db.query_one("SELECT * FROM cards WHERE id = ?", (columns["id"],))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def leere_tabelle():
|
||||||
|
db.execute("DELETE FROM cards")
|
||||||
|
yield
|
||||||
|
db.execute("DELETE FROM cards")
|
||||||
|
|
||||||
|
|
||||||
|
def test_gleiche_email_wird_gefunden():
|
||||||
|
_card(first_name="Anna", last_name="Berg", email="A.Berg@Firma.de")
|
||||||
|
neu = _card(first_name="A.", last_name="Berg", email="a.berg@firma.de")
|
||||||
|
|
||||||
|
treffer = duplicates.find(neu)
|
||||||
|
|
||||||
|
assert [t["reason"] for t in treffer] == ["gleiche E-Mail"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_telefonnummer_in_anderer_schreibweise():
|
||||||
|
_card(first_name="Anna", last_name="Berg", phone="+49 511 1234560")
|
||||||
|
neu = _card(first_name="Anna", last_name="B.", mobile="0511/1234560")
|
||||||
|
|
||||||
|
treffer = duplicates.find(neu)
|
||||||
|
|
||||||
|
assert [t["reason"] for t in treffer] == ["gleiche Telefonnummer"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_name_und_firma_zusammen():
|
||||||
|
_card(last_name="Berg", company="Berg & Partner GmbH")
|
||||||
|
neu = _card(last_name="berg", company="berg & partner gmbh")
|
||||||
|
|
||||||
|
assert [t["reason"] for t in duplicates.find(neu)] == ["gleicher Name und dieselbe Firma"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_gleicher_nachname_allein_reicht_nicht():
|
||||||
|
_card(last_name="Berg", company="Andere GmbH")
|
||||||
|
neu = _card(last_name="Berg", company="Berg & Partner GmbH")
|
||||||
|
|
||||||
|
assert duplicates.find(neu) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_kurze_nummern_werden_nicht_verglichen():
|
||||||
|
# Eine dreistellige Durchwahl darf keine Dublette erzeugen.
|
||||||
|
_card(first_name="Anna", last_name="Berg", phone="123")
|
||||||
|
neu = _card(first_name="Bea", last_name="Kurz", phone="123")
|
||||||
|
|
||||||
|
assert duplicates.find(neu) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_leere_felder_erzeugen_keine_treffer():
|
||||||
|
_card(first_name="Anna", last_name="Berg")
|
||||||
|
neu = _card(first_name="Bea", last_name="Kurz")
|
||||||
|
|
||||||
|
assert duplicates.find(neu) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_die_karte_selbst_ist_keine_dublette():
|
||||||
|
karte = _card(first_name="Anna", last_name="Berg", email="a@b.de")
|
||||||
|
|
||||||
|
assert duplicates.find(karte) == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_noch_nicht_gelesene_karten_bleiben_aussen_vor():
|
||||||
|
_card(first_name="Anna", last_name="Berg", email="a@b.de", extract_status="offen")
|
||||||
|
neu = _card(first_name="Anna", last_name="Berg", email="a@b.de")
|
||||||
|
|
||||||
|
assert duplicates.find(neu) == []
|
||||||
45
tests/test_extract.py
Normal file
45
tests/test_extract.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"""Der Parametersatz haengt am Modell - das war ein echter Produktionsfehler:
|
||||||
|
claude-haiku-4-5 lehnt output_config.effort mit einem 400 ab.
|
||||||
|
"""
|
||||||
|
from app import extract
|
||||||
|
|
||||||
|
|
||||||
|
def test_effort_nur_fuer_modelle_die_es_koennen():
|
||||||
|
assert "claude-haiku-4-5".startswith(extract._NO_EFFORT)
|
||||||
|
assert "claude-sonnet-4-5".startswith(extract._NO_EFFORT)
|
||||||
|
assert not "claude-opus-5".startswith(extract._NO_EFFORT)
|
||||||
|
assert not "claude-sonnet-5".startswith(extract._NO_EFFORT)
|
||||||
|
|
||||||
|
|
||||||
|
def test_server_seitiger_fallback_nur_fuer_die_grossen():
|
||||||
|
assert "claude-opus-5".startswith(extract._WITH_FALLBACK)
|
||||||
|
assert "claude-fable-5-1".startswith(extract._WITH_FALLBACK)
|
||||||
|
assert not "claude-haiku-4-5".startswith(extract._WITH_FALLBACK)
|
||||||
|
assert not "claude-sonnet-5".startswith(extract._WITH_FALLBACK)
|
||||||
|
|
||||||
|
|
||||||
|
def test_output_config_ohne_effort_bleibt_gueltig():
|
||||||
|
ohne = extract._output_config(with_effort=False)
|
||||||
|
mit = extract._output_config(with_effort=True)
|
||||||
|
|
||||||
|
assert "effort" not in ohne
|
||||||
|
assert ohne["format"]["type"] == "json_schema"
|
||||||
|
assert mit["effort"] == "low"
|
||||||
|
assert mit["format"] == ohne["format"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_schema_verlangt_alle_felder_und_verbietet_zusaetze():
|
||||||
|
assert extract.SCHEMA["additionalProperties"] is False
|
||||||
|
assert set(extract.SCHEMA["required"]) == {
|
||||||
|
"is_business_card",
|
||||||
|
"rotation",
|
||||||
|
*extract.FIELDS,
|
||||||
|
}
|
||||||
|
for field in extract.FIELDS:
|
||||||
|
assert extract.SCHEMA["properties"][field]["type"] == ["string", "null"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_rotation_ist_auf_vier_werte_beschraenkt():
|
||||||
|
"""Das Modell sagt uns, wie herum die Karte gehoert - geometrisch ist das
|
||||||
|
nicht bestimmbar."""
|
||||||
|
assert extract.SCHEMA["properties"]["rotation"]["enum"] == [0, 90, 180, 270]
|
||||||
@@ -17,7 +17,16 @@ def test_zuschnitte_haben_kartenformat():
|
|||||||
|
|
||||||
for crop in crops:
|
for crop in crops:
|
||||||
height, width = crop.shape[:2]
|
height, width = crop.shape[:2]
|
||||||
assert (width, height) == (segment.OUT_WIDTH, segment.OUT_HEIGHT)
|
assert abs(width / height - segment.CARD_RATIO) < 0.01
|
||||||
|
assert segment.MIN_WIDTH <= width <= segment.OUT_WIDTH
|
||||||
|
|
||||||
|
|
||||||
|
def test_zuschnitt_wird_nicht_hochskaliert():
|
||||||
|
# Auf einem Stapelfoto ist eine Karte schmaler als OUT_WIDTH - der
|
||||||
|
# Zuschnitt darf die Vorlage nicht kuenstlich aufblasen.
|
||||||
|
crops, _ = segment.segment(photo_with_cards(count=12))
|
||||||
|
|
||||||
|
assert all(crop.shape[1] < segment.OUT_WIDTH for crop in crops)
|
||||||
|
|
||||||
|
|
||||||
def test_zuschnitt_enthaelt_die_karte_und_nicht_den_tisch():
|
def test_zuschnitt_enthaelt_die_karte_und_nicht_den_tisch():
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
from app import vcard
|
from app import vcard
|
||||||
|
|
||||||
BASE = {
|
BASE = {
|
||||||
"full_name": "Dr. Anna Berg",
|
"first_name": "Dr. Anna",
|
||||||
"first_name": "Anna",
|
|
||||||
"last_name": "Berg",
|
"last_name": "Berg",
|
||||||
"job_title": "Geschäftsführerin",
|
"job_title": "Geschäftsführerin",
|
||||||
"company": "Berg & Partner GmbH",
|
"company": "Berg & Partner GmbH",
|
||||||
@@ -28,7 +27,7 @@ def test_enthaelt_die_pflichtzeilen():
|
|||||||
assert output.startswith("BEGIN:VCARD\r\nVERSION:3.0\r\n")
|
assert output.startswith("BEGIN:VCARD\r\nVERSION:3.0\r\n")
|
||||||
assert output.endswith("END:VCARD\r\n")
|
assert output.endswith("END:VCARD\r\n")
|
||||||
assert "FN:Dr. Anna Berg" in output
|
assert "FN:Dr. Anna Berg" in output
|
||||||
assert "N:Berg;Anna;;;" in output
|
assert "N:Berg;Dr. Anna;;;" in output
|
||||||
assert "TEL;TYPE=CELL:+49 170 1234567" in output
|
assert "TEL;TYPE=CELL:+49 170 1234567" in output
|
||||||
|
|
||||||
|
|
||||||
@@ -52,20 +51,20 @@ def test_leere_karte_bekommt_trotzdem_einen_namen():
|
|||||||
assert "FN:Unbenannt" in output
|
assert "FN:Unbenannt" in output
|
||||||
|
|
||||||
|
|
||||||
|
def test_name_setzt_sich_aus_vor_und_nachname_zusammen():
|
||||||
|
output = vcard.build({"first_name": "Dr. Anna", "last_name": "Berg"})
|
||||||
|
|
||||||
|
assert "FN:Dr. Anna Berg" in output
|
||||||
|
assert "N:Berg;Dr. Anna;;;" in output
|
||||||
|
|
||||||
|
|
||||||
def test_firma_ersetzt_den_namen_wenn_keiner_erkannt_wurde():
|
def test_firma_ersetzt_den_namen_wenn_keiner_erkannt_wurde():
|
||||||
output = vcard.build({"company": "Berg & Partner GmbH"})
|
output = vcard.build({"company": "Berg & Partner GmbH"})
|
||||||
|
|
||||||
assert "FN:Berg & Partner GmbH" in output
|
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():
|
def test_dateiname_ist_dateisystemtauglich():
|
||||||
assert vcard.filename(BASE) == "Dr-Anna-Berg.vcf"
|
assert vcard.filename(BASE) == "Dr-Anna-Berg.vcf"
|
||||||
assert vcard.filename({}) == "Unbenannt.vcf"
|
assert vcard.filename({}) == "Unbenannt.vcf"
|
||||||
assert vcard.filename({"full_name": "?!"}) == "kontakt.vcf"
|
assert vcard.filename({"first_name": "?!"}) == "kontakt.vcf"
|
||||||
|
|||||||
Reference in New Issue
Block a user