|
44 | 44 |
|
45 | 45 | def _vcard_escape(text: str) -> str: |
46 | 46 | """Escape a string for use as a vCard 3.0 text value (RFC 2426 Section 2.4.2).""" |
47 | | - return text.replace("\\", "\\\\").replace(";", "\\;").replace(",", "\\,").replace("\n", "\\n") |
| 47 | + normalized = text.replace("\r\n", "\n").replace("\r", "\n") |
| 48 | + return normalized.replace("\\", "\\\\").replace(";", "\\;").replace(",", "\\,").replace("\n", "\\n") |
48 | 49 |
|
49 | 50 |
|
50 | 51 | def _normalize_entries(entries: list[dict[str, str]], default_type: str) -> list[dict[str, str]]: |
@@ -320,28 +321,50 @@ def _strip_updated_fields(lines: list[str], skip_fields: set[str]) -> list[str]: |
320 | 321 | return result |
321 | 322 |
|
322 | 323 |
|
| 324 | +def _synthesize_fn(card: Any) -> str: |
| 325 | + """Derive a display name from a parsed vCard's existing N or FN fields.""" |
| 326 | + old_n = card.get("N") |
| 327 | + if old_n and hasattr(old_n, "fields"): |
| 328 | + fn = f"{old_n.fields.given} {old_n.fields.family}".strip() |
| 329 | + if fn: |
| 330 | + return fn |
| 331 | + return str(card.get("FN", "")) |
| 332 | + |
| 333 | + |
| 334 | +def _apply_name_updates(new_lines: list[str], insert_before: int, card: Any, updates: dict[str, Any]) -> bool: |
| 335 | + """Insert updated N and FN lines. Returns True if FN was inserted.""" |
| 336 | + fn_inserted = False |
| 337 | + if updates.get("full_name"): |
| 338 | + new_lines.insert(insert_before, f"FN:{_vcard_escape(updates['full_name'])}") |
| 339 | + fn_inserted = True |
| 340 | + if "given_name" not in updates and "family_name" not in updates: |
| 341 | + return fn_inserted |
| 342 | + old_n = card.get("N") |
| 343 | + has_fields = old_n and hasattr(old_n, "fields") |
| 344 | + family = updates.get("family_name", str(old_n.fields.family) if has_fields else "") |
| 345 | + given = updates.get("given_name", str(old_n.fields.given) if has_fields else "") |
| 346 | + additional = str(old_n.fields.additional) if has_fields and old_n.fields.additional else "" |
| 347 | + prefix = str(old_n.fields.prefix) if has_fields and old_n.fields.prefix else "" |
| 348 | + suffix = str(old_n.fields.suffix) if has_fields and old_n.fields.suffix else "" |
| 349 | + n_parts = ";".join(_vcard_escape(p) for p in [family, given, additional, prefix, suffix]) |
| 350 | + new_lines.insert(insert_before, f"N:{n_parts}") |
| 351 | + if not fn_inserted: |
| 352 | + new_lines.insert(insert_before, f"FN:{_vcard_escape(f'{given} {family}'.strip())}") |
| 353 | + fn_inserted = True |
| 354 | + return fn_inserted |
| 355 | + |
| 356 | + |
323 | 357 | def _apply_contact_updates(vcard_data: str, updates: dict[str, Any]) -> str: |
324 | 358 | """Apply partial field updates to existing vCard data, return new vCard string.""" |
325 | 359 | skip_fields = {field for key, field in _UPDATE_FIELD_MAP if key in updates} |
326 | 360 | if ("given_name" in updates or "family_name" in updates) and "full_name" not in updates: |
327 | 361 | skip_fields.add("FN") |
328 | 362 | new_lines = _strip_updated_fields(_unfold_vcard_lines(vcard_data), skip_fields) |
329 | 363 | insert_before = len(new_lines) - 1 |
330 | | - if updates.get("full_name"): |
331 | | - new_lines.insert(insert_before, f"FN:{_vcard_escape(updates['full_name'])}") |
332 | | - if "given_name" in updates or "family_name" in updates: |
333 | | - card = ICal.from_ical(vcard_data) |
334 | | - old_n = card.get("N") |
335 | | - has_fields = old_n and hasattr(old_n, "fields") |
336 | | - family = updates.get("family_name", str(old_n.fields.family) if has_fields else "") |
337 | | - given = updates.get("given_name", str(old_n.fields.given) if has_fields else "") |
338 | | - additional = str(old_n.fields.additional) if has_fields and old_n.fields.additional else "" |
339 | | - prefix = str(old_n.fields.prefix) if has_fields and old_n.fields.prefix else "" |
340 | | - suffix = str(old_n.fields.suffix) if has_fields and old_n.fields.suffix else "" |
341 | | - n_parts = ";".join(_vcard_escape(p) for p in [family, given, additional, prefix, suffix]) |
342 | | - new_lines.insert(insert_before, f"N:{n_parts}") |
343 | | - if not updates.get("full_name"): |
344 | | - new_lines.insert(insert_before, f"FN:{_vcard_escape(f'{given} {family}'.strip())}") |
| 364 | + card = ICal.from_ical(vcard_data) |
| 365 | + fn_inserted = _apply_name_updates(new_lines, insert_before, card, updates) |
| 366 | + if "FN" in skip_fields and not fn_inserted: |
| 367 | + new_lines.insert(insert_before, f"FN:{_vcard_escape(_synthesize_fn(card))}") |
345 | 368 | for key, vcard_field in _SIMPLE_UPDATE_FIELDS: |
346 | 369 | if updates.get(key): |
347 | 370 | new_lines.insert(insert_before, f"{vcard_field}:{_vcard_escape(updates[key])}") |
|
0 commit comments