From c2a73cdc2d102a6b4fa9377d7761640301a4dd93 Mon Sep 17 00:00:00 2001 From: TowyTowy Date: Wed, 15 Jul 2026 20:28:25 +0200 Subject: [PATCH] Fix: bday conversion clobbers NICKNAME when FN is empty The share-by-map BDAY-to-ICS conversion builds a placeholder mapping for the SUMMARY/DESCRIPTION/alarm templates. Each block sets the fallback marker for its own placeholder (e.g. {nickname} -> '!nickname!', {n:f} -> '!n:f!'), but the {fn} block wrote the empty-FN fallback to {nickname} instead of {fn}. As a result, for a VCARD whose FN is present but empty, a genuine NICKNAME was overwritten with '!fn!' in the generated event, and the {fn} placeholder never received its '!fn!' marker, so a '[{fn}|...]' fallback in a template failed to resolve. Assign the fallback to {fn}, matching the surrounding blocks. Co-Authored-By: Claude --- CHANGELOG.md | 1 + radicale/item/__init__.py | 2 +- radicale/tests/test_sharing.py | 39 ++++++++++++++++++++++++++++++++-- 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 036c34bc..f5c45514 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * Fix: time-range filter treated a VEVENT with a whole-day DURATION (e.g. P1D, P2D) as zero-length (timedelta.seconds instead of total_seconds), so such events were missing from calendar-query REPORT results * Fix: calendar-data expand (REPORT) left recurrence properties (e.g. RDATE) on the expanded single-occurrence VEVENTs; a single try/except around the sequential delattr() calls stopped at the first absent property (e.g. missing EXDATE), so later ones were never removed * Fix: text-match filter on a structured property (e.g. vCard N or ADR) crashed with HTTP 500 (AttributeError: 'Name'/'Address' object has no attribute 'lower') because vobject parses these into non-string objects; their text representation is now used +* Fix: sharing bday-to-ICS conversion assigned the empty-FN fallback marker to the {nickname} placeholder instead of {fn}, so a VCARD with an empty FN got its NICKNAME overwritten with "!fn!" in the generated SUMMARY/DESCRIPTION and the {fn} fallback never resolved ## 3.7.6 * Extension: item verification on commandline diff --git a/radicale/item/__init__.py b/radicale/item/__init__.py index 7a33ce03..11076d10 100644 --- a/radicale/item/__init__.py +++ b/radicale/item/__init__.py @@ -717,7 +717,7 @@ class Item: if hasattr(self.vobject_item, "fn") and self.vobject_item.fn.value != "": placeholder_mapping['{fn}'] = self.vobject_item.fn.value else: - placeholder_mapping['{nickname}'] = '!fn!' + placeholder_mapping['{fn}'] = '!fn!' # rfc6350#6.2 FamilyName;GivenName;AdditionalNames;HonorificPrefixes;HonorificSuffixes if hasattr(self.vobject_item, "n") and self.vobject_item.n.value.family != "": diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 78601aa4..1c908430 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -28,11 +28,13 @@ import re import sys import tempfile import urllib -from typing import Dict, Sequence, Tuple, Union +from typing import Dict, Sequence, Tuple, Union, cast import pytest +import vobject -from radicale import pathutils, sharing, xmlutils +from radicale import pathutils, sharing, storage, xmlutils +from radicale.item import Item from radicale.tests import BaseTest from radicale.tests.helpers import get_file_content @@ -5328,6 +5330,39 @@ permissions: RrWw""") assert "Test-FN-C3 (1990/5)" in answer assert "Test-FN-C3 (1990/6)" in answer + def test_sharing_bday_conversion_empty_fn(self) -> None: + """BDAY-to-ICS conversion of a VCARD with an empty FN property. + + vCard mandates FN, but some clients emit it empty. The fallback for + an empty ``{fn}`` must be assigned to the ``{fn}`` placeholder, not to + ``{nickname}``: otherwise a present NICKNAME gets clobbered with the + ``!fn!`` marker and the ``{fn}`` fallback never triggers. + """ + vcard = vobject.readOne( + "BEGIN:VCARD\r\n" + "VERSION:3.0\r\n" + "UID:contact-empty-fn\r\n" + "N:FamilyTest;GivenTest;;;\r\n" + "FN:\r\n" + "NICKNAME:Test-NICKNAME\r\n" + "BDAY:1990-05-06\r\n" + "END:VCARD\r\n") + + class _StubCollection: + path = "test" + + item = Item(collection=cast(storage.BaseCollection, _StubCollection()), + vobject_item=vcard, href="contact-empty-fn.vcf") + converted = item.convert_vcf_to_ics(ShareActions={"config": { + "conversion_bday_summary_template": "{nickname} (BDAY)", + "conversion_bday_description_template": "[{fn}|no-fn]"}}) + assert converted is not None + serialized = converted.serialize() + # NICKNAME must survive, not be overwritten by the '!fn!' marker + assert "SUMMARY:Test-NICKNAME (BDAY)" in serialized + # empty {fn} must expose the '!fn!' marker so the fallback resolves + assert "DESCRIPTION:no-fn" in serialized + def test_sharing_api_map_vcf_bday_age_template(self) -> None: """share-by-map with conversion=bday template tests with age.""" self.configure({"auth": {"type": "htpasswd",