Skip to content

Commit 9e68de6

Browse files
authored
Fix Contacts vCard escaping and group orphan cleanup (#38)
1 parent 6daba5a commit 9e68de6

2 files changed

Lines changed: 370 additions & 14 deletions

File tree

src/nc_mcp_server/tools/contacts.py

Lines changed: 97 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,67 @@ def _vcard_escape(text: str) -> str:
4848
return normalized.replace("\\", "\\\\").replace(";", "\\;").replace(",", "\\,").replace("\n", "\\n")
4949

5050

51+
def _parse_org_components(raw_value: str) -> list[str]:
52+
"""Split a raw vCard ORG value into unescaped component strings.
53+
54+
Correctly distinguishes component-separator ';' from escaped '\\;' (literal
55+
semicolon within a component). Also unescapes \\\\, \\, and \\n.
56+
"""
57+
components: list[str] = []
58+
current: list[str] = []
59+
i = 0
60+
while i < len(raw_value):
61+
if raw_value[i] == "\\" and i + 1 < len(raw_value):
62+
nc = raw_value[i + 1]
63+
if nc == ";":
64+
current.append(";")
65+
elif nc == ",":
66+
current.append(",")
67+
elif nc in ("n", "N"):
68+
current.append("\n")
69+
elif nc == "\\":
70+
current.append("\\")
71+
else:
72+
current.append(raw_value[i : i + 2])
73+
i += 2
74+
elif raw_value[i] == ";":
75+
components.append("".join(current))
76+
current = []
77+
i += 1
78+
else:
79+
current.append(raw_value[i])
80+
i += 1
81+
components.append("".join(current))
82+
return components
83+
84+
85+
def _extract_raw_org(vcard_data: str) -> str | None:
86+
"""Extract ORG from raw vCard text, correctly handling escaped semicolons.
87+
88+
icalendar's ORG parser splits on ALL semicolons including escaped ones,
89+
so we parse the raw line ourselves. Returns components joined by ';'
90+
with '\\;' for literal semicolons within a component, or None if absent.
91+
"""
92+
for line in vcard_data.replace("\r\n ", "").replace("\r\n\t", "").splitlines():
93+
key = line.split(";")[0].split(":")[0]
94+
bare = key.split(".", 1)[1] if "." in key else key
95+
if bare.upper() == "ORG" and ":" in line:
96+
raw_value = line.split(":", 1)[1]
97+
parts = _parse_org_components(raw_value)
98+
return ";".join(p.replace("\\", "\\\\").replace(";", "\\;") for p in parts)
99+
return None
100+
101+
102+
def _vcard_escape_org(text: str) -> str:
103+
"""Escape an ORG value, preserving ';' as the component separator (RFC 2426 Section 3.5.5).
104+
105+
Uses the same escape-aware parser as _extract_raw_org so that \\; (literal
106+
semicolon) and \\\\; (backslash-terminated component + separator) are both
107+
handled correctly.
108+
"""
109+
return ";".join(_vcard_escape(c) for c in _parse_org_components(text))
110+
111+
51112
def _normalize_entries(entries: list[dict[str, str]], default_type: str) -> list[dict[str, str]]:
52113
"""Normalize typed entries, ensuring each has 'value' and a default 'type'."""
53114
result: list[dict[str, str]] = []
@@ -217,10 +278,9 @@ def _format_contact(vcard_data: str) -> dict[str, Any]:
217278
name = _parse_structured_name(card)
218279
if name:
219280
contact["name"] = name
220-
org = card.get("ORG")
221-
if org is not None:
222-
parts = org.ical_value if hasattr(org, "ical_value") else (str(org),)
223-
contact["organization"] = parts[0] if len(parts) == 1 else ";".join(parts)
281+
raw_org = _extract_raw_org(vcard_data)
282+
if raw_org is not None:
283+
contact["organization"] = raw_org
224284
for field, key in [("TITLE", "title"), ("NOTE", "note"), ("BDAY", "birthday"), ("REV", "revision")]:
225285
val = card.get(field)
226286
if val is not None:
@@ -249,8 +309,9 @@ def _build_vcard(fields: dict[str, Any]) -> str:
249309
if not fields.get("full_name"):
250310
fn = f"{given} {family}".strip()
251311
lines.append(f"FN:{_vcard_escape(fn)}")
252-
elif fields.get("full_name") and ";" not in fields.get("full_name", ""):
253-
parts = fields["full_name"].split(maxsplit=1)
312+
elif fields.get("full_name"):
313+
clean = fields["full_name"].replace(";", ",")
314+
parts = clean.split(maxsplit=1)
254315
given = parts[0] if parts else ""
255316
family = parts[1] if len(parts) > 1 else ""
256317
lines.append(f"N:{_vcard_escape(family)};{_vcard_escape(given)};;;")
@@ -261,7 +322,7 @@ def _build_vcard(fields: dict[str, Any]) -> str:
261322
type_part = f";TYPE={entry['type']}" if entry.get("type") else ""
262323
lines.append(f"TEL{type_part}:{_vcard_escape(entry['value'])}")
263324
if fields.get("organization"):
264-
lines.append(f"ORG:{_vcard_escape(fields['organization'])}")
325+
lines.append(f"ORG:{_vcard_escape_org(fields['organization'])}")
265326
if fields.get("title"):
266327
lines.append(f"TITLE:{_vcard_escape(fields['title'])}")
267328
if fields.get("note"):
@@ -312,18 +373,39 @@ def _unfold_vcard_lines(vcard_data: str) -> list[str]:
312373
return lines
313374

314375

376+
_GROUP_METADATA_FIELDS = {"X-ABLABEL"}
377+
378+
315379
def _strip_updated_fields(lines: list[str], skip_fields: set[str]) -> list[str]:
316380
"""Remove lines whose vCard field name is in skip_fields.
317381
318-
Handles group prefixes (e.g. 'item1.EMAIL;TYPE=WORK:...' → field 'EMAIL').
382+
Handles group prefixes (e.g. 'item1.EMAIL;TYPE=WORK:...' → field 'EMAIL')
383+
and also removes orphaned group metadata (X-ABLabel etc.) when all
384+
"real" properties in that group have been stripped.
319385
"""
386+
group_real: dict[str, list[str]] = {}
387+
for line in lines:
388+
raw_field = line.split(";")[0].split(":")[0].upper() if ":" in line else ""
389+
if "." in raw_field:
390+
group, field_name = raw_field.split(".", 1)
391+
if field_name not in _GROUP_METADATA_FIELDS:
392+
group_real.setdefault(group, []).append(field_name)
393+
orphan_groups: set[str] = set()
394+
for group, fields in group_real.items():
395+
if all(f in skip_fields for f in fields):
396+
orphan_groups.add(group)
320397
result: list[str] = []
321398
for line in lines:
322-
field_name = line.split(";")[0].split(":")[0].upper() if ":" in line else ""
323-
if "." in field_name:
324-
field_name = field_name.split(".", 1)[1]
325-
if field_name not in skip_fields:
326-
result.append(line)
399+
raw_field = line.split(";")[0].split(":")[0].upper() if ":" in line else ""
400+
group = ""
401+
field_name = raw_field
402+
if "." in raw_field:
403+
group, field_name = raw_field.split(".", 1)
404+
if field_name in skip_fields:
405+
continue
406+
if group and group in orphan_groups:
407+
continue
408+
result.append(line)
327409
return result
328410

329411

@@ -376,7 +458,8 @@ def _apply_contact_updates(vcard_data: str, updates: dict[str, Any]) -> str:
376458
new_lines.insert(insert_before, f"FN:{_vcard_escape(_synthesize_fn(card))}")
377459
for key, vcard_field in _SIMPLE_UPDATE_FIELDS:
378460
if updates.get(key):
379-
new_lines.insert(insert_before, f"{vcard_field}:{_vcard_escape(updates[key])}")
461+
escape = _vcard_escape_org if vcard_field == "ORG" else _vcard_escape
462+
new_lines.insert(insert_before, f"{vcard_field}:{escape(updates[key])}")
380463
for prop_name, entries_key in [("EMAIL", "email_entries"), ("TEL", "phone_entries")]:
381464
if entries_key in updates:
382465
for entry in updates[entries_key]:

0 commit comments

Comments
 (0)