Files
business-card-scanner/app/vcard.py
Lucas Orth ab993e98e1 Visitenkarten-Scanner: Stapelscan, Extraktion, Übersicht, vCard-Export
Selbst gehostete PWA, die Visitenkarten von einem Foto freistellt, ausliest
und als Kontakt bereitstellt.

- Stapelscan: OpenCV findet die Kartenrechtecke über mehrere Binärmasken,
  entzerrt sie perspektivisch und schneidet sie einzeln aus. Ohne Fund gilt
  das ganze Foto als eine Karte.
- Extraktion: ein Aufruf je Zuschnitt an das Vision-Modell mit
  JSON-Schema. Kein vorgeschaltetes OCR - das würde Layout und
  Schriftgrößen wegwerfen, aus denen die Feldzuordnung entsteht.
- Metadaten: Aufnahmezeit und GPS aus den EXIF-Daten des Fotos, Ortsname
  über Nominatim, Browserstandort nur als Rückfallebene.
- Übersicht mit Volltextsuche und Filtern, Detailansicht mit Korrekturmaske.
- Notizfeld je Karte, Erinnerungen per Mail inklusive Nachholen verpasster
  Termine nach einem Neustart.
- vCard 3.0 einzeln und als Sammeldatei, Karte gilt danach als exportiert.
- Anmeldung über ein Passwort, Sitzung als signiertes Cookie.
- Deployment per Tag-Push nach den Konventionen in DEPLOY.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-06 11:45:56 +02:00

91 lines
3.0 KiB
Python

"""vCard 3.0 erzeugen - das Format, das iOS beim Antippen zuverlaessig oeffnet."""
from datetime import datetime
_ESCAPES = (("\\", "\\\\"), (";", "\\;"), (",", "\\,"), ("\n", "\\n"))
def _escape(value) -> str:
text = str(value)
for char, replacement in _ESCAPES:
text = text.replace(char, replacement)
return text
def _line(name: str, value) -> list:
return [name + ":" + _escape(value)] if value else []
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"
def _note(card: dict) -> str:
parts = []
if card.get("notes"):
parts.append(card["notes"])
stamp = card.get("captured_at") or card.get("created_at")
scanned = ""
if stamp:
try:
when = datetime.fromisoformat(stamp).strftime("%d.%m.%Y um %H:%M")
scanned = "Gescannt am " + when
except ValueError:
scanned = "Gescannt am " + str(stamp)
if card.get("place"):
scanned = (scanned + " in " if scanned else "Gescannt in ") + card["place"]
if scanned:
parts.append(scanned)
return "\n".join(parts)
def build(card: dict) -> str:
lines = ["BEGIN:VCARD", "VERSION:3.0"]
lines.append(
"N:{};{};;;".format(
_escape(card.get("last_name") or ""), _escape(card.get("first_name") or "")
)
)
lines.append("FN:" + _escape(_display_name(card)))
lines += _line("ORG", card.get("company"))
lines += _line("TITLE", card.get("job_title"))
if card.get("email"):
lines.append("EMAIL;TYPE=INTERNET,WORK:" + _escape(card["email"]))
if card.get("phone"):
lines.append("TEL;TYPE=WORK,VOICE:" + _escape(card["phone"]))
if card.get("mobile"):
lines.append("TEL;TYPE=CELL:" + _escape(card["mobile"]))
if card.get("fax"):
lines.append("TEL;TYPE=WORK,FAX:" + _escape(card["fax"]))
if card.get("website"):
lines.append("URL:" + _escape(card["website"]))
if any(card.get(k) for k in ("street", "postal_code", "city", "country")):
lines.append(
"ADR;TYPE=WORK:;;{};{};;{};{}".format(
_escape(card.get("street") or ""),
_escape(card.get("city") or ""),
_escape(card.get("postal_code") or ""),
_escape(card.get("country") or ""),
)
)
if card.get("lat") is not None and card.get("lon") is not None:
lines.append("GEO:{};{}".format(card["lat"], card["lon"]))
note = _note(card)
if note:
lines.append("NOTE:" + _escape(note))
lines.append("END:VCARD")
return "\r\n".join(lines) + "\r\n"
def build_many(cards: list) -> str:
return "".join(build(card) for card in cards)
def filename(card: dict) -> str:
base = _display_name(card).replace(" ", "-")
safe = "".join(c for c in base if c.isalnum() or c in "-_")
return (safe or "kontakt") + ".vcf"