fix(contacts): persist all documented fields on create (fixes #716)#719
fix(contacts): persist all documented fields on create (fixes #716)#719cbcoutinho wants to merge 8 commits intomasterfrom
Conversation
create_contact previously read only fn/email/tel from contact_data and silently dropped org, organization, note, title, nickname, bday, categories, url — and didn't accept phone as an alias for tel, so the reporter's exact call lost every field except fn and email. Introduce _build_contact_from_data, share it with update_contact's fallback, and normalise str→list inputs so pythonvCard4 doesn't iterate bare strings character-by-character for list-typed properties. Closes #716 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code ReviewGood fix for issue #716 — the root cause analysis is accurate and the Must fix1. Missing type annotation on Per the project's CLAUDE.md convention, all function signatures must be typed. # current
def _wrap_contact_field(value) -> list[dict]:
# should be
def _wrap_contact_field(value: str | dict | list | None) -> list[dict]:2. Comma-in-org-name bug
Comma-splitting makes sense for def _as_list_no_split(value):
return value if isinstance(value, list) else [value]
# Use _as_list (with comma-split) only for categories
# Use _as_list_no_split for org, nickname, url3.
The same gap exists for the Should address4. The docstring says "Normalize an email/tel/url input" but 5. No unit tests for The integration tests added are good, but they go through the full HTTP stack. Pure unit tests would be faster and catch edge cases independently:
Nit
Overall the approach is correct and the tests validate the happy path well. The org-comma bug (#2) and the update-path inconsistency (#3) are the ones most likely to surface in real usage. |
…-dropped-fields-716
|
Thanks for the review — addressing each item in a follow-up commit (not yet pushed). Per-item response1. Missing type annotation on 2. Comma-in-org-name bug — fixed. Split the single inner
Live-verified via MCP: 3.
Live-verified:
4. 5. Missing pure unit tests for
6. Unannotated Test matrix
Pushing the follow-up commit shortly. |
- Type-annotate _wrap_contact_field signature; drop stale "url" mention from its docstring (url is handled by the list-coercion helper, not this one). - Split the shape-coercion helper so comma-splitting only applies to categories: _as_str_list (no split) for org/nickname/url, _split_categories (comma split) for CATEGORIES. Fixes the case where organization="Smith, Jones & Associates" was mangled into a two- component ORG. - Share _normalize_contact_data between create and update so _merge_vcard_properties only sees canonical keys; add URL handlers in both update branches so the primary update path no longer drops URL silently. - Annotate the Contact(**kwargs) type:ignore with the reason (pythonvCard4 typeshed doesn't accept **dict[str, Any]). - Add tests/unit/client/test_contacts.py (pure unit, no HTTP) covering the #716 round-trip, comma-in-org regression, invalid-bday warning, tel/phone precedence, categories string-vs-list behaviour, and direct _normalize_contact_data cases. - Extend the MCP workflow test with an update-with-url step asserting the URL handler in _merge_vcard_properties. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR Review: fix(contacts): persist all documented fields on create (fixes #716)Nice fix for a real silent-data-loss bug. The root cause is clearly identified and the helper decomposition is clean. Some observations below — a mix of required fixes and things to consider. Bug:
|
|
Thanks for the second pass. Responding to the NICKNAME/BDAY/CATEGORIES gap claim specifically — the rest of the items ( The gap claim is inaccurate
Empirically confirmed against the current code: >>> existing = '…\nNICKNAME:Bob\nBDAY:1990-05-01\nCATEGORIES:friends,work\n…'
>>> _merge_vcard_properties(existing, {"nickname": "Robert",
... "bday": "1991-06-02",
... "categories": ["vip", "new"]}, uid="…")
BEGIN:VCARD
...
NICKNAME:Robert ← was Bob
BDAY:1991-06-02 ← was 1990-05-01
CATEGORIES:vip,new ← was friends,work
END:VCARDAnd with no existing NICKNAME/BDAY/CATEGORIES lines, the add-new branch correctly appends them. Pinning this with testsTo turn this into a durable guard rather than a reply, I added a new
All 7 pass against Adjacent real concernsYour general suspicion that create and update can drift is valid — it's just not manifesting as a gap for these three keys today. Two smaller divergences are worth noting for follow-up (not blocking this PR):
Happy to open a follow-up issue for those rather than expand this PR. |
…ge_vcard_properties PR #719 review raised a claim that these three fields fall through to the "keep unchanged" catch-all in _merge_vcard_properties. The existing elif branches for NICKNAME/BDAY/CATEGORIES already prevent that, but the behaviour wasn't pinned by a test. Add a focused TestMergeVcardProperties class that calls the merge helper directly and asserts: - Existing NICKNAME/BDAY/CATEGORIES lines are overwritten by new values. - When the existing vCard has none of these lines, update adds them. - A URL update doesn't clobber unrelated ORG/NOTE/TEL properties. If the primary update path ever regresses for these fields, these tests will catch it immediately. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR Review — fix/contacts-create-dropped-fields-716Good fix overall. The root cause is well-diagnosed, the helper decomposition is clean, and the test suite is thorough. A few observations below, roughly ordered by importance. Bug / CorrectnessDouble normalization in
The simplest fix is to remove the double call from Code Quality
The unknown-key check compares against the normalized data = _normalize_contact_data(contact_data)
# ...
unknown = set(data) - _SUPPORTED_CONTACT_KEYSAfter normalization,
Untyped generics in helper signatures Per the project's CLAUDE.md conventions ( # Current
def _wrap_contact_field(value: str | dict | list | None) -> list[dict]:
def _as_str_list(value: str | list) -> list[str]:
def _split_categories(value: str | list) -> list[str]:
kwargs: dict = ...
# Preferred
def _wrap_contact_field(value: str | dict[str, Any] | list[str | dict[str, Any]] | None) -> list[dict[str, Any]]:
def _as_str_list(value: str | list[str]) -> list[str]:
def _split_categories(value: str | list[str]) -> list[str]:
kwargs: dict[str, Any] = ...Minor Observations (no action required)Multi-URL merge only keeps the first URL in the list In both the existing-property and new-property branches of # Only the first URL is written; multiple-URL contacts are uncommon and
# the vCard-merge parser doesn't attempt to map list positions to existing lines.
url_value = value[0] if isinstance(value, list) and value else value
fn = data.get("fn")
if not fn:
raise ValueError("contact_data must include 'fn' (formatted full name)")…or at minimum a warning log. Test CoverageThe test suite is excellent — unit tests cover alias precedence, char-iteration regression, bday edge cases, empty values, and unknown keys. The One small gap: SummaryThe fix is correct and the regression test is solid. The main actionable items are:
Happy to approve after (1) and (2) are addressed; (3) is cosmetic. |
Pulls the remaining review feedback into one commit: - Remove the double _normalize_contact_data call: the helper now assumes canonical keys, and create_contact normalises before calling it (update_contact already did). Docstring states the invariant. - Drop phone/organization from _SUPPORTED_CONTACT_KEYS; they never reach the unknown-key check post-normalisation. - Tighten generics to dict[str, Any] / list[str] across helpers and ContactsClient signatures. - Comment both URL-merge sites noting only the first URL is written. - Log a warning when fn is missing from contact_data. - Test coverage for _wrap_contact_field dropping value-less dicts and for the fn-missing warning; _vcard helper now mirrors the real call chain (normalise → build). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pulls the remaining review feedback into one commit: - Remove the double _normalize_contact_data call: the helper now assumes canonical keys, and create_contact normalises before calling it (update_contact already did). Docstring states the invariant. - Drop phone/organization from _SUPPORTED_CONTACT_KEYS; they never reach the unknown-key check post-normalisation. - Tighten generics to dict[str, Any] / list[str] across helpers and ContactsClient signatures. - Comment both URL-merge sites noting only the first URL is written. - Log a warning when fn is missing from contact_data. - Test coverage for _wrap_contact_field dropping value-less dicts and for the fn-missing warning; _vcard helper now mirrors the real call chain (normalise → build). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
65c9231 to
59c28f7
Compare
Pulls the remaining review feedback into one commit: - Remove the double _normalize_contact_data call: the helper now assumes canonical keys, and create_contact normalises before calling it (update_contact already did). Docstring states the invariant. - Drop phone/organization from _SUPPORTED_CONTACT_KEYS; they never reach the unknown-key check post-normalisation. - Tighten generics to dict[str, Any] / list[str] across helpers and ContactsClient signatures. - Comment both URL-merge sites noting only the first URL is written. - Log a warning when fn is missing from contact_data. - Test coverage for _wrap_contact_field dropping value-less dicts and for the fn-missing warning; _vcard helper now mirrors the real call chain (normalise → build). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
59c28f7 to
e2283ff
Compare
|
Review: fix(contacts): persist all documented fields on create Good fix for a real user-facing bug. The _build_contact_from_data helper is clean, the alias normalization logic is correct, and the test coverage is thorough. A few things worth addressing: Dead code in _merge_vcard_properties _normalize_contact_data is now called before _merge_vcard_properties in update_contact, so "organization" will never appear in contact_data at that point. Two dead code paths remain: In the update-existing branch: In the add-new branch: These should be simplified to just check "org". No functional impact, but it is a trap for future readers. Silent drop of list/dict email/tel when updating a contact without an existing EMAIL/TEL line _wrap_contact_field correctly handles str or dict or list for create, but the _merge_vcard_properties add-new section only handles str: If an update passes {"email": [{"value": "work@example.com", "type": ["WORK"]}]} to a contact with no existing EMAIL line, the value is silently dropped. This is pre-existing, but now that _wrap_contact_field exists and is used on the create path, applying it here would close the gap. At minimum the limitation should be noted in a comment. Missing @pytest.mark.integration on the new regression test test_create_contact_persists_all_documented_fields in tests/client/contacts/test_contacts_operations.py hits real Nextcloud via nc_client but lacks the mark. Other tests in the same file carry @pytest.mark.integration. Without it the test is excluded from "pytest -m integration" runs but included in the default suite - opposite of intended. BDAY in _merge_vcard_properties does not validate like _build_contact_from_data does _build_contact_from_data guards against invalid ISO strings and logs a warning. The merge path writes the value verbatim. A date object works fine (str(date(...)) is ISO), but an invalid string like "not-a-date" would be written to the vCard without warning. Minor inconsistency worth aligning. Minor observation on TestMergeVcardProperties The test class correctly documents existing behavior for NICKNAME/BDAY/CATEGORIES, but those handlers were already present in _merge_vcard_properties before this PR (the PR only added URL). The tests are useful regression coverage - just worth noting they pin pre-existing behavior, not new behavior added by this PR. Overall The core fix is correct and well-structured. The alias normalization returning a new dict (not mutating), the _as_str_list wrapper preventing pythonvCard4 char-iteration, and the ISO date validation for BDAY are all solid. The dead-code cleanup and missing test mark are the most actionable items before merge. |
Code ReviewGood fix overall. The root cause is correctly identified, the central A few things worth addressing before merging: Bug: list-form email/tel silently drops the existing line in _merge_vcard_propertiesThe PR enables dict/list email inputs via elif property_name == "EMAIL" and "email" in contact_data:
if "email" not in updated_properties:
if isinstance(contact_data["email"], str): # only handles str
updated_lines.append(...)
updated_properties.add("email") # marks done even if nothing appended
else:
updated_lines.append(line)If a caller passes Neither block was touched by this PR, but the PR now documents and accepts these richer forms for create — so an update roundtrip will silently drop the email. Suggested fix: add an Dead code in _merge_vcard_propertiesAfter Update-existing branch (~line 532): elif property_name == "ORG" and (
"org" in contact_data or "organization" in contact_data # "organization" is dead
):
org_value = contact_data.get("org") or contact_data.get("organization") # second get() always NoneAdd-new section (~line 583): elif key in ["org", "organization"]: # "organization" is deadBoth BDAY validation inconsistency between create and update paths
elif key == "bday":
updated_lines.append(f"BDAY:{value}") # no ISO validationA non-ISO BDAY string on update produces a malformed vCard. Worth extracting the parse step into a small helper or into Minor: vCard property injection via newlines (pre-existing, noting because the PR widens the surface)
Nit
Summary: The core fix is correct and the helper factoring is clean. The list-email/tel silent-drop issue in
|
- _merge_vcard_properties no longer silently drops the existing EMAIL / TEL line when contact_data supplies a dict/list shape: the input is unhandled by the text merge, so the original line is preserved instead of being consumed and replaced with nothing. - Extracted _parse_bday so the update path validates ISO format the same way create does. Invalid → keep existing BDAY line (or skip on add-new) rather than writing a malformed one. - Added _safe_vcard_value to escape newlines per RFC 6350 §3.4 at every interpolation site in _merge_vcard_properties, blocking value-driven property injection (e.g. NOTE: containing a literal \n + EMAIL:). - Removed dead "organization" alias references from _merge_vcard_properties: unreachable since update_contact normalises before calling. - New regression tests pin all four behaviours (dict-email preserves existing line, list-tel ditto, invalid-bday-update preserves original, invalid-bday-add-new is dropped, newline-in-note no injection). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code Review: PR #719 — fix(contacts): persist all documented fields on createGood fix for a real user-visible bug. The overall approach is sound: extracting Bug: list-form org mangled in _merge_vcard_properties
The correct vCard serialisation is The Bug: bare-string type in _wrap_contact_field is character-iteratedThe function does: If a caller passes a dict with Undocumented silent-drop: dict/list email or tel on updateIn This is a defensible safety choice, but callers who pass the create-style dict form into Test gap: list-form org through _merge_vcard_properties
OverallThe core fix is correct and well-structured. Alias normalisation is centralised, the helper extraction is clean, and the injection-hardening via |
- _merge_vcard_properties: list-form ORG was passed through
_safe_vcard_value unchanged, emitting a Python repr on the wire. Both
branches now ;-join list components per RFC 6350 §6.6.4 (ORG is
Company;Department;…) before interpolation.
- _wrap_contact_field: a dict whose ``type`` was a bare string used to
hit ``list("WORK")`` and explode into ``["W","O","R","K"]``. Wrap
bare-string types into a single-element list before the list() call.
Regression tests pin both shapes:
- list-org overwrites and add-new produce ``ORG:Acme;Engineering``
- dict email with ``type="WORK"`` (bare str) emits ``EMAIL;TYPE=WORK:``,
not ``EMAIL;TYPE=W,O,R,K:``.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code Review — Fix contacts: persist all documented fields on create (#719)Good overall fix. The root cause is correctly identified and the Issues1. Silent no-op when updating email/tel with dict/list input on a contact that has no existing email/telIn elif key == "email" and isinstance(value, str):
updated_lines.append(f"EMAIL:{_safe_vcard_value(value)}")
elif key == "tel" and isinstance(value, str):
updated_lines.append(f"TEL:{_safe_vcard_value(value)}")Meanwhile # create a contact with no email
await create_contact(uid=uid, contact_data={"fn": "Alice"})
# try to add email via update — dict input, contact has no existing EMAIL line
await update_contact(uid=uid, contact_data={"email": {"value": "alice@work.com", "type": "WORK"}})
# -> EMAIL is never written. No error. No warning.The unit test Suggestion: Either (a) fall back to 2.
|
Surface the text-merge update path's limitation rather than silently no-op: when contact_data['email'] or ['tel'] arrives as a dict/list on update, log a warning at the top of _merge_vcard_properties pointing callers at plain str or create_contact. Existing EMAIL/TEL lines are still preserved unchanged. Bring nc_contacts_update_contact docstring into parity with create — the update tool now documents the same keys plus the explicit single-string limitation for email/tel and the BDAY validation / URL first-only behaviours. Three new TestMergeVcardProperties cases pin the warning: dict email warns, list tel warns, plain str email is silent (no false positives). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Code Review: PR 719 final passThis PR has gone through thorough iterative review and the code has materially improved with each commit. The core fix is correct, the helper decomposition is clean, and the test suite (411 lines of targeted unit tests + integration coverage) is exemplary. Here is where things stand after the last commit. Items addressed: All major issues from previous rounds are resolved: type annotations, comma-in-org-name, BDAY validation parity, vCard injection via Remaining items 1. Warning message is imprecise when the contact has no existing EMAIL/TEL line The warning fires before the line-scan loop and says "existing EMAIL lines are preserved unchanged". If the contact has no EMAIL line, this is technically true but the more actionable fact -- that the dict/list email will not be added either -- is not stated. The test Adding "and no new EMAIL lines are added" to the warning text would close the gap. Minor, does not block merge. 2. RFC 6350 section 3.4 requires escaping backslashes in TEXT values in addition to newlines. The injection risk from an unescaped backslash is low (it is not a line terminator), but for correctness the helper could escape backslash first then escape newlines. Alternatively a docstring note clarifying the intentional scope (injection prevention only; full TEXT escaping deferred to pythonvCard4 on the create path) would be sufficient. 3. Missing
Overall: Solid, well-scoped fix for a real silent data-loss bug. The test pyramid (unit + client integration + MCP workflow smoke) is exactly the right shape. Items 1 and 3 are most actionable before merge; item 2 is a minor RFC compliance/documentation note. Happy to approve once the integration marker situation is confirmed. |
Summary
nc_contacts_createwas silently droppingEMAIL,TEL,ORG,NOTE(andTITLE,NICKNAME,BDAY,CATEGORIES,URL) on contact creation.create_contactinclient/contacts.pyonly readfn/email/telfromcontact_data; every other key was ignored. The reporter's use ofphoneandorganization— valid aliases in intent — weren't even recognized for the two nominally-supported fields, so their entire payload collapsed to justFN+EMAIL._build_contact_from_datahelper that maps all documented keys to pythonvCard4'sContactconstructor. Normalisesstr → listwhere the library would otherwise iterate bare strings character-by-character for list-typed properties (e.g.ORG:A;c;m;egivenorg="Acme"). The same helper is now used byupdate_contact's fallback branch so create/update stay consistent.nc_contacts_create_contacttool docstring to enumerate every supported key.Reproduction (pre-fix)
Given the reporter's call shape:
The server persisted only:
Verification (post-fix)
Same payload, verified end-to-end via stdio MCP:
Test plan
uv run pytest tests/client/contacts/ -v(new regression test inspects raw vCard via_get_raw_vcard)uv run pytest tests/server/test_contacts_mcp.py -v(MCP workflow extended to assertORG:/NOTE:round-trip throughaddressdata)uv run pytest tests/unit/— 537 passeduv run ruff check && uv run ruff format— cleanuv run ty check -- nextcloud_mcp_server— cleanThis PR was generated with the help of AI, and reviewed by a Human