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>
91 lines
3.0 KiB
Python
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"
|