diff --git a/README.md b/README.md index 402f7a0..cb85f2e 100644 --- a/README.md +++ b/README.md @@ -31,16 +31,32 @@ der Browserstandort wäre dann das Wohnzimmer statt der Messe. Die Koordinaten löst Nominatim in einen Ortsnamen auf. Fehlen EXIF-Daten, greift der Browserstandort als Rückfallebene. -**Übersicht.** Alle Karten mit Volltextsuche über Name, Firma, Ort und Notiz. -Filter für „nicht exportiert“, „unvollständig“ und „mit Erinnerung“. +**Anlass.** Ein Feld auf der Startseite („Hannover Messe 2026“) gilt für alle +folgenden Scans und wird im Browser gemerkt. Verlässlicher als der Ortsname aus +den Koordinaten und die eigentliche Ordnung über den Bestand. -**Export.** Ein Tippen auf *In Kontakte speichern* liefert eine vCard 3.0, die -iOS direkt in die Kontakte-App übernimmt. Die Karte gilt danach als exportiert. -Bewusst einzeln: ein Sammelexport schiebt zwanzig Kontakte ungeprüft ins -Adressbuch. +**Dubletten.** Auf Messen trifft man Leute wieder. Die Detailansicht zeigt +Karten, die dieselbe Person meinen könnten – gleiche E-Mail, gleiche +Telefonnummer (Schreibweise egal, verglichen werden die letzten acht Ziffern) +oder gleicher Nachname bei gleicher Firma. + +**Rückseite.** Nachträglich ein zweites Bild zur Karte aufnehmen. Es wird +ausgelesen, füllt aber nur Felder, die die Vorderseite offen gelassen hat – +dort steht oft nur die Mobilnummer. + +**Übersicht.** Alle Karten mit Volltextsuche über Name, Firma, Anlass, Ort und +Notiz. Filter für „nicht exportiert“, „unvollständig“ und „mit Erinnerung“. + +**Export und Teilen.** *In Kontakte speichern* liefert eine vCard 3.0, die iOS +direkt in die Kontakte-App übernimmt. *Als vCard teilen* geht über das +System-Teilenblatt – per AirDrop, Mail oder Nachricht, ohne Umweg über den +Download. Bewusst einzeln: ein Sammelexport schiebt zwanzig Kontakte ungeprüft +ins Adressbuch. **Notizen und Erinnerungen.** Freitext pro Karte. Erinnerungen mit Termin gehen als Mail raus; verpasste Termine holt der Scheduler beim nächsten Start nach. +Schnellwahl für die üblichen Abstände, damit das Datumsfeld selten gebraucht +wird. ## Stack @@ -109,6 +125,7 @@ app/ FastAPI-Anwendung segment.py OpenCV-Pipeline: Karten finden und entzerren extract.py Vision-Modell, strukturiertes JSON imaging.py EXIF-Auswertung, Bildkonvertierung + duplicates.py Ähnliche Karten finden vcard.py vCard 3.0 reminders.py Scheduler für die Erinnerungsmails static/ PWA: HTML, CSS, JavaScript, Service Worker diff --git a/app/db.py b/app/db.py index fa068e7..32cdcec 100644 --- a/app/db.py +++ b/app/db.py @@ -15,7 +15,9 @@ CREATE TABLE IF NOT EXISTS cards ( captured_at TEXT, batch_id TEXT, image_file TEXT, + back_image_file TEXT, source TEXT NOT NULL DEFAULT 'batch', + occasion TEXT, full_name TEXT, first_name TEXT, last_name TEXT, @@ -71,10 +73,27 @@ def connect() -> sqlite3.Connection: _conn.execute("PRAGMA journal_mode=WAL") _conn.execute("PRAGMA foreign_keys=ON") _conn.executescript(SCHEMA) + _migrate(_conn) _conn.commit() return _conn +# Spalten, die nach dem ersten Deploy dazugekommen sind. CREATE TABLE IF NOT +# EXISTS ruehrt eine bestehende Tabelle nicht an - ohne das hier liefe die +# App auf dem Server mit dem alten Schema weiter. +LATER_COLUMNS = { + "back_image_file": "TEXT", + "occasion": "TEXT", +} + + +def _migrate(conn: sqlite3.Connection) -> None: + present = {row["name"] for row in conn.execute("PRAGMA table_info(cards)")} + for name, kind in LATER_COLUMNS.items(): + if name not in present: + conn.execute(f"ALTER TABLE cards ADD COLUMN {name} {kind}") + + def query(sql: str, params: Iterable[Any] = ()) -> list[sqlite3.Row]: with _lock: return connect().execute(sql, tuple(params)).fetchall() diff --git a/app/duplicates.py b/app/duplicates.py new file mode 100644 index 0000000..4586392 --- /dev/null +++ b/app/duplicates.py @@ -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, full_name, 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 diff --git a/app/main.py b/app/main.py index 8d9a956..cd5f561 100644 --- a/app/main.py +++ b/app/main.py @@ -10,7 +10,10 @@ from fastapi import Depends, FastAPI, Form, HTTPException, Request, Response, Up from fastapi.responses import FileResponse, JSONResponse, PlainTextResponse from fastapi.staticfiles import StaticFiles -from . import config, db, extract, geocode, imaging, reminders, security, segment, vcard +from . import ( + config, db, duplicates, extract, geocode, imaging, reminders, security, + segment, vcard, +) logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s") log = logging.getLogger("bcs") @@ -21,7 +24,7 @@ MAX_UPLOAD_BYTES = 30 * 1024 * 1024 EDITABLE = [ "full_name", "first_name", "last_name", "job_title", "company", "department", "email", "phone", "mobile", "fax", "website", - "street", "postal_code", "city", "country", "notes", + "street", "postal_code", "city", "country", "notes", "occasion", ] @@ -59,6 +62,9 @@ def now_iso() -> str: def card_dict(row) -> dict: card = dict(row) card["image_url"] = f"/api/cards/{card['id']}/image" if card.get("image_file") else None + card["back_image_url"] = ( + f"/api/cards/{card['id']}/image?side=back" if card.get("back_image_file") else None + ) card["has_contact"] = any( card.get(f) for f in ("full_name", "company", "email", "phone", "mobile") ) @@ -129,18 +135,19 @@ def _process_photo(raw: bytes) -> tuple: _reading: set = set() -def start_reading(card_ids: list) -> None: - task = asyncio.create_task(_read_cards(card_ids)) +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) -> None: +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] - images = [(config.IMAGE_DIR / row["image_file"]).read_bytes() for row in rows] + 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) @@ -149,7 +156,7 @@ async def _read_cards(card_ids: list) -> None: 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["image_file"] + path = config.IMAGE_DIR / row[column] path.write_bytes( await asyncio.to_thread(imaging.rotate_jpeg, path.read_bytes(), rotation) ) @@ -176,6 +183,7 @@ async def scan( file: UploadFile, lat: float = Form(None), lon: float = Form(None), + occasion: str = Form(""), ) -> JSONResponse: raw = await file.read() if not raw: @@ -219,6 +227,7 @@ async def scan( "lon": meta["lon"], "place": place, "geo_source": geo_source, + "occasion": occasion.strip() or None, "extract_status": "offen", } names = ", ".join(columns) @@ -247,7 +256,7 @@ def list_cards(q: str = "", filter: str = "alle") -> dict: needle = f"%{q.strip()}%" searchable = [ "full_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) + ")") params += [needle] * len(searchable) @@ -305,6 +314,7 @@ def get_card(card_id: str) -> dict: "SELECT * FROM reminders WHERE card_id = ? ORDER BY due_at", (card_id,) ) ] + card["duplicates"] = duplicates.find(card) return card @@ -327,21 +337,52 @@ async def update_card(card_id: str, request: Request) -> dict: @app.delete("/api/cards/{card_id}", dependencies=[auth]) def delete_card(card_id: str) -> dict: row = _card_or_404(card_id) - if row["image_file"]: - (config.IMAGE_DIR / row["image_file"]).unlink(missing_ok=True) + for name in (row["image_file"], row["back_image_file"]): + if name: + (config.IMAGE_DIR / name).unlink(missing_ok=True) db.execute("DELETE FROM cards WHERE id = ?", (card_id,)) return {"ok": True} @app.get("/api/cards/{card_id}/image", dependencies=[auth]) -def card_image(card_id: str) -> FileResponse: +def card_image(card_id: str, side: str = "front") -> FileResponse: row = _card_or_404(card_id) - path = config.IMAGE_DIR / (row["image_file"] or "") - if not row["image_file"] or not path.exists(): + name = row["back_image_file"] if side == "back" else row["image_file"] + path = config.IMAGE_DIR / (name or "") + if not name or not path.exists(): raise HTTPException(status_code=404, detail="Kein Bild gespeichert") return FileResponse(path, media_type="image/jpeg") +@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]) diff --git a/static/app.js b/static/app.js index c475ff4..a20fb80 100644 --- a/static/app.js +++ b/static/app.js @@ -18,6 +18,7 @@ postal_code: "PLZ", city: "Ort", country: "Land", + occasion: "Anlass", }; const FIELD_ORDER = Object.keys(FIELD_LABELS); @@ -27,7 +28,7 @@ query: "", filter: "alle", cards: [], - stats: { total: 0, offen: 0, erinnerungen: 0 }, + stats: { total: 0, offen: 0, erinnerungen: 0, laeuft: 0 }, editing: false, }; @@ -44,6 +45,15 @@ "&": "&", "<": "<", ">": ">", '"': """, "'": "'", })[c]); + const store = { + get(key) { + try { return localStorage.getItem(key) || ""; } catch (_) { return ""; } + }, + set(key, value) { + try { localStorage.setItem(key, value); } catch (_) { /* privater Modus */ } + }, + }; + // ------------------------------------------------------------------ Helfer function toast(message) { @@ -55,7 +65,7 @@ } function busy(on, text) { - $("busy-text").textContent = text || "Karten werden gelesen"; + $("busy-text").textContent = text || "Karten werden freigestellt"; $("busy-overlay").hidden = !on; } @@ -98,6 +108,9 @@ card.company || "Ohne Namen"; + const subtitle = (card) => + [card.job_title, card.company].filter(Boolean).join(" · ") || "Keine Firma erkannt"; + /** Position des Browsers als Rückfallebene. Die EXIF-Daten des Fotos sind * genauer, deshalb blockiert das hier nichts und scheitert leise. */ function browserPosition() { @@ -111,24 +124,31 @@ }); } - /** Zustand einer Karte als abgestufte Pillen: schwarz, was erledigt ist, - * grau umrandet, was noch offen oder auffaellig ist. */ + /** 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.place) tags.push(["", card.place]); - if (card.extract_status === "offen") { - tags.push(["pill-solid", "wird gelesen"]); - } else { - tags.push( - card.exported_at ? ["pill-solid", "exportiert"] : ["", "nicht exportiert"] - ); - if (card.extract_status === "fehler") tags.push(["", "Lesefehler"]); - } + 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]) => `${esc(text)}`) .join(""); } + function cardRow(card) { + return ` + `; + } + // ----------------------------------------------------------------- Routing function go(route, cardId) { @@ -143,11 +163,13 @@ Object.entries(screens).forEach(([name, node]) => { node.hidden = name !== route; }); document.querySelectorAll(".tab").forEach((tab) => { - const active = tab.dataset.route === route || (route === "karte" && tab.dataset.route === "karten"); + const active = + tab.dataset.route === route || (route === "karte" && tab.dataset.route === "karten"); tab.setAttribute("aria-selected", active ? "true" : "false"); }); window.scrollTo(0, 0); + if (route === "scan") loadRecent(); if (route === "karten") loadCards(); if (route === "karte") loadCard(state.cardId); if (route === "erinnerungen") loadReminders(); @@ -160,19 +182,18 @@ busy(true, "Karten werden freigestellt"); const body = new FormData(); body.append("file", file); + body.append("occasion", $("occasion-input").value.trim()); const coords = await browserPosition(); if (coords) { body.append("lat", coords.latitude); body.append("lon", coords.longitude); } - busy(true, "Karten werden gelesen"); try { const result = await api("/api/scan", { method: "POST", body }); renderScanResult(result); await refreshStats(); - toast( - `${result.count} Karte${result.count === 1 ? "" : "n"} erfasst, wird gelesen` - ); + loadRecent(); + toast(`${result.count} Karte${result.count === 1 ? "" : "n"} erfasst, wird gelesen`); } catch (error) { toast(error.message); } finally { @@ -181,31 +202,43 @@ } function renderScanResult(result) { - const rows = result.cards - .map( - (card) => ` - ` - ) - .join(""); - $("scan-result").innerHTML = `
Ergebnis
-
- Gefundene Karten - ${result.count} +
+ ${result.count} Karte${result.count === 1 ? "" : "n"} gefunden + wird gelesen
${result.fallback ? `

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

` : ""} -
${rows}
`; +
+ ${result.cards.map(cardRow).join("")} +
`; + } + + 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 + ? `
${recent.map(cardRow).join("")}
+ ${data.cards.length > 3 + ? `` + : ""}` + : `
+

Noch nichts erfasst

+

Mach oben ein Foto – der Rest passiert von allein.

+
`; + schedulePoll(); + } catch (error) { + toast(error.message); + } } // ------------------------------------------------------------- Kartenliste @@ -224,7 +257,7 @@ } } - /** Das Auslesen laeuft auf dem Server weiter, nachdem der Scan geantwortet + /** 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); @@ -232,6 +265,7 @@ 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); } @@ -254,23 +288,7 @@ ${state.cards.length} Karte${state.cards.length === 1 ? "" : "n"} ${state.stats.laeuft ? `${state.stats.laeuft} werden gelesen` : ""}
-
- ${state.cards - .map( - (card) => ` - ` - ) - .join("")} -
`; +
${state.cards.map(cardRow).join("")}
`; } // ----------------------------------------------------------- Kartendetail @@ -280,7 +298,7 @@ const card = await api(`/api/cards/${cardId}`); renderCard(card); if (card.extract_status === "offen") { - state.stats = { ...state.stats, laeuft: 1 }; + state.stats = { ...state.stats, laeuft: Math.max(1, state.stats.laeuft) }; schedulePoll(); } } catch (error) { @@ -290,10 +308,7 @@ function dataTable(card) { const rows = FIELD_ORDER.filter((field) => card[field]) - .map( - (field) => - `${FIELD_LABELS[field]}${esc(card[field])}` - ) + .map((field) => `${FIELD_LABELS[field]}${esc(card[field])}`) .join(""); return rows ? `${rows}
` @@ -310,18 +325,47 @@ ).join("")}`; } - function renderCard(card) { - const meta = []; - if (card.place) meta.push(esc(card.place)); - if (card.lat != null) meta.push(`${card.lat.toFixed(4)}, ${card.lon.toFixed(4)}`); + function duplicateBlock(card) { + if (!card.duplicates || !card.duplicates.length) return ""; + return ` +
Schon einmal erfasst?
+
+ ${card.duplicates + .map( + (other) => ` + ` + ) + .join("")} +
`; + } + 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 ? `
${card.reminders .map( (reminder) => `
- ${esc(reminder.text)} + ${esc(reminder.text)} @@ -335,14 +379,12 @@ : `

Keine Erinnerung gesetzt.

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

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

+

${esc(card.occasion || (card.source === "stapel" ? "Aus Stapelscan" : "Einzelscan"))}

${esc(displayName(card))}

-

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

+

${esc(subtitle(card))}

Erfasst @@ -350,18 +392,27 @@ Ort - ${meta.length ? esc(meta[0]) : "Unbekannt"} + ${esc(card.place || "Unbekannt")}
${card.image_url - ? `Visitenkarte` + ? `Vorderseite` : ""} + ${card.back_image_url + ? `Rückseite` + : ``} In Kontakte speichern → + ${card.extract_status === "offen" ? `

Die Karte wird gerade ausgelesen – die Felder erscheinen gleich.

` @@ -370,9 +421,9 @@ ? `

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

` : ""} - + ${duplicateBlock(card)} + + @@ -390,7 +441,12 @@ Text -
@@ -125,7 +145,7 @@ diff --git a/static/style.css b/static/style.css index b1d99f7..c51f3a5 100644 --- a/static/style.css +++ b/static/style.css @@ -27,7 +27,7 @@ html, body { margin: 0; padding: 0; - background: var(--page); + background: var(--bg); color: var(--ink); font-family: var(--sans); font-size: 15px; @@ -35,7 +35,17 @@ html, body { -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, .topbar { + -webkit-user-select: none; + user-select: none; +} button, input, textarea, select { font: inherit; @@ -81,8 +91,6 @@ a { color: inherit; } margin: 0 auto; min-height: 100vh; background: var(--bg); - border-left: 1px solid var(--ink); - border-right: 1px solid var(--ink); display: flex; flex-direction: column; } @@ -95,6 +103,7 @@ a { color: inherit; } align-items: center; gap: 12px; padding: 13px var(--pad); + padding-top: calc(13px + env(safe-area-inset-top)); background: var(--bg); border-bottom: 3px solid var(--ink); } @@ -117,7 +126,12 @@ a { color: inherit; } .topbar .spacer { margin-left: auto; } main { flex: 1; display: flex; flex-direction: column; } -.screen { flex: 1; padding: var(--pad) var(--pad) 30px; } +.screen { flex: 1; padding: var(--pad) var(--pad) 30px; animation: rise 0.18s ease-out; } + +@keyframes rise { + from { opacity: 0; transform: translateY(7px); } + to { opacity: 1; transform: none; } +} .screen[hidden] { display: none; } .stack > * + * { margin-top: 12px; } @@ -191,6 +205,7 @@ main { flex: 1; display: flex; flex-direction: column; } justify-content: space-between; gap: 10px; width: 100%; + min-height: 54px; padding: 16px; background: var(--bg); border: 2px solid var(--ink); @@ -203,6 +218,24 @@ main { flex: 1; display: flex; flex-direction: column; } text-align: left; } +/* 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); } @@ -274,7 +307,7 @@ main { flex: 1; display: flex; flex-direction: column; } /* Ungewaehlt zurueckgenommen, damit die Auswahl ueberhaupt auffaellt. */ .chip { - padding: 7px 11px; + padding: 9px 12px; border: 1px solid var(--rule); background: var(--bg); color: var(--ink-3); @@ -402,15 +435,16 @@ main { flex: 1; display: flex; flex-direction: column; } } .tab { + position: relative; flex: 1; - padding: 12px 4px calc(12px + env(safe-area-inset-bottom)); + padding: 10px 4px calc(10px + env(safe-area-inset-bottom)); background: var(--bg); border: none; border-left: 1px solid var(--rule); color: var(--ink-3); font-size: 10px; font-weight: 700; - letter-spacing: 0.12em; + letter-spacing: 0.1em; text-transform: uppercase; cursor: pointer; } @@ -418,15 +452,27 @@ main { flex: 1; display: flex; flex-direction: column; } .tab:first-child { border-left: none; } .tab[aria-selected="true"] { background: var(--ink); color: var(--bg); } -.tab .count { - display: block; - font-size: 17px; +.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; } + +.tab-badge { + position: absolute; + top: 6px; + right: 50%; + transform: translateX(26px); + min-width: 18px; + padding: 1px 4px; + background: var(--ink); + color: var(--bg); + border: 1px solid var(--bg); + font-size: 10px; font-weight: 800; - color: var(--ink); - margin-bottom: 1px; + letter-spacing: 0; } -.tab[aria-selected="true"] .count { color: var(--bg); } +.tab-badge[hidden] { display: none; } +.tab[aria-selected="true"] .tab-badge { background: var(--bg); color: var(--ink); border-color: var(--ink); } /* ------------------------------------------------------------------- Zustaende */ @@ -484,8 +530,11 @@ main { flex: 1; display: flex; flex-direction: column; } .toast[hidden] { display: none; } +/* Nur am grossen Bildschirm ein Rahmen - auf dem Telefon fuellt die App + den Schirm, wie eine App das tut. */ @media (min-width: 600px) { :root { --pad: 26px; } + body { background: var(--page); } .app { border: 1px solid var(--ink); margin: 26px auto; min-height: calc(100vh - 52px); } .d-xl { font-size: 52px; } } diff --git a/tests/test_api.py b/tests/test_api.py index 6b362f0..6b8f2cb 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -262,3 +262,96 @@ def test_handeintrag_ueberlebt_den_hintergrundlauf(client, monkeypatch): card = client.get(f"/api/cards/{card_id}").json() assert card["full_name"] == "Von 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() diff --git a/tests/test_duplicates.py b/tests/test_duplicates.py new file mode 100644 index 0000000..70c85b1 --- /dev/null +++ b/tests/test_duplicates.py @@ -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(full_name="Anna Berg", email="A.Berg@Firma.de") + neu = _card(full_name="A. 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(full_name="Anna Berg", phone="+49 511 1234560") + neu = _card(full_name="Anna 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(full_name="Anna Berg", phone="123") + neu = _card(full_name="Bea Kurz", phone="123") + + assert duplicates.find(neu) == [] + + +def test_leere_felder_erzeugen_keine_treffer(): + _card(full_name="Anna Berg") + neu = _card(full_name="Bea Kurz") + + assert duplicates.find(neu) == [] + + +def test_die_karte_selbst_ist_keine_dublette(): + karte = _card(full_name="Anna Berg", email="a@b.de") + + assert duplicates.find(karte) == [] + + +def test_noch_nicht_gelesene_karten_bleiben_aussen_vor(): + _card(full_name="Anna Berg", email="a@b.de", extract_status="offen") + neu = _card(full_name="Anna Berg", email="a@b.de") + + assert duplicates.find(neu) == []