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:
Chris Coutinho
2026-05-21 10:07:47 +02:00
co-authored by Claude Opus 4.7
parent 04a6294cc5
commit 688a03f00a
5 changed files with 179 additions and 3 deletions
+34
View File
@@ -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,
}