@@ -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+
51112def _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+
315379def _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