Anlass, Dubletten, Rückseite, Teilen; Startseite und App-Anmutung
All checks were successful
Build & Deploy / deploy (push) Successful in 10m41s

Vier Funktionen:

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

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

Oberfläche:

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lucas Orth
2026-09-06 15:09:08 +02:00
parent a7fbc15792
commit bf0a397597
9 changed files with 660 additions and 149 deletions

View File

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

View File

@@ -15,7 +15,9 @@ CREATE TABLE IF NOT EXISTS cards (
captured_at TEXT,
batch_id TEXT,
image_file TEXT,
back_image_file TEXT,
source TEXT NOT NULL DEFAULT 'batch',
occasion TEXT,
full_name TEXT,
first_name TEXT,
last_name TEXT,
@@ -71,10 +73,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()

55
app/duplicates.py Normal file
View File

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

View File

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

View File

@@ -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 @@
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
})[c]);
const store = {
get(key) {
try { return localStorage.getItem(key) || ""; } catch (_) { return ""; }
},
set(key, value) {
try { localStorage.setItem(key, value); } catch (_) { /* privater Modus */ }
},
};
// ------------------------------------------------------------------ Helfer
function toast(message) {
@@ -55,7 +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]) => `<span class="pill ${extra}">${esc(text)}</span>`)
.join("");
}
function cardRow(card) {
return `
<button class="row" data-card="${esc(card.id)}">
<span class="row-head">
<span class="row-title">${esc(displayName(card))}</span>
<span class="row-meta">${esc(formatDate(card.captured_at || card.created_at))}</span>
</span>
<span class="row-sub">${esc(subtitle(card))}</span>
<span class="row-tags">${cardTags(card)}</span>
</button>`;
}
// ----------------------------------------------------------------- Routing
function go(route, cardId) {
@@ -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) => `
<button class="row" data-card="${esc(card.id)}">
<span class="row-head">
<span class="row-title">${esc(displayName(card))}</span>
<span class="row-meta">wird gelesen</span>
</span>
<span class="row-sub">${esc(card.company || card.email || "Zuschnitt gespeichert")}</span>
</button>`
)
.join("");
$("scan-result").innerHTML = `
<div class="section-label"><span class="label">Ergebnis</span></div>
<div class="strip" style="border-top:3px solid #000">
<span class="label">Gefundene Karten</span>
<span class="display d-lg mono-num">${result.count}</span>
<div class="strip">
<span class="label">${result.count} Karte${result.count === 1 ? "" : "n"} gefunden</span>
<span class="pill">wird gelesen</span>
</div>
${result.fallback
? `<p class="hint" style="margin-top:10px">Kein Kartenrechteck erkannt – das ganze Foto
wurde als eine Karte behandelt. Für einen Stapel hilft ein dunkler, einfarbiger
Untergrund mit Abstand zwischen den Karten.</p>`
: ""}
<div class="rows" style="margin-top:14px">${rows}</div>`;
<div class="rows" style="margin-top:12px">
${result.cards.map(cardRow).join("")}
</div>`;
}
async function loadRecent() {
try {
const data = await api("/api/cards?filter=alle");
state.stats = data.stats;
updateCounters();
const recent = data.cards.slice(0, 3);
$("recent-list").innerHTML = recent.length
? `<div class="rows">${recent.map(cardRow).join("")}</div>
${data.cards.length > 3
? `<button class="btn btn-quiet" data-goto="karten" style="margin-top:12px">
Alle ${data.stats.total} Karten <span class="arrow">→</span>
</button>`
: ""}`
: `<div class="empty">
<p class="label">Noch nichts erfasst</p>
<p class="hint" style="margin:8px 0 0">Mach oben ein Foto – der Rest passiert von allein.</p>
</div>`;
schedulePoll();
} catch (error) {
toast(error.message);
}
}
// ------------------------------------------------------------- Kartenliste
@@ -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 @@
<span class="label">${state.cards.length} Karte${state.cards.length === 1 ? "" : "n"}</span>
${state.stats.laeuft ? `<span class="pill">${state.stats.laeuft} werden gelesen</span>` : ""}
</div>
<div class="rows">
${state.cards
.map(
(card) => `
<button class="row" data-card="${esc(card.id)}">
<span class="row-head">
<span class="row-title">${esc(displayName(card))}</span>
<span class="row-meta">${esc(formatDate(card.captured_at || card.created_at))}</span>
</span>
<span class="row-sub">
${esc([card.job_title, card.company].filter(Boolean).join(" · ") || "Keine Firma erkannt")}
</span>
<span class="row-tags">${cardTags(card)}</span>
</button>`
)
.join("")}
</div>`;
<div class="rows">${state.cards.map(cardRow).join("")}</div>`;
}
// ----------------------------------------------------------- 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) =>
`<tr><th>${FIELD_LABELS[field]}</th><td>${esc(card[field])}</td></tr>`
)
.map((field) => `<tr><th>${FIELD_LABELS[field]}</th><td>${esc(card[field])}</td></tr>`)
.join("");
return rows
? `<table class="data">${rows}</table>`
@@ -310,18 +325,47 @@
).join("")}</div>`;
}
function renderCard(card) {
const meta = [];
if (card.place) meta.push(esc(card.place));
if (card.lat != null) meta.push(`${card.lat.toFixed(4)}, ${card.lon.toFixed(4)}`);
function duplicateBlock(card) {
if (!card.duplicates || !card.duplicates.length) return "";
return `
<div class="section-label"><span class="label">Schon einmal erfasst?</span></div>
<div class="rows">
${card.duplicates
.map(
(other) => `
<button class="row" data-card="${esc(other.id)}">
<span class="row-head">
<span class="row-title">${esc(displayName(other))}</span>
<span class="row-meta">${esc(formatDate(other.captured_at || other.created_at))}</span>
</span>
<span class="row-sub">${esc(other.reason)}${other.occasion ? " · " + esc(other.occasion) : ""}</span>
</button>`
)
.join("")}
</div>`;
}
const QUICK_DATES = [
["In 3 Tagen", 3],
["In 1 Woche", 7],
["In 2 Wochen", 14],
["In 1 Monat", 30],
];
function localInputValue(date) {
const pad = (n) => String(n).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
`T${pad(date.getHours())}:${pad(date.getMinutes())}`;
}
function renderCard(card) {
const reminders = card.reminders.length
? `<div class="rows">${card.reminders
.map(
(reminder) => `
<div class="row" style="cursor:default">
<span class="row-head">
<span class="row-title" style="font-size:14px">${esc(reminder.text)}</span>
<span class="row-title" style="font-size:15px">${esc(reminder.text)}</span>
<button class="badge" data-delete-reminder="${esc(reminder.id)}">Löschen</button>
</span>
<span class="row-sub">
@@ -335,14 +379,12 @@
: `<p class="hint">Keine Erinnerung gesetzt.</p>`;
screens.karte.innerHTML = `
<button class="chip" data-back>← Zurück</button>
<button class="chip" data-back>← Karten</button>
<div class="block-invert">
<p class="label">${card.source === "stapel" ? "Aus Stapelscan" : "Einzelscan"}</p>
<p class="label">${esc(card.occasion || (card.source === "stapel" ? "Aus Stapelscan" : "Einzelscan"))}</p>
<h1 class="display d-lg" style="margin-top:8px">${esc(displayName(card))}</h1>
<p class="muted" style="margin:6px 0 0">
${esc([card.job_title, card.company].filter(Boolean).join(" · ") || "Keine Firma erkannt")}
</p>
<p class="muted" style="margin:6px 0 0">${esc(subtitle(card))}</p>
<div class="figures">
<span class="figure">
<span class="label">Erfasst</span>
@@ -350,18 +392,27 @@
</span>
<span class="figure">
<span class="label">Ort</span>
<span class="display d-md">${meta.length ? esc(meta[0]) : "Unbekannt"}</span>
<span class="display d-md">${esc(card.place || "Unbekannt")}</span>
</span>
</div>
</div>
${card.image_url
? `<img class="card-image" src="${esc(card.image_url)}" alt="Visitenkarte">`
? `<img class="card-image" src="${esc(card.image_url)}" alt="Vorderseite">`
: ""}
${card.back_image_url
? `<img class="card-image" src="${esc(card.back_image_url)}" alt="Rückseite">`
: `<label class="btn btn-quiet" for="input-back">
Rückseite hinzufügen <span class="arrow">+</span>
<input id="input-back" type="file" accept="image/*" capture="environment" hidden>
</label>`}
<a class="btn btn-primary" href="/vcf/${esc(card.id)}" download>
In Kontakte speichern <span class="arrow">→</span>
</a>
<button class="btn" data-share="${esc(card.id)}">
Als vCard teilen <span class="arrow">↗</span>
</button>
${card.extract_status === "offen"
? `<p class="hint">Die Karte wird gerade ausgelesen – die Felder erscheinen gleich.</p>`
@@ -370,9 +421,9 @@
? `<p class="hint">Lesefehler: ${esc(card.extract_error || "unbekannt")}</p>`
: ""}
<div class="section-label">
<span class="label">Daten</span>
</div>
${duplicateBlock(card)}
<div class="section-label"><span class="label">Daten</span></div>
<button class="chip" data-toggle-edit style="margin-bottom:10px">
${state.editing ? "Fertig" : "Bearbeiten"}
</button>
@@ -390,7 +441,12 @@
<span class="label">Text</span>
<input name="text" placeholder="Angebot nachfassen" required>
</label>
<label class="field">
<div class="chips" style="margin-top:12px">
${QUICK_DATES.map(
([text, days]) => `<button class="chip" type="button" data-quick="${days}">${text}</button>`
).join("")}
</div>
<label class="field" style="margin-top:12px">
<span class="label">Termin</span>
<input name="due" type="datetime-local" required>
</label>
@@ -417,9 +473,39 @@
});
});
$("reminder-form").addEventListener("submit", async (event) => {
const backInput = $("input-back");
if (backInput) {
backInput.addEventListener("change", async (event) => {
const file = event.target.files[0];
event.target.value = "";
if (!file) return;
busy(true, "Rückseite wird gelesen");
try {
const body = new FormData();
body.append("file", file);
await api(`/api/cards/${card.id}/back`, { method: "POST", body });
toast("Rückseite hinzugefügt");
loadCard(card.id);
} catch (error) {
toast(error.message);
} finally {
busy(false);
}
});
}
const form = $("reminder-form");
form.querySelectorAll("[data-quick]").forEach((button) => {
button.addEventListener("click", () => {
const date = new Date();
date.setDate(date.getDate() + Number(button.dataset.quick));
date.setHours(9, 0, 0, 0);
form.elements.due.value = localInputValue(date);
});
});
form.addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.currentTarget;
const due = form.elements.due.value;
if (!due) return;
try {
@@ -440,6 +526,34 @@
});
}
/** 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);
}
}
// ------------------------------------------------------------ Erinnerungen
async function loadReminders() {
@@ -473,11 +587,15 @@
// -------------------------------------------------------------- Zähler
function badge(id, value) {
const node = $(id);
node.textContent = value;
node.hidden = !value;
}
function updateCounters() {
$("figure-total").textContent = state.stats.total;
$("figure-open").textContent = state.stats.offen;
$("tab-count-cards").textContent = state.stats.total;
$("tab-count-reminders").textContent = state.stats.erinnerungen;
badge("tab-count-cards", state.stats.total);
badge("tab-count-reminders", state.stats.erinnerungen);
}
async function refreshStats() {
@@ -492,9 +610,15 @@
// ------------------------------------------------------------- Ereignisse
document.addEventListener("click", (event) => {
const shareButton = event.target.closest("[data-share]");
if (shareButton) return shareCard(shareButton.dataset.share);
const cardButton = event.target.closest("[data-card]");
if (cardButton) return go("karte", cardButton.dataset.card);
const goto = event.target.closest("[data-goto]");
if (goto) return go(goto.dataset.goto);
if (event.target.closest("[data-back]")) {
state.editing = false;
return go("karten");
@@ -547,6 +671,12 @@
loadCards();
});
// Der Anlass gilt für eine ganze Messe, nicht für einen Scan.
$("occasion-input").value = store.get("occasion");
$("occasion-input").addEventListener("change", (event) => {
store.set("occasion", event.target.value.trim());
});
["input-camera", "input-library"].forEach((id) => {
$(id).addEventListener("change", (event) => {
const file = event.target.files[0];
@@ -560,10 +690,10 @@
const body = new FormData();
body.append("password", $("login-password").value);
try {
await fetch("/api/login", { method: "POST", body, credentials: "same-origin" })
.then((response) => {
if (!response.ok) throw new Error("Passwort stimmt nicht");
});
const response = await fetch("/api/login", {
method: "POST", body, credentials: "same-origin",
});
if (!response.ok) throw new Error("Passwort stimmt nicht");
$("login-overlay").hidden = true;
$("login-error").hidden = true;
boot();
@@ -593,7 +723,6 @@
if (!session.extraction_available) {
toast("Kein API-Schlüssel gesetzt – Karten werden nur zugeschnitten.");
}
await refreshStats();
applyRoute();
} catch (error) {
toast("Server nicht erreichbar");

View File

@@ -34,46 +34,44 @@
<main>
<!-- ------------------------------------------------------------- Scan -->
<section class="screen stack" id="screen-scan">
<div class="block-invert">
<p class="label">Stapel oder Einzelkarte</p>
<h1 class="display d-xl" style="margin-top:8px">Karten<br>erfassen</h1>
<p class="muted" style="margin:12px 0 0">
Karten nebeneinander auf einen dunklen, einfarbigen Untergrund legen,
Handy parallel darüber halten. Ein Foto genügt für den ganzen Tisch.
</p>
<div class="figures">
<span class="figure">
<span class="label">Erfasst</span>
<span class="display d-lg mono-num" id="figure-total">0</span>
</span>
<span class="figure">
<span class="label">Nicht exportiert</span>
<span class="display d-lg mono-num" id="figure-open">0</span>
</span>
</div>
</div>
<label class="field">
<span class="label">Anlass · gilt für die nächsten Scans</span>
<input id="occasion-input" placeholder="z. B. Hannover Messe 2026"
autocomplete="off" enterkeyhint="done">
</label>
<label class="btn btn-primary" for="input-camera">
Foto aufnehmen <span class="arrow">→</span>
<label class="action" for="input-camera">
<span class="action-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8">
<path d="M3 7h3l1.5-2h9L18 7h3v12H3z"></path>
<circle cx="12" cy="13" r="3.6"></circle>
</svg>
</span>
<span class="action-body">
<span class="display d-lg">Foto aufnehmen</span>
<span class="action-hint">
Karten nebeneinander auf einen dunklen Untergrund legen.
Ein Foto reicht für den ganzen Tisch.
</span>
</span>
<input id="input-camera" type="file" accept="image/*" capture="environment" hidden>
</label>
<label class="btn btn-quiet" for="input-library">
Aus Fotos wählen <span class="arrow">↗</span>
<input id="input-library" type="file" accept="image/*" hidden>
</label>
<p class="hint">
Die Kamera liefert die volle Auflösung – bei zwanzig Karten auf einem Foto
ist das der Unterschied zwischen lesbar und nicht lesbar.
</p>
<div id="scan-result"></div>
<div class="section-label"><span class="label">Zuletzt erfasst</span></div>
<div id="recent-list"></div>
</section>
<!-- ------------------------------------------------------------ Karten -->
<section class="screen stack" id="screen-cards" hidden>
<div class="search">
<input id="search-input" type="search" placeholder="Name, Firma, Ort, Notiz …"
<input id="search-input" type="search" placeholder="Name, Firma, Anlass, Notiz …"
autocomplete="off" enterkeyhint="search">
<button id="search-clear" type="button" aria-label="Suche leeren">×</button>
</div>
@@ -97,12 +95,34 @@
</main>
<nav class="tabs" role="tablist">
<button class="tab" role="tab" data-route="scan" aria-selected="true">Scan</button>
<button class="tab" role="tab" data-route="scan" aria-selected="true">
<span class="tab-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
<path d="M3 7h3l1.5-2h9L18 7h3v12H3z"></path>
<circle cx="12" cy="13" r="3.4"></circle>
</svg>
</span>
<span class="tab-label">Scannen</span>
</button>
<button class="tab" role="tab" data-route="karten" aria-selected="false">
<span class="count mono-num" id="tab-count-cards">0</span>Karten
<span class="tab-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
<rect x="3" y="6" width="18" height="13"></rect>
<path d="M7 11h5M7 15h8"></path>
</svg>
</span>
<span class="tab-label">Karten</span>
<span class="tab-badge mono-num" id="tab-count-cards" hidden>0</span>
</button>
<button class="tab" role="tab" data-route="erinnerungen" aria-selected="false">
<span class="count mono-num" id="tab-count-reminders">0</span>Termine
<span class="tab-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9">
<circle cx="12" cy="12" r="8.5"></circle>
<path d="M12 7v5.2l3.4 2"></path>
</svg>
</span>
<span class="tab-label">Termine</span>
<span class="tab-badge mono-num" id="tab-count-reminders" hidden>0</span>
</button>
</nav>
</div>
@@ -125,7 +145,7 @@
<div class="overlay" id="busy-overlay" hidden>
<div class="block-invert stack" style="text-align:center">
<div class="spinner" style="margin:0 auto"></div>
<p class="label" id="busy-text">Karten werden gelesen</p>
<p class="label" id="busy-text">Karten werden freigestellt</p>
</div>
</div>

View File

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

View File

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

88
tests/test_duplicates.py Normal file
View File

@@ -0,0 +1,88 @@
"""Dublettenerkennung. Auf Messen trifft man Leute wieder."""
import uuid
import pytest
from app import db, duplicates
def _card(**felder):
columns = {
"id": uuid.uuid4().hex,
"created_at": "2026-09-01T10:00:00+00:00",
"extract_status": "ok",
**felder,
}
names = ", ".join(columns)
db.execute(
f"INSERT INTO cards ({names}) VALUES ({', '.join('?' for _ in columns)})",
list(columns.values()),
)
return db.query_one("SELECT * FROM cards WHERE id = ?", (columns["id"],))
@pytest.fixture(autouse=True)
def leere_tabelle():
db.execute("DELETE FROM cards")
yield
db.execute("DELETE FROM cards")
def test_gleiche_email_wird_gefunden():
_card(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) == []