Skip to content

Commit c467079

Browse files
committed
Normalize CRLF in vCard escaping and ensure FN is never missing after updates
1 parent fa9eb0f commit c467079

2 files changed

Lines changed: 75 additions & 16 deletions

File tree

src/nc_mcp_server/tools/contacts.py

Lines changed: 39 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,8 @@
4444

4545
def _vcard_escape(text: str) -> str:
4646
"""Escape a string for use as a vCard 3.0 text value (RFC 2426 Section 2.4.2)."""
47-
return text.replace("\\", "\\\\").replace(";", "\\;").replace(",", "\\,").replace("\n", "\\n")
47+
normalized = text.replace("\r\n", "\n").replace("\r", "\n")
48+
return normalized.replace("\\", "\\\\").replace(";", "\\;").replace(",", "\\,").replace("\n", "\\n")
4849

4950

5051
def _normalize_entries(entries: list[dict[str, str]], default_type: str) -> list[dict[str, str]]:
@@ -320,28 +321,50 @@ def _strip_updated_fields(lines: list[str], skip_fields: set[str]) -> list[str]:
320321
return result
321322

322323

324+
def _synthesize_fn(card: Any) -> str:
325+
"""Derive a display name from a parsed vCard's existing N or FN fields."""
326+
old_n = card.get("N")
327+
if old_n and hasattr(old_n, "fields"):
328+
fn = f"{old_n.fields.given} {old_n.fields.family}".strip()
329+
if fn:
330+
return fn
331+
return str(card.get("FN", ""))
332+
333+
334+
def _apply_name_updates(new_lines: list[str], insert_before: int, card: Any, updates: dict[str, Any]) -> bool:
335+
"""Insert updated N and FN lines. Returns True if FN was inserted."""
336+
fn_inserted = False
337+
if updates.get("full_name"):
338+
new_lines.insert(insert_before, f"FN:{_vcard_escape(updates['full_name'])}")
339+
fn_inserted = True
340+
if "given_name" not in updates and "family_name" not in updates:
341+
return fn_inserted
342+
old_n = card.get("N")
343+
has_fields = old_n and hasattr(old_n, "fields")
344+
family = updates.get("family_name", str(old_n.fields.family) if has_fields else "")
345+
given = updates.get("given_name", str(old_n.fields.given) if has_fields else "")
346+
additional = str(old_n.fields.additional) if has_fields and old_n.fields.additional else ""
347+
prefix = str(old_n.fields.prefix) if has_fields and old_n.fields.prefix else ""
348+
suffix = str(old_n.fields.suffix) if has_fields and old_n.fields.suffix else ""
349+
n_parts = ";".join(_vcard_escape(p) for p in [family, given, additional, prefix, suffix])
350+
new_lines.insert(insert_before, f"N:{n_parts}")
351+
if not fn_inserted:
352+
new_lines.insert(insert_before, f"FN:{_vcard_escape(f'{given} {family}'.strip())}")
353+
fn_inserted = True
354+
return fn_inserted
355+
356+
323357
def _apply_contact_updates(vcard_data: str, updates: dict[str, Any]) -> str:
324358
"""Apply partial field updates to existing vCard data, return new vCard string."""
325359
skip_fields = {field for key, field in _UPDATE_FIELD_MAP if key in updates}
326360
if ("given_name" in updates or "family_name" in updates) and "full_name" not in updates:
327361
skip_fields.add("FN")
328362
new_lines = _strip_updated_fields(_unfold_vcard_lines(vcard_data), skip_fields)
329363
insert_before = len(new_lines) - 1
330-
if updates.get("full_name"):
331-
new_lines.insert(insert_before, f"FN:{_vcard_escape(updates['full_name'])}")
332-
if "given_name" in updates or "family_name" in updates:
333-
card = ICal.from_ical(vcard_data)
334-
old_n = card.get("N")
335-
has_fields = old_n and hasattr(old_n, "fields")
336-
family = updates.get("family_name", str(old_n.fields.family) if has_fields else "")
337-
given = updates.get("given_name", str(old_n.fields.given) if has_fields else "")
338-
additional = str(old_n.fields.additional) if has_fields and old_n.fields.additional else ""
339-
prefix = str(old_n.fields.prefix) if has_fields and old_n.fields.prefix else ""
340-
suffix = str(old_n.fields.suffix) if has_fields and old_n.fields.suffix else ""
341-
n_parts = ";".join(_vcard_escape(p) for p in [family, given, additional, prefix, suffix])
342-
new_lines.insert(insert_before, f"N:{n_parts}")
343-
if not updates.get("full_name"):
344-
new_lines.insert(insert_before, f"FN:{_vcard_escape(f'{given} {family}'.strip())}")
364+
card = ICal.from_ical(vcard_data)
365+
fn_inserted = _apply_name_updates(new_lines, insert_before, card, updates)
366+
if "FN" in skip_fields and not fn_inserted:
367+
new_lines.insert(insert_before, f"FN:{_vcard_escape(_synthesize_fn(card))}")
345368
for key, vcard_field in _SIMPLE_UPDATE_FIELDS:
346369
if updates.get(key):
347370
new_lines.insert(insert_before, f"{vcard_field}:{_vcard_escape(updates[key])}")

tests/integration/test_contacts.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,18 @@ async def test_create_special_chars_roundtrip(self, nc_mcp: McpTestHelper) -> No
155155
assert fetched.get("organization") == "R&D <Team>"
156156
assert fetched.get("title") == 'VP "Sales"'
157157

158+
@pytest.mark.asyncio
159+
async def test_create_note_with_crlf(self, nc_mcp: McpTestHelper) -> None:
160+
"""Notes with Windows line endings must roundtrip without bare \\r corruption."""
161+
contact = await _create(nc_mcp, "crlf-note", note="Line1\r\nLine2\rLine3\nLine4")
162+
fetched = json.loads(await nc_mcp.call("get_contact", uid=contact["uid"], book_id=BOOK_ID))
163+
note = fetched.get("note", "")
164+
assert "Line1" in note
165+
assert "Line2" in note
166+
assert "Line3" in note
167+
assert "Line4" in note
168+
assert "\r" not in note
169+
158170
@pytest.mark.asyncio
159171
async def test_create_no_name_raises(self, nc_mcp: McpTestHelper) -> None:
160172
with pytest.raises((ToolError, ValueError)):
@@ -352,6 +364,30 @@ async def test_update_folded_note(self, nc_mcp: McpTestHelper) -> None:
352364
assert updated.get("note") == "Short note"
353365
assert updated.get("organization") == "Keep Corp"
354366

367+
@pytest.mark.asyncio
368+
async def test_update_clear_full_name_keeps_fn_from_n(self, nc_mcp: McpTestHelper) -> None:
369+
"""Clearing full_name must not produce a vCard without FN — synthesize from N."""
370+
uid = await _put_vcard_with_name(nc_mcp, "clr-fn", given="John", family="Doe")
371+
contact = json.loads(await nc_mcp.call("get_contact", uid=uid, book_id=BOOK_ID))
372+
updated = json.loads(
373+
await nc_mcp.call("update_contact", uid=uid, etag=contact["etag"], full_name="", book_id=BOOK_ID)
374+
)
375+
assert updated["full_name"], "FN must not be empty after clearing full_name"
376+
assert "John" in updated["full_name"]
377+
assert "Doe" in updated["full_name"]
378+
379+
@pytest.mark.asyncio
380+
async def test_update_clear_full_name_without_n(self, nc_mcp: McpTestHelper) -> None:
381+
"""Clearing full_name on a contact with only FN (no structured N) keeps old FN."""
382+
created = await _create(nc_mcp, "clr-fn-only")
383+
contact = json.loads(await nc_mcp.call("get_contact", uid=created["uid"], book_id=BOOK_ID))
384+
original_fn = contact["full_name"]
385+
updated = json.loads(
386+
await nc_mcp.call("update_contact", uid=created["uid"], etag=contact["etag"], full_name="", book_id=BOOK_ID)
387+
)
388+
assert updated["full_name"], "FN must not be empty"
389+
assert PREFIX in updated["full_name"] or updated["full_name"] == original_fn
390+
355391

356392
async def _put_vcard_with_categories(nc_mcp: McpTestHelper, suffix: str, categories: list[str]) -> str:
357393
"""Create a test contact with CATEGORIES via direct CardDAV PUT. Returns UID."""

0 commit comments

Comments
 (0)