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]) => ``)
.join("");
}
+ function cardRow(card) {
+ return `
+
+
+ ${esc(displayName(card))}
+ ${esc(formatDate(card.captured_at || card.created_at))}
+
+ ${esc(subtitle(card))}
+ ${cardTags(card)}
+ `;
+ }
+
// ----------------------------------------------------------------- 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) => `
-
-
- ${esc(displayName(card))}
- wird gelesen
-
- ${esc(card.company || card.email || "Zuschnitt gespeichert")}
- `
- )
- .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
+ ? `
+ Alle ${data.stats.total} Karten →
+ `
+ : ""}`
+ : `
+
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) => `
-
-
- ${esc(displayName(card))}
- ${esc(formatDate(card.captured_at || card.created_at))}
-
-
- ${esc([card.job_title, card.company].filter(Boolean).join(" · ") || "Keine Firma erkannt")}
-
- ${cardTags(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
? ``
@@ -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) => `
+
+
+ ${esc(displayName(other))}
+ ${esc(formatDate(other.captured_at || other.created_at))}
+
+ ${esc(other.reason)}${other.occasion ? " · " + esc(other.occasion) : ""}
+ `
+ )
+ .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)}
Löschen
@@ -335,14 +379,12 @@
: `Keine Erinnerung gesetzt.
`;
screens.karte.innerHTML = `
- ← Zurück
+ ← Karten
-
${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
- ? ` `
+ ? ` `
: ""}
+ ${card.back_image_url
+ ? ` `
+ : `
+ Rückseite hinzufügen +
+
+ `}
In Kontakte speichern →
+
+ Als vCard teilen ↗
+
${card.extract_status === "offen"
? `Die Karte wird gerade ausgelesen – die Felder erscheinen gleich.
`
@@ -370,9 +421,9 @@
? `Lesefehler: ${esc(card.extract_error || "unbekannt")}
`
: ""}
-
- Daten
-
+ ${duplicateBlock(card)}
+
+ Daten
${state.editing ? "Fertig" : "Bearbeiten"}
@@ -390,7 +441,12 @@
Text
-
+
+ ${QUICK_DATES.map(
+ ([text, days]) => `${text} `
+ ).join("")}
+
+
Termin
@@ -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");
diff --git a/static/index.html b/static/index.html
index 2f1f569..51725a9 100644
--- a/static/index.html
+++ b/static/index.html
@@ -34,46 +34,44 @@
-
-
Stapel oder Einzelkarte
-
Karten erfassen
-
- Karten nebeneinander auf einen dunklen, einfarbigen Untergrund legen,
- Handy parallel darüber halten. Ein Foto genügt für den ganzen Tisch.
-
-
-
- Erfasst
- 0
-
-
- Nicht exportiert
- 0
-
-
-
+
+ Anlass · gilt für die nächsten Scans
+
+
-
- Foto aufnehmen →
+
+
+
+
+
+
+
+
+ Foto aufnehmen
+
+ Karten nebeneinander auf einen dunklen Untergrund legen.
+ Ein Foto reicht für den ganzen Tisch.
+
+
+
Aus Fotos wählen ↗
-
- Die Kamera liefert die volle Auflösung – bei zwanzig Karten auf einem Foto
- ist das der Unterschied zwischen lesbar und nicht lesbar.
-
-
+
+ Zuletzt erfasst
+
- Scan
+
+
+
+
+
+
+
+ Scannen
+
- 0 Karten
+
+
+
+
+
+
+ Karten
+ 0
- 0 Termine
+
+
+
+
+
+
+ Termine
+ 0
@@ -125,7 +145,7 @@
-
Karten werden gelesen
+
Karten werden freigestellt
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) == []