Files
business-card-scanner/app/vcard.py
Lucas Orth a7fbc15792
All checks were successful
Build & Deploy / deploy (push) Successful in 1m24s
Hintergrundverarbeitung, Bildaufbereitung, Sammelexport entfernt
Auslesen im Hintergrund: /api/scan antwortet, sobald die Karten
freigestellt und gespeichert sind, und stößt die Extraktion als Task an.
Ein Zwanzigerstapel blockierte vorher den Upload für die ganze Dauer
aller Modellaufrufe. Die Oberfläche zeigt "wird gelesen" und lädt nach,
solange etwas offen ist.

Zwei Folgen davon sind mitbehandelt: Beim Schreiben der Ergebnisse steht
COALESCE, damit ein Handeintrag während des Lesens nicht überschrieben
wird, und offene Karten werden beim Start nachgeholt, statt dauerhaft in
der Warteschlange zu hängen.

Bildaufbereitung des Zuschnitts:
- Der Einzug zieht die erkannten Ecken um 1,5 % zur Mitte, damit kein
  Untergrund im Zuschnitt bleibt.
- Die Beleuchtung wird ausgeglichen (Division durch eine weichgezeichnete
  Fassung), damit das Papier weiß wird statt grau. Dunkle Karten bleiben
  unangetastet - bei ihnen ist das Dunkle das Papier, kein Schatten.
- Das Modell meldet die nötige Drehung im Schema; das gespeicherte Bild
  wird entsprechend gedreht. Geometrisch ist die Lage nicht bestimmbar.

Sammelexport entfernt: Der Button "Alle als vCard" ist weg, mit ihm der
Endpunkt /vcf sowie vcard.build_many und db.execute_many, die dadurch
keinen Aufrufer mehr hatten.

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

87 lines
2.9 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 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"