fix(contacts): surface ORG/TITLE/NOTE/URL/CATEGORIES/PHOTO on read (refs #716)
PR #719 fixed the contact-create path so all documented fields persist to the vCard, but the read path (list/search via MCP) still returned ``organization: null`` / ``note: null`` / ``title: null`` because pythonvCard4 has no typed parser for ORG/TITLE — they land in ``Contact.custom`` — and the server-side mapper never read ``note``/``urls``/``categories``/``photo`` even when present. Reads now surface what the write side persisted: - ``client/contacts.py``: new ``_first_custom`` helper pulls raw values from ``Contact.custom`` for ORG/TITLE/unencoded PHOTO. ``list_contacts`` extends its per-contact dict with org/title/note/url/categories/photo. - ``server/contacts.py``: ``_raw_contact_to_model`` maps the new keys onto ``Contact.organization`` / ``.title`` / ``.note`` / ``.urls`` / ``.categories`` / ``.photo``. URL accepts both list and plain-string shapes; categories accepts comma-separated strings for forward-compat. Coverage: - Unit: ``TestFirstCustom`` (five cases incl. bare-string library shape) and three new ``_raw_contact_to_model`` cases covering the full field set, plain-string URL, and comma-string categories. - Integration: ``test_mcp_contacts_workflow`` now decodes the ``nc_contacts_search_contacts`` response and asserts ``organization`` / ``note`` round-trip — direct regression coverage for elvisdragonmao's report on issue #716. Verified end-to-end against the local single-user docker stack: creating a contact with ``{organization, title, note, url, categories}`` and reading it back via ``nc_contacts_search_contacts`` returns every field populated. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
04a6294cc5
commit
688a03f00a
@@ -119,6 +119,24 @@ def _parse_bday(value: str | date | None) -> date | None:
|
||||
return None
|
||||
|
||||
|
||||
def _first_custom(custom: dict[str, str | list[str]], key: str) -> str | None:
|
||||
"""Return the first raw value pythonvCard4 stashed in ``custom[key]``.
|
||||
|
||||
The library has no typed parser for ORG / TITLE / unencoded PHOTO, so they
|
||||
end up in ``Contact.custom`` keyed by property name. The library's typeshed
|
||||
declares the values as ``str | list[str]`` even though the current parser
|
||||
always appends to a list — accept both shapes so we don't break on a future
|
||||
library version that switches to bare strings. Returns ``None`` when the
|
||||
key is absent or the value is empty.
|
||||
"""
|
||||
values = custom.get(key)
|
||||
if isinstance(values, list):
|
||||
return values[0] if values else None
|
||||
if isinstance(values, str):
|
||||
return values or None
|
||||
return None
|
||||
|
||||
|
||||
def _safe_vcard_value(value: Any) -> Any:
|
||||
"""Escape newlines in a value so it can't inject additional vCard properties.
|
||||
|
||||
@@ -446,6 +464,16 @@ class ContactsClient(BaseNextcloudClient):
|
||||
|
||||
contact = Contact.from_vcard(addressdata)
|
||||
|
||||
# pythonvCard4's parser has no branch for ORG / TITLE — they fall
|
||||
# through into ``contact.custom`` as a list of raw values. PHOTO
|
||||
# only gets typed-parsed when the line carries an ``ENCODING=``
|
||||
# parameter; otherwise it lands in ``custom`` too. Pull them out
|
||||
# here so the read side surfaces what the write side persisted
|
||||
# (issue #716 follow-up).
|
||||
org_value = _first_custom(contact.custom, "ORG")
|
||||
title_value = _first_custom(contact.custom, "TITLE")
|
||||
photo_value = contact.photo_data or _first_custom(contact.custom, "PHOTO")
|
||||
|
||||
contacts.append(
|
||||
{
|
||||
"vcard_id": vcard_id,
|
||||
@@ -458,6 +486,12 @@ class ContactsClient(BaseNextcloudClient):
|
||||
else contact.bday,
|
||||
"email": contact.email,
|
||||
"tel": contact.tel,
|
||||
"org": org_value,
|
||||
"title": title_value,
|
||||
"note": contact.note,
|
||||
"url": contact.url,
|
||||
"categories": contact.categories,
|
||||
"photo": photo_value,
|
||||
},
|
||||
"addressdata": addressdata,
|
||||
}
|
||||
|
||||
@@ -65,15 +65,35 @@ def _parse_vcard_fields(
|
||||
def _raw_contact_to_model(raw: dict) -> Contact:
|
||||
"""Convert a raw contact dict from the contacts client to a Contact model.
|
||||
|
||||
Maps fullname, nickname, birthday, email, and tel fields.
|
||||
Email/tel values may be plain strings, dicts with ``value``/``type`` keys,
|
||||
or lists of either – see :func:`_parse_vcard_fields`.
|
||||
Maps fullname, nickname, birthday, email, tel, org, title, note, url,
|
||||
categories, and photo fields. Email/tel values may be plain strings, dicts
|
||||
with ``value``/``type`` keys, or lists of either – see
|
||||
:func:`_parse_vcard_fields`.
|
||||
"""
|
||||
contact_info = raw.get("contact", {})
|
||||
|
||||
emails = _parse_vcard_fields(contact_info.get("email"), "email")
|
||||
phones = _parse_vcard_fields(contact_info.get("tel"), "phone")
|
||||
|
||||
# URL is parsed by pythonvCard4 into a plain ``list[str]``. Single-string
|
||||
# inputs surface as such too. Either way wrap each into a ContactField.
|
||||
raw_urls = contact_info.get("url")
|
||||
if isinstance(raw_urls, str):
|
||||
raw_urls = [raw_urls] if raw_urls else []
|
||||
urls = [
|
||||
ContactField(type="url", value=u)
|
||||
for u in (raw_urls or [])
|
||||
if isinstance(u, str) and u
|
||||
]
|
||||
|
||||
# CATEGORIES is parsed as ``list[str]``. Accept a comma-separated string
|
||||
# too for forward-compat with library updates that might change shape.
|
||||
raw_categories = contact_info.get("categories") or []
|
||||
if isinstance(raw_categories, str):
|
||||
categories = [c.strip() for c in raw_categories.split(",") if c.strip()]
|
||||
else:
|
||||
categories = [c for c in raw_categories if isinstance(c, str) and c]
|
||||
|
||||
# Nickname goes into custom_fields (no dedicated model field)
|
||||
custom_fields: dict[str, Any] = {}
|
||||
nickname = contact_info.get("nickname")
|
||||
@@ -84,11 +104,17 @@ def _raw_contact_to_model(raw: dict) -> Contact:
|
||||
uid=raw["vcard_id"],
|
||||
fn=contact_info.get("fullname", ""),
|
||||
etag=raw.get("getetag"),
|
||||
organization=contact_info.get("org"),
|
||||
title=contact_info.get("title"),
|
||||
note=contact_info.get("note"),
|
||||
photo=contact_info.get("photo"),
|
||||
birthday=contact_info["birthday"].isoformat()
|
||||
if isinstance(contact_info.get("birthday"), date)
|
||||
else contact_info.get("birthday"),
|
||||
emails=emails,
|
||||
phones=phones,
|
||||
urls=urls,
|
||||
categories=categories,
|
||||
custom_fields=custom_fields,
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user