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

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