diff --git a/README.md b/README.md index cb85f2e..78fef98 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,15 @@ 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“. +**Bild nachbearbeiten.** Drehen in 90-Grad-Schritten, Ausrichten über einen +Schieberegler und Zuschneiden per Rahmen. Jede Korrektur rechnet der Server vom +unbearbeiteten Original, das beim ersten Eingriff daneben abgelegt wird – so +summieren sich keine Verluste und *Zurücksetzen* führt zurück zum Scan. + +**Klickbare Felder.** E-Mail, Web, Telefon und Mobil sind Handlungen, keine +Zeichenketten. Die Adresse öffnet die Navigations-App, die unter *Mehr* +eingestellt ist (Apple Karten, Google Maps oder OpenStreetMap). + **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 @@ -124,8 +133,8 @@ Karte und sagt das in der Oberfläche. Einzelne Fehlgriffe lassen sich pro Karte 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 + imaging.py EXIF, Drehen, Zuschneiden 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 32cdcec..1efdd6d 100644 --- a/app/db.py +++ b/app/db.py @@ -92,6 +92,24 @@ def _migrate(conn: sqlite3.Connection) -> None: for name, kind in LATER_COLUMNS.items(): if name not in present: conn.execute(f"ALTER TABLE cards ADD COLUMN {name} {kind}") + _split_legacy_names(conn) + + +def _split_legacy_names(conn: sqlite3.Connection) -> None: + """full_name ist als Feld entfallen. Aeltere Datensaetze haben nur diesen + einen Namen - am letzten Leerzeichen trennen, damit sie nicht namenlos + dastehen. Die Spalte bleibt: SQLite baut zum Loeschen die Tabelle neu, + und der Gewinn waere ein paar Byte.""" + rows = conn.execute( + "SELECT id, full_name FROM cards " + "WHERE full_name IS NOT NULL AND first_name IS NULL AND last_name IS NULL" + ).fetchall() + for row in rows: + first, _, last = row["full_name"].strip().rpartition(" ") + conn.execute( + "UPDATE cards SET first_name = ?, last_name = ? WHERE id = ?", + (first or None, last or None, row["id"]), + ) def query(sql: str, params: Iterable[Any] = ()) -> list[sqlite3.Row]: diff --git a/app/duplicates.py b/app/duplicates.py index 4586392..904b184 100644 --- a/app/duplicates.py +++ b/app/duplicates.py @@ -39,7 +39,7 @@ def find(card) -> list: """Aehnliche Karten, neueste zuerst.""" others = db.query( """ - SELECT id, full_name, first_name, last_name, company, email, phone, mobile, + SELECT id, first_name, last_name, company, email, phone, mobile, occasion, captured_at, created_at FROM cards WHERE id <> ? AND extract_status <> 'offen' diff --git a/app/extract.py b/app/extract.py index 001be7a..2a97b1e 100644 --- a/app/extract.py +++ b/app/extract.py @@ -16,7 +16,7 @@ from . import config, imaging log = logging.getLogger(__name__) FIELDS = [ - "full_name", "first_name", "last_name", "job_title", "company", "department", + "first_name", "last_name", "job_title", "company", "department", "email", "phone", "mobile", "fax", "website", "street", "postal_code", "city", "country", ] @@ -52,8 +52,8 @@ Regeln: - phone ist die Festnetznummer, mobile die Mobilnummer. Ist nur eine Nummer ohne Kennzeichnung angegeben, gehoert sie in phone. - company ist der Firmenname, job_title die Funktion der Person. -- full_name ist der vollstaendige Name inklusive Titeln wie "Dr.", - first_name und last_name ohne Titel. +- first_name ist der Vorname einschliesslich akademischer Titel ("Dr. Anna"), + last_name der Nachname. - website ohne Protokollpraefix. - Zeigt der Ausschnitt keine Visitenkarte (Tischkante, Notizzettel, leere Flaeche), setze is_business_card auf false und alle Felder auf null. diff --git a/app/imaging.py b/app/imaging.py index b1c4b90..f4804f2 100644 --- a/app/imaging.py +++ b/app/imaging.py @@ -80,6 +80,35 @@ def encode_jpeg(rgb_or_bgr: np.ndarray, quality: int = 88, bgr: bool = True) -> return buffer.getvalue() +def edit_jpeg(raw: bytes, rotate: int = 0, angle: float = 0.0, crop: dict = None) -> bytes: + """Drehen, ausrichten, zuschneiden - in dieser Reihenfolge. + + Wird immer auf das unbearbeitete Original angewandt, nie auf ein schon + bearbeitetes Bild: sonst summieren sich Verluste bei jeder Korrektur. + """ + image = Image.open(io.BytesIO(raw)) + if rotate % 360: + image = image.rotate(-rotate, expand=True) + if angle: + # Ohne expand: der Rahmen bleibt, die Ecken laufen weiss aus. Was + # dabei ueber den Rand geht, schneidet der Nutzer anschliessend weg. + image = image.rotate( + -angle, expand=False, resample=Image.BICUBIC, fillcolor=(255, 255, 255) + ) + if crop: + width, height = image.size + left = max(0, round(crop["x"] * width)) + top = max(0, round(crop["y"] * height)) + right = min(width, round((crop["x"] + crop["w"]) * width)) + bottom = min(height, round((crop["y"] + crop["h"]) * height)) + if right - left > 10 and bottom - top > 10: + image = image.crop((left, top, right, bottom)) + + buffer = io.BytesIO() + image.convert("RGB").save(buffer, format="JPEG", quality=90, optimize=True) + return buffer.getvalue() + + def rotate_jpeg(raw: bytes, degrees: int) -> bytes: """Im Uhrzeigersinn drehen. Arbeitet auf dem fertigen JPEG, nicht auf dem grossen Rohbild - gebraucht wird das nur fuer einzelne Karten.""" diff --git a/app/main.py b/app/main.py index cd5f561..574b7d3 100644 --- a/app/main.py +++ b/app/main.py @@ -22,9 +22,9 @@ STATIC_DIR = Path(__file__).resolve().parent.parent / "static" MAX_UPLOAD_BYTES = 30 * 1024 * 1024 EDITABLE = [ - "full_name", "first_name", "last_name", "job_title", "company", "department", + "first_name", "last_name", "job_title", "company", "department", "email", "phone", "mobile", "fax", "website", - "street", "postal_code", "city", "country", "notes", "occasion", + "street", "postal_code", "city", "country", "notes", "occasion", "place", ] @@ -66,7 +66,7 @@ def card_dict(row) -> dict: 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") + card.get(f) for f in ("last_name", "company", "email", "phone", "mobile") ) return card @@ -255,7 +255,7 @@ def list_cards(q: str = "", filter: str = "alle") -> dict: if q.strip(): needle = f"%{q.strip()}%" searchable = [ - "full_name", "company", "job_title", "email", "phone", "mobile", + "first_name", "last_name", "company", "job_title", "email", "phone", "mobile", "website", "city", "place", "notes", "department", "street", "occasion", ] where.append("(" + " OR ".join(f"{c} LIKE ?" for c in searchable) + ")") @@ -265,7 +265,7 @@ def list_cards(q: str = "", filter: str = "alle") -> dict: elif filter == "unvollstaendig": where.append( "(extract_status = 'fehler' " - "OR (extract_status = 'ok' AND full_name IS NULL AND company IS NULL))" + "OR (extract_status = 'ok' AND last_name IS NULL AND company IS NULL))" ) elif filter == "erinnerung": where.append( @@ -340,6 +340,7 @@ def delete_card(card_id: str) -> dict: for name in (row["image_file"], row["back_image_file"]): if name: (config.IMAGE_DIR / name).unlink(missing_ok=True) + _original_of(name).unlink(missing_ok=True) db.execute("DELETE FROM cards WHERE id = ?", (card_id,)) return {"ok": True} @@ -354,6 +355,51 @@ def card_image(card_id: str, side: str = "front") -> FileResponse: return FileResponse(path, media_type="image/jpeg") +def _original_of(name: str) -> Path: + """Unbearbeitete Fassung neben dem Bild. Damit bleibt jede Korrektur + zuruecknehmbar und wird nicht auf eine schon bearbeitete Datei gerechnet.""" + return config.IMAGE_DIR / (name[:-4] + "-orig.jpg") + + +@app.post("/api/cards/{card_id}/edit", dependencies=[auth]) +async def edit_image(card_id: str, request: Request) -> dict: + row = _card_or_404(card_id) + payload = await request.json() + side = payload.get("side", "front") + name = row["back_image_file"] if side == "back" else row["image_file"] + if not name: + raise HTTPException(status_code=404, detail="Kein Bild gespeichert") + + current = config.IMAGE_DIR / name + original = _original_of(name) + if not original.exists(): + original.write_bytes(current.read_bytes()) + + if payload.get("reset"): + current.write_bytes(original.read_bytes()) + return card_dict(_card_or_404(card_id)) + + rotate = int(payload.get("rotate") or 0) + if rotate % 90: + raise HTTPException(status_code=400, detail="Drehung nur in 90-Grad-Schritten") + angle = max(-20.0, min(20.0, float(payload.get("angle") or 0))) + + crop = payload.get("crop") + if crop is not None: + try: + crop = {key: float(crop[key]) for key in ("x", "y", "w", "h")} + except (KeyError, TypeError, ValueError) as exc: + raise HTTPException(status_code=400, detail="Zuschnitt unvollstaendig") from exc + if crop["w"] <= 0.02 or crop["h"] <= 0.02: + raise HTTPException(status_code=400, detail="Zuschnitt zu klein") + + edited = await asyncio.to_thread( + imaging.edit_jpeg, original.read_bytes(), rotate, angle, crop + ) + current.write_bytes(edited) + return card_dict(_card_or_404(card_id)) + + @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 @@ -402,7 +448,7 @@ def download_vcard(card_id: str) -> Response: def list_reminders() -> dict: rows = db.query( """ - SELECT r.*, c.full_name, c.company + SELECT r.*, c.first_name, c.last_name, c.company FROM reminders r JOIN cards c ON c.id = r.card_id ORDER BY r.sent_at IS NOT NULL, r.due_at """ diff --git a/app/reminders.py b/app/reminders.py index f5dbae9..0a878c9 100644 --- a/app/reminders.py +++ b/app/reminders.py @@ -19,10 +19,14 @@ def _now() -> str: return datetime.now(timezone.utc).isoformat() +def _name(row) -> str: + return " ".join(filter(None, (row["first_name"], row["last_name"]))) + + def _body(row) -> str: - who = row["full_name"] or row["company"] or "Unbekannter Kontakt" + who = _name(row) or row["company"] or "Unbekannter Kontakt" lines = [row["text"], "", "-" * 40, f"Kontakt: {who}"] - if row["company"] and row["full_name"]: + if row["company"] and _name(row): lines.append(f"Firma: {row['company']}") if row["email"]: lines.append(f"E-Mail: {row['email']}") @@ -41,7 +45,7 @@ def due_reminders() -> list: return db.query( """ SELECT r.id, r.card_id, r.text, r.due_at, - c.full_name, c.company, c.email, c.phone, c.mobile, c.place + c.first_name, c.last_name, c.company, c.email, c.phone, c.mobile, c.place FROM reminders r JOIN cards c ON c.id = r.card_id WHERE r.sent_at IS NULL AND r.due_at <= ? @@ -62,7 +66,7 @@ def process_due() -> int: sent = 0 for row in rows: - who = row["full_name"] or row["company"] or "Kontakt" + who = _name(row) or row["company"] or "Kontakt" try: mailer.send(f"Erinnerung: {who}", _body(row)) except Exception as exc: diff --git a/app/vcard.py b/app/vcard.py index 2387f8c..1e5399e 100644 --- a/app/vcard.py +++ b/app/vcard.py @@ -16,8 +16,6 @@ def _line(name: str, value) -> list: def _display_name(card: dict) -> str: - if card.get("full_name"): - return card["full_name"] joined = " ".join(filter(None, (card.get("first_name"), card.get("last_name")))) return joined or card.get("company") or "Unbenannt" diff --git a/static/app.js b/static/app.js index a20fb80..87ef231 100644 --- a/static/app.js +++ b/static/app.js @@ -3,7 +3,6 @@ "use strict"; const FIELD_LABELS = { - full_name: "Name", first_name: "Vorname", last_name: "Nachname", job_title: "Position", @@ -19,6 +18,7 @@ city: "Ort", country: "Land", occasion: "Anlass", + place: "Scan-Ort", }; const FIELD_ORDER = Object.keys(FIELD_LABELS); @@ -38,6 +38,7 @@ karten: $("screen-cards"), karte: $("screen-detail"), erinnerungen: $("screen-reminders"), + mehr: $("screen-mehr"), }; const esc = (value) => @@ -103,11 +104,37 @@ }; const displayName = (card) => - card.full_name || [card.first_name, card.last_name].filter(Boolean).join(" ") || card.company || "Ohne Namen"; + const NAV_APPS = { + apple: { + label: "Apple Karten", + url: (query) => `https://maps.apple.com/?q=${encodeURIComponent(query)}`, + }, + google: { + label: "Google Maps", + url: (query) => + `https://www.google.com/maps/search/?api=1&query=${encodeURIComponent(query)}`, + }, + osm: { + label: "OpenStreetMap", + url: (query) => + `https://www.openstreetmap.org/search?query=${encodeURIComponent(query)}`, + }, + }; + + const navApp = () => (NAV_APPS[store.get("navApp")] ? store.get("navApp") : "apple"); + + const address = (card) => + [card.street, [card.postal_code, card.city].filter(Boolean).join(" "), card.country] + .filter(Boolean) + .join(", "); + + const link = (href, text) => + `${esc(text)}`; + const subtitle = (card) => [card.job_title, card.company].filter(Boolean).join(" · ") || "Keine Firma erkannt"; @@ -115,11 +142,17 @@ * genauer, deshalb blockiert das hier nichts und scheitert leise. */ function browserPosition() { return new Promise((resolve) => { - if (!navigator.geolocation) return resolve(null); + if (!navigator.geolocation) return resolve({ error: "Gerät liefert keinen Standort" }); navigator.geolocation.getCurrentPosition( - (position) => resolve(position.coords), - () => resolve(null), - { timeout: 6000, maximumAge: 300000 } + (position) => resolve({ coords: position.coords }), + (error) => + resolve({ + error: + error.code === error.PERMISSION_DENIED + ? "Standortfreigabe fehlt" + : "Standort nicht ermittelbar", + }), + { enableHighAccuracy: false, timeout: 8000, maximumAge: 300000 } ); }); } @@ -173,6 +206,7 @@ if (route === "karten") loadCards(); if (route === "karte") loadCard(state.cardId); if (route === "erinnerungen") loadReminders(); + if (route === "mehr") renderSettings(state.session || {}); } // ------------------------------------------------------------------- Scan @@ -183,17 +217,21 @@ 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); + const position = await browserPosition(); + if (position.coords) { + body.append("lat", position.coords.latitude); + body.append("lon", position.coords.longitude); } try { const result = await api("/api/scan", { method: "POST", body }); renderScanResult(result); await refreshStats(); loadRecent(); - toast(`${result.count} Karte${result.count === 1 ? "" : "n"} erfasst, wird gelesen`); + const ohneOrt = result.cards.every((card) => !card.place); + toast( + `${result.count} Karte${result.count === 1 ? "" : "n"} erfasst` + + (ohneOrt && position.error ? ` · ohne Ort (${position.error})` : ", wird gelesen") + ); } catch (error) { toast(error.message); } finally { @@ -306,12 +344,38 @@ } } + /** E-Mail, Web, Nummern und Adresse sind Handlungen, keine Zeichenketten. */ + function fieldCell(card, field) { + const value = card[field]; + if (field === "email") return link(`mailto:${value}`, value); + if (field === "website") { + return link(/^https?:\/\//i.test(value) ? value : `https://${value}`, value); + } + if (["phone", "mobile", "fax"].includes(field)) { + return link(`tel:${value.replace(/[^\d+]/g, "")}`, value); + } + return esc(value); + } + + // Straße, PLZ, Ort und Land stehen in der Tabelle als eine Adresszeile. + const DISPLAY_ORDER = [ + "first_name", "last_name", "job_title", "company", "department", + "email", "phone", "mobile", "fax", "website", "address", "occasion", "place", + ]; + function dataTable(card) { - const rows = FIELD_ORDER.filter((field) => card[field]) - .map((field) => `${FIELD_LABELS[field]}${esc(card[field])}`) - .join(""); - return rows - ? `${rows}
` + const postal = address(card); + const rows = DISPLAY_ORDER.map((field) => { + if (field === "address") { + if (!postal) return ""; + return `Adresse${link(NAV_APPS[navApp()].url(postal), postal)}`; + } + if (!card[field]) return ""; + return `${FIELD_LABELS[field]}${fieldCell(card, field)}`; + }).filter(Boolean); + + return rows.length + ? `${rows.join("")}
` : `

Keine Felder erkannt. Über „Bearbeiten“ nachtragen.

`; } @@ -345,6 +409,8 @@ `; } + const GEO_SOURCE = { exif: "aus dem Foto", browser: "vom Gerät" }; + const QUICK_DATES = [ ["In 3 Tagen", 3], ["In 1 Woche", 7], @@ -391,17 +457,23 @@ ${esc(formatDate(card.captured_at || card.created_at))} - Ort + Ort${card.geo_source ? " · " + GEO_SOURCE[card.geo_source] : ""} ${esc(card.place || "Unbekannt")} ${card.image_url - ? `Vorderseite` + ? `Vorderseite + ` : ""} ${card.back_image_url - ? `Rückseite` + ? `Rückseite + ` : `