fix(contacts): parse Apple/iCloud item-grouped vCard EMAIL/TEL properties (#1438)
_parse_vcards matched property names with a bare line.startswith("EMAIL") /
"TEL" / "FN:" / "UID:". RFC 6350 property groups — emitted by default by Apple
Contacts / iCloud and many CardDAV servers — prefix the name with a group token,
e.g. item1.EMAIL;type=pref:jane@example.com. Those lines never matched, so emails
and phone numbers from any Apple-synced or Apple-exported address book were
silently dropped (breaking contact search by email, composer autocomplete, and
vCard/CSV export round-trips).
Strip an optional leading group token before matching and value extraction;
no-op for non-grouped lines.
Adds tests/test_contacts_vcard_parse.py (grouped + plain) — the grouped case
fails before this change and passes after.
Co-authored-by: NubsCarson <nubs@nubs.site>
This commit is contained in:
@@ -130,21 +130,28 @@ def _parse_vcards(text: str) -> List[Dict]:
|
|||||||
contact = {"name": "", "emails": [], "phones": [], "uid": ""}
|
contact = {"name": "", "emails": [], "phones": [], "uid": ""}
|
||||||
for line in block.split("\n"):
|
for line in block.split("\n"):
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
if line.startswith("FN:") or line.startswith("FN;"):
|
# Strip an optional RFC 6350 group prefix (e.g. "item1.EMAIL;...")
|
||||||
contact["name"] = _vunesc(line.split(":", 1)[1]) if ":" in line else ""
|
# that Apple Contacts / iCloud / many CardDAV servers emit by
|
||||||
elif line.startswith("EMAIL"):
|
# default — without this the property-name checks below miss those
|
||||||
|
# lines and silently drop the email / phone. The group token only
|
||||||
|
# precedes the property name, so it is safe to strip for matching
|
||||||
|
# and value extraction, and a no-op for non-grouped lines.
|
||||||
|
name_part = re.sub(r"^[A-Za-z0-9-]+\.", "", line, count=1)
|
||||||
|
if name_part.startswith("FN:") or name_part.startswith("FN;"):
|
||||||
|
contact["name"] = _vunesc(name_part.split(":", 1)[1]) if ":" in name_part else ""
|
||||||
|
elif name_part.startswith("EMAIL"):
|
||||||
# Handle EMAIL:foo@bar OR EMAIL;TYPE=...:foo@bar OR EMAIL;PREF=1:foo@bar
|
# Handle EMAIL:foo@bar OR EMAIL;TYPE=...:foo@bar OR EMAIL;PREF=1:foo@bar
|
||||||
if ":" in line:
|
if ":" in name_part:
|
||||||
email_addr = _vunesc(line.split(":", 1)[1])
|
email_addr = _vunesc(name_part.split(":", 1)[1])
|
||||||
if email_addr and email_addr not in contact["emails"]:
|
if email_addr and email_addr not in contact["emails"]:
|
||||||
contact["emails"].append(email_addr)
|
contact["emails"].append(email_addr)
|
||||||
elif line.startswith("TEL"):
|
elif name_part.startswith("TEL"):
|
||||||
if ":" in line:
|
if ":" in name_part:
|
||||||
phone = _vunesc(line.split(":", 1)[1])
|
phone = _vunesc(name_part.split(":", 1)[1])
|
||||||
if phone and phone not in contact["phones"]:
|
if phone and phone not in contact["phones"]:
|
||||||
contact["phones"].append(phone)
|
contact["phones"].append(phone)
|
||||||
elif line.startswith("UID:"):
|
elif name_part.startswith("UID:"):
|
||||||
contact["uid"] = _vunesc(line[4:])
|
contact["uid"] = _vunesc(name_part[4:])
|
||||||
if contact["name"] or contact["emails"]:
|
if contact["name"] or contact["emails"]:
|
||||||
contacts.append(contact)
|
contacts.append(contact)
|
||||||
return contacts
|
return contacts
|
||||||
|
|||||||
38
tests/test_contacts_vcard_parse.py
Normal file
38
tests/test_contacts_vcard_parse.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
"""Regression: _parse_vcards must read Apple/iCloud item-grouped properties.
|
||||||
|
|
||||||
|
RFC 6350 property groups (the default emitted by Apple Contacts.app / iCloud and
|
||||||
|
many CardDAV servers) prefix the property name with a group token, e.g.
|
||||||
|
`item1.EMAIL;type=pref:jane@example.com`. The parser matched property names with
|
||||||
|
a bare `line.startswith("EMAIL")` / `"TEL"` / `"FN:"`, so grouped lines never
|
||||||
|
matched and the email / phone were silently dropped — breaking contact search by
|
||||||
|
email, the email-composer autocomplete, and vCard/CSV export round-trips for any
|
||||||
|
address book synced from Apple.
|
||||||
|
"""
|
||||||
|
from routes.contacts_routes import _parse_vcards
|
||||||
|
|
||||||
|
|
||||||
|
def test_apple_item_grouped_properties_parsed():
|
||||||
|
vcf = (
|
||||||
|
"BEGIN:VCARD\nVERSION:3.0\nFN:Jane Doe\n"
|
||||||
|
"item1.EMAIL;type=INTERNET;type=pref:jane@example.com\n"
|
||||||
|
"item2.TEL;type=CELL;type=pref:+15550100\n"
|
||||||
|
"UID:abc-123\nEND:VCARD\n"
|
||||||
|
)
|
||||||
|
c = _parse_vcards(vcf)[0]
|
||||||
|
assert c["emails"] == ["jane@example.com"]
|
||||||
|
assert c["phones"] == ["+15550100"]
|
||||||
|
assert c["uid"] == "abc-123"
|
||||||
|
|
||||||
|
|
||||||
|
def test_plain_ungrouped_properties_still_parsed():
|
||||||
|
vcf = (
|
||||||
|
"BEGIN:VCARD\nVERSION:3.0\nFN:John Smith\n"
|
||||||
|
"EMAIL;TYPE=INTERNET:john@example.com\n"
|
||||||
|
"TEL;TYPE=CELL:+15550199\n"
|
||||||
|
"UID:xyz\nEND:VCARD\n"
|
||||||
|
)
|
||||||
|
c = _parse_vcards(vcf)[0]
|
||||||
|
assert c["name"] == "John Smith"
|
||||||
|
assert c["emails"] == ["john@example.com"]
|
||||||
|
assert c["phones"] == ["+15550199"]
|
||||||
|
assert c["uid"] == "xyz"
|
||||||
Reference in New Issue
Block a user