Files
icloud-contacts-sync/src/vcard_parser.py
T
2026-08-05 15:44:37 +02:00

124 lines
4.3 KiB
Python

# Copyright (c) 2026 Stefan Koelle (https://stefankoelle.de)
# Licensed under the MIT License. See LICENSE file in project root for details.
"""Parst rohen vCard-Text in ein flaches dict, passend zum contacts-Schema."""
import json
import logging
import vobject
logger = logging.getLogger(__name__)
def _get(vcard, attr, default=None):
return getattr(vcard, attr).value if hasattr(vcard, attr) else default
def _scalar(val):
"""Macht aus einem potenziellen List-Wert einen String (fuer MySQL-PARAMETER)."""
if val is None:
return None
if isinstance(val, (list, tuple)):
return ",".join(str(v) for v in val)
return str(val)
def _type_str(obj) -> str:
"""Liest den TYPE-Parameter sicher aus einem vobject-Element."""
if hasattr(obj, "type_paramlist") and obj.type_paramlist:
return ",".join(obj.type_paramlist)
params = getattr(obj, "params", {})
type_val = params.get("TYPE") or params.get("type")
if type_val:
return type_val if isinstance(type_val, str) else ",".join(type_val)
return "other"
def parse_vcard(raw_text: str, account: str, etag: str | None = None) -> dict | None:
try:
vcard = vobject.readOne(raw_text)
except Exception as exc:
logger.warning("vCard konnte nicht geparst werden: %s", exc)
return None
uid = _get(vcard, "uid")
if not uid:
logger.warning("vCard ohne UID übersprungen")
return None
n = getattr(vcard, "n", None)
given_name = _scalar(n.value.given) if n else None
family_name = _scalar(n.value.family) if n else None
middle_name = _scalar(n.value.additional) if n else None
prefix = _scalar(n.value.prefix) if n else None
suffix = _scalar(n.value.suffix) if n else None
emails = [{"type": _type_str(e), "value": e.value}
for e in getattr(vcard, "email_list", [])]
phones = [{"type": _type_str(t), "value": t.value}
for t in getattr(vcard, "tel_list", [])]
addresses = []
for a in getattr(vcard, "adr_list", []):
v = a.value
addresses.append({
"type": _type_str(a),
"street": v.street, "city": v.city, "region": v.region,
"zip": v.code, "country": v.country,
})
urls = [{"type": _type_str(u), "value": u.value}
for u in getattr(vcard, "url_list", [])]
social_profiles = []
for sp in vcard.contents.get("x-socialprofile", []):
params = getattr(sp, "params", {})
type_val = params.get("TYPE") or params.get("type") or "other"
if isinstance(type_val, list):
type_val = type_val[0] if type_val else "other"
username = params.get("X-USER") or params.get("x-user") or ""
if isinstance(username, list):
username = username[0] if username else ""
social_profiles.append({
"type": type_val,
"username": username,
"url": sp.value if sp.value else "",
})
categories = [c.strip() for c in vcard.categories.value] if hasattr(vcard, "categories") else []
birthday = str(vcard.bday.value)[:10] if hasattr(vcard, "bday") else None
org = None
if hasattr(vcard, "org"):
org_val = vcard.org.value
org = org_val[0] if isinstance(org_val, list) else org_val
return {
"account": account,
"uid": uid,
"etag": etag,
"full_name": _scalar(_get(vcard, "fn")),
"given_name": given_name,
"family_name": family_name,
"middle_name": middle_name,
"prefix": prefix,
"suffix": suffix,
"nickname": _scalar(_get(vcard, "nickname")),
"organization": org,
"job_title": _scalar(_get(vcard, "title")),
"department": None,
"birthday": birthday,
"anniversary": None,
"notes": _scalar(_get(vcard, "note")),
"photo_base64": None,
"emails": json.dumps(emails, ensure_ascii=False),
"phones": json.dumps(phones, ensure_ascii=False),
"addresses": json.dumps(addresses, ensure_ascii=False),
"urls": json.dumps(urls, ensure_ascii=False),
"social_profiles": json.dumps(social_profiles, ensure_ascii=False),
"related_names": json.dumps([], ensure_ascii=False),
"categories": json.dumps(categories, ensure_ascii=False),
"raw_vcard": raw_text,
"source": "icloud",
}