33import logging
44import xml .etree .ElementTree as ET
55from datetime import date
6+ from typing import Any
67
78from pythonvCard4 .vcard import Contact
89
1112logger = logging .getLogger (__name__ )
1213
1314
14- # Canonical keys that _build_contact_from_data consumes. Aliases (``phone``, ``organization``)
15- # are normalised to their canonical form by _normalize_contact_data before lookup.
15+ # Canonical keys accepted by _build_contact_from_data. Callers normalise aliases
16+ # (``phone``→``tel``, ``organization``→``org``) via _normalize_contact_data beforehand
17+ # so the set never needs to list them.
1618_SUPPORTED_CONTACT_KEYS = frozenset (
1719 {
1820 "fn" ,
1921 "email" ,
2022 "tel" ,
21- "phone" ,
2223 "org" ,
23- "organization" ,
2424 "note" ,
2525 "title" ,
2626 "nickname" ,
3131)
3232
3333
34- def _normalize_contact_data (contact_data : dict ) -> dict :
34+ def _normalize_contact_data (contact_data : dict [ str , Any ] ) -> dict [ str , Any ] :
3535 """Map documented aliases to canonical keys.
3636
3737 ``phone`` → ``tel``, ``organization`` → ``org``. The canonical key wins if both
@@ -50,16 +50,19 @@ def _normalize_contact_data(contact_data: dict) -> dict:
5050 return normalised
5151
5252
53- def _wrap_contact_field (value : str | dict | list | None ) -> list [dict ]:
53+ def _wrap_contact_field (
54+ value : str | dict [str , Any ] | list [str | dict [str , Any ]] | None ,
55+ ) -> list [dict [str , Any ]]:
5456 """Normalize an email/tel input into pythonvCard4's list-of-dicts shape.
5557
5658 Accepts a plain string, a dict already in ``{value, type}`` form, or a list of
57- either. Empty strings are dropped. Always returns a list (possibly empty).
59+ either. Empty strings and dicts without a ``value`` key are dropped. Always
60+ returns a list (possibly empty).
5861 """
5962 if value is None or value == "" :
6063 return []
6164 items = value if isinstance (value , list ) else [value ]
62- out : list [dict ] = []
65+ out : list [dict [ str , Any ] ] = []
6366 for item in items :
6467 if isinstance (item , dict ) and item .get ("value" ):
6568 types = item .get ("type" ) or ["HOME" ]
@@ -69,7 +72,7 @@ def _wrap_contact_field(value: str | dict | list | None) -> list[dict]:
6972 return out
7073
7174
72- def _as_str_list (value : str | list ) -> list [str ]:
75+ def _as_str_list (value : str | list [ str ] ) -> list [str ]:
7376 """Wrap a bare string in a list. Does NOT split on commas.
7477
7578 Used for ORG/NICKNAME/URL where commas are part of the value (e.g.
@@ -79,7 +82,7 @@ def _as_str_list(value: str | list) -> list[str]:
7982 return value if isinstance (value , list ) else [value ]
8083
8184
82- def _split_categories (value : str | list ) -> list [str ]:
85+ def _split_categories (value : str | list [ str ] ) -> list [str ]:
8386 """Normalise CATEGORIES input: a comma-separated string is split into a list.
8487
8588 Unlike ORG/NICKNAME, CATEGORIES is canonically comma-separated in vCards
@@ -92,16 +95,25 @@ def _split_categories(value: str | list) -> list[str]:
9295 return [v .strip () for v in value .split ("," ) if v .strip ()]
9396
9497
95- def _build_contact_from_data (contact_data : dict , uid : str ) -> Contact :
98+ def _build_contact_from_data (contact_data : dict [ str , Any ] , uid : str ) -> Contact :
9699 """Build a pythonvCard4 Contact from an MCP ``contact_data`` dict.
97100
98101 Maps every key documented on ``nc_contacts_create_contact`` onto the underlying
99102 library, normalising shapes (list/str) to avoid pythonvCard4's char-by-char
100103 iteration of bare strings — see issue #716.
104+
105+ Callers must pre-normalise aliases via ``_normalize_contact_data`` before
106+ invoking this helper; it assumes canonical keys only.
101107 """
102- data = _normalize_contact_data (contact_data )
108+ data = contact_data
109+
110+ if not data .get ("fn" ):
111+ logger .warning (
112+ "contact_data missing required 'fn' field; pythonvCard4 may reject or "
113+ "produce an invalid vCard"
114+ )
103115
104- kwargs : dict = {"fn" : data .get ("fn" ), "uid" : uid }
116+ kwargs : dict [ str , Any ] = {"fn" : data .get ("fn" ), "uid" : uid }
105117
106118 emails = _wrap_contact_field (data .get ("email" ))
107119 if emails :
@@ -259,11 +271,15 @@ async def delete_addressbook(self, *, name: str):
259271 url = f"{ carddav_path } /{ name } /"
260272 await self ._make_request ("DELETE" , url )
261273
262- async def create_contact (self , * , addressbook : str , uid : str , contact_data : dict ):
274+ async def create_contact (
275+ self , * , addressbook : str , uid : str , contact_data : dict [str , Any ]
276+ ):
263277 """Create a new contact."""
264278 carddav_path = self ._get_carddav_base_path ()
265279 url = f"{ carddav_path } /{ addressbook } /{ uid } .vcf"
266280
281+ # Normalise aliases here so the helper's invariant (canonical keys only) holds.
282+ contact_data = _normalize_contact_data (contact_data )
267283 vcard = _build_contact_from_data (contact_data , uid ).to_vcard ()
268284
269285 headers = {
@@ -280,7 +296,12 @@ async def delete_contact(self, *, addressbook: str, uid: str):
280296 await self ._make_request ("DELETE" , url )
281297
282298 async def update_contact (
283- self , * , addressbook : str , uid : str , contact_data : dict , etag : str = ""
299+ self ,
300+ * ,
301+ addressbook : str ,
302+ uid : str ,
303+ contact_data : dict [str , Any ],
304+ etag : str = "" ,
284305 ):
285306 """Update an existing contact while preserving all existing properties."""
286307 carddav_path = self ._get_carddav_base_path ()
@@ -429,7 +450,7 @@ async def _get_raw_vcard(self, addressbook: str, uid: str) -> tuple[str, str]:
429450 raise
430451
431452 def _merge_vcard_properties (
432- self , raw_vcard : str , contact_data : dict , uid : str
453+ self , raw_vcard : str , contact_data : dict [ str , Any ] , uid : str
433454 ) -> str :
434455 """Merge new contact data into existing raw vCard while preserving all properties."""
435456 try :
@@ -521,6 +542,9 @@ def _merge_vcard_properties(
521542 elif property_name == "URL" and "url" in contact_data :
522543 if "url" not in updated_properties :
523544 url_value = contact_data ["url" ]
545+ # Only the first URL from a list is written; multi-URL
546+ # contacts are rare and this text merge doesn't attempt
547+ # position-stable mapping to existing URL lines.
524548 if isinstance (url_value , list ):
525549 url_value = url_value [0 ] if url_value else ""
526550 if url_value :
@@ -561,6 +585,8 @@ def _merge_vcard_properties(
561585 elif key == "title" :
562586 updated_lines .append (f"TITLE:{ value } " )
563587 elif key == "url" :
588+ # Only the first URL is written on add-new; see note in the
589+ # update-existing branch above.
564590 url_value = (
565591 value [0 ] if isinstance (value , list ) and value else value
566592 )
0 commit comments