Anlass, Dubletten, Rückseite, Teilen; Startseite und App-Anmutung
All checks were successful
Build & Deploy / deploy (push) Successful in 10m41s
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:
19
app/db.py
19
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()
|
||||
|
||||
55
app/duplicates.py
Normal file
55
app/duplicates.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Karten finden, die dieselbe Person meinen.
|
||||
|
||||
Auf Messen trifft man Leute wieder, und dieselbe Karte landet ein zweites Mal
|
||||
im Stapel. Verglichen wird nicht der ganze Datensatz, sondern das, was eine
|
||||
Person eindeutig macht - in dieser Reihenfolge, weil die Aussagekraft faellt.
|
||||
"""
|
||||
from . import db
|
||||
|
||||
|
||||
def _phone_key(value) -> str:
|
||||
"""Nur die letzten acht Ziffern. Damit passen +49 511 123456,
|
||||
0049511123456 und 0511/123456 aufeinander."""
|
||||
digits = "".join(character for character in (value or "") if character.isdigit())
|
||||
return digits[-8:] if len(digits) >= 8 else ""
|
||||
|
||||
|
||||
def _phone_keys(row) -> set:
|
||||
return {_phone_key(row["phone"]), _phone_key(row["mobile"])} - {""}
|
||||
|
||||
|
||||
def _clean(value) -> str:
|
||||
return (value or "").strip().lower()
|
||||
|
||||
|
||||
def _match(card, other) -> str:
|
||||
email = _clean(card["email"])
|
||||
if email and _clean(other["email"]) == email:
|
||||
return "gleiche E-Mail"
|
||||
if _phone_keys(card) & _phone_keys(other):
|
||||
return "gleiche Telefonnummer"
|
||||
last_name, company = _clean(card["last_name"]), _clean(card["company"])
|
||||
if last_name and company:
|
||||
if _clean(other["last_name"]) == last_name and _clean(other["company"]) == company:
|
||||
return "gleicher Name und dieselbe Firma"
|
||||
return ""
|
||||
|
||||
|
||||
def find(card) -> list:
|
||||
"""Aehnliche Karten, neueste zuerst."""
|
||||
others = db.query(
|
||||
"""
|
||||
SELECT id, 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
|
||||
69
app/main.py
69
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])
|
||||
|
||||
Reference in New Issue
Block a user