Merge pull request #719 from cbcoutinho/fix/contacts-create-dropped-fields-716
fix(contacts): persist all documented fields on create (fixes #716)
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import logging
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
from pythonvCard4.vcard import Contact
|
||||
|
||||
@@ -11,6 +12,184 @@ from .base import BaseNextcloudClient
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Canonical keys accepted by _build_contact_from_data. Callers normalise aliases
|
||||
# (``phone``→``tel``, ``organization``→``org``) via _normalize_contact_data beforehand
|
||||
# so the set never needs to list them.
|
||||
_SUPPORTED_CONTACT_KEYS = frozenset(
|
||||
{
|
||||
"fn",
|
||||
"email",
|
||||
"tel",
|
||||
"org",
|
||||
"note",
|
||||
"title",
|
||||
"nickname",
|
||||
"bday",
|
||||
"categories",
|
||||
"url",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _normalize_contact_data(contact_data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Map documented aliases to canonical keys.
|
||||
|
||||
``phone`` → ``tel``, ``organization`` → ``org``. The canonical key wins if both
|
||||
are supplied, so callers who set ``tel`` don't lose it to a stray ``phone`` entry.
|
||||
Returns a new dict — does not mutate the caller's argument.
|
||||
"""
|
||||
normalised = dict(contact_data)
|
||||
if "phone" in normalised and "tel" not in normalised:
|
||||
normalised["tel"] = normalised.pop("phone")
|
||||
else:
|
||||
normalised.pop("phone", None)
|
||||
if "organization" in normalised and "org" not in normalised:
|
||||
normalised["org"] = normalised.pop("organization")
|
||||
else:
|
||||
normalised.pop("organization", None)
|
||||
return normalised
|
||||
|
||||
|
||||
def _wrap_contact_field(
|
||||
value: str | dict[str, Any] | list[str | dict[str, Any]] | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Normalize an email/tel input into pythonvCard4's list-of-dicts shape.
|
||||
|
||||
Accepts a plain string, a dict already in ``{value, type}`` form, or a list of
|
||||
either. Empty strings and dicts without a ``value`` key are dropped. Always
|
||||
returns a list (possibly empty).
|
||||
"""
|
||||
if value is None or value == "":
|
||||
return []
|
||||
items = value if isinstance(value, list) else [value]
|
||||
out: list[dict[str, Any]] = []
|
||||
for item in items:
|
||||
if isinstance(item, dict) and item.get("value"):
|
||||
types = item.get("type") or ["HOME"]
|
||||
# Wrap a bare string so ``list("WORK")`` doesn't iterate it into
|
||||
# ``["W", "O", "R", "K"]`` — same char-iteration footgun this whole
|
||||
# helper exists to avoid for the outer ``value``.
|
||||
if isinstance(types, str):
|
||||
types = [types]
|
||||
out.append({"value": item["value"], "type": list(types)})
|
||||
elif isinstance(item, str) and item:
|
||||
out.append({"value": item, "type": ["HOME"]})
|
||||
return out
|
||||
|
||||
|
||||
def _as_str_list(value: str | list[str]) -> list[str]:
|
||||
"""Wrap a bare string in a list. Does NOT split on commas.
|
||||
|
||||
Used for ORG/NICKNAME/URL where commas are part of the value (e.g.
|
||||
``"Smith, Jones & Associates"``) and only the list wrapper is needed to
|
||||
prevent pythonvCard4 from iterating the string character-by-character.
|
||||
"""
|
||||
return value if isinstance(value, list) else [value]
|
||||
|
||||
|
||||
def _split_categories(value: str | list[str]) -> list[str]:
|
||||
"""Normalise CATEGORIES input: a comma-separated string is split into a list.
|
||||
|
||||
Unlike ORG/NICKNAME, CATEGORIES is canonically comma-separated in vCards
|
||||
(``CATEGORIES:a,b,c``) so splitting a bare string is the expected shape.
|
||||
Lists pass through unchanged — callers that already provide ``["a,b"]`` keep
|
||||
their exact item, no double-splitting.
|
||||
"""
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
return [v.strip() for v in value.split(",") if v.strip()]
|
||||
|
||||
|
||||
def _parse_bday(value: str | date | None) -> date | None:
|
||||
"""Parse a BDAY input to a ``date``. Logs and returns ``None`` if unparseable.
|
||||
|
||||
Shared by the create path (``_build_contact_from_data``) and the update path
|
||||
(``_merge_vcard_properties``) so a non-ISO BDAY is rejected consistently
|
||||
instead of being written raw on update.
|
||||
"""
|
||||
if value is None or value == "":
|
||||
return None
|
||||
if isinstance(value, date):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
return date.fromisoformat(value)
|
||||
except ValueError:
|
||||
logger.warning("Ignoring non-ISO bday value: %r", value)
|
||||
return None
|
||||
|
||||
|
||||
def _safe_vcard_value(value: Any) -> Any:
|
||||
"""Escape newlines in a value so it can't inject additional vCard properties.
|
||||
|
||||
Per RFC 6350 §3.4 newlines inside a property value are encoded as ``\\n``.
|
||||
Unfolding this on the read side is pythonvCard4's job; we only need to make
|
||||
sure ``contact_data`` strings don't terminate the line on the way out.
|
||||
"""
|
||||
if isinstance(value, str):
|
||||
return value.replace("\r\n", "\\n").replace("\n", "\\n").replace("\r", "\\n")
|
||||
return value
|
||||
|
||||
|
||||
def _build_contact_from_data(contact_data: dict[str, Any], uid: str) -> Contact:
|
||||
"""Build a pythonvCard4 Contact from an MCP ``contact_data`` dict.
|
||||
|
||||
Maps every key documented on ``nc_contacts_create_contact`` onto the underlying
|
||||
library, normalising shapes (list/str) to avoid pythonvCard4's char-by-char
|
||||
iteration of bare strings — see issue #716.
|
||||
|
||||
Callers must pre-normalise aliases via ``_normalize_contact_data`` before
|
||||
invoking this helper; it assumes canonical keys only.
|
||||
"""
|
||||
data = contact_data
|
||||
|
||||
if not data.get("fn"):
|
||||
logger.warning(
|
||||
"contact_data missing required 'fn' field; pythonvCard4 may reject or "
|
||||
"produce an invalid vCard"
|
||||
)
|
||||
|
||||
kwargs: dict[str, Any] = {"fn": data.get("fn"), "uid": uid}
|
||||
|
||||
emails = _wrap_contact_field(data.get("email"))
|
||||
if emails:
|
||||
kwargs["email"] = emails
|
||||
|
||||
tels = _wrap_contact_field(data.get("tel"))
|
||||
if tels:
|
||||
kwargs["tel"] = tels
|
||||
|
||||
if data.get("org"):
|
||||
kwargs["org"] = _as_str_list(data["org"])
|
||||
|
||||
if data.get("note"):
|
||||
kwargs["note"] = data["note"]
|
||||
|
||||
if data.get("title"):
|
||||
kwargs["title"] = data["title"]
|
||||
|
||||
if data.get("nickname"):
|
||||
kwargs["nickname"] = _as_str_list(data["nickname"])
|
||||
|
||||
if data.get("categories"):
|
||||
kwargs["categories"] = _split_categories(data["categories"])
|
||||
|
||||
if data.get("url"):
|
||||
kwargs["url"] = _as_str_list(data["url"])
|
||||
|
||||
bday = _parse_bday(data.get("bday"))
|
||||
if bday is not None:
|
||||
kwargs["bday"] = bday
|
||||
|
||||
unknown = set(data) - _SUPPORTED_CONTACT_KEYS
|
||||
if unknown:
|
||||
logger.debug("Ignoring unknown contact_data keys: %s", sorted(unknown))
|
||||
|
||||
# kwargs built dynamically from contact_data; pythonvCard4's Contact typeshed
|
||||
# has specific typed params and doesn't accept **dict[str, Any].
|
||||
return Contact(**kwargs) # type: ignore[arg-type]
|
||||
|
||||
|
||||
class ContactsClient(BaseNextcloudClient):
|
||||
"""Client for NextCloud CardDAV contact operations."""
|
||||
|
||||
@@ -122,18 +301,16 @@ class ContactsClient(BaseNextcloudClient):
|
||||
url = f"{carddav_path}/{name}/"
|
||||
await self._make_request("DELETE", url)
|
||||
|
||||
async def create_contact(self, *, addressbook: str, uid: str, contact_data: dict):
|
||||
async def create_contact(
|
||||
self, *, addressbook: str, uid: str, contact_data: dict[str, Any]
|
||||
):
|
||||
"""Create a new contact."""
|
||||
carddav_path = self._get_carddav_base_path()
|
||||
url = f"{carddav_path}/{addressbook}/{uid}.vcf"
|
||||
|
||||
contact = Contact(fn=contact_data.get("fn"), uid=uid) # type: ignore
|
||||
if "email" in contact_data:
|
||||
contact.email = [{"value": contact_data["email"], "type": ["HOME"]}]
|
||||
if "tel" in contact_data:
|
||||
contact.tel = [{"value": contact_data["tel"], "type": ["HOME"]}]
|
||||
|
||||
vcard = contact.to_vcard()
|
||||
# Normalise aliases here so the helper's invariant (canonical keys only) holds.
|
||||
contact_data = _normalize_contact_data(contact_data)
|
||||
vcard = _build_contact_from_data(contact_data, uid).to_vcard()
|
||||
|
||||
headers = {
|
||||
"Content-Type": "text/vcard; charset=utf-8",
|
||||
@@ -149,12 +326,20 @@ class ContactsClient(BaseNextcloudClient):
|
||||
await self._make_request("DELETE", url)
|
||||
|
||||
async def update_contact(
|
||||
self, *, addressbook: str, uid: str, contact_data: dict, etag: str = ""
|
||||
self,
|
||||
*,
|
||||
addressbook: str,
|
||||
uid: str,
|
||||
contact_data: dict[str, Any],
|
||||
etag: str = "",
|
||||
):
|
||||
"""Update an existing contact while preserving all existing properties."""
|
||||
carddav_path = self._get_carddav_base_path()
|
||||
url = f"{carddav_path}/{addressbook}/{uid}.vcf"
|
||||
|
||||
# Canonicalise aliases up front so both code paths (merge + fallback) agree.
|
||||
contact_data = _normalize_contact_data(contact_data)
|
||||
|
||||
# Get raw vCard content to preserve all properties including extended ones
|
||||
raw_vcard_content = ""
|
||||
if not etag:
|
||||
@@ -177,12 +362,7 @@ class ContactsClient(BaseNextcloudClient):
|
||||
)
|
||||
else:
|
||||
# Fallback to creating new vCard if we couldn't get existing
|
||||
contact = Contact(fn=contact_data.get("fn"), uid=uid) # type: ignore
|
||||
if "email" in contact_data:
|
||||
contact.email = [{"value": contact_data["email"], "type": ["HOME"]}]
|
||||
if "tel" in contact_data:
|
||||
contact.tel = [{"value": contact_data["tel"], "type": ["HOME"]}]
|
||||
vcard_content = contact.to_vcard()
|
||||
vcard_content = _build_contact_from_data(contact_data, uid).to_vcard()
|
||||
|
||||
headers = {
|
||||
"Content-Type": "text/vcard; charset=utf-8",
|
||||
@@ -300,9 +480,31 @@ class ContactsClient(BaseNextcloudClient):
|
||||
raise
|
||||
|
||||
def _merge_vcard_properties(
|
||||
self, raw_vcard: str, contact_data: dict, uid: str
|
||||
self, raw_vcard: str, contact_data: dict[str, Any], uid: str
|
||||
) -> str:
|
||||
"""Merge new contact data into existing raw vCard while preserving all properties."""
|
||||
"""Merge new contact data into existing raw vCard while preserving all properties.
|
||||
|
||||
Limitation: dict / list-form ``email`` and ``tel`` inputs are not applied
|
||||
by this text-merge path. Existing EMAIL/TEL lines are preserved unchanged,
|
||||
and no new lines are written for the dict/list inputs. Pass plain strings
|
||||
to update EMAIL/TEL here, or recreate via ``create_contact`` for full
|
||||
multi-entry support with TYPE annotations.
|
||||
"""
|
||||
# Surface dict/list email/tel up front rather than silently no-op in the
|
||||
# add-new loop below (where the isinstance(value, str) guard skips them).
|
||||
for _key in ("email", "tel"):
|
||||
_value = contact_data.get(_key)
|
||||
if _value is not None and not isinstance(_value, str):
|
||||
logger.warning(
|
||||
"update_contact: %s=%r (dict/list shape) is not applied via "
|
||||
"the text-merge update path; existing %s lines are preserved "
|
||||
"unchanged. Use a plain string to update %s here, or recreate "
|
||||
"via create_contact for multi-entry support.",
|
||||
_key,
|
||||
_value,
|
||||
_key.upper(),
|
||||
_key.upper(),
|
||||
)
|
||||
try:
|
||||
# Instead of using pythonvCard4 which has formatting issues,
|
||||
# let's do a simple text-based merge to preserve exact formatting
|
||||
@@ -327,21 +529,27 @@ class ContactsClient(BaseNextcloudClient):
|
||||
|
||||
# Handle updates for specific properties
|
||||
if property_name == "FN" and "fn" in contact_data:
|
||||
updated_lines.append(f"FN:{contact_data['fn']}")
|
||||
updated_lines.append(f"FN:{_safe_vcard_value(contact_data['fn'])}")
|
||||
updated_properties.add("fn")
|
||||
elif property_name == "EMAIL" and "email" in contact_data:
|
||||
# Replace first email with new one, preserve others
|
||||
if "email" not in updated_properties:
|
||||
if isinstance(contact_data["email"], str):
|
||||
email_value = _safe_vcard_value(contact_data["email"])
|
||||
# Try to preserve the original format as much as possible
|
||||
if ";TYPE=" in line:
|
||||
type_part = line.split(";TYPE=")[1].split(":")[0]
|
||||
updated_lines.append(
|
||||
f"EMAIL;TYPE={type_part}:{contact_data['email']}"
|
||||
f"EMAIL;TYPE={type_part}:{email_value}"
|
||||
)
|
||||
else:
|
||||
updated_lines.append(f"EMAIL:{contact_data['email']}")
|
||||
updated_properties.add("email")
|
||||
updated_lines.append(f"EMAIL:{email_value}")
|
||||
updated_properties.add("email")
|
||||
else:
|
||||
# Dict / list inputs aren't translatable to a single
|
||||
# text-merge replacement; keep the original line so we
|
||||
# don't silently drop the contact's email.
|
||||
updated_lines.append(line)
|
||||
else:
|
||||
# Keep additional emails unchanged
|
||||
updated_lines.append(line)
|
||||
@@ -349,46 +557,79 @@ class ContactsClient(BaseNextcloudClient):
|
||||
# Similar handling for phone numbers
|
||||
if "tel" not in updated_properties:
|
||||
if isinstance(contact_data["tel"], str):
|
||||
tel_value = _safe_vcard_value(contact_data["tel"])
|
||||
if ";TYPE=" in line:
|
||||
type_part = line.split(";TYPE=")[1].split(":")[0]
|
||||
updated_lines.append(
|
||||
f"TEL;TYPE={type_part}:{contact_data['tel']}"
|
||||
f"TEL;TYPE={type_part}:{tel_value}"
|
||||
)
|
||||
else:
|
||||
updated_lines.append(f"TEL:{contact_data['tel']}")
|
||||
updated_properties.add("tel")
|
||||
updated_lines.append(f"TEL:{tel_value}")
|
||||
updated_properties.add("tel")
|
||||
else:
|
||||
# Same reasoning as the EMAIL branch above: don't drop.
|
||||
updated_lines.append(line)
|
||||
else:
|
||||
# Keep additional phone numbers unchanged
|
||||
updated_lines.append(line)
|
||||
elif property_name == "NOTE" and "note" in contact_data:
|
||||
updated_lines.append(f"NOTE:{contact_data['note']}")
|
||||
updated_lines.append(
|
||||
f"NOTE:{_safe_vcard_value(contact_data['note'])}"
|
||||
)
|
||||
updated_properties.add("note")
|
||||
elif property_name == "NICKNAME" and "nickname" in contact_data:
|
||||
nickname_value = contact_data["nickname"]
|
||||
if isinstance(nickname_value, list):
|
||||
nickname_value = ",".join(nickname_value)
|
||||
updated_lines.append(f"NICKNAME:{nickname_value}")
|
||||
updated_lines.append(
|
||||
f"NICKNAME:{_safe_vcard_value(nickname_value)}"
|
||||
)
|
||||
updated_properties.add("nickname")
|
||||
elif property_name == "BDAY" and "bday" in contact_data:
|
||||
updated_lines.append(f"BDAY:{contact_data['bday']}")
|
||||
updated_properties.add("bday")
|
||||
parsed_bday = _parse_bday(contact_data["bday"])
|
||||
if parsed_bday is not None:
|
||||
updated_lines.append(f"BDAY:{parsed_bday.isoformat()}")
|
||||
updated_properties.add("bday")
|
||||
else:
|
||||
# Invalid input — keep the existing BDAY rather than
|
||||
# writing a malformed line or silently dropping it.
|
||||
updated_lines.append(line)
|
||||
elif property_name == "CATEGORIES" and "categories" in contact_data:
|
||||
categories_value = contact_data["categories"]
|
||||
if isinstance(categories_value, list):
|
||||
categories_value = ",".join(categories_value)
|
||||
updated_lines.append(f"CATEGORIES:{categories_value}")
|
||||
updated_properties.add("categories")
|
||||
elif property_name == "ORG" and (
|
||||
"org" in contact_data or "organization" in contact_data
|
||||
):
|
||||
org_value = contact_data.get("org") or contact_data.get(
|
||||
"organization"
|
||||
updated_lines.append(
|
||||
f"CATEGORIES:{_safe_vcard_value(categories_value)}"
|
||||
)
|
||||
updated_lines.append(f"ORG:{org_value}")
|
||||
updated_properties.add("categories")
|
||||
elif property_name == "ORG" and "org" in contact_data:
|
||||
org_value = contact_data["org"]
|
||||
# ORG is structured (Company;Department;…) per RFC 6350 §6.6.4;
|
||||
# join list components with ';' so callers using the same shape
|
||||
# ``_build_contact_from_data`` accepts don't get a Python repr.
|
||||
if isinstance(org_value, list):
|
||||
org_value = ";".join(org_value)
|
||||
updated_lines.append(f"ORG:{_safe_vcard_value(org_value)}")
|
||||
updated_properties.add("org")
|
||||
elif property_name == "TITLE" and "title" in contact_data:
|
||||
updated_lines.append(f"TITLE:{contact_data['title']}")
|
||||
updated_lines.append(
|
||||
f"TITLE:{_safe_vcard_value(contact_data['title'])}"
|
||||
)
|
||||
updated_properties.add("title")
|
||||
elif property_name == "URL" and "url" in contact_data:
|
||||
if "url" not in updated_properties:
|
||||
url_value = contact_data["url"]
|
||||
# Only the first URL from a list is written; multi-URL
|
||||
# contacts are rare and this text merge doesn't attempt
|
||||
# position-stable mapping to existing URL lines.
|
||||
if isinstance(url_value, list):
|
||||
url_value = url_value[0] if url_value else ""
|
||||
if url_value:
|
||||
updated_lines.append(f"URL:{_safe_vcard_value(url_value)}")
|
||||
updated_properties.add("url")
|
||||
else:
|
||||
# Keep additional URLs unchanged
|
||||
updated_lines.append(line)
|
||||
else:
|
||||
# Keep all other properties unchanged (preserves all extended/custom fields)
|
||||
updated_lines.append(line)
|
||||
@@ -397,29 +638,47 @@ class ContactsClient(BaseNextcloudClient):
|
||||
for key, value in contact_data.items():
|
||||
if key not in updated_properties:
|
||||
if key == "fn":
|
||||
updated_lines.append(f"FN:{value}")
|
||||
updated_lines.append(f"FN:{_safe_vcard_value(value)}")
|
||||
elif key == "email" and isinstance(value, str):
|
||||
updated_lines.append(f"EMAIL:{value}")
|
||||
updated_lines.append(f"EMAIL:{_safe_vcard_value(value)}")
|
||||
elif key == "tel" and isinstance(value, str):
|
||||
updated_lines.append(f"TEL:{value}")
|
||||
updated_lines.append(f"TEL:{_safe_vcard_value(value)}")
|
||||
elif key == "note":
|
||||
updated_lines.append(f"NOTE:{value}")
|
||||
updated_lines.append(f"NOTE:{_safe_vcard_value(value)}")
|
||||
elif key == "nickname":
|
||||
nickname_value = (
|
||||
value if isinstance(value, str) else ",".join(value)
|
||||
)
|
||||
updated_lines.append(f"NICKNAME:{nickname_value}")
|
||||
updated_lines.append(
|
||||
f"NICKNAME:{_safe_vcard_value(nickname_value)}"
|
||||
)
|
||||
elif key == "bday":
|
||||
updated_lines.append(f"BDAY:{value}")
|
||||
parsed_bday = _parse_bday(value)
|
||||
if parsed_bday is not None:
|
||||
updated_lines.append(f"BDAY:{parsed_bday.isoformat()}")
|
||||
elif key == "categories":
|
||||
categories_value = (
|
||||
value if isinstance(value, str) else ",".join(value)
|
||||
)
|
||||
updated_lines.append(f"CATEGORIES:{categories_value}")
|
||||
elif key in ["org", "organization"]:
|
||||
updated_lines.append(f"ORG:{value}")
|
||||
updated_lines.append(
|
||||
f"CATEGORIES:{_safe_vcard_value(categories_value)}"
|
||||
)
|
||||
elif key == "org":
|
||||
# See ORG note in update-existing branch above.
|
||||
org_value = (
|
||||
";".join(value) if isinstance(value, list) else value
|
||||
)
|
||||
updated_lines.append(f"ORG:{_safe_vcard_value(org_value)}")
|
||||
elif key == "title":
|
||||
updated_lines.append(f"TITLE:{value}")
|
||||
updated_lines.append(f"TITLE:{_safe_vcard_value(value)}")
|
||||
elif key == "url":
|
||||
# Only the first URL is written on add-new; see note in the
|
||||
# update-existing branch above.
|
||||
url_value = (
|
||||
value[0] if isinstance(value, list) and value else value
|
||||
)
|
||||
if url_value:
|
||||
updated_lines.append(f"URL:{_safe_vcard_value(url_value)}")
|
||||
|
||||
# Add the END:VCARD line
|
||||
updated_lines.append("END:VCARD")
|
||||
|
||||
@@ -273,7 +273,23 @@ def configure_contacts_tools(mcp: FastMCP):
|
||||
not the display name. Use nc_contacts_list_addressbooks to
|
||||
find available URI slugs.
|
||||
uid: The unique ID for the contact.
|
||||
contact_data: A dictionary with the contact's details, e.g. {"fn": "John Doe", "email": "john.doe@example.com"}.
|
||||
contact_data: A dictionary with the contact's details. Supported keys:
|
||||
|
||||
- ``fn`` (str, required): Formatted full name.
|
||||
- ``email`` (str or list of str/dicts): Email address(es).
|
||||
- ``tel`` / ``phone`` (str or list): Phone number(s).
|
||||
- ``org`` / ``organization`` (str or list of str): Organization.
|
||||
Lists become semicolon-separated ORG components per RFC 6350.
|
||||
- ``title`` (str): Job title.
|
||||
- ``note`` (str): Free-form note.
|
||||
- ``nickname`` (str or list of str).
|
||||
- ``bday`` (ISO date str ``"YYYY-MM-DD"`` or ``datetime.date``).
|
||||
- ``categories`` (list of str, or comma-separated str).
|
||||
- ``url`` (str or list of str).
|
||||
|
||||
Unknown keys are ignored. Example:
|
||||
``{"fn": "John Doe", "email": "john@example.com",
|
||||
"organization": "Acme", "note": "Met at conference"}``.
|
||||
"""
|
||||
client = await get_client(ctx)
|
||||
return await client.contacts.create_contact(
|
||||
@@ -316,7 +332,30 @@ def configure_contacts_tools(mcp: FastMCP):
|
||||
not the display name. Use nc_contacts_list_addressbooks to
|
||||
find available URI slugs.
|
||||
uid: The unique ID of the contact to update.
|
||||
contact_data: A dictionary with the contact's updated details, e.g. {"fn": "Jane Doe", "email": "jane.doe@example.com"}.
|
||||
contact_data: A dictionary with the contact's updated details. Supported
|
||||
keys mirror nc_contacts_create_contact:
|
||||
|
||||
- ``fn`` (str): Formatted full name.
|
||||
- ``email`` (str): Email address. **Update path supports plain
|
||||
strings only**; dict / list-form inputs are not applied — the
|
||||
existing EMAIL line is preserved unchanged and a warning is
|
||||
logged. Use create_contact for multi-entry support with TYPE
|
||||
annotations.
|
||||
- ``tel`` / ``phone`` (str): Phone number. Same single-string
|
||||
limitation as ``email`` above.
|
||||
- ``org`` / ``organization`` (str or list of str): Organization.
|
||||
Lists become semicolon-separated ORG components per RFC 6350.
|
||||
- ``title`` (str): Job title.
|
||||
- ``note`` (str): Free-form note.
|
||||
- ``nickname`` (str or list of str).
|
||||
- ``bday`` (ISO date str ``"YYYY-MM-DD"`` or ``datetime.date``).
|
||||
Non-ISO strings are rejected with a warning; the existing
|
||||
BDAY line is preserved.
|
||||
- ``categories`` (list of str, or comma-separated str).
|
||||
- ``url`` (str or list of str). Only the first URL is written
|
||||
on update; multi-URL contacts should use create_contact.
|
||||
|
||||
Example: ``{"fn": "Jane Doe", "email": "jane.doe@example.com"}``.
|
||||
etag: Optional ETag for optimistic concurrency control.
|
||||
"""
|
||||
client = await get_client(ctx)
|
||||
|
||||
Reference in New Issue
Block a user