From c2560b23edd9b6a9a23e0b4d1580a46a30f1d921 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Thu, 28 May 2026 22:03:52 +0200 Subject: [PATCH 01/49] sharing/bday/template: doc --- DOCUMENTATION.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index da78ebb9..84cc8766 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -2287,6 +2287,8 @@ Default: `true` ##### default_permissions_create_token +_(>= 3.7.0)_ + Default permissions for create token-based sharing Default: `r` @@ -2295,12 +2297,30 @@ Supported: `rwEePp` ##### default_permissions_create_map +_(>= 3.7.0)_ + Default permissions for map-based sharing Default: `r` Supported: `rwEePp` +##### conversion_bday_summary_template + +_(>= 3.7.5)_ + +Global template for summary of conversion "bday" + +Default: `{{n:f} {n:g} {n:a}|{fn}|{nickname}} (BDAY)` + +##### #conversion_bday_description_template + +_(>= 3.7.5)_ + +Global template for description of conversion "bday" + +Default: `BDAY={year}-{month}-{day}` + ## Supported Clients Radicale has been tested with: From 63ef2f71939bd3eac1dcbf4a9493408ac8850566 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Thu, 28 May 2026 22:04:11 +0200 Subject: [PATCH 02/49] sharing/bday/template: reference config --- config | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/config b/config index 0efb2ab0..eca80d4d 100644 --- a/config +++ b/config @@ -356,6 +356,11 @@ # Supported: rwEePp #default_permissions_create_map = r +# Global template for summary of conversion "bday" +#conversion_bday_summary_template = "{{n:f} {n:g} {n:a}|{fn}|{nickname}} (BDAY)" + +# Global template for description of conversion "bday" +#conversion_bday_description_template = "BDAY={year}-{month}-{day}" [web] From 8329cfa660af6549302101ee418248e8b9f4e1fb Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Thu, 28 May 2026 22:04:31 +0200 Subject: [PATCH 03/49] sharing/bday/template: config parser --- radicale/config.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/radicale/config.py b/radicale/config.py index 394dac97..4442b323 100644 --- a/radicale/config.py +++ b/radicale/config.py @@ -593,7 +593,16 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([ ("default_permissions_create_map", { "value": "r", "help": "default permissions for map-based sharing", - "type": rights_permission})])), + "type": rights_permission}), + ("conversion_bday_summary_template", { + "value": "{{n:f} {n:g} {n:a}|{fn}|{nickname}} (BDAY)", + "help": "conversion bday summary template", + "type": str}), + ("conversion_bday_description_template", { + "value": "BDAY={year}-{month}-{day}", + "help": "conversion bday description template", + "type": str}), + ])), ("hook", OrderedDict([ ("type", { "value": "none", From aae887ee5c0f5e3965874283ca2dbac1e651ad9b Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Thu, 28 May 2026 22:06:31 +0200 Subject: [PATCH 04/49] sharing/bday/template: get config --- radicale/sharing/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index fb162ffc..3a21c53c 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -166,6 +166,8 @@ class BaseSharing: self.default_permissions_create_map = configuration.get("sharing", "default_permissions_create_map") self.permit_properties_overlay = configuration.get("sharing", "permit_properties_overlay") self.enforce_properties_overlay = configuration.get("sharing", "enforce_properties_overlay") + self.conversion_bday_summary_template = configuration.get("sharing", "conversion_bday_summary_template") + self.conversion_bday_description_template = configuration.get("sharing", "conversion_bday_description_template") logger.info("sharing.collection_by_map : %s", self.sharing_collection_by_map) logger.info("sharing.collection_by_token: %s", self.sharing_collection_by_token) @@ -175,6 +177,8 @@ class BaseSharing: logger.info("sharing.default_permissions_create_map : %r", self.default_permissions_create_map) logger.info("sharing.permit_properties_overlay: %s", self.permit_properties_overlay) logger.info("sharing.enforce_properties_overlay: %s", self.enforce_properties_overlay) + logger.info("sharing.conversion_bday_summary_template: %s", self.conversion_bday_summary_template) + logger.info("sharing.conversion_bday_description_template: %s", self.conversion_bday_description_template) # database tasks self.sharing_db_type = configuration.get("sharing", "type") From d4c545f6e4930046a0b43aeee3039c212c3a9bb5 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Thu, 28 May 2026 22:07:26 +0200 Subject: [PATCH 05/49] sharing/bday/template: store in "Actions" --- radicale/sharing/__init__.py | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 3a21c53c..db190a11 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -407,6 +407,42 @@ class BaseSharing: else: logger.trace("sharing/map: not active") + if share is not None: + if share['Conversion'] == "bday": + # autogenerate Actions if not existing + if share['Actions'] is None: + share['Actions'] = { + 'template': { + 'conversion_bday_summary_template': self.conversion_bday_summary_template, + 'conversion_bday_description_template': self.conversion_bday_description_template, + } + } + elif 'template' in share['Actions']: + if 'conversion_bday_summary_template' in share['Actions']['template']: + # nothing to do + pass + else: + share['Actions']['template'].update( + {'conversion_bday_summary_template': self.conversion_bday_summary_template} + ) + if 'conversion_bday_description_template' in share['Actions']['template']: + # nothing to do + pass + else: + share['Actions']['template'].update( + {'conversion_bday_description_template': self.conversion_bday_summary_template} + ) + else: + share['Actions'].update( + {'template': { + 'conversion_bday_summary_template': self.conversion_bday_summary_template, + 'conversion_bday_description_template': self.conversion_bday_description_template, + } + } + ) + + logger.info("sharing/%s: resolved path %r->%r, user %r->%r, Permissions=%r Conversion=%r Actions=%r", share['ShareType'], share['PathOrToken'], share['PathMapped'], user, share['Owner'], share['Permissions'], share['Conversion'], share['Actions']) + return share # adjust a share From 66e97cbc5b2a445bcb0b4afbab18a0794b6b54b8 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Thu, 28 May 2026 22:08:10 +0200 Subject: [PATCH 06/49] sharing/bday/template: implementation of placeholder replacement --- radicale/app/get.py | 4 +- radicale/item/__init__.py | 82 ++++++++++++++++++++++++++++++++++-- radicale/storage/__init__.py | 4 +- 3 files changed, 82 insertions(+), 8 deletions(-) diff --git a/radicale/app/get.py b/radicale/app/get.py index 21bd0349..542ff4d2 100644 --- a/radicale/app/get.py +++ b/radicale/app/get.py @@ -136,9 +136,9 @@ class ApplicationPartGet(ApplicationBase): if share and share['Conversion'] == "bday": if isinstance(item, storage.BaseCollection): # convert VCF to ICS - answer = item.serialize(vcf_to_ics=True) + answer = item.serialize(vcf_to_ics=True, ShareActions=share['Actions']) else: - item_converted = item.convert_vcf_to_ics() + item_converted = item.convert_vcf_to_ics(ShareActions=share['Actions']) if item_converted is not None: answer = item_converted.serialize() else: diff --git a/radicale/item/__init__.py b/radicale/item/__init__.py index a3fea54f..c68d7df7 100644 --- a/radicale/item/__init__.py +++ b/radicale/item/__init__.py @@ -504,7 +504,37 @@ class Item: self.component_name self._vobject_item = orig_vobject_item - def convert_vcf_to_ics(self) -> Union["Item", None]: + def replace_placeholders(self, text: str, placeholder_mapping: dict) -> str: + for placeholder in placeholder_mapping: + text = text.replace(placeholder, placeholder_mapping[placeholder]) + + # resolve {..|..} recursive + pattern = re.compile('(.*)({)([^|]+)\\|(.+)(})(.*)') + logger.trace("item/convert_vcf_to_ics: resolve {..|..} starting with: %r", text) + while True: + match = pattern.match(text) + if not match: + # nothing more todo + break + else: + if match[3].startswith('!') and match[3].endswith('!'): + # not resolved variable + if '|' in match[4]: + # further recursion required + text = match[1] + match[2] + match[4] + match[5] + match[6] + logger.trace("item/convert_vcf_to_ics: resolve {..|..} match/replace/continue result: %r", text) + else: + text = match[1] + match[4] + match[6] + logger.trace("item/convert_vcf_to_ics: resolve {..|..} match/replace/final result: %r", text) + break + else: + # resolved variable + text = match[1] + match[3] + match[6] + logger.trace("item/convert_vcf_to_ics: resolve {..|..} match/replace(resolved) result: %r", text) + return text + + def convert_vcf_to_ics(self, ShareActions: dict = {}) -> Union["Item", None]: + logger.trace("item/convert_vcf_to_ics: ShareActions: %r", ShareActions) logger.trace("item/convert_vcf_to_ics: convert VCF to ICS (href): %r", self.href) logger.trace("item/convert_vcf_to_ics: convert VCF to ICS (vobject): %r", self.vobject_item) if self.vobject_item.name != "VCARD": @@ -529,23 +559,55 @@ class Item: else: pass + placeholder_mapping: dict = {} + bdayS = match[1] + match[2] + match[3] bdaySdesc = match[1] + "-" + match[2] + "-" + match[3] bdayY = int(match[1]) bdayM = int(match[2]) bdayD = int(match[3]) + placeholder_mapping['{year}'] = match[1] + placeholder_mapping['{month}'] = match[2] + placeholder_mapping['{day}'] = match[3] + # create ICS if hasattr(self.vobject_item, "fn"): name = self.vobject_item.fn.value elif hasattr(self.vobject_item, "n"): - name = self.vobject_item.n.value + name = self.vobject_item.n.value.family + " " + self.vobject_item.n.value.given elif hasattr(self.vobject_item, "nickname"): name = self.vobject_item.nickname.value else: logger.trace("item/convert_vcf_to_ics: has bday but neither FN or N or NICKNAME (skip): %r", self.href) return None + if hasattr(self.vobject_item, "nickname") and self.vobject_item.nickname.value != "": + placeholder_mapping['{nickname}'] = self.vobject_item.nickname.value + else: + placeholder_mapping['{nickname}'] = '!nickname!' + + 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!' + + # rfc6350#6.2 FamilyName;GivenName;AdditionalNames;HonorificPrefixes;HonorificSuffixes + if hasattr(self.vobject_item, "n") and self.vobject_item.n.value.family != "": + placeholder_mapping['{n:f}'] = self.vobject_item.n.value.family + else: + placeholder_mapping['{n:f}'] = '!n:f!' + + if hasattr(self.vobject_item, "n") and self.vobject_item.n.value.given != "": + placeholder_mapping['{n:g}'] = self.vobject_item.n.value.given + else: + placeholder_mapping['{n:g}'] = '!n:g!' + + if hasattr(self.vobject_item, "n") and self.vobject_item.n.value.additional != "": + placeholder_mapping['{n:a}'] = self.vobject_item.n.value.additional + else: + placeholder_mapping['{n:a}'] = '!n:a!' + # create VCALENDAR item_ics = vobject.newFromBehavior('vcalendar') @@ -579,7 +641,14 @@ class Item: item_ics.vevent.add('uid').value = match[1] + match[2] + match[3] + "@" + name.replace(" ", "-") + UID_SUFFIX # set SUMMARY - item_ics.vevent.add('summary').value = name + " (BDAY)" + summary = name + " (BDAY)" # default + if ShareActions is not None and 'template' in ShareActions: + if 'conversion_bday_summary_template' in ShareActions['template']: + summary = ShareActions['template']['conversion_bday_summary_template'] + summary = self.replace_placeholders(summary, placeholder_mapping) + else: + summary = name + " (BDAY)" + item_ics.vevent.add('summary').value = summary # set RRULE item_ics.vevent.add('rrule').value = "FREQ=YEARLY" @@ -588,7 +657,12 @@ class Item: item_ics.vevent.add('transp').value = "TRANSPARENT" # add description - item_ics.vevent.add('description').value = "BDAY=" + bdaySdesc + description = "BDAY=" + bdaySdesc # default + if ShareActions is not None and 'template' in ShareActions: + if 'conversion_bday_description_template' in ShareActions['template']: + description = ShareActions['template']['conversion_bday_description_template'] + description = self.replace_placeholders(description, placeholder_mapping) + item_ics.vevent.add('description').value = description href = self.href if href is not None: diff --git a/radicale/storage/__init__.py b/radicale/storage/__init__.py index b6182a03..219732ff 100644 --- a/radicale/storage/__init__.py +++ b/radicale/storage/__init__.py @@ -223,7 +223,7 @@ class BaseCollection: """Get the HTTP-datetime of when the collection was modified.""" raise NotImplementedError - def serialize(self, vcf_to_ics: bool = False) -> str: + def serialize(self, vcf_to_ics: bool = False, ShareActions: dict = {}) -> str: """Get the unicode string representing the whole collection.""" if self.tag == "VCALENDAR": in_vcalendar = False @@ -288,7 +288,7 @@ class BaseCollection: items = [] for item in self.get_all(): logger.trace("storage/convert VCF to ICS: %r:", item) - item_ics = item.convert_vcf_to_ics() + item_ics = item.convert_vcf_to_ics(ShareActions=ShareActions) if item_ics is None: continue else: From 1ea3933faf41ca78bb853dc1bba996ed5410977e Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Thu, 28 May 2026 22:08:41 +0200 Subject: [PATCH 07/49] sharing/bday/template: test case extension/fixes --- radicale/tests/static/contact2-with-bday.vcf | 2 +- radicale/tests/static/contact3-with-bday.vcf | 3 +- radicale/tests/test_sharing.py | 129 ++++++++++++++++++- 3 files changed, 129 insertions(+), 5 deletions(-) diff --git a/radicale/tests/static/contact2-with-bday.vcf b/radicale/tests/static/contact2-with-bday.vcf index daff0d92..4f24f403 100644 --- a/radicale/tests/static/contact2-with-bday.vcf +++ b/radicale/tests/static/contact2-with-bday.vcf @@ -2,7 +2,7 @@ BEGIN:VCARD VERSION:3.0 PRODID:+//IDN bitfire.at//DAVx5/3.3.5-ose ez-vcard/0.11.0 UID:contact2-with-bday -N:Test;N;;; +N:FamilyTest;GivenTest;AdditionalsTest;; FN:Test-FN NICKNAME:Test-NICKNAME BDAY:1970-01-01 diff --git a/radicale/tests/static/contact3-with-bday.vcf b/radicale/tests/static/contact3-with-bday.vcf index 9a40865c..a0ee2f2a 100644 --- a/radicale/tests/static/contact3-with-bday.vcf +++ b/radicale/tests/static/contact3-with-bday.vcf @@ -2,8 +2,7 @@ BEGIN:VCARD VERSION:3.0 PRODID:+//Manual/0.0.1 UID:05cf4901-e581-44ee-bb0a-1e1a875da44a -N:Test;N;C3;; +N:Family3Test;Given3Test;;; FN:Test-FN-C3 -NICKNAME:Test-NICKNAME-C3 BDAY:1990-01-01 END:VCARD diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 19c052e1..67b7540a 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -4931,6 +4931,130 @@ permissions: RrWw""") status, prop = response["D:getcontenttype"] assert "text/calendar" in str(prop.text) + def test_sharing_api_map_vcf_bday_template(self) -> None: + """share-by-map with conversion=bday format tests.""" + self.configure({"auth": {"type": "htpasswd", + "htpasswd_filename": self.htpasswd_file_path, + "htpasswd_encryption": "plain"}, + "sharing": { + "type": "csv", + "permit_create_map": True, + "permit_properties_overlay": "True", + "enforce_properties_overlay": "True", + "collection_by_map": "True"}, + "logging": {"request_header_on_debug": "False", + "response_content_on_debug": "True", + "response_header_on_debug": "True", + "request_content_on_debug": "True"}, + "rights": {"type": "owner_only"}}) + + json_dict: dict + + logging.info("\n*** prepare and test access") + + for db_type in list(filter(lambda item: item != "none", sharing.INTERNAL_TYPES)): + logging.info("\n*** test: %s", db_type) + self.configure({"sharing": {"type": db_type}}) + + path_mapped = "/owner/adressbook-" + db_type + ".vcf/" + path_shared_r = "/user/calendar-bday-abook-shared-by-owner-r-" + db_type + ".ics/" + self.create_addressbook(path_mapped, login="owner:ownerpw") + + contact2 = get_file_content("contact2-with-bday.vcf") + path2 = path_mapped + "/contact2-with-bday.vcf" + path_shared_2 = path_shared_r + "/contact2-with-bday.vcf" + self.put(path2, contact2, login="owner:ownerpw") + + contact3 = get_file_content("contact3-with-bday.vcf") + path3 = path_mapped + "/contact3-with-bday.vcf" + path_shared_3 = path_shared_r + "/contact3-with-bday.vcf" + self.put(path3, contact3, login="owner:ownerpw") + + # create map + logging.info("\n*** create map(bday) user/owner:r -> ok") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_r + json_dict['Conversion'] = "bday" + json_dict['Permissions'] = "rP" + json_dict['Enabled'] = True + json_dict['Enabled'] = True + json_dict['Hidden'] = False + json_dict['Properties'] = {"D:displayname": "Test-BDAY"} + _, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + + # enable map by user + logging.info("\n*** enable map(bday) by user") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_r + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) + + self.configure({"sharing": { + "conversion_bday_summary_template": "{{fn}|{n:f} {n:g} {n:a}|{nickname}} (BDAY)", + "conversion_bday_description_template": "BDAY={year}-{month}-{day}", + }}) + + # verify content as user + logging.info("\n*** GET collection user format:default -> ok") + _, headers, answer = self.request("GET", path_shared_2, login="user:userpw") + assert "SUMMARY:Test-FN (BDAY)" in answer + + self.configure({"sharing": {"conversion_bday_summary_template": "{{fn}|{n:f} {n:g} {n:a}|{nickname}} (Birthday)"}}) + logging.info("\n*** GET collection user format:text -> ok") + _, headers, answer = self.request("GET", path_shared_2, login="user:userpw") + assert "SUMMARY:Test-FN (Birthday)" in answer + + self.configure({"sharing": {"conversion_bday_summary_template": "{{n:f} {n:g} {n:a}|{fn}|{nickname}} (Birthday)"}}) + logging.info("\n*** GET collection user format:n -> ok") + _, headers, answer = self.request("GET", path_shared_2, login="user:userpw") + assert "SUMMARY:FamilyTest GivenTest AdditionalsTest (Birthday)" in answer + + self.configure({"sharing": {"conversion_bday_summary_template": "{{nickname}|{n:f} {n:g} {n:a}|{fn}} (Birthday)"}}) + logging.info("\n*** GET collection user format:nickname -> ok") + _, headers, answer = self.request("GET", path_shared_2, login="user:userpw") + assert "SUMMARY:Test-NICKNAME (Birthday)" in answer + + self.configure({"sharing": {"conversion_bday_summary_template": "{nickname} (Birthday)"}}) + logging.info("\n*** GET collection user format:nickname not resolvable -> ok") + _, headers, answer = self.request("GET", path_shared_3, login="user:userpw") + assert "SUMMARY:!nickname! (Birthday)" in answer + + self.configure({"sharing": {"conversion_bday_summary_template": "{{nickname}|{nickname}|{fn}} (Birthday)"}}) + logging.info("\n*** GET collection user format:nickname with fn fallback -> ok") + _, headers, answer = self.request("GET", path_shared_3, login="user:userpw") + assert "SUMMARY:Test-FN-C3 (Birthday)" in answer + + self.configure({"sharing": {"conversion_bday_summary_template": "{{nickname}|{nickname}|{n:g} {n:f}} (Birthday)"}}) + logging.info("\n*** GET collection user format:nickname with n fallback -> ok") + _, headers, answer = self.request("GET", path_shared_3, login="user:userpw") + assert "SUMMARY:Given3Test Family3Test (Birthday)" in answer + + self.configure({"sharing": {"conversion_bday_summary_template": "{{nickname}|{nickname}|{n:f}, {n:g}} (Birthday)"}}) + logging.info("\n*** GET collection user format:nickname with n fallback -> ok") + _, headers, answer = self.request("GET", path_shared_3, login="user:userpw") + assert "SUMMARY:Family3Test\\, Given3Test (Birthday)" in answer + + self.configure({"sharing": {"conversion_bday_summary_template": "{{nickname}|{nickname}|{n:f} {n:g} {n:a}} (Birthday)"}}) + logging.info("\n*** GET collection user format:nickname with n fallback -> ok") + _, headers, answer = self.request("GET", path_shared_3, login="user:userpw") + assert "SUMMARY:Family3Test Given3Test !n:a! (Birthday)" in answer + assert "DESCRIPTION:BDAY=1990-01-01" in answer + + self.configure({"sharing": {"conversion_bday_description_template": "Birthday={year}{month}{day}"}}) + logging.info("\n*** GET collection user format: description -> ok") + _, headers, answer = self.request("GET", path_shared_3, login="user:userpw") + assert "DESCRIPTION:Birthday=19900101" in answer + + self.configure({"sharing": {"conversion_bday_description_template": "year={year} month={month} day={day}\nfn='{fn}'\nn:g='{n:g}'\nn:f='{n:f}'\nn:a='{n:a}'"}}) + logging.info("\n*** GET collection user format: description -> ok") + _, headers, answer = self.request("GET", path_shared_3, login="user:userpw") + assert "DESCRIPTION:year=1990 month=01 day=01" in answer + def test_sharing_api_map_vcf_bday_complex(self) -> None: """share-by-map with conversion=bday complex tests.""" self.configure({"auth": {"type": "htpasswd", @@ -5161,7 +5285,7 @@ permissions: RrWw""") _, answer = self.get(path_mapped, login="owner:ownerpw") assert "contact1" in answer assert "contact2" in answer - assert "NICKNAME-C3" in answer + assert "Family3Test" in answer # create map logging.info("\n*** create bday owner to itself -> ok") @@ -5277,6 +5401,7 @@ permissions: RrWw""") "htpasswd_encryption": "plain"}, "sharing": { "type": "csv", + "conversion_bday_summary_template": "{fn} (BDAY)", "permit_create_token": True, "permit_properties_overlay": "True", "enforce_properties_overlay": "True", @@ -5328,7 +5453,7 @@ permissions: RrWw""") _, answer = self.get(path_mapped, login="owner:ownerpw") assert "contact1" in answer assert "contact2" in answer - assert "NICKNAME-C3" in answer + assert "Family3Test" in answer # create map logging.info("\n*** create token with bday conversion (default permissions) -> ok") From d13061560a3e267f6a688ddfbc6a14f2a58a261e Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Thu, 28 May 2026 22:17:20 +0200 Subject: [PATCH 08/49] sharing/bday/template: extend doc --- DOCUMENTATION.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 84cc8766..5d9a2565 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -2311,7 +2311,19 @@ _(>= 3.7.5)_ Global template for summary of conversion "bday" -Default: `{{n:f} {n:g} {n:a}|{fn}|{nickname}} (BDAY)` +Default: `"{{n:f} {n:g} {n:a}|{fn}|{nickname}} (BDAY)"` + +Supported placeholders (data used from VCARD) + * `{year}`: year of birthday (RFC6350#6.2.5) + * `{month}`: month of birthday (RFC6350#6.2.5) + * `{day}`: day of birthday (RFC6350#6.2.5) + * `{fn}`: full name (RFC6350#6.2.1) + * `{n:f}`: family name (RFC6350#6.2.2) + * `{n:g}`: given name (RFC6350#6.2.2) + * `{n:a}`: additional name (RFC6350#6.2.2) + * `{nickname}`: nick name (RFC6350#6.2.3) + +Fallback is supported if placeholders inside `{...|...}` (first successful resolved one is used) ##### #conversion_bday_description_template @@ -2319,7 +2331,9 @@ _(>= 3.7.5)_ Global template for description of conversion "bday" -Default: `BDAY={year}-{month}-{day}` +Default: `"BDAY={year}-{month}-{day}"` + +Supported placeholders see `conversion_bday_summary_template` ## Supported Clients From d63c718ce1cf4b037d27a8947f4f4604655a4192 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Thu, 28 May 2026 22:51:39 +0200 Subject: [PATCH 09/49] sharing/actions: enable --- SHARING.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/SHARING.md b/SHARING.md index 29ce35da..93d29204 100644 --- a/SHARING.md +++ b/SHARING.md @@ -43,7 +43,7 @@ Types of supported sharing configuration: * `TimestampCreated`: unixtime of creation * `TimestampUpdated`: unixtime of last update * `Properties`: overlay properties (limited set whitelisted) - * `Actions`: (reserved for future usage) + * `Actions`: specific configuration `Enabled*`: _owner_ AND _user_ have to enable a share to become usable @@ -365,6 +365,7 @@ Create a share by mapping a collection of an `Owner` to an `User`. | Enabled | bool | optional (owner/default:False) | | Hidden | bool | optional (owner/default:True) | | Properties | optional | +| Actions | optional | * Output: text/plain|application/json @@ -519,6 +520,7 @@ Execute delete+create in case `PathOrToken` needs to be changed. | Enabled | bool | adjust | optional(owner) | optional(user) | | Hidden | bool | adjust | optional(owner) | optional(user) | | Properties | str | adjust | optional | optional | +| Actions | str | adjust | optional | not-permitted | * Output: text/plain|application/json From 583815c6d68c5ec887cb062a989c7a9c4f910b1a Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Thu, 28 May 2026 22:52:06 +0200 Subject: [PATCH 10/49] sharing/actions: implementation --- radicale/sharing/__init__.py | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index db190a11..7f2f4ce0 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -123,6 +123,13 @@ TOKEN_PATTERN_V1: str = "v1/[a-zA-Z0-9_\\-]{44}" OVERLAY_PROPERTIES_WHITELIST: Sequence[str] = ("C:calendar-description", "ICAL:calendar-color", "CR:addressbook-description", "INF:addressbook-color", "D:displayname", "ICAL:calendar-order") +ACTIONS_WHITELIST: dict = { + "template": { + 'conversion_bday_summary_template': str, + 'conversion_bday_description_template': str, + } + } + CONVERSIONS_WHITELIST: Sequence[str] = ("bday", "none") @@ -829,7 +836,21 @@ class BaseSharing: return httputils.bad_request("Conversion not supported: %r" % Conversion) if 'Actions' in request_data: - return httputils.bad_request("Actions currently not supported (reserved for future needs)") + valid = True # default + for level1 in request_data['Actions']: + if level1 in ACTIONS_WHITELIST: + for level2 in request_data['Actions'][level1]: + if level2 in ACTIONS_WHITELIST[level1]: + pass + else: + valid = False + break + else: + valid = False + break + if not valid: + return httputils.bad_request("Actions format not valid") + Actions = request_data['Actions'] if 'Enabled' in request_data: Enabled = request_data['Enabled'] @@ -1152,7 +1173,8 @@ class BaseSharing: OwnerOrUser=user, User=User, Timestamp=Timestamp, - Properties=Properties) + Properties=Properties, + Actions=Actions) else: result = self.database_update_sharing( ShareType=ShareType, @@ -1164,7 +1186,8 @@ class BaseSharing: OwnerOrUser=user, User=User, Timestamp=Timestamp, - Properties=Properties) + Properties=Properties, + Actions=Actions) elif user == share['User']: # User is only allowed to update Properties From 1ef02e9fe37b31907ca41c66f6b4f6c73304332a Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Thu, 28 May 2026 22:52:20 +0200 Subject: [PATCH 11/49] sharing/bday/template per share test --- radicale/tests/test_sharing.py | 140 ++++++++++++++++++++++++++++++++- 1 file changed, 139 insertions(+), 1 deletion(-) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 67b7540a..836cd16f 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -4932,7 +4932,7 @@ permissions: RrWw""") assert "text/calendar" in str(prop.text) def test_sharing_api_map_vcf_bday_template(self) -> None: - """share-by-map with conversion=bday format tests.""" + """share-by-map with conversion=bday template tests.""" self.configure({"auth": {"type": "htpasswd", "htpasswd_filename": self.htpasswd_file_path, "htpasswd_encryption": "plain"}, @@ -5055,6 +5055,144 @@ permissions: RrWw""") _, headers, answer = self.request("GET", path_shared_3, login="user:userpw") assert "DESCRIPTION:year=1990 month=01 day=01" in answer + def test_sharing_api_map_vcf_bday_per_share_template(self) -> None: + """share-by-map with conversion=bday template per share tests.""" + self.configure({"auth": {"type": "htpasswd", + "htpasswd_filename": self.htpasswd_file_path, + "htpasswd_encryption": "plain"}, + "sharing": { + "type": "csv", + "permit_create_map": True, + "permit_properties_overlay": "True", + "enforce_properties_overlay": "True", + "collection_by_map": "True"}, + "logging": {"request_header_on_debug": "False", + "response_content_on_debug": "True", + "response_header_on_debug": "True", + "request_content_on_debug": "True"}, + "rights": {"type": "owner_only"}}) + + json_dict: dict + + logging.info("\n*** prepare and test access") + + for db_type in list(filter(lambda item: item != "none", sharing.INTERNAL_TYPES)): + logging.info("\n*** test: %s", db_type) + self.configure({"sharing": {"type": db_type}}) + + path_mapped_2 = "/owner/adressbook2-" + db_type + ".vcf/" + path_mapped_3 = "/owner/adressbook3-" + db_type + ".vcf/" + + path_shared_2r = "/user/calendar-bday-abook2-shared-by-owner-r-" + db_type + ".ics/" + path_shared_3r = "/user/calendar-bday-abook3-shared-by-owner-r-" + db_type + ".ics/" + + self.create_addressbook(path_mapped_2, login="owner:ownerpw") + self.create_addressbook(path_mapped_3, login="owner:ownerpw") + + contact2 = get_file_content("contact2-with-bday.vcf") + path2 = path_mapped_2 + "/contact2-with-bday.vcf" + path_shared_2 = path_shared_2r + "/contact2-with-bday.vcf" + self.put(path2, contact2, login="owner:ownerpw") + + contact3 = get_file_content("contact3-with-bday.vcf") + path3 = path_mapped_3 + "/contact3-with-bday.vcf" + path_shared_3 = path_shared_3r + "/contact3-with-bday.vcf" + self.put(path3, contact3, login="owner:ownerpw") + + # create map + logging.info("\n*** create map(bday) user/owner:r -> ok") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped_2 + json_dict['PathOrToken'] = path_shared_2r + json_dict['Conversion'] = "bday" + json_dict['Permissions'] = "rP" + json_dict['Enabled'] = True + json_dict['Enabled'] = True + json_dict['Hidden'] = False + json_dict['Properties'] = {"D:displayname": "Test-BDAY2"} + json_dict['Actions'] = {"template": { + "conversion_bday_summary_template": "{fn} (BDAY2)", + "conversion_bday_description_template": "BDAY2={year}-{month}-{day}", + }} + _, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + + logging.info("\n*** create map(bday) user/owner:r -> ok") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped_3 + json_dict['PathOrToken'] = path_shared_3r + json_dict['Conversion'] = "bday" + json_dict['Permissions'] = "rP" + json_dict['Enabled'] = True + json_dict['Enabled'] = True + json_dict['Hidden'] = False + json_dict['Properties'] = {"D:displayname": "Test-Birthday3"} + json_dict['Actions'] = {"template": { + "conversion_bday_summary_template": "{fn} (Birthday3)", + "conversion_bday_description_template": "Birthday3={year}-{month}-{day}", + }} + _, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + + # enable map by user + logging.info("\n*** enable map(bday) by user") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped_2 + json_dict['PathOrToken'] = path_shared_2r + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) + + logging.info("\n*** enable map(bday) by user") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped_3 + json_dict['PathOrToken'] = path_shared_3r + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) + + # verify content as user + logging.info("\n*** GET collection user template 2 -> ok") + _, headers, answer = self.request("GET", path_shared_2, login="user:userpw") + assert "SUMMARY:Test-FN (BDAY2)" in answer + + logging.info("\n*** GET collection user template 3 -> ok") + _, headers, answer = self.request("GET", path_shared_3, login="user:userpw") + assert "SUMMARY:Test-FN-C3 (Birthday3)" in answer + + # update template + logging.info("\n*** update map(bday) user/owner:r -> ok") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped_2 + json_dict['PathOrToken'] = path_shared_2r + json_dict['Actions'] = {"template": { + "conversion_bday_summary_template": "{fn} (BDAY2x)", + "conversion_bday_description_template": "BDAY2x={year}-{month}-{day}", + }} + _, headers, answer = self._sharing_api_json("map", "update", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + + # verify content as user + logging.info("\n*** GET collection user template 2 -> ok") + _, headers, answer = self.request("GET", path_shared_2, login="user:userpw") + assert "SUMMARY:Test-FN (BDAY2x)" in answer + + # update template + logging.info("\n*** update map(bday) user/owner:r with wrong Action -> problem") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped_2 + json_dict['PathOrToken'] = path_shared_2r + json_dict['Actions'] = {"template": { + "conversion_bday_summary_template_UNSUPPORTED": "{fn} (BDAY2x)", + "conversion_bday_description_template_UNSUPPORTED": "BDAY2x={year}-{month}-{day}", + }} + _, headers, answer = self._sharing_api_json("map", "update", check=400, login="owner:ownerpw", json_dict=json_dict) + def test_sharing_api_map_vcf_bday_complex(self) -> None: """share-by-map with conversion=bday complex tests.""" self.configure({"auth": {"type": "htpasswd", From 764eb8fd88e15eadd1ff75ebc7755eb7be5babe8 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 29 May 2026 08:53:00 +0200 Subject: [PATCH 12/49] doc: fix typo --- DOCUMENTATION.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 5d9a2565..669cb8be 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -2325,7 +2325,7 @@ Supported placeholders (data used from VCARD) Fallback is supported if placeholders inside `{...|...}` (first successful resolved one is used) -##### #conversion_bday_description_template +##### conversion_bday_description_template _(>= 3.7.5)_ From fcad6f4254a5659f88fa98d18ab8cae6f683baf2 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 29 May 2026 08:56:25 +0200 Subject: [PATCH 13/49] sharing/bday/trigger: doc --- DOCUMENTATION.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 669cb8be..d518b95c 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -2335,6 +2335,20 @@ Default: `"BDAY={year}-{month}-{day}"` Supported placeholders see `conversion_bday_summary_template` +##### conversion_bday_alarm_trigger_template + +_(>= 3.7.5)_ + +Global template for alarm trigger of conversion "bday" + +Default: `"-15H;BDAY tomorrow|9H;BDAY today"` + +Supported format: `TIMEDELTA;DESCRIPTION` (separated by `|` if more alarms should be generated) + +Supported format for `TIMEDELTA`: `[+-]?[0-9]+[WDHM]` + +Supported placeholders for `DESCRIPTION` see `conversion_bday_summary_template` + ## Supported Clients Radicale has been tested with: From 48ab13bc2c81c1dd726a04cb31c7c92baf6ecf4b Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 29 May 2026 08:56:59 +0200 Subject: [PATCH 14/49] sharing/bday/trigger: reference config --- config | 3 +++ 1 file changed, 3 insertions(+) diff --git a/config b/config index eca80d4d..9c16fe65 100644 --- a/config +++ b/config @@ -362,6 +362,9 @@ # Global template for description of conversion "bday" #conversion_bday_description_template = "BDAY={year}-{month}-{day}" +# Global template of alarm trigger of conversion "bday" +#conversion_bday_alarm_trigger_template = "-15H;BDAY tomorrow|9H;BDAY today" + [web] # Web interface backend From 0c16337b509102eaa177811e53454881af27292d Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 29 May 2026 08:58:07 +0200 Subject: [PATCH 15/49] sharing/bday: add forgotten actions to pass --- radicale/app/propfind.py | 10 +++++----- radicale/app/report.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index 4ba26020..1a574c68 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -230,7 +230,7 @@ def xml_propfind_response( element.text = item.etag else: if share_bday_automap: - item_converted = item.convert_vcf_to_ics() + item_converted = item.convert_vcf_to_ics(ShareActions=share['Actions']) if item_converted: element.text = item_converted.etag else: @@ -382,14 +382,14 @@ def xml_propfind_response( logger.trace("PROPFIND/xml_propfind_response/getcontentlength: start bday automap handling for collection") length = 0 for entry in item.get_all(): - item_ics = entry.convert_vcf_to_ics() + item_ics = entry.convert_vcf_to_ics(ShareActions=share['Actions']) if item_ics is None: continue length += len(item_ics.vobject_item.serialize().encode(encoding)) element.text = str(length) else: logger.trace("PROPFIND/xml_propfind_response/getcontentlength: start bday automap handling for single item") - item_converted = item.convert_vcf_to_ics() + item_converted = item.convert_vcf_to_ics(ShareActions=share['Actions']) if item_converted is not None: element.text = str(len(item_converted.serialize())) else: @@ -457,7 +457,7 @@ def xml_propfind_response( logger.trace("PROPFIND/xml_propfind_response/getcontentcount: start bday automap handling") items = [] for entry in item.get_all(): - item_ics = entry.convert_vcf_to_ics() + item_ics = entry.convert_vcf_to_ics(ShareActions=share['Actions']) if item_ics is None: continue items.append(item_ics.vobject_item) @@ -626,7 +626,7 @@ class ApplicationPartPropfind(ApplicationBase): for item, permission, raw_permissions in item_list: if self._sharing._enabled and share: if share['Conversion'] == "bday" and not isinstance(item, storage.BaseCollection): - if not item.convert_vcf_to_ics(): + if not item.convert_vcf_to_ics(ShareActions=share['Actions']): if len_item_list == 1: # only dedicated item requested return httputils.NOT_FOUND diff --git a/radicale/app/report.py b/radicale/app/report.py index 93461e2e..05fda541 100644 --- a/radicale/app/report.py +++ b/radicale/app/report.py @@ -267,7 +267,7 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element], # autoconvert retrieved_items_vcf_to_ics = [] for item, flag in retrieved_items: - item_ics = item.convert_vcf_to_ics() + item_ics = item.convert_vcf_to_ics(ShareActions=share['Actions']) if item_ics is None: continue else: From c5c74d1753539795ec2b2003c043a9a7c916a7d5 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 29 May 2026 09:00:04 +0200 Subject: [PATCH 16/49] sharing/bday: trigger config parser --- radicale/config.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/radicale/config.py b/radicale/config.py index 4442b323..005fcf26 100644 --- a/radicale/config.py +++ b/radicale/config.py @@ -602,6 +602,10 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([ "value": "BDAY={year}-{month}-{day}", "help": "conversion bday description template", "type": str}), + ("conversion_bday_alarm_trigger_template", { + "value": "-15H;BDAY tomorrow|9H;BDAY today", + "help": "conversion bday alarm trigger template", + "type": str}), ])), ("hook", OrderedDict([ ("type", { From 3a5f8ddfd028cff6b51b63258dcdcb57f43f8828 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 29 May 2026 09:00:30 +0200 Subject: [PATCH 17/49] sharing/bday/trigger: implementation --- radicale/item/__init__.py | 45 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/radicale/item/__init__.py b/radicale/item/__init__.py index c68d7df7..e14012a5 100644 --- a/radicale/item/__init__.py +++ b/radicale/item/__init__.py @@ -533,6 +533,34 @@ class Item: logger.trace("item/convert_vcf_to_ics: resolve {..|..} match/replace(resolved) result: %r", text) return text + def trigger_to_timedelta(self, trigger) -> Union[datetime.timedelta, None]: + # workaround as vobject is not supporting direct set of value + # limited implementatino of reverse function of timedeltaToString in vobject/icalendar.py + pattern = re.compile('([+-])?([0-9]+)([WDHM])$') + match = pattern.match(trigger) + if not match: + logger.error("item/convert_vcf_to_ics: trigger time value not valid: %r", trigger) + return None + + sign = 1 + if match[1] == "-": + sign = -1 + + value = int(match[2]) * sign + + td: Union[datetime.timedelta, None] = None + + if match[3] == "D": + td = datetime.timedelta(days=value) + elif match[3] == "M": + td = datetime.timedelta(minutes=value) + elif match[3] == "H": + td = datetime.timedelta(hours=value) + elif match[3] == "W": + td = datetime.timedelta(weeks=value) + + return td + def convert_vcf_to_ics(self, ShareActions: dict = {}) -> Union["Item", None]: logger.trace("item/convert_vcf_to_ics: ShareActions: %r", ShareActions) logger.trace("item/convert_vcf_to_ics: convert VCF to ICS (href): %r", self.href) @@ -646,10 +674,23 @@ class Item: if 'conversion_bday_summary_template' in ShareActions['template']: summary = ShareActions['template']['conversion_bday_summary_template'] summary = self.replace_placeholders(summary, placeholder_mapping) - else: - summary = name + " (BDAY)" item_ics.vevent.add('summary').value = summary + # set VALARM + if ShareActions is not None and 'template' in ShareActions: + alarm_trigger = ShareActions['template']['conversion_bday_alarm_trigger_template'] + if alarm_trigger is not None and alarm_trigger != "": + for entry in alarm_trigger.split('|'): + (trigger, description) = entry.split(';') + logger.trace("item/convert_vcf_to_ics: alarm trigger entry: %r (trigger=%r description=%r)", entry, trigger, description) + td = self.trigger_to_timedelta(trigger) + if td is not None: + description = self.replace_placeholders(description, placeholder_mapping) + valarm = item_ics.vevent.add('valarm') + valarm.add('action').value = "DISPLAY" + valarm.add('description').value = description + valarm.add('trigger').value = td + # set RRULE item_ics.vevent.add('rrule').value = "FREQ=YEARLY" From c3bf3e8f7534ac34e48e6e2f0b418c95d172b9ab Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 29 May 2026 09:00:51 +0200 Subject: [PATCH 18/49] sharing/bday/trigger: config reader --- radicale/sharing/__init__.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 7f2f4ce0..44a6b3d4 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -127,6 +127,7 @@ ACTIONS_WHITELIST: dict = { "template": { 'conversion_bday_summary_template': str, 'conversion_bday_description_template': str, + 'conversion_bday_alarm_trigger_template': str, } } @@ -175,6 +176,7 @@ class BaseSharing: self.enforce_properties_overlay = configuration.get("sharing", "enforce_properties_overlay") self.conversion_bday_summary_template = configuration.get("sharing", "conversion_bday_summary_template") self.conversion_bday_description_template = configuration.get("sharing", "conversion_bday_description_template") + self.conversion_bday_alarm_trigger_template = configuration.get("sharing", "conversion_bday_alarm_trigger_template") logger.info("sharing.collection_by_map : %s", self.sharing_collection_by_map) logger.info("sharing.collection_by_token: %s", self.sharing_collection_by_token) @@ -186,6 +188,7 @@ class BaseSharing: logger.info("sharing.enforce_properties_overlay: %s", self.enforce_properties_overlay) logger.info("sharing.conversion_bday_summary_template: %s", self.conversion_bday_summary_template) logger.info("sharing.conversion_bday_description_template: %s", self.conversion_bday_description_template) + logger.info("sharing.conversion_bday_alarm_trigger_template: %s", self.conversion_bday_alarm_trigger_template) # database tasks self.sharing_db_type = configuration.get("sharing", "type") @@ -422,6 +425,7 @@ class BaseSharing: 'template': { 'conversion_bday_summary_template': self.conversion_bday_summary_template, 'conversion_bday_description_template': self.conversion_bday_description_template, + 'conversion_bday_alarm_trigger_template': self.conversion_bday_alarm_trigger_template, } } elif 'template' in share['Actions']: @@ -432,18 +436,28 @@ class BaseSharing: share['Actions']['template'].update( {'conversion_bday_summary_template': self.conversion_bday_summary_template} ) + if 'conversion_bday_description_template' in share['Actions']['template']: # nothing to do pass else: share['Actions']['template'].update( - {'conversion_bday_description_template': self.conversion_bday_summary_template} + {'conversion_bday_description_template': self.conversion_bday_description_template} + ) + + if 'conversion_bday_alarm_trigger_template' in share['Actions']['template']: + # nothing to do + pass + else: + share['Actions']['template'].update( + {'conversion_bday_alarm_trigger_template': self.conversion_bday_alarm_trigger_template} ) else: share['Actions'].update( {'template': { 'conversion_bday_summary_template': self.conversion_bday_summary_template, 'conversion_bday_description_template': self.conversion_bday_description_template, + 'conversion_bday_alarm_trigger_template': self.conversion_bday_alarm_trigger_template, } } ) From 7ce34598786895effec791fc269a4b828e3ccc9d Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 29 May 2026 09:01:11 +0200 Subject: [PATCH 19/49] sharing/bday/trigger: test cases --- radicale/tests/test_sharing.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 836cd16f..9b35eb6f 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -4997,6 +4997,7 @@ permissions: RrWw""") self.configure({"sharing": { "conversion_bday_summary_template": "{{fn}|{n:f} {n:g} {n:a}|{nickname}} (BDAY)", "conversion_bday_description_template": "BDAY={year}-{month}-{day}", + "conversion_bday_alarm_trigger_template": "-15H;BDAY tomorrow|9H;BDAY today", }}) # verify content as user @@ -5054,6 +5055,18 @@ permissions: RrWw""") logging.info("\n*** GET collection user format: description -> ok") _, headers, answer = self.request("GET", path_shared_3, login="user:userpw") assert "DESCRIPTION:year=1990 month=01 day=01" in answer + assert "DESCRIPTION:BDAY tomorrow" in answer + assert "DESCRIPTION:BDAY today" in answer + assert "TRIGGER:-PT15H" in answer + assert "TRIGGER:PT9H" in answer + + self.configure({"sharing": {"conversion_bday_alarm_trigger_template": "-12H;Birthday tomorrow of {fn}|12H;Birthday today of {n:g} {n:f}'"}}) + logging.info("\n*** GET collection user format: description -> ok") + _, headers, answer = self.request("GET", path_shared_3, login="user:userpw") + assert "DESCRIPTION:Birthday tomorrow of Test-FN-C3" in answer + assert "DESCRIPTION:Birthday today of Given3Test Family3Test" in answer + assert "TRIGGER:-PT12H" in answer + assert "TRIGGER:PT12H" in answer def test_sharing_api_map_vcf_bday_per_share_template(self) -> None: """share-by-map with conversion=bday template per share tests.""" From c54e41bccda747d3ef8f2c4e2d7432ac43d3fffd Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 29 May 2026 18:05:56 +0200 Subject: [PATCH 20/49] sharing/bday/trigger: disable by default, adjust fallback pattern --- DOCUMENTATION.md | 2 +- config | 5 +++-- radicale/config.py | 4 ++-- radicale/item/__init__.py | 10 +++++----- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index d518b95c..c3514372 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -2323,7 +2323,7 @@ Supported placeholders (data used from VCARD) * `{n:a}`: additional name (RFC6350#6.2.2) * `{nickname}`: nick name (RFC6350#6.2.3) -Fallback is supported if placeholders inside `{...|...}` (first successful resolved one is used) +Fallback is supported if placeholders inside `[...|...]` (first successful resolved one is used) ##### conversion_bday_description_template diff --git a/config b/config index 9c16fe65..455b481e 100644 --- a/config +++ b/config @@ -357,13 +357,14 @@ #default_permissions_create_map = r # Global template for summary of conversion "bday" -#conversion_bday_summary_template = "{{n:f} {n:g} {n:a}|{fn}|{nickname}} (BDAY)" +#conversion_bday_summary_template = "[{n:f} {n:g} {n:a}|{fn}|{nickname}] (BDAY)" # Global template for description of conversion "bday" #conversion_bday_description_template = "BDAY={year}-{month}-{day}" # Global template of alarm trigger of conversion "bday" -#conversion_bday_alarm_trigger_template = "-15H;BDAY tomorrow|9H;BDAY today" +# Example: "-15H;BDAY tomorrow|9H;BDAY today" +#conversion_bday_alarm_trigger_template = "" [web] diff --git a/radicale/config.py b/radicale/config.py index 005fcf26..00871ccd 100644 --- a/radicale/config.py +++ b/radicale/config.py @@ -595,7 +595,7 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([ "help": "default permissions for map-based sharing", "type": rights_permission}), ("conversion_bday_summary_template", { - "value": "{{n:f} {n:g} {n:a}|{fn}|{nickname}} (BDAY)", + "value": "[{n:f} {n:g} {n:a}|{fn}|{nickname}] (BDAY)", "help": "conversion bday summary template", "type": str}), ("conversion_bday_description_template", { @@ -603,7 +603,7 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([ "help": "conversion bday description template", "type": str}), ("conversion_bday_alarm_trigger_template", { - "value": "-15H;BDAY tomorrow|9H;BDAY today", + "value": "", "help": "conversion bday alarm trigger template", "type": str}), ])), diff --git a/radicale/item/__init__.py b/radicale/item/__init__.py index e14012a5..9c1151e2 100644 --- a/radicale/item/__init__.py +++ b/radicale/item/__init__.py @@ -509,8 +509,8 @@ class Item: text = text.replace(placeholder, placeholder_mapping[placeholder]) # resolve {..|..} recursive - pattern = re.compile('(.*)({)([^|]+)\\|(.+)(})(.*)') - logger.trace("item/convert_vcf_to_ics: resolve {..|..} starting with: %r", text) + pattern = re.compile('(.*)(\\[)([^|]+)\\|(.+)(\\])(.*)') + logger.trace("item/convert_vcf_to_ics: resolve [..|..] starting with: %r", text) while True: match = pattern.match(text) if not match: @@ -522,15 +522,15 @@ class Item: if '|' in match[4]: # further recursion required text = match[1] + match[2] + match[4] + match[5] + match[6] - logger.trace("item/convert_vcf_to_ics: resolve {..|..} match/replace/continue result: %r", text) + logger.trace("item/convert_vcf_to_ics: resolve [..|..] match/replace/continue result: %r", text) else: text = match[1] + match[4] + match[6] - logger.trace("item/convert_vcf_to_ics: resolve {..|..} match/replace/final result: %r", text) + logger.trace("item/convert_vcf_to_ics: resolve [..|..] match/replace/final result: %r", text) break else: # resolved variable text = match[1] + match[3] + match[6] - logger.trace("item/convert_vcf_to_ics: resolve {..|..} match/replace(resolved) result: %r", text) + logger.trace("item/convert_vcf_to_ics: resolve [..|..] match/replace(resolved) result: %r", text) return text def trigger_to_timedelta(self, trigger) -> Union[datetime.timedelta, None]: From 5477d6b2da1467eeb3cc66865aaca847577052e8 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 30 May 2026 08:45:30 +0200 Subject: [PATCH 21/49] sharing/bday/doc: align disabled alarm by default --- DOCUMENTATION.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index c3514372..cd16dc6b 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -2341,7 +2341,7 @@ _(>= 3.7.5)_ Global template for alarm trigger of conversion "bday" -Default: `"-15H;BDAY tomorrow|9H;BDAY today"` +Default: `` Supported format: `TIMEDELTA;DESCRIPTION` (separated by `|` if more alarms should be generated) From 586ae79b8efeb9992c687b08b6d38cdb24889d76 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 30 May 2026 08:46:04 +0200 Subject: [PATCH 22/49] sharing/bday: option for age_max --- DOCUMENTATION.md | 8 ++++++++ config | 5 +++++ radicale/config.py | 4 ++++ 3 files changed, 17 insertions(+) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index cd16dc6b..ea65aaf5 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -2349,6 +2349,14 @@ Supported format for `TIMEDELTA`: `[+-]?[0-9]+[WDHM]` Supported placeholders for `DESCRIPTION` see `conversion_bday_summary_template` +##### conversion_bday_age_max + +_(>= 3.7.5)_ + +Global max limit of "bday" age, only active in case of `{age}` is used as placeholder + +Default: `99` + ## Supported Clients Radicale has been tested with: diff --git a/config b/config index 455b481e..19aab2b7 100644 --- a/config +++ b/config @@ -366,6 +366,11 @@ # Example: "-15H;BDAY tomorrow|9H;BDAY today" #conversion_bday_alarm_trigger_template = "" +# Global max limit of "bday" age +# Only active in case of {age} is used as placeholder +#conversion_bday_age_max = 99 + + [web] # Web interface backend diff --git a/radicale/config.py b/radicale/config.py index 00871ccd..413b0dfa 100644 --- a/radicale/config.py +++ b/radicale/config.py @@ -606,6 +606,10 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([ "value": "", "help": "conversion bday alarm trigger template", "type": str}), + ("conversion_bday_age_max", { + "value": 99, + "help": "conversion bday age max", + "type": positive_int}), ])), ("hook", OrderedDict([ ("type", { From 8c4085c5e172d9125911386668d385a411ba575a Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 30 May 2026 08:53:07 +0200 Subject: [PATCH 23/49] sharing/bday/age: doc --- DOCUMENTATION.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index ea65aaf5..6275d587 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -2323,6 +2323,9 @@ Supported placeholders (data used from VCARD) * `{n:a}`: additional name (RFC6350#6.2.2) * `{nickname}`: nick name (RFC6350#6.2.3) +Supported extra placeholders + * `{age}`: age, toggles to creation of single events instead using RRULE + Fallback is supported if placeholders inside `[...|...]` (first successful resolved one is used) ##### conversion_bday_description_template From e821258fa0a7278e048e51e438d0bee9fa0b6b06 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 30 May 2026 08:54:41 +0200 Subject: [PATCH 24/49] sharing/bday/age_max: fix default --- radicale/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radicale/config.py b/radicale/config.py index 413b0dfa..24899190 100644 --- a/radicale/config.py +++ b/radicale/config.py @@ -607,7 +607,7 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([ "help": "conversion bday alarm trigger template", "type": str}), ("conversion_bday_age_max", { - "value": 99, + "value": "99", "help": "conversion bday age max", "type": positive_int}), ])), From 3aed27ff6560a3d7432cb1a44256c6cb32087365 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 30 May 2026 08:57:01 +0200 Subject: [PATCH 25/49] sharing/bday: add support for max_age, improve Actions validation on API --- radicale/sharing/__init__.py | 105 +++++++++++++++++++++++------------ 1 file changed, 69 insertions(+), 36 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 44a6b3d4..2e815528 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -124,11 +124,14 @@ TOKEN_PATTERN_V1: str = "v1/[a-zA-Z0-9_\\-]{44}" OVERLAY_PROPERTIES_WHITELIST: Sequence[str] = ("C:calendar-description", "ICAL:calendar-color", "CR:addressbook-description", "INF:addressbook-color", "D:displayname", "ICAL:calendar-order") ACTIONS_WHITELIST: dict = { - "template": { + 'template': { 'conversion_bday_summary_template': str, 'conversion_bday_description_template': str, 'conversion_bday_alarm_trigger_template': str, - } + }, + 'limit': { + 'conversion_bday_age_max': "positive_int", + }, } CONVERSIONS_WHITELIST: Sequence[str] = ("bday", "none") @@ -177,6 +180,7 @@ class BaseSharing: self.conversion_bday_summary_template = configuration.get("sharing", "conversion_bday_summary_template") self.conversion_bday_description_template = configuration.get("sharing", "conversion_bday_description_template") self.conversion_bday_alarm_trigger_template = configuration.get("sharing", "conversion_bday_alarm_trigger_template") + self.conversion_bday_age_max = configuration.get("sharing", "conversion_bday_age_max") logger.info("sharing.collection_by_map : %s", self.sharing_collection_by_map) logger.info("sharing.collection_by_token: %s", self.sharing_collection_by_token) @@ -189,6 +193,7 @@ class BaseSharing: logger.info("sharing.conversion_bday_summary_template: %s", self.conversion_bday_summary_template) logger.info("sharing.conversion_bday_description_template: %s", self.conversion_bday_description_template) logger.info("sharing.conversion_bday_alarm_trigger_template: %s", self.conversion_bday_alarm_trigger_template) + logger.info("sharing.conversion_bday_age_max: %s", self.conversion_bday_age_max) # database tasks self.sharing_db_type = configuration.get("sharing", "type") @@ -426,41 +431,61 @@ class BaseSharing: 'conversion_bday_summary_template': self.conversion_bday_summary_template, 'conversion_bday_description_template': self.conversion_bday_description_template, 'conversion_bday_alarm_trigger_template': self.conversion_bday_alarm_trigger_template, - } + }, + 'limit': { + 'conversion_bday_age_max': self.conversion_bday_age_max, + }, } - elif 'template' in share['Actions']: - if 'conversion_bday_summary_template' in share['Actions']['template']: - # nothing to do - pass - else: - share['Actions']['template'].update( - {'conversion_bday_summary_template': self.conversion_bday_summary_template} - ) - - if 'conversion_bday_description_template' in share['Actions']['template']: - # nothing to do - pass - else: - share['Actions']['template'].update( - {'conversion_bday_description_template': self.conversion_bday_description_template} - ) - - if 'conversion_bday_alarm_trigger_template' in share['Actions']['template']: - # nothing to do - pass - else: - share['Actions']['template'].update( - {'conversion_bday_alarm_trigger_template': self.conversion_bday_alarm_trigger_template} - ) else: - share['Actions'].update( - {'template': { - 'conversion_bday_summary_template': self.conversion_bday_summary_template, - 'conversion_bday_description_template': self.conversion_bday_description_template, - 'conversion_bday_alarm_trigger_template': self.conversion_bday_alarm_trigger_template, - } - } - ) + if 'template' in share['Actions']: + if 'conversion_bday_summary_template' in share['Actions']['template']: + # nothing to do + pass + else: + share['Actions']['template'].update( + {'conversion_bday_summary_template': self.conversion_bday_summary_template} + ) + + if 'conversion_bday_description_template' in share['Actions']['template']: + # nothing to do + pass + else: + share['Actions']['template'].update( + {'conversion_bday_description_template': self.conversion_bday_description_template} + ) + + if 'conversion_bday_alarm_trigger_template' in share['Actions']['template']: + # nothing to do + pass + else: + share['Actions']['template'].update( + {'conversion_bday_alarm_trigger_template': self.conversion_bday_alarm_trigger_template} + ) + else: + share['Actions'].update( + {'template': { + 'conversion_bday_summary_template': self.conversion_bday_summary_template, + 'conversion_bday_description_template': self.conversion_bday_description_template, + 'conversion_bday_alarm_trigger_template': self.conversion_bday_alarm_trigger_template, + } + } + ) + + if 'limit' in share['Actions']: + if 'conversion_bday_age_max' in share['Actions']['limit']: + # nothing to do + pass + else: + share['Actions']['limit'].update( + {'conversion_bday_age_max': self.conversion_bday_age_max} + ) + else: + share['Actions'].update( + {'limit': { + 'conversion_bday_age_max': self.conversion_bday_age_max, + } + } + ) logger.info("sharing/%s: resolved path %r->%r, user %r->%r, Permissions=%r Conversion=%r Actions=%r", share['ShareType'], share['PathOrToken'], share['PathMapped'], user, share['Owner'], share['Permissions'], share['Conversion'], share['Actions']) @@ -851,19 +876,27 @@ class BaseSharing: if 'Actions' in request_data: valid = True # default + hint = "" for level1 in request_data['Actions']: if level1 in ACTIONS_WHITELIST: for level2 in request_data['Actions'][level1]: if level2 in ACTIONS_WHITELIST[level1]: + if ACTIONS_WHITELIST[level1][level2] == "positive_int": + if int(request_data['Actions'][level1][level2]) < 0: + hint = "'" + level1 + "': {'" + level2 + "'} is negative" + valid = False + break pass else: + hint = "'" + level1 + "': {'" + level2 + "'} is not supported" valid = False break else: + hint = "'" + level1 + "' is not supported" valid = False break if not valid: - return httputils.bad_request("Actions format not valid") + return httputils.bad_request("Actions format not valid: " + hint) Actions = request_data['Actions'] if 'Enabled' in request_data: From a42d2243c03ac188eb1b7276cadb13e9ebafc978 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 30 May 2026 09:11:18 +0200 Subject: [PATCH 26/49] sharing/bday/conversion: add support for "age" --- radicale/item/__init__.py | 136 ++++++++++++++++++++++++-------------- 1 file changed, 88 insertions(+), 48 deletions(-) diff --git a/radicale/item/__init__.py b/radicale/item/__init__.py index 9c1151e2..400c7bcb 100644 --- a/radicale/item/__init__.py +++ b/radicale/item/__init__.py @@ -645,65 +645,105 @@ class Item: else: item_ics.add('prodid').value = PRODID_CONVERTED - # create EVENT - item_ics.add('vevent') - - # set DTSTART - dtstart = datetime.date(bdayY, bdayM, bdayD) - item_ics.vevent.add('dtstart').value = dtstart - - # calculate and set DTEND - dtend = dtstart + datetime.timedelta(days=1) - item_ics.vevent.add('dtend').value = dtend - - # set UID - if hasattr(self.vobject_item, "uid"): - pattern = re.compile('^(.*)-[0-9a-fA-F]{12}(.*)$') - match = pattern.match(self.vobject_item.uid.value) - if match: - # replace part of UUID by bday - item_ics.vevent.add('uid').value = match[1] + '-' + 'bda0' + bdayS + match[2] - else: - item_ics.vevent.add('uid').value = self.vobject_item.uid.value + UID_SUFFIX - else: - item_ics.vevent.add('uid').value = match[1] + match[2] + match[3] + "@" + name.replace(" ", "-") + UID_SUFFIX - - # set SUMMARY + # create SUMMARY summary = name + " (BDAY)" # default if ShareActions is not None and 'template' in ShareActions: if 'conversion_bday_summary_template' in ShareActions['template']: summary = ShareActions['template']['conversion_bday_summary_template'] summary = self.replace_placeholders(summary, placeholder_mapping) - item_ics.vevent.add('summary').value = summary - # set VALARM - if ShareActions is not None and 'template' in ShareActions: - alarm_trigger = ShareActions['template']['conversion_bday_alarm_trigger_template'] - if alarm_trigger is not None and alarm_trigger != "": - for entry in alarm_trigger.split('|'): - (trigger, description) = entry.split(';') - logger.trace("item/convert_vcf_to_ics: alarm trigger entry: %r (trigger=%r description=%r)", entry, trigger, description) - td = self.trigger_to_timedelta(trigger) - if td is not None: - description = self.replace_placeholders(description, placeholder_mapping) - valarm = item_ics.vevent.add('valarm') - valarm.add('action').value = "DISPLAY" - valarm.add('description').value = description - valarm.add('trigger').value = td - - # set RRULE - item_ics.vevent.add('rrule').value = "FREQ=YEARLY" - - # add transparency - item_ics.vevent.add('transp').value = "TRANSPARENT" - - # add description + # create DESCRIPTION description = "BDAY=" + bdaySdesc # default if ShareActions is not None and 'template' in ShareActions: if 'conversion_bday_description_template' in ShareActions['template']: description = ShareActions['template']['conversion_bday_description_template'] description = self.replace_placeholders(description, placeholder_mapping) - item_ics.vevent.add('description').value = description + + # check ALARM + alarm_trigger = "" # default + if ShareActions is not None and 'template' in ShareActions: + alarm_trigger = ShareActions['template']['conversion_bday_alarm_trigger_template'] + + vevent_enable_age = False + age_max = 0 + if "{age}" in summary or "{age}" in description or "age" in alarm_trigger: + if ShareActions is not None and 'limit' in ShareActions: + if 'conversion_bday_age_max' in ShareActions['limit']: + age_max = ShareActions['limit']['conversion_bday_age_max'] + vevent_enable_age = True + + # create UID + if hasattr(self.vobject_item, "uid"): + pattern = re.compile('^(.*)-[0-9a-fA-F]{12}(.*)$') + match = pattern.match(self.vobject_item.uid.value) + if match: + # replace part of UUID by bday + uid = match[1] + '-' + 'bda0' + bdayS + match[2] + else: + uid = self.vobject_item.uid.value + UID_SUFFIX + else: + uid = match[1] + match[2] + match[3] + "@" + name.replace(" ", "-") + UID_SUFFIX + + age = 0 + while age <= age_max: + # create EVENT + vevent = item_ics.add('vevent') + + # set DTSTART + if vevent_enable_age: + dtstart = datetime.date(bdayY + age, bdayM, bdayD) + else: + dtstart = datetime.date(bdayY, bdayM, bdayD) + vevent.add('dtstart').value = dtstart + + # calculate and set DTEND + dtend = dtstart + datetime.timedelta(days=1) + vevent.add('dtend').value = dtend + + # set UID + if vevent_enable_age: + uid_value = uid + "-AGE-" + str(age) + else: + uid_value = uid + vevent.add('uid').value = uid_value + + # set SUMMARY + if vevent_enable_age: + summary_value = summary.replace("{age}", str(age)) + else: + summary_value = summary + vevent.add('summary').value = summary_value + + # set VALARM + if alarm_trigger is not None and alarm_trigger != "": + for entry in alarm_trigger.split('|'): + (trigger, alarm_description) = entry.split(';') + logger.trace("item/convert_vcf_to_ics: alarm trigger entry: %r (trigger=%r description=%r)", entry, trigger, description) + td = self.trigger_to_timedelta(trigger) + if td is not None: + alarm_description = self.replace_placeholders(alarm_description, placeholder_mapping) + alarm_description_value = alarm_description.replace("{age}", str(age)) + valarm = vevent.add('valarm') + valarm.add('action').value = "DISPLAY" + valarm.add('description').value = alarm_description_value + valarm.add('trigger').value = td + + # set RRULE + if not vevent_enable_age: + vevent.add('rrule').value = "FREQ=YEARLY" + + # add transparency + vevent.add('transp').value = "TRANSPARENT" + + # set DESCRIPTION + if vevent_enable_age: + description_value = description.replace("{age}", str(age)) + else: + description_value = description + vevent.add('description').value = description_value + + # increase age + age = age + 1 href = self.href if href is not None: From 6310e071e39fd8ef20e1382587870060a3ac6dc9 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 30 May 2026 09:11:46 +0200 Subject: [PATCH 27/49] sharing/csv: fix for json/dict structure --- radicale/sharing/csv.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/radicale/sharing/csv.py b/radicale/sharing/csv.py index 5eb491cd..e1c97ed1 100644 --- a/radicale/sharing/csv.py +++ b/radicale/sharing/csv.py @@ -441,6 +441,8 @@ class Sharing(sharing.BaseSharing): field = field.replace("{'", '{"') # replace for JSON start {' -> {" field = field.replace("'}", '"}') # replace for JSON end '} -> "} field = field.replace("': '", '": "') # replace for JSON entry/value ': ' -> ": " + field = field.replace("': {", '": {') # replace for JSON entry/value ': { -> ": { + field = field.replace("': ", '": ') # replace for JSON entry/value ': -> ":(whitespace) field = field.replace("', '", '", "') # replace for JSON delimiter ', ' -> ", " logger.trace("json prep quote replacer match (after) : %s", field) try: From 497a0eeb44d04a6ef83aaaf972096a92ae259fce Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 30 May 2026 09:12:20 +0200 Subject: [PATCH 28/49] sharing/bday/template: fix change of fallback placeholder --- radicale/tests/test_sharing.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 9b35eb6f..c66707fd 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -4995,7 +4995,7 @@ permissions: RrWw""") _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) self.configure({"sharing": { - "conversion_bday_summary_template": "{{fn}|{n:f} {n:g} {n:a}|{nickname}} (BDAY)", + "conversion_bday_summary_template": "[{fn}|{n:f} {n:g} {n:a}|{nickname}] (BDAY)", "conversion_bday_description_template": "BDAY={year}-{month}-{day}", "conversion_bday_alarm_trigger_template": "-15H;BDAY tomorrow|9H;BDAY today", }}) @@ -5005,17 +5005,17 @@ permissions: RrWw""") _, headers, answer = self.request("GET", path_shared_2, login="user:userpw") assert "SUMMARY:Test-FN (BDAY)" in answer - self.configure({"sharing": {"conversion_bday_summary_template": "{{fn}|{n:f} {n:g} {n:a}|{nickname}} (Birthday)"}}) + self.configure({"sharing": {"conversion_bday_summary_template": "[{fn}|{n:f} {n:g} {n:a}|{nickname}] (Birthday)"}}) logging.info("\n*** GET collection user format:text -> ok") _, headers, answer = self.request("GET", path_shared_2, login="user:userpw") assert "SUMMARY:Test-FN (Birthday)" in answer - self.configure({"sharing": {"conversion_bday_summary_template": "{{n:f} {n:g} {n:a}|{fn}|{nickname}} (Birthday)"}}) + self.configure({"sharing": {"conversion_bday_summary_template": "[{n:f} {n:g} {n:a}|{fn}|{nickname}] (Birthday)"}}) logging.info("\n*** GET collection user format:n -> ok") _, headers, answer = self.request("GET", path_shared_2, login="user:userpw") assert "SUMMARY:FamilyTest GivenTest AdditionalsTest (Birthday)" in answer - self.configure({"sharing": {"conversion_bday_summary_template": "{{nickname}|{n:f} {n:g} {n:a}|{fn}} (Birthday)"}}) + self.configure({"sharing": {"conversion_bday_summary_template": "[{nickname}|{n:f} {n:g} {n:a}|{fn}] (Birthday)"}}) logging.info("\n*** GET collection user format:nickname -> ok") _, headers, answer = self.request("GET", path_shared_2, login="user:userpw") assert "SUMMARY:Test-NICKNAME (Birthday)" in answer @@ -5025,22 +5025,22 @@ permissions: RrWw""") _, headers, answer = self.request("GET", path_shared_3, login="user:userpw") assert "SUMMARY:!nickname! (Birthday)" in answer - self.configure({"sharing": {"conversion_bday_summary_template": "{{nickname}|{nickname}|{fn}} (Birthday)"}}) + self.configure({"sharing": {"conversion_bday_summary_template": "[{nickname}|{nickname}|{fn}] (Birthday)"}}) logging.info("\n*** GET collection user format:nickname with fn fallback -> ok") _, headers, answer = self.request("GET", path_shared_3, login="user:userpw") assert "SUMMARY:Test-FN-C3 (Birthday)" in answer - self.configure({"sharing": {"conversion_bday_summary_template": "{{nickname}|{nickname}|{n:g} {n:f}} (Birthday)"}}) + self.configure({"sharing": {"conversion_bday_summary_template": "[{nickname}|{nickname}|{n:g} {n:f}] (Birthday)"}}) logging.info("\n*** GET collection user format:nickname with n fallback -> ok") _, headers, answer = self.request("GET", path_shared_3, login="user:userpw") assert "SUMMARY:Given3Test Family3Test (Birthday)" in answer - self.configure({"sharing": {"conversion_bday_summary_template": "{{nickname}|{nickname}|{n:f}, {n:g}} (Birthday)"}}) + self.configure({"sharing": {"conversion_bday_summary_template": "[{nickname}|{nickname}|{n:f}, {n:g}] (Birthday)"}}) logging.info("\n*** GET collection user format:nickname with n fallback -> ok") _, headers, answer = self.request("GET", path_shared_3, login="user:userpw") assert "SUMMARY:Family3Test\\, Given3Test (Birthday)" in answer - self.configure({"sharing": {"conversion_bday_summary_template": "{{nickname}|{nickname}|{n:f} {n:g} {n:a}} (Birthday)"}}) + self.configure({"sharing": {"conversion_bday_summary_template": "[{nickname}|{nickname}|{n:f} {n:g} {n:a}] (Birthday)"}}) logging.info("\n*** GET collection user format:nickname with n fallback -> ok") _, headers, answer = self.request("GET", path_shared_3, login="user:userpw") assert "SUMMARY:Family3Test Given3Test !n:a! (Birthday)" in answer From ad42f00a27e70ce8c9b895019df6d70cd7d88b4a Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 30 May 2026 09:12:43 +0200 Subject: [PATCH 29/49] sharing/bday/age: add test cases --- radicale/tests/test_sharing.py | 174 +++++++++++++++++++++++++++++++++ 1 file changed, 174 insertions(+) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index c66707fd..34939c9d 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -5068,6 +5068,180 @@ permissions: RrWw""") assert "TRIGGER:-PT12H" in answer assert "TRIGGER:PT12H" in answer + 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", + "htpasswd_filename": self.htpasswd_file_path, + "htpasswd_encryption": "plain"}, + "sharing": { + "type": "csv", + "permit_create_map": True, + "permit_properties_overlay": "True", + "enforce_properties_overlay": "True", + "collection_by_map": "True"}, + "logging": {"request_header_on_debug": "False", + "response_content_on_debug": "True", + "response_header_on_debug": "True", + "request_content_on_debug": "True"}, + "rights": {"type": "owner_only"}}) + + json_dict: dict + + logging.info("\n*** prepare and test access") + + for db_type in list(filter(lambda item: item != "none", sharing.INTERNAL_TYPES)): + logging.info("\n*** test: %s", db_type) + self.configure({"sharing": {"type": db_type}}) + + path_mapped = "/owner/adressbook-" + db_type + ".vcf/" + path_shared_r = "/user/calendar-bday-abook-shared-by-owner-r-" + db_type + ".ics/" + self.create_addressbook(path_mapped, login="owner:ownerpw") + + contact2 = get_file_content("contact2-with-bday.vcf") + path2 = path_mapped + "/contact2-with-bday.vcf" + path_shared_2 = path_shared_r + "/contact2-with-bday.vcf" + self.put(path2, contact2, login="owner:ownerpw") + + # create map + logging.info("\n*** create map(bday) user/owner:r -> ok") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_r + json_dict['Conversion'] = "bday" + json_dict['Permissions'] = "rP" + json_dict['Enabled'] = True + json_dict['Enabled'] = True + json_dict['Hidden'] = False + json_dict['Properties'] = {"D:displayname": "Test-BDAY"} + _, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + + # enable map by user + logging.info("\n*** enable map(bday) by user") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_r + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) + + self.configure({"sharing": { + "conversion_bday_summary_template": "[{fn}|{n:f} {n:g} {n:a}|{nickname}] (BDAY)", + "conversion_bday_description_template": "BDAY={year}-{month}-{day}", + }}) + + # verify content as user + logging.info("\n*** GET collection user format:default -> ok") + _, headers, answer = self.request("GET", path_shared_2, login="user:userpw") + assert "SUMMARY:Test-FN (BDAY)" in answer + + self.configure({"sharing": { + "conversion_bday_summary_template": "[{fn}|{n:f} {n:g} {n:a}|{nickname}] ({age}. Birthday)", + }}) + logging.info("\n*** GET collection user format:text -> ok") + _, headers, answer = self.request("GET", path_shared_2, login="user:userpw") + assert "SUMMARY:Test-FN (0. Birthday)" in answer + assert "SUMMARY:Test-FN (1. Birthday)" in answer + assert "SUMMARY:Test-FN (99. Birthday)" in answer + assert "SUMMARY:Test-FN (100. Birthday)" not in answer + + self.configure({"sharing": { + "conversion_bday_summary_template": "[{fn}|{n:f} {n:g} {n:a}|{nickname}] (BDAY)", + "conversion_bday_description_template": "AGE={age}", + }}) + logging.info("\n*** GET collection user format:text -> ok") + _, headers, answer = self.request("GET", path_shared_2, login="user:userpw") + assert "SUMMARY:Test-FN (BDAY)" in answer + assert "DESCRIPTION:AGE=0" in answer + assert "DESCRIPTION:AGE=1" in answer + assert "DESCRIPTION:AGE=99" in answer + assert "DESCRIPTION:AGE=100" not in answer + + self.configure({"sharing": { + "conversion_bday_summary_template": "[{fn}|{n:f} {n:g} {n:a}|{nickname}] (BDAY)", + "conversion_bday_description_template": "BDAY={year}-{month}-{day}", + "conversion_bday_alarm_trigger_template": "-15H;alarm {fn} {age}. birthday", + }}) + logging.info("\n*** GET collection user format:text -> ok") + _, headers, answer = self.request("GET", path_shared_2, login="user:userpw") + assert "SUMMARY:Test-FN (BDAY)" in answer + assert "DESCRIPTION:BDAY=1970-01-01" in answer + assert "DESCRIPTION:alarm Test-FN 0. birthday" in answer + assert "DESCRIPTION:alarm Test-FN 1. birthday" in answer + assert "DESCRIPTION:alarm Test-FN 99. birthday" in answer + assert "DESCRIPTION:alarm Test-FN 100. birthday" not in answer + + # update template + logging.info("\n*** update map(bday) user/owner:r -> ok") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_r + json_dict['Actions'] = {"limit": { + "conversion_bday_age_max": 5, + }} + _, headers, answer = self._sharing_api_json("map", "update", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + + logging.info("\n*** GET collection user format:text -> ok") + _, headers, answer = self.request("GET", path_shared_2, login="user:userpw") + assert "SUMMARY:Test-FN (BDAY)" in answer + assert "DESCRIPTION:BDAY=1970-01-01" in answer + assert "DESCRIPTION:alarm Test-FN 0. birthday" in answer + assert "DESCRIPTION:alarm Test-FN 1. birthday" in answer + assert "DESCRIPTION:alarm Test-FN 5. birthday" in answer + assert "DESCRIPTION:alarm Test-FN 6. birthday" not in answer + assert "DESCRIPTION:alarm Test-FN 99. birthday" not in answer + assert "DESCRIPTION:alarm Test-FN 100. birthday" not in answer + + self.configure({"sharing": { + "conversion_bday_summary_template": "[{fn}|{n:f} {n:g} {n:a}|{nickname}] (BDAY)", + "conversion_bday_description_template": "AGE={age}", + "conversion_bday_alarm_trigger_template": "", + }}) + logging.info("\n*** GET collection user limit age to 5 -> ok") + _, headers, answer = self.request("GET", path_shared_2, login="user:userpw") + assert "SUMMARY:Test-FN (BDAY)" in answer + assert "DESCRIPTION:AGE=0" in answer + assert "DESCRIPTION:AGE=1" in answer + assert "DESCRIPTION:AGE=5" in answer + assert "DESCRIPTION:AGE=6" not in answer + assert "DESCRIPTION:AGE=99" not in answer + assert "DESCRIPTION:AGE=100" not in answer + + # update template with invalid data test + logging.info("\n*** update map(bday) user/owner:r -> age_max not negative") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_r + json_dict['Actions'] = {"limit": { + "conversion_bday_age_max": -1, + }} + _, headers, answer = self._sharing_api_json("map", "update", check=400, login="owner:ownerpw", json_dict=json_dict) + + logging.info("\n*** update map(bday) user/owner:r -> unsupported level 1") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_r + json_dict['Actions'] = {"level1": { + "level2": "test", + }} + _, headers, answer = self._sharing_api_json("map", "update", check=400, login="owner:ownerpw", json_dict=json_dict) + + logging.info("\n*** update map(bday) user/owner:r -> unsupported level 2") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_r + json_dict['Actions'] = {"limit": { + "level2": "test", + }} + _, headers, answer = self._sharing_api_json("map", "update", check=400, login="owner:ownerpw", json_dict=json_dict) + def test_sharing_api_map_vcf_bday_per_share_template(self) -> None: """share-by-map with conversion=bday template per share tests.""" self.configure({"auth": {"type": "htpasswd", From 3673854ab5f3f4e7df427ad653661595a630d547 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 30 May 2026 09:15:37 +0200 Subject: [PATCH 30/49] sharing/bday/templates: changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 588c598a..7254d93c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ # Changelog ## 3.7.5.dev +* Add: [sharing] conversion_bday_summary_template (customize summary) +* Add: [sharing] conversion_bday_description_template (customize description) +* Add: [sharing] conversion_bday_alarm_trigger_template (customize alarms) +* Add: [sharing] conversion_bday_age_max (limit in case of "age" placeholder is used which blocks using RRULE) ## 3.7.4 * Fix: sharing: PROPFIND returns now empty owner element in case of a mapped share as clients try PROPFIND on this not accessable href From 91a47520efee29628eb37dc8054a7a49413d01ad Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 30 May 2026 09:24:12 +0200 Subject: [PATCH 31/49] minor review --- SHARING.md | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/SHARING.md b/SHARING.md index 93d29204..6fd52791 100644 --- a/SHARING.md +++ b/SHARING.md @@ -246,6 +246,7 @@ Can be selected by `HTTP_ACCEPT` - default is equal to provided `CONTENT_TYPE` * `Enabled`: owner/user selected by authentication * `Hidden`: owner/user selected by authentication * `Properties`: properties to overlay + * `Actions`: specific configuration ### API Hooks @@ -315,7 +316,8 @@ Create a share by mapping a collection of an `Owner` to a token. | Permissions | str | optional (default:rp) | | Enabled | bool | optional (owner/default:False) | | Hidden | bool | optional (owner/default:True) | -| Properties | str | optional | +| Properties | str(dict) | optional | +| Actions | str(dict) | optional | * Output: text/plain|application/json @@ -364,8 +366,8 @@ Create a share by mapping a collection of an `Owner` to an `User`. | Permissions | str | optional (default:r) | | Enabled | bool | optional (owner/default:False) | | Hidden | bool | optional (owner/default:True) | -| Properties | optional | -| Actions | optional | +| Properties | str(dict) | optional | +| Actions | str(dict) | optional | * Output: text/plain|application/json @@ -519,8 +521,8 @@ Execute delete+create in case `PathOrToken` needs to be changed. | Permissions | str | adjust | optional | not-permitted | | Enabled | bool | adjust | optional(owner) | optional(user) | | Hidden | bool | adjust | optional(owner) | optional(user) | -| Properties | str | adjust | optional | optional | -| Actions | str | adjust | optional | not-permitted | +| Properties | str(dict) | adjust | optional | optional | +| Actions | str(dict) | adjust | optional | not-permitted | * Output: text/plain|application/json @@ -677,7 +679,6 @@ curl -u user:$userpw -d "$xml_pfc" -X PROPFIND http://localhost:5232/user/cal1-f Owner can create for itself or for particular user a virtual bday collection from an existing addressbook. - ### Examples Preconditions: @@ -689,6 +690,8 @@ Preconditions: #### Examples using API +##### Mapping + * Create as *map* ```bash @@ -714,6 +717,8 @@ END:VCALENDAR Via WebUI an additional (virtual) calendar collection appears +##### Mapping as token + * Create as *token* ```bash @@ -724,7 +729,7 @@ Status='success' PathOrToken='/.token/v1/lqqwqhZYTGi9uSPsixien_8G5jiSK0FfhNFRGG_t8UA0/' ``` - * Fetch *map* + * Fetch *token* ```bash ## Fetch VCALENDAR auto-created from VADDRESSBOOK From e26a2cfcf42bce7f26a6e3c3afeae8a4272e0ed3 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 30 May 2026 10:49:21 +0200 Subject: [PATCH 32/49] auth/test: increase delay limit for Windows --- radicale/tests/test_auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radicale/tests/test_auth.py b/radicale/tests/test_auth.py index 408ad3f0..e7e15a47 100644 --- a/radicale/tests/test_auth.py +++ b/radicale/tests/test_auth.py @@ -301,7 +301,7 @@ class TestBaseAuthRequests(BaseTest): delay = .3 delay_min = delay * 0.9 # no random jitter during test delay_max = delay + 0.2 # no random jitter during test - if sys.platform == "darwin": # no reliable sleep times + if sys.platform == "darwin" or sys.platform == 'win32': # no reliable sleep times delay_max = delay_max * 1.5 time_begin = datetime.datetime.now() From 71643860a441c0863fe82c60b8b1737dcd59a2cd Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 30 May 2026 16:16:32 +0200 Subject: [PATCH 33/49] sharing/bday/Actions: align config section --- radicale/item/__init__.py | 22 +++++++++--------- radicale/sharing/__init__.py | 41 +++++++++------------------------- radicale/tests/test_sharing.py | 12 +++++----- 3 files changed, 28 insertions(+), 47 deletions(-) diff --git a/radicale/item/__init__.py b/radicale/item/__init__.py index 400c7bcb..faa602dc 100644 --- a/radicale/item/__init__.py +++ b/radicale/item/__init__.py @@ -647,29 +647,29 @@ class Item: # create SUMMARY summary = name + " (BDAY)" # default - if ShareActions is not None and 'template' in ShareActions: - if 'conversion_bday_summary_template' in ShareActions['template']: - summary = ShareActions['template']['conversion_bday_summary_template'] + if ShareActions is not None and 'config' in ShareActions: + if 'conversion_bday_summary_template' in ShareActions['config']: + summary = ShareActions['config']['conversion_bday_summary_template'] summary = self.replace_placeholders(summary, placeholder_mapping) # create DESCRIPTION description = "BDAY=" + bdaySdesc # default - if ShareActions is not None and 'template' in ShareActions: - if 'conversion_bday_description_template' in ShareActions['template']: - description = ShareActions['template']['conversion_bday_description_template'] + if ShareActions is not None and 'config' in ShareActions: + if 'conversion_bday_description_template' in ShareActions['config']: + description = ShareActions['config']['conversion_bday_description_template'] description = self.replace_placeholders(description, placeholder_mapping) # check ALARM alarm_trigger = "" # default - if ShareActions is not None and 'template' in ShareActions: - alarm_trigger = ShareActions['template']['conversion_bday_alarm_trigger_template'] + if ShareActions is not None and 'config' in ShareActions: + alarm_trigger = ShareActions['config']['conversion_bday_alarm_trigger_template'] vevent_enable_age = False age_max = 0 if "{age}" in summary or "{age}" in description or "age" in alarm_trigger: - if ShareActions is not None and 'limit' in ShareActions: - if 'conversion_bday_age_max' in ShareActions['limit']: - age_max = ShareActions['limit']['conversion_bday_age_max'] + if ShareActions is not None and 'config' in ShareActions: + if 'conversion_bday_age_max' in ShareActions['config']: + age_max = ShareActions['config']['conversion_bday_age_max'] vevent_enable_age = True # create UID diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 2e815528..faa448b3 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -425,67 +425,48 @@ class BaseSharing: if share is not None: if share['Conversion'] == "bday": # autogenerate Actions if not existing - if share['Actions'] is None: + if share['Actions'] is None or 'config' not in share['Actions']: share['Actions'] = { - 'template': { + 'config': { 'conversion_bday_summary_template': self.conversion_bday_summary_template, 'conversion_bday_description_template': self.conversion_bday_description_template, 'conversion_bday_alarm_trigger_template': self.conversion_bday_alarm_trigger_template, - }, - 'limit': { 'conversion_bday_age_max': self.conversion_bday_age_max, }, } else: - if 'template' in share['Actions']: - if 'conversion_bday_summary_template' in share['Actions']['template']: + if 'config' in share['Actions']: + if 'conversion_bday_summary_template' in share['Actions']['config']: # nothing to do pass else: - share['Actions']['template'].update( + share['Actions']['config'].update( {'conversion_bday_summary_template': self.conversion_bday_summary_template} ) - if 'conversion_bday_description_template' in share['Actions']['template']: + if 'conversion_bday_description_template' in share['Actions']['config']: # nothing to do pass else: - share['Actions']['template'].update( + share['Actions']['config'].update( {'conversion_bday_description_template': self.conversion_bday_description_template} ) - if 'conversion_bday_alarm_trigger_template' in share['Actions']['template']: + if 'conversion_bday_alarm_trigger_template' in share['Actions']['config']: # nothing to do pass else: - share['Actions']['template'].update( + share['Actions']['config'].update( {'conversion_bday_alarm_trigger_template': self.conversion_bday_alarm_trigger_template} ) - else: - share['Actions'].update( - {'template': { - 'conversion_bday_summary_template': self.conversion_bday_summary_template, - 'conversion_bday_description_template': self.conversion_bday_description_template, - 'conversion_bday_alarm_trigger_template': self.conversion_bday_alarm_trigger_template, - } - } - ) - if 'limit' in share['Actions']: - if 'conversion_bday_age_max' in share['Actions']['limit']: + if 'conversion_bday_age_max' in share['Actions']['config']: # nothing to do pass else: - share['Actions']['limit'].update( + share['Actions']['config'].update( {'conversion_bday_age_max': self.conversion_bday_age_max} ) - else: - share['Actions'].update( - {'limit': { - 'conversion_bday_age_max': self.conversion_bday_age_max, - } - } - ) logger.info("sharing/%s: resolved path %r->%r, user %r->%r, Permissions=%r Conversion=%r Actions=%r", share['ShareType'], share['PathOrToken'], share['PathMapped'], user, share['Owner'], share['Permissions'], share['Conversion'], share['Actions']) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 34939c9d..7c8ce93c 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -5178,7 +5178,7 @@ permissions: RrWw""") json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped json_dict['PathOrToken'] = path_shared_r - json_dict['Actions'] = {"limit": { + json_dict['Actions'] = {"config": { "conversion_bday_age_max": 5, }} _, headers, answer = self._sharing_api_json("map", "update", check=200, login="owner:ownerpw", json_dict=json_dict) @@ -5237,7 +5237,7 @@ permissions: RrWw""") json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped json_dict['PathOrToken'] = path_shared_r - json_dict['Actions'] = {"limit": { + json_dict['Actions'] = {"config": { "level2": "test", }} _, headers, answer = self._sharing_api_json("map", "update", check=400, login="owner:ownerpw", json_dict=json_dict) @@ -5298,7 +5298,7 @@ permissions: RrWw""") json_dict['Enabled'] = True json_dict['Hidden'] = False json_dict['Properties'] = {"D:displayname": "Test-BDAY2"} - json_dict['Actions'] = {"template": { + json_dict['Actions'] = {"config": { "conversion_bday_summary_template": "{fn} (BDAY2)", "conversion_bday_description_template": "BDAY2={year}-{month}-{day}", }} @@ -5317,7 +5317,7 @@ permissions: RrWw""") json_dict['Enabled'] = True json_dict['Hidden'] = False json_dict['Properties'] = {"D:displayname": "Test-Birthday3"} - json_dict['Actions'] = {"template": { + json_dict['Actions'] = {"config": { "conversion_bday_summary_template": "{fn} (Birthday3)", "conversion_bday_description_template": "Birthday3={year}-{month}-{day}", }} @@ -5355,7 +5355,7 @@ permissions: RrWw""") json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped_2 json_dict['PathOrToken'] = path_shared_2r - json_dict['Actions'] = {"template": { + json_dict['Actions'] = {"config": { "conversion_bday_summary_template": "{fn} (BDAY2x)", "conversion_bday_description_template": "BDAY2x={year}-{month}-{day}", }} @@ -5374,7 +5374,7 @@ permissions: RrWw""") json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped_2 json_dict['PathOrToken'] = path_shared_2r - json_dict['Actions'] = {"template": { + json_dict['Actions'] = {"config": { "conversion_bday_summary_template_UNSUPPORTED": "{fn} (BDAY2x)", "conversion_bday_description_template_UNSUPPORTED": "BDAY2x={year}-{month}-{day}", }} From ebb359c5cc347e9609865bcb40f412d9ee191646 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 30 May 2026 16:22:02 +0200 Subject: [PATCH 34/49] sharing/bday/age_max: input validation --- radicale/config.py | 2 +- radicale/sharing/__init__.py | 29 +++++++++++++----- radicale/tests/test_sharing.py | 55 +++++++++++++++++++++++++++++++++- 3 files changed, 76 insertions(+), 10 deletions(-) diff --git a/radicale/config.py b/radicale/config.py index 24899190..b92c6bb4 100644 --- a/radicale/config.py +++ b/radicale/config.py @@ -609,7 +609,7 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([ ("conversion_bday_age_max", { "value": "99", "help": "conversion bday age max", - "type": positive_int}), + "type": sharing.check_bday_max_age}), ])), ("hook", OrderedDict([ ("type", { diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index faa448b3..1b5020ca 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -24,7 +24,7 @@ import uuid from csv import DictWriter from datetime import datetime from http import client -from typing import Sequence, Union +from typing import Any, Sequence, Union from urllib.parse import parse_qs from radicale import (config, httputils, pathutils, rights, storage, types, @@ -123,14 +123,24 @@ TOKEN_PATTERN_V1: str = "v1/[a-zA-Z0-9_\\-]{44}" OVERLAY_PROPERTIES_WHITELIST: Sequence[str] = ("C:calendar-description", "ICAL:calendar-color", "CR:addressbook-description", "INF:addressbook-color", "D:displayname", "ICAL:calendar-order") +SHARING_BDAY_AGE_MAX: int = 199 # maximum age to prevent unexpected DoS by config + + +def check_bday_max_age(data: Any) -> int: + value = int(data) + if value < 0: + raise ValueError("value is negative: %d" % value) + if value > SHARING_BDAY_AGE_MAX: + raise ValueError("value exceeds maximum (%d): %d" % (SHARING_BDAY_AGE_MAX, value)) + return value + + ACTIONS_WHITELIST: dict = { - 'template': { + 'config': { 'conversion_bday_summary_template': str, 'conversion_bday_description_template': str, 'conversion_bday_alarm_trigger_template': str, - }, - 'limit': { - 'conversion_bday_age_max': "positive_int", + 'conversion_bday_age_max': check_bday_max_age, }, } @@ -862,9 +872,12 @@ class BaseSharing: if level1 in ACTIONS_WHITELIST: for level2 in request_data['Actions'][level1]: if level2 in ACTIONS_WHITELIST[level1]: - if ACTIONS_WHITELIST[level1][level2] == "positive_int": - if int(request_data['Actions'][level1][level2]) < 0: - hint = "'" + level1 + "': {'" + level2 + "'} is negative" + logger.trace(api_info + ": Actions validation: type='%r'", type(ACTIONS_WHITELIST[level1][level2])) + if callable(ACTIONS_WHITELIST[level1][level2]): + try: + value = ACTIONS_WHITELIST[level1][level2](request_data['Actions'][level1][level2]) + except ValueError: + hint = "'" + level1 + "': {'" + level2 + "'} is out-of-range" valid = False break pass diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 7c8ce93c..3f468e56 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -5131,6 +5131,26 @@ permissions: RrWw""") "conversion_bday_description_template": "BDAY={year}-{month}-{day}", }}) + logging.info("\n*** configuration test: conversion_bday_age_max < 0") + try: + self.configure({"sharing": { + "conversion_bday_age_max": -1, + }}) + except RuntimeError: + pass + else: + raise + + logging.info("\n*** configuration test: conversion_bday_age_max > MAX") + try: + self.configure({"sharing": { + "conversion_bday_age_max": 200, + }}) + except RuntimeError: + pass + else: + raise + # verify content as user logging.info("\n*** GET collection user format:default -> ok") _, headers, answer = self.request("GET", path_shared_2, login="user:userpw") @@ -5217,11 +5237,44 @@ permissions: RrWw""") json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped json_dict['PathOrToken'] = path_shared_r - json_dict['Actions'] = {"limit": { + json_dict['Actions'] = {"config": { "conversion_bday_age_max": -1, }} _, headers, answer = self._sharing_api_json("map", "update", check=400, login="owner:ownerpw", json_dict=json_dict) + # update template with invalid data test + logging.info("\n*** update map(bday) user/owner:r -> age_max exceeds MAX") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_r + json_dict['Actions'] = {"config": { + "conversion_bday_age_max": (sharing.SHARING_BDAY_AGE_MAX + 1), + }} + _, headers, answer = self._sharing_api_json("map", "update", check=400, login="owner:ownerpw", json_dict=json_dict) + + # update template with valid data test + logging.info("\n*** update map(bday) user/owner:r -> age_max ok") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_r + json_dict['Actions'] = {"config": { + "conversion_bday_age_max": 0, + }} + _, headers, answer = self._sharing_api_json("map", "update", check=200, login="owner:ownerpw", json_dict=json_dict) + + # update template with valid data test + logging.info("\n*** update map(bday) user/owner:r -> age_max < MAX") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_r + json_dict['Actions'] = {"config": { + "conversion_bday_age_max": sharing.SHARING_BDAY_AGE_MAX, + }} + _, headers, answer = self._sharing_api_json("map", "update", check=200, login="owner:ownerpw", json_dict=json_dict) + logging.info("\n*** update map(bday) user/owner:r -> unsupported level 1") json_dict = {} json_dict['User'] = "user" From 2360a50195d681cbe3da48983b8b51095b54af02 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 30 May 2026 18:00:57 +0200 Subject: [PATCH 35/49] sharing/bday: parameter validation --- radicale/config.py | 6 +- radicale/item/__init__.py | 126 ++++++++++++++++++----------------- radicale/sharing/__init__.py | 51 ++++++++++++-- 3 files changed, 112 insertions(+), 71 deletions(-) diff --git a/radicale/config.py b/radicale/config.py index b92c6bb4..9679c975 100644 --- a/radicale/config.py +++ b/radicale/config.py @@ -597,15 +597,15 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([ ("conversion_bday_summary_template", { "value": "[{n:f} {n:g} {n:a}|{fn}|{nickname}] (BDAY)", "help": "conversion bday summary template", - "type": str}), + "type": sharing.check_template}), ("conversion_bday_description_template", { "value": "BDAY={year}-{month}-{day}", "help": "conversion bday description template", - "type": str}), + "type": sharing.check_template}), ("conversion_bday_alarm_trigger_template", { "value": "", "help": "conversion bday alarm trigger template", - "type": str}), + "type": sharing.check_template_alarm_trigger}), ("conversion_bday_age_max", { "value": "99", "help": "conversion bday age max", diff --git a/radicale/item/__init__.py b/radicale/item/__init__.py index faa602dc..2e8a6ef4 100644 --- a/radicale/item/__init__.py +++ b/radicale/item/__init__.py @@ -47,6 +47,8 @@ PRODID_CONVERTED = u"-//Radicale//NONSGML " + utils.package_version("radicale") PRODID_SUFFIX = " (auto-converted by Radicale " + utils.package_version("radicale") + ")" UID_SUFFIX = "-auto-converted-by-Radicale" +VCF_TO_ICS_SUPPORTED_PLACEHOLDERS: list = ["fn", "n:f", "n:g", "n:a", "age", "nickname", "year", "month", "day"] + def read_components(s: str) -> List[vobject.base.Component]: """Wrapper for vobject.readComponents""" @@ -362,6 +364,65 @@ def verify(file: str, encoding: str): return True +def replace_placeholders(text: str, placeholder_mapping: dict) -> str: + for placeholder in placeholder_mapping: + text = text.replace(placeholder, placeholder_mapping[placeholder]) + + # resolve {..|..} recursive + pattern = re.compile('(.*)(\\[)([^|]+)\\|(.+)(\\])(.*)') + logger.trace("item/convert_vcf_to_ics: resolve [..|..] starting with: %r", text) + while True: + match = pattern.match(text) + if not match: + # nothing more todo + break + else: + if match[3].startswith('!') and match[3].endswith('!'): + # not resolved variable + if '|' in match[4]: + # further recursion required + text = match[1] + match[2] + match[4] + match[5] + match[6] + logger.trace("item/convert_vcf_to_ics: resolve [..|..] match/replace/continue result: %r", text) + else: + text = match[1] + match[4] + match[6] + logger.trace("item/convert_vcf_to_ics: resolve [..|..] match/replace/final result: %r", text) + break + else: + # resolved variable + text = match[1] + match[3] + match[6] + logger.trace("item/convert_vcf_to_ics: resolve [..|..] match/replace(resolved) result: %r", text) + return text + + +def trigger_to_timedelta(trigger) -> Union[datetime.timedelta, None]: + # workaround as vobject is not supporting direct set of value + # limited implementatino of reverse function of timedeltaToString in vobject/icalendar.py + pattern = re.compile('([+-])?([0-9]+)([WDHM])$') + match = pattern.match(trigger) + if not match: + logger.error("item/convert_vcf_to_ics: trigger time value not valid: %r", trigger) + return None + + sign = 1 + if match[1] == "-": + sign = -1 + + value = int(match[2]) * sign + + td: Union[datetime.timedelta, None] = None + + if match[3] == "D": + td = datetime.timedelta(days=value) + elif match[3] == "M": + td = datetime.timedelta(minutes=value) + elif match[3] == "H": + td = datetime.timedelta(hours=value) + elif match[3] == "W": + td = datetime.timedelta(weeks=value) + + return td + + class Item: """Class for address book and calendar entries.""" @@ -504,63 +565,6 @@ class Item: self.component_name self._vobject_item = orig_vobject_item - def replace_placeholders(self, text: str, placeholder_mapping: dict) -> str: - for placeholder in placeholder_mapping: - text = text.replace(placeholder, placeholder_mapping[placeholder]) - - # resolve {..|..} recursive - pattern = re.compile('(.*)(\\[)([^|]+)\\|(.+)(\\])(.*)') - logger.trace("item/convert_vcf_to_ics: resolve [..|..] starting with: %r", text) - while True: - match = pattern.match(text) - if not match: - # nothing more todo - break - else: - if match[3].startswith('!') and match[3].endswith('!'): - # not resolved variable - if '|' in match[4]: - # further recursion required - text = match[1] + match[2] + match[4] + match[5] + match[6] - logger.trace("item/convert_vcf_to_ics: resolve [..|..] match/replace/continue result: %r", text) - else: - text = match[1] + match[4] + match[6] - logger.trace("item/convert_vcf_to_ics: resolve [..|..] match/replace/final result: %r", text) - break - else: - # resolved variable - text = match[1] + match[3] + match[6] - logger.trace("item/convert_vcf_to_ics: resolve [..|..] match/replace(resolved) result: %r", text) - return text - - def trigger_to_timedelta(self, trigger) -> Union[datetime.timedelta, None]: - # workaround as vobject is not supporting direct set of value - # limited implementatino of reverse function of timedeltaToString in vobject/icalendar.py - pattern = re.compile('([+-])?([0-9]+)([WDHM])$') - match = pattern.match(trigger) - if not match: - logger.error("item/convert_vcf_to_ics: trigger time value not valid: %r", trigger) - return None - - sign = 1 - if match[1] == "-": - sign = -1 - - value = int(match[2]) * sign - - td: Union[datetime.timedelta, None] = None - - if match[3] == "D": - td = datetime.timedelta(days=value) - elif match[3] == "M": - td = datetime.timedelta(minutes=value) - elif match[3] == "H": - td = datetime.timedelta(hours=value) - elif match[3] == "W": - td = datetime.timedelta(weeks=value) - - return td - def convert_vcf_to_ics(self, ShareActions: dict = {}) -> Union["Item", None]: logger.trace("item/convert_vcf_to_ics: ShareActions: %r", ShareActions) logger.trace("item/convert_vcf_to_ics: convert VCF to ICS (href): %r", self.href) @@ -650,14 +654,14 @@ class Item: if ShareActions is not None and 'config' in ShareActions: if 'conversion_bday_summary_template' in ShareActions['config']: summary = ShareActions['config']['conversion_bday_summary_template'] - summary = self.replace_placeholders(summary, placeholder_mapping) + summary = replace_placeholders(summary, placeholder_mapping) # create DESCRIPTION description = "BDAY=" + bdaySdesc # default if ShareActions is not None and 'config' in ShareActions: if 'conversion_bday_description_template' in ShareActions['config']: description = ShareActions['config']['conversion_bday_description_template'] - description = self.replace_placeholders(description, placeholder_mapping) + description = replace_placeholders(description, placeholder_mapping) # check ALARM alarm_trigger = "" # default @@ -719,9 +723,9 @@ class Item: for entry in alarm_trigger.split('|'): (trigger, alarm_description) = entry.split(';') logger.trace("item/convert_vcf_to_ics: alarm trigger entry: %r (trigger=%r description=%r)", entry, trigger, description) - td = self.trigger_to_timedelta(trigger) + td = trigger_to_timedelta(trigger) if td is not None: - alarm_description = self.replace_placeholders(alarm_description, placeholder_mapping) + alarm_description = replace_placeholders(alarm_description, placeholder_mapping) alarm_description_value = alarm_description.replace("{age}", str(age)) valarm = vevent.add('valarm') valarm.add('action').value = "DISPLAY" diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 1b5020ca..dca7a89d 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -27,8 +27,8 @@ from http import client from typing import Any, Sequence, Union from urllib.parse import parse_qs -from radicale import (config, httputils, pathutils, rights, storage, types, - utils) +from radicale import (config, httputils, item, pathutils, rights, storage, + types, utils) from radicale.log import logger INTERNAL_TYPES: Sequence[str] = ("csv", "files", "none") @@ -135,11 +135,49 @@ def check_bday_max_age(data: Any) -> int: return value +def check_template(data: Any) -> str: + placeholder_mapping: dict = {} + for placeholder in item.VCF_TO_ICS_SUPPORTED_PLACEHOLDERS: + placeholder_mapping["{" + placeholder + "}"] = '!' + placeholder + '!' + + result = item.replace_placeholders(data, placeholder_mapping) + logger.trace("replace placeholders: %r -> %r", data, result) + pattern = re.compile('.*{.*}.*') + if pattern.search(result): + raise ValueError("template contains unsupported placeholder {..}: %r" % result) + return data + + +def check_template_alarm_trigger(data: Any) -> str: + if data is not None and data != '': + for entry in data.split('|'): + try: + (trigger, alarm_description) = entry.split(';') + except ValueError: + raise ValueError("alarm trigger template misses ;") + + if trigger is not None and trigger != '': + td = item.trigger_to_timedelta(trigger) + if td is None: + raise ValueError("alarm trigger template contains unsupported trigger: %r" % trigger) + else: + raise ValueError("alarm trigger template misses trigger") + + if alarm_description is not None and alarm_description != '': + try: + check_template(alarm_description) + except Exception as e: + raise e + else: + raise ValueError("alarm trigger template misses description") + return data + + ACTIONS_WHITELIST: dict = { 'config': { - 'conversion_bday_summary_template': str, - 'conversion_bday_description_template': str, - 'conversion_bday_alarm_trigger_template': str, + 'conversion_bday_summary_template': check_template, + 'conversion_bday_description_template': check_template, + 'conversion_bday_alarm_trigger_template': check_template_alarm_trigger, 'conversion_bday_age_max': check_bday_max_age, }, } @@ -872,12 +910,11 @@ class BaseSharing: if level1 in ACTIONS_WHITELIST: for level2 in request_data['Actions'][level1]: if level2 in ACTIONS_WHITELIST[level1]: - logger.trace(api_info + ": Actions validation: type='%r'", type(ACTIONS_WHITELIST[level1][level2])) if callable(ACTIONS_WHITELIST[level1][level2]): try: value = ACTIONS_WHITELIST[level1][level2](request_data['Actions'][level1][level2]) except ValueError: - hint = "'" + level1 + "': {'" + level2 + "'} is out-of-range" + hint = "'" + level1 + "': {'" + level2 + "'} is not supported" valid = False break pass From 40330ca800b7c6cdcc8a5010905c3448cd1776a8 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 30 May 2026 18:01:22 +0200 Subject: [PATCH 36/49] sharing/bday: fix typo --- radicale/tests/test_sharing.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 3f468e56..714cc4e9 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -5060,7 +5060,7 @@ permissions: RrWw""") assert "TRIGGER:-PT15H" in answer assert "TRIGGER:PT9H" in answer - self.configure({"sharing": {"conversion_bday_alarm_trigger_template": "-12H;Birthday tomorrow of {fn}|12H;Birthday today of {n:g} {n:f}'"}}) + self.configure({"sharing": {"conversion_bday_alarm_trigger_template": "-12H;Birthday tomorrow of {fn}|12H;Birthday today of {n:g} {n:f}"}}) logging.info("\n*** GET collection user format: description -> ok") _, headers, answer = self.request("GET", path_shared_3, login="user:userpw") assert "DESCRIPTION:Birthday tomorrow of Test-FN-C3" in answer From 0d198e56ac8caa3d5c615899210d9162c8b2ea85 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 30 May 2026 18:01:38 +0200 Subject: [PATCH 37/49] sharing/bday: parameter config tests --- radicale/tests/test_sharing.py | 93 +++++++++++++++++++++++++++++++++- 1 file changed, 92 insertions(+), 1 deletion(-) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 714cc4e9..71756e18 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -5068,6 +5068,97 @@ permissions: RrWw""") assert "TRIGGER:-PT12H" in answer assert "TRIGGER:PT12H" in answer + logging.info("\n*** configuration test: conversion_bday_summary_template not supported") + try: + self.configure({"sharing": { + "conversion_bday_description_template": "year={year} month={month} day={day}\nfn='{fn}'\nn:g='{n:g}'\nn:f='{n:f}'\nn:a='{notsupported}'", + }}) + except RuntimeError: + pass + else: + raise + + logging.info("\n*** configuration test: conversion_bday_description_template not supported") + try: + self.configure({"sharing": { + "conversion_bday_summary_template": "[{nickname}|{nickname}|{n:f} {n:g} {n:a}] {notsupportedplaceholder}", + }}) + except RuntimeError: + pass + else: + raise + + logging.info("\n*** configuration test: conversion_bday_alarm_trigger_template not supported") + try: + self.configure({"sharing": { + "conversion_bday_alarm_trigger_template": "-12T;Birthday tomorrow of {fn}" + }}) + except RuntimeError: + pass + else: + raise + + logging.info("\n*** configuration test: conversion_bday_alarm_trigger_template not supported") + try: + self.configure({"sharing": { + "conversion_bday_alarm_trigger_template": "BROKEN;Birthday tomorrow of {fn}" + }}) + except RuntimeError: + pass + else: + raise + + logging.info("\n*** configuration test: conversion_bday_alarm_trigger_template not supported") + try: + self.configure({"sharing": { + "conversion_bday_alarm_trigger_template": "-+BROKEN;Birthday tomorrow of {fn}" + }}) + except RuntimeError: + pass + else: + raise + + logging.info("\n*** configuration test: conversion_bday_alarm_trigger_template not supported") + try: + self.configure({"sharing": { + "conversion_bday_alarm_trigger_template": "+0;Birthday tomorrow of {fn}" + }}) + except RuntimeError: + pass + else: + raise + + logging.info("\n*** configuration test: conversion_bday_alarm_trigger_template not supported") + try: + self.configure({"sharing": { + "conversion_bday_alarm_trigger_template": "-12H" + }}) + except RuntimeError: + pass + else: + raise + + logging.info("\n*** configuration test: conversion_bday_alarm_trigger_template not supported") + try: + self.configure({"sharing": { + "conversion_bday_alarm_trigger_template": "-12H;" + }}) + except RuntimeError: + pass + else: + raise + + # update template + logging.info("\n*** update map(bday) user/owner:r with invalid config -> 400") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_r + json_dict['Actions'] = {"config": { + "conversion_bday_alarm_trigger_template": "-12H;" + }} + _, headers, answer = self._sharing_api_json("map", "update", check=400, login="owner:ownerpw", json_dict=json_dict) + 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", @@ -5144,7 +5235,7 @@ permissions: RrWw""") logging.info("\n*** configuration test: conversion_bday_age_max > MAX") try: self.configure({"sharing": { - "conversion_bday_age_max": 200, + "conversion_bday_age_max": (sharing.SHARING_BDAY_AGE_MAX + 1), }}) except RuntimeError: pass From 70ec04342338abd59dca8ceb9c911cec2cf39db5 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 30 May 2026 18:20:35 +0200 Subject: [PATCH 38/49] sharing/bday/add support for custom categories --- CHANGELOG.md | 1 + DOCUMENTATION.md | 8 ++++++++ config | 3 +++ radicale/config.py | 4 ++++ radicale/item/__init__.py | 10 ++++++++++ radicale/sharing/__init__.py | 12 ++++++++++++ radicale/tests/test_sharing.py | 9 +++++++++ 7 files changed, 47 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7254d93c..36f62c2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * Add: [sharing] conversion_bday_summary_template (customize summary) * Add: [sharing] conversion_bday_description_template (customize description) * Add: [sharing] conversion_bday_alarm_trigger_template (customize alarms) +* Add: [sharing] conversion_bday_categories (customize) * Add: [sharing] conversion_bday_age_max (limit in case of "age" placeholder is used which blocks using RRULE) ## 3.7.4 diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 6275d587..2e6fccbd 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -2352,6 +2352,14 @@ Supported format for `TIMEDELTA`: `[+-]?[0-9]+[WDHM]` Supported placeholders for `DESCRIPTION` see `conversion_bday_summary_template` +##### conversion_bday_categories + +_(>= 3.7.5)_ + +Global categories of conversion "bday", separated by `,` + +Default: `Birthday` + ##### conversion_bday_age_max _(>= 3.7.5)_ diff --git a/config b/config index 19aab2b7..1876b629 100644 --- a/config +++ b/config @@ -366,6 +366,9 @@ # Example: "-15H;BDAY tomorrow|9H;BDAY today" #conversion_bday_alarm_trigger_template = "" +# Global categories of conversion "bday" +#conversion_bday_categories = "Birthday" + # Global max limit of "bday" age # Only active in case of {age} is used as placeholder #conversion_bday_age_max = 99 diff --git a/radicale/config.py b/radicale/config.py index 9679c975..27bba0d2 100644 --- a/radicale/config.py +++ b/radicale/config.py @@ -606,6 +606,10 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([ "value": "", "help": "conversion bday alarm trigger template", "type": sharing.check_template_alarm_trigger}), + ("conversion_bday_categories", { + "value": "Birthday", + "help": "conversion bday categories", + "type": str}), ("conversion_bday_age_max", { "value": "99", "help": "conversion bday age max", diff --git a/radicale/item/__init__.py b/radicale/item/__init__.py index 2e8a6ef4..3e0fb599 100644 --- a/radicale/item/__init__.py +++ b/radicale/item/__init__.py @@ -663,6 +663,12 @@ class Item: description = ShareActions['config']['conversion_bday_description_template'] description = replace_placeholders(description, placeholder_mapping) + # create CATEGORIES + categories: list = ["Birthday"] # default + if ShareActions is not None and 'config' in ShareActions: + if 'conversion_bday_categories' in ShareActions['config']: + categories = ShareActions['config']['conversion_bday_categories'].split(',') + # check ALARM alarm_trigger = "" # default if ShareActions is not None and 'config' in ShareActions: @@ -718,6 +724,10 @@ class Item: summary_value = summary vevent.add('summary').value = summary_value + # set CATEGORIES + if categories is not None and categories != []: + vevent.add('categories').value = categories + # set VALARM if alarm_trigger is not None and alarm_trigger != "": for entry in alarm_trigger.split('|'): diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index dca7a89d..6356fd86 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -178,6 +178,7 @@ ACTIONS_WHITELIST: dict = { 'conversion_bday_summary_template': check_template, 'conversion_bday_description_template': check_template, 'conversion_bday_alarm_trigger_template': check_template_alarm_trigger, + 'conversion_bday_categories': str, 'conversion_bday_age_max': check_bday_max_age, }, } @@ -228,6 +229,7 @@ class BaseSharing: self.conversion_bday_summary_template = configuration.get("sharing", "conversion_bday_summary_template") self.conversion_bday_description_template = configuration.get("sharing", "conversion_bday_description_template") self.conversion_bday_alarm_trigger_template = configuration.get("sharing", "conversion_bday_alarm_trigger_template") + self.conversion_bday_categories = configuration.get("sharing", "conversion_bday_categories") self.conversion_bday_age_max = configuration.get("sharing", "conversion_bday_age_max") logger.info("sharing.collection_by_map : %s", self.sharing_collection_by_map) @@ -241,6 +243,7 @@ class BaseSharing: logger.info("sharing.conversion_bday_summary_template: %s", self.conversion_bday_summary_template) logger.info("sharing.conversion_bday_description_template: %s", self.conversion_bday_description_template) logger.info("sharing.conversion_bday_alarm_trigger_template: %s", self.conversion_bday_alarm_trigger_template) + logger.info("sharing.conversion_bday_categories: %s", self.conversion_bday_categories) logger.info("sharing.conversion_bday_age_max: %s", self.conversion_bday_age_max) # database tasks @@ -479,6 +482,7 @@ class BaseSharing: 'conversion_bday_summary_template': self.conversion_bday_summary_template, 'conversion_bday_description_template': self.conversion_bday_description_template, 'conversion_bday_alarm_trigger_template': self.conversion_bday_alarm_trigger_template, + 'conversion_bday_categories': self.conversion_bday_categories, 'conversion_bday_age_max': self.conversion_bday_age_max, }, } @@ -508,6 +512,14 @@ class BaseSharing: {'conversion_bday_alarm_trigger_template': self.conversion_bday_alarm_trigger_template} ) + if 'conversion_bday_categories' in share['Actions']['config']: + # nothing to do + pass + else: + share['Actions']['config'].update( + {'conversion_bday_categories': self.conversion_bday_categories} + ) + if 'conversion_bday_age_max' in share['Actions']['config']: # nothing to do pass diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 71756e18..be44d0e8 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -4830,6 +4830,7 @@ permissions: RrWw""") assert "DTEND;VALUE=DATE:19700102" in answer assert "TRANSP:TRANSPARENT" in answer assert "DESCRIPTION:BDAY=1970-01-01" in answer + assert "CATEGORIES:Birthday" in answer # content type must be adjusted assert 'Content-Type' in headers assert 'text/calendar' in headers['Content-Type'] @@ -4879,6 +4880,7 @@ permissions: RrWw""") assert "DTEND;VALUE=DATE:19700102" in answer assert "TRANSP:TRANSPARENT" in answer assert "DESCRIPTION:BDAY=1970-01-01" in answer + assert "CATEGORIES:Birthday" in answer # content type must be adjusted assert 'Content-Type' in headers assert 'text/calendar' in headers['Content-Type'] @@ -5068,6 +5070,13 @@ permissions: RrWw""") assert "TRIGGER:-PT12H" in answer assert "TRIGGER:PT12H" in answer + self.configure({"sharing": {"conversion_bday_categories": "Birthday,Geburtstag"}}) + logging.info("\n*** GET collection user format: description -> ok") + _, headers, answer = self.request("GET", path_shared_3, login="user:userpw") + assert "DESCRIPTION:Birthday tomorrow of Test-FN-C3" in answer + assert "DESCRIPTION:Birthday today of Given3Test Family3Test" in answer + assert "CATEGORIES:Birthday,Geburtstag" in answer + logging.info("\n*** configuration test: conversion_bday_summary_template not supported") try: self.configure({"sharing": { From 07c5873bdd350d1c575295adfcdb793e1f140272 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 31 May 2026 07:06:35 +0200 Subject: [PATCH 39/49] sharing/bday/templating: default config rework --- radicale/config.py | 8 ++--- radicale/item/__init__.py | 57 ++++++++++++++++++++-------------- radicale/sharing/__init__.py | 32 +++++++++++++------ radicale/tests/test_sharing.py | 12 +++---- 4 files changed, 67 insertions(+), 42 deletions(-) diff --git a/radicale/config.py b/radicale/config.py index 27bba0d2..a1688305 100644 --- a/radicale/config.py +++ b/radicale/config.py @@ -595,11 +595,11 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([ "help": "default permissions for map-based sharing", "type": rights_permission}), ("conversion_bday_summary_template", { - "value": "[{n:f} {n:g} {n:a}|{fn}|{nickname}] (BDAY)", + "value": sharing.SHARING_BDAY_SUMMARY_TEMPLATE_DEFAULT, "help": "conversion bday summary template", "type": sharing.check_template}), ("conversion_bday_description_template", { - "value": "BDAY={year}-{month}-{day}", + "value": sharing.SHARING_BDAY_DESCRIPTION_TEMPLATE_DEFAULT, "help": "conversion bday description template", "type": sharing.check_template}), ("conversion_bday_alarm_trigger_template", { @@ -607,11 +607,11 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([ "help": "conversion bday alarm trigger template", "type": sharing.check_template_alarm_trigger}), ("conversion_bday_categories", { - "value": "Birthday", + "value": sharing.SHARING_BDAY_CATEGORIES_DEFAULT, "help": "conversion bday categories", "type": str}), ("conversion_bday_age_max", { - "value": "99", + "value": str(sharing.SHARING_BDAY_AGE_MAX_DEFAULT), "help": "conversion bday age max", "type": sharing.check_bday_max_age}), ])), diff --git a/radicale/item/__init__.py b/radicale/item/__init__.py index 3e0fb599..07e0f7b3 100644 --- a/radicale/item/__init__.py +++ b/radicale/item/__init__.py @@ -38,7 +38,7 @@ from typing import (Any, Callable, List, MutableMapping, Optional, Sequence, import vobject from radicale import storage # noqa:F401 -from radicale import pathutils, utils +from radicale import pathutils, sharing, utils from radicale.item import filter as radicale_filter from radicale.log import logger @@ -594,7 +594,6 @@ class Item: placeholder_mapping: dict = {} bdayS = match[1] + match[2] + match[3] - bdaySdesc = match[1] + "-" + match[2] + "-" + match[3] bdayY = int(match[1]) bdayM = int(match[2]) bdayD = int(match[3]) @@ -649,37 +648,49 @@ class Item: else: item_ics.add('prodid').value = PRODID_CONVERTED - # create SUMMARY - summary = name + " (BDAY)" # default - if ShareActions is not None and 'config' in ShareActions: - if 'conversion_bday_summary_template' in ShareActions['config']: - summary = ShareActions['config']['conversion_bday_summary_template'] - summary = replace_placeholders(summary, placeholder_mapping) + # prepare SUMMARY + if ShareActions is not None and 'config' in ShareActions and 'conversion_bday_summary_template' in ShareActions['config']: + summary = ShareActions['config']['conversion_bday_summary_template'] + elif ShareActions is not None and 'config_default' in ShareActions and 'conversion_bday_summary_template' in ShareActions['config_default']: + summary = ShareActions['config_default']['conversion_bday_summary_template'] + else: + summary = sharing.SHARING_BDAY_SUMMARY_TEMPLATE_DEFAULT # fallback + summary = replace_placeholders(summary, placeholder_mapping) - # create DESCRIPTION - description = "BDAY=" + bdaySdesc # default - if ShareActions is not None and 'config' in ShareActions: - if 'conversion_bday_description_template' in ShareActions['config']: - description = ShareActions['config']['conversion_bday_description_template'] - description = replace_placeholders(description, placeholder_mapping) + # prepare DESCRIPTION + if ShareActions is not None and 'config' in ShareActions and 'conversion_bday_description_template' in ShareActions['config']: + description = ShareActions['config']['conversion_bday_description_template'] + elif ShareActions is not None and 'config_default' in ShareActions and 'conversion_bday_description_template' in ShareActions['config_default']: + description = ShareActions['config_default']['conversion_bday_description_template'] + else: + description = sharing.SHARING_BDAY_DESCRIPTION_TEMPLATE_DEFAULT # fallback + description = replace_placeholders(description, placeholder_mapping) # create CATEGORIES - categories: list = ["Birthday"] # default - if ShareActions is not None and 'config' in ShareActions: - if 'conversion_bday_categories' in ShareActions['config']: - categories = ShareActions['config']['conversion_bday_categories'].split(',') + if ShareActions is not None and 'config' in ShareActions and 'conversion_bday_categories' in ShareActions['config']: + categories = ShareActions['config']['conversion_bday_categories'].split(',') + elif ShareActions is not None and 'config_default' in ShareActions and 'conversion_bday_categories' in ShareActions['config_default']: + categories = ShareActions['config_default']['conversion_bday_categories'].split(',') + else: + categories = sharing.SHARING_BDAY_CATEGORIES_DEFAULT.split(',') # fallback # check ALARM - alarm_trigger = "" # default - if ShareActions is not None and 'config' in ShareActions: + if ShareActions is not None and 'config' in ShareActions and 'conversion_bday_alarm_trigger_template' in ShareActions['config']: alarm_trigger = ShareActions['config']['conversion_bday_alarm_trigger_template'] + elif ShareActions is not None and 'config_default' in ShareActions and 'conversion_bday_alarm_trigger_template' in ShareActions['config_default']: + alarm_trigger = ShareActions['config_default']['conversion_bday_alarm_trigger_template'] + else: + alarm_trigger = "" # default vevent_enable_age = False age_max = 0 if "{age}" in summary or "{age}" in description or "age" in alarm_trigger: - if ShareActions is not None and 'config' in ShareActions: - if 'conversion_bday_age_max' in ShareActions['config']: - age_max = ShareActions['config']['conversion_bday_age_max'] + if ShareActions is not None and 'config' in ShareActions and 'conversion_bday_age_max' in ShareActions['config']: + age_max = ShareActions['config']['conversion_bday_age_max'] + elif ShareActions is not None and 'config_default' in ShareActions and 'conversion_bday_age_max' in ShareActions['config_default']: + age_max = ShareActions['config_default']['conversion_bday_age_max'] + else: + age_max = sharing.SHARING_BDAY_AGE_MAX_DEFAULT # fallback vevent_enable_age = True # create UID diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 6356fd86..d2eb90dc 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -123,15 +123,19 @@ TOKEN_PATTERN_V1: str = "v1/[a-zA-Z0-9_\\-]{44}" OVERLAY_PROPERTIES_WHITELIST: Sequence[str] = ("C:calendar-description", "ICAL:calendar-color", "CR:addressbook-description", "INF:addressbook-color", "D:displayname", "ICAL:calendar-order") -SHARING_BDAY_AGE_MAX: int = 199 # maximum age to prevent unexpected DoS by config +SHARING_BDAY_AGE_MAX_LIMIT: int = 199 # maximum age to prevent unexpected DoS by config +SHARING_BDAY_AGE_MAX_DEFAULT: int = 99 +SHARING_BDAY_SUMMARY_TEMPLATE_DEFAULT: str = "[{n:f} {n:g} {n:a}|{fn}|{nickname}] ({year}) (BDAY)" +SHARING_BDAY_DESCRIPTION_TEMPLATE_DEFAULT: str = "BDAY={year}-{month}-{day}" +SHARING_BDAY_CATEGORIES_DEFAULT: str = 'Birthday' def check_bday_max_age(data: Any) -> int: value = int(data) if value < 0: raise ValueError("value is negative: %d" % value) - if value > SHARING_BDAY_AGE_MAX: - raise ValueError("value exceeds maximum (%d): %d" % (SHARING_BDAY_AGE_MAX, value)) + if value > SHARING_BDAY_AGE_MAX_LIMIT: + raise ValueError("value exceeds maximum (%d): %d" % (SHARING_BDAY_AGE_MAX_LIMIT, value)) return value @@ -478,7 +482,7 @@ class BaseSharing: # autogenerate Actions if not existing if share['Actions'] is None or 'config' not in share['Actions']: share['Actions'] = { - 'config': { + 'config_default': { 'conversion_bday_summary_template': self.conversion_bday_summary_template, 'conversion_bday_description_template': self.conversion_bday_description_template, 'conversion_bday_alarm_trigger_template': self.conversion_bday_alarm_trigger_template, @@ -492,7 +496,9 @@ class BaseSharing: # nothing to do pass else: - share['Actions']['config'].update( + if 'config_default' not in share['Actions']: + share['Actions'].update({'config_default': {}}) + share['Actions']['config_default'].update( {'conversion_bday_summary_template': self.conversion_bday_summary_template} ) @@ -500,7 +506,9 @@ class BaseSharing: # nothing to do pass else: - share['Actions']['config'].update( + if 'config_default' not in share['Actions']: + share['Actions'].update({'config_default': {}}) + share['Actions']['config_default'].update( {'conversion_bday_description_template': self.conversion_bday_description_template} ) @@ -508,7 +516,9 @@ class BaseSharing: # nothing to do pass else: - share['Actions']['config'].update( + if 'config_default' not in share['Actions']: + share['Actions'].update({'config_default': {}}) + share['Actions']['config_default'].update( {'conversion_bday_alarm_trigger_template': self.conversion_bday_alarm_trigger_template} ) @@ -516,7 +526,9 @@ class BaseSharing: # nothing to do pass else: - share['Actions']['config'].update( + if 'config_default' not in share['Actions']: + share['Actions'].update({'config_default': {}}) + share['Actions']['config_default'].update( {'conversion_bday_categories': self.conversion_bday_categories} ) @@ -524,7 +536,9 @@ class BaseSharing: # nothing to do pass else: - share['Actions']['config'].update( + if 'config_default' not in share['Actions']: + share['Actions'].update({'config_default': {}}) + share['Actions']['config_default'].update( {'conversion_bday_age_max': self.conversion_bday_age_max} ) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index be44d0e8..8d1ee198 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -5008,7 +5008,7 @@ permissions: RrWw""") assert "SUMMARY:Test-FN (BDAY)" in answer self.configure({"sharing": {"conversion_bday_summary_template": "[{fn}|{n:f} {n:g} {n:a}|{nickname}] (Birthday)"}}) - logging.info("\n*** GET collection user format:text -> ok") + logging.info("\n*** GET collection user format with fn+Birthday -> ok") _, headers, answer = self.request("GET", path_shared_2, login="user:userpw") assert "SUMMARY:Test-FN (Birthday)" in answer @@ -5244,7 +5244,7 @@ permissions: RrWw""") logging.info("\n*** configuration test: conversion_bday_age_max > MAX") try: self.configure({"sharing": { - "conversion_bday_age_max": (sharing.SHARING_BDAY_AGE_MAX + 1), + "conversion_bday_age_max": (sharing.SHARING_BDAY_AGE_MAX_LIMIT + 1), }}) except RuntimeError: pass @@ -5349,7 +5349,7 @@ permissions: RrWw""") json_dict['PathMapped'] = path_mapped json_dict['PathOrToken'] = path_shared_r json_dict['Actions'] = {"config": { - "conversion_bday_age_max": (sharing.SHARING_BDAY_AGE_MAX + 1), + "conversion_bday_age_max": (sharing.SHARING_BDAY_AGE_MAX_LIMIT + 1), }} _, headers, answer = self._sharing_api_json("map", "update", check=400, login="owner:ownerpw", json_dict=json_dict) @@ -5371,7 +5371,7 @@ permissions: RrWw""") json_dict['PathMapped'] = path_mapped json_dict['PathOrToken'] = path_shared_r json_dict['Actions'] = {"config": { - "conversion_bday_age_max": sharing.SHARING_BDAY_AGE_MAX, + "conversion_bday_age_max": sharing.SHARING_BDAY_AGE_MAX_LIMIT, }} _, headers, answer = self._sharing_api_json("map", "update", check=200, login="owner:ownerpw", json_dict=json_dict) @@ -5952,8 +5952,8 @@ permissions: RrWw""") logging.info("\n*** GET bday with token") _, answer = self.get(path_shared) assert "VCARD" not in answer - assert "Test-FN-C3 (BDAY)" in answer - assert "Test-FN (BDAY)" in answer + assert "Test-FN-C3 (BDAY)" in answer # contact2 + assert "Test-FN (BDAY)" in answer # contact1 # verify content as owner logging.info("\n*** GET collection owner -> ok") From 8a28141c3eda83e9b8ac95fd53fd253db6fe9bb7 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 31 May 2026 07:25:51 +0200 Subject: [PATCH 40/49] sharing/bday/summary template: adjust default --- DOCUMENTATION.md | 2 +- config | 2 +- radicale/sharing/__init__.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 2e6fccbd..f9a51a04 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -2311,7 +2311,7 @@ _(>= 3.7.5)_ Global template for summary of conversion "bday" -Default: `"{{n:f} {n:g} {n:a}|{fn}|{nickname}} (BDAY)"` +Default: `"{{n:f} {n:g}|{fn}|{nickname}} ({year}) (BDAY)"` Supported placeholders (data used from VCARD) * `{year}`: year of birthday (RFC6350#6.2.5) diff --git a/config b/config index 1876b629..4e9a5255 100644 --- a/config +++ b/config @@ -357,7 +357,7 @@ #default_permissions_create_map = r # Global template for summary of conversion "bday" -#conversion_bday_summary_template = "[{n:f} {n:g} {n:a}|{fn}|{nickname}] (BDAY)" +#conversion_bday_summary_template = "[{n:f} {n:g} |{fn}|{nickname}] ({year}) (BDAY)" # Global template for description of conversion "bday" #conversion_bday_description_template = "BDAY={year}-{month}-{day}" diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index d2eb90dc..7c5d5339 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -125,7 +125,7 @@ OVERLAY_PROPERTIES_WHITELIST: Sequence[str] = ("C:calendar-description", "ICAL:c SHARING_BDAY_AGE_MAX_LIMIT: int = 199 # maximum age to prevent unexpected DoS by config SHARING_BDAY_AGE_MAX_DEFAULT: int = 99 -SHARING_BDAY_SUMMARY_TEMPLATE_DEFAULT: str = "[{n:f} {n:g} {n:a}|{fn}|{nickname}] ({year}) (BDAY)" +SHARING_BDAY_SUMMARY_TEMPLATE_DEFAULT: str = "[{n:f} {n:g}|{fn}|{nickname}] ({year}) (BDAY)" SHARING_BDAY_DESCRIPTION_TEMPLATE_DEFAULT: str = "BDAY={year}-{month}-{day}" SHARING_BDAY_CATEGORIES_DEFAULT: str = 'Birthday' From d6369588f48e3240dc6c5604732ceceab714d74e Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 31 May 2026 07:26:19 +0200 Subject: [PATCH 41/49] sharing/bday/summary: check for not to be empty --- radicale/config.py | 2 +- radicale/sharing/__init__.py | 11 +++++++++-- radicale/tests/test_sharing.py | 20 ++++++++++++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/radicale/config.py b/radicale/config.py index a1688305..cf390a2d 100644 --- a/radicale/config.py +++ b/radicale/config.py @@ -597,7 +597,7 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([ ("conversion_bday_summary_template", { "value": sharing.SHARING_BDAY_SUMMARY_TEMPLATE_DEFAULT, "help": "conversion bday summary template", - "type": sharing.check_template}), + "type": sharing.check_template_not_empty}), ("conversion_bday_description_template", { "value": sharing.SHARING_BDAY_DESCRIPTION_TEMPLATE_DEFAULT, "help": "conversion bday description template", diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 7c5d5339..05a90745 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -152,6 +152,13 @@ def check_template(data: Any) -> str: return data +def check_template_not_empty(data: Any) -> str: + result = check_template(data) + if result == "": + raise ValueError("template not allowed to be empty") + return data + + def check_template_alarm_trigger(data: Any) -> str: if data is not None and data != '': for entry in data.split('|'): @@ -169,7 +176,7 @@ def check_template_alarm_trigger(data: Any) -> str: if alarm_description is not None and alarm_description != '': try: - check_template(alarm_description) + check_template_not_empty(alarm_description) except Exception as e: raise e else: @@ -179,7 +186,7 @@ def check_template_alarm_trigger(data: Any) -> str: ACTIONS_WHITELIST: dict = { 'config': { - 'conversion_bday_summary_template': check_template, + 'conversion_bday_summary_template': check_template_not_empty, 'conversion_bday_description_template': check_template, 'conversion_bday_alarm_trigger_template': check_template_alarm_trigger, 'conversion_bday_categories': str, diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 8d1ee198..24678e64 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -5097,6 +5097,26 @@ permissions: RrWw""") else: raise + logging.info("\n*** configuration test: conversion_bday_summary_template empty") + try: + self.configure({"sharing": { + "conversion_bday_summary_template": "", + }}) + except RuntimeError: + pass + else: + raise + + logging.info("\n*** configuration test: conversion_bday_description_template empty") + try: + self.configure({"sharing": { + "conversion_bday_description_template": "", + }}) + except RuntimeError: + pass + else: + pass + logging.info("\n*** configuration test: conversion_bday_alarm_trigger_template not supported") try: self.configure({"sharing": { From d192cb085d98860c39cea464260a2cf7874ff501 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 31 May 2026 07:26:44 +0200 Subject: [PATCH 42/49] sharing/bday: add class + status --- radicale/item/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/radicale/item/__init__.py b/radicale/item/__init__.py index 07e0f7b3..92b2835f 100644 --- a/radicale/item/__init__.py +++ b/radicale/item/__init__.py @@ -739,6 +739,12 @@ class Item: if categories is not None and categories != []: vevent.add('categories').value = categories + # set CLASS + vevent.add('class').value = "PRIVATE" + + # set STATUS + vevent.add('class').value = "CONFIRMED" + # set VALARM if alarm_trigger is not None and alarm_trigger != "": for entry in alarm_trigger.split('|'): From 543ceb6719e1bf388af9d8415eca79349fc4c17b Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 31 May 2026 07:27:01 +0200 Subject: [PATCH 43/49] sharing/bday: startup info cosmetics --- radicale/sharing/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 05a90745..bee138b0 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -251,10 +251,10 @@ class BaseSharing: logger.info("sharing.default_permissions_create_map : %r", self.default_permissions_create_map) logger.info("sharing.permit_properties_overlay: %s", self.permit_properties_overlay) logger.info("sharing.enforce_properties_overlay: %s", self.enforce_properties_overlay) - logger.info("sharing.conversion_bday_summary_template: %s", self.conversion_bday_summary_template) - logger.info("sharing.conversion_bday_description_template: %s", self.conversion_bday_description_template) - logger.info("sharing.conversion_bday_alarm_trigger_template: %s", self.conversion_bday_alarm_trigger_template) - logger.info("sharing.conversion_bday_categories: %s", self.conversion_bday_categories) + logger.info("sharing.conversion_bday_summary_template: %r", self.conversion_bday_summary_template) + logger.info("sharing.conversion_bday_description_template: %r", self.conversion_bday_description_template) + logger.info("sharing.conversion_bday_alarm_trigger_template: %r", self.conversion_bday_alarm_trigger_template) + logger.info("sharing.conversion_bday_categories: %r", self.conversion_bday_categories) logger.info("sharing.conversion_bday_age_max: %s", self.conversion_bday_age_max) # database tasks From 3297ffbe93470525f4a63e21224d38bc3b4da9b9 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 31 May 2026 07:32:02 +0200 Subject: [PATCH 44/49] sharing/bday: fix default config format --- DOCUMENTATION.md | 4 ++-- config | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index f9a51a04..7f6d958e 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -2311,7 +2311,7 @@ _(>= 3.7.5)_ Global template for summary of conversion "bday" -Default: `"{{n:f} {n:g}|{fn}|{nickname}} ({year}) (BDAY)"` +Default: `{{n:f} {n:g}|{fn}|{nickname}} ({year}) (BDAY)` Supported placeholders (data used from VCARD) * `{year}`: year of birthday (RFC6350#6.2.5) @@ -2334,7 +2334,7 @@ _(>= 3.7.5)_ Global template for description of conversion "bday" -Default: `"BDAY={year}-{month}-{day}"` +Default: `BDAY={year}-{month}-{day}` Supported placeholders see `conversion_bday_summary_template` diff --git a/config b/config index 4e9a5255..77f42526 100644 --- a/config +++ b/config @@ -357,17 +357,17 @@ #default_permissions_create_map = r # Global template for summary of conversion "bday" -#conversion_bday_summary_template = "[{n:f} {n:g} |{fn}|{nickname}] ({year}) (BDAY)" +#conversion_bday_summary_template = [{n:f} {n:g} |{fn}|{nickname}] ({year}) (BDAY) # Global template for description of conversion "bday" -#conversion_bday_description_template = "BDAY={year}-{month}-{day}" +#conversion_bday_description_template = BDAY={year}-{month}-{day} # Global template of alarm trigger of conversion "bday" # Example: "-15H;BDAY tomorrow|9H;BDAY today" #conversion_bday_alarm_trigger_template = "" # Global categories of conversion "bday" -#conversion_bday_categories = "Birthday" +#conversion_bday_categories = Birthday # Global max limit of "bday" age # Only active in case of {age} is used as placeholder From 8e519c3fbf4f1ddf5df3e6f5aad09fde5c760bdf Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 31 May 2026 07:32:16 +0200 Subject: [PATCH 45/49] sharing/bday: bugfix --- radicale/item/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radicale/item/__init__.py b/radicale/item/__init__.py index 92b2835f..3a777d9a 100644 --- a/radicale/item/__init__.py +++ b/radicale/item/__init__.py @@ -743,7 +743,7 @@ class Item: vevent.add('class').value = "PRIVATE" # set STATUS - vevent.add('class').value = "CONFIRMED" + vevent.add('status').value = "CONFIRMED" # set VALARM if alarm_trigger is not None and alarm_trigger != "": From 043de9d0aae0d2865cc550616e3ad5daa6fbbe5d Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 31 May 2026 07:48:14 +0200 Subject: [PATCH 46/49] sharing/bday: set description only if template result is not empty --- radicale/item/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/radicale/item/__init__.py b/radicale/item/__init__.py index 3a777d9a..14d563c7 100644 --- a/radicale/item/__init__.py +++ b/radicale/item/__init__.py @@ -771,7 +771,8 @@ class Item: description_value = description.replace("{age}", str(age)) else: description_value = description - vevent.add('description').value = description_value + if description != "": + vevent.add('description').value = description_value # increase age age = age + 1 From 5cbd1d56e5ad83f0690ea00c848a012b9b926329 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 31 May 2026 07:48:47 +0200 Subject: [PATCH 47/49] sharing/bday/description empty: test --- radicale/tests/test_sharing.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 24678e64..e28a0ef6 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -5077,6 +5077,15 @@ permissions: RrWw""") assert "DESCRIPTION:Birthday today of Given3Test Family3Test" in answer assert "CATEGORIES:Birthday,Geburtstag" in answer + self.configure({ + "sharing": {"conversion_bday_description_template": "", + "conversion_bday_alarm_trigger_template": "", + } + }) + logging.info("\n*** GET collection user format: no description -> ok") + _, headers, answer = self.request("GET", path_shared_3, login="user:userpw") + assert "DESCRIPTION" not in answer + logging.info("\n*** configuration test: conversion_bday_summary_template not supported") try: self.configure({"sharing": { From 07f06e385bd2a58a14f5e37be108e3c721571a8b Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 31 May 2026 07:49:08 +0200 Subject: [PATCH 48/49] sharing/bday: extend changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36f62c2e..9dcdc6ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ * Add: [sharing] conversion_bday_alarm_trigger_template (customize alarms) * Add: [sharing] conversion_bday_categories (customize) * Add: [sharing] conversion_bday_age_max (limit in case of "age" placeholder is used which blocks using RRULE) +* Extension: [sharing/bday conversion]: add STATUS + CLASS fields ## 3.7.4 * Fix: sharing: PROPFIND returns now empty owner element in case of a mapped share as clients try PROPFIND on this not accessable href From f43c4767ba9aa5a2b33d103f16a29f6d87908368 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 31 May 2026 07:49:22 +0200 Subject: [PATCH 49/49] sharing/bday: test status+class --- radicale/tests/test_sharing.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index e28a0ef6..3d08ab67 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -4881,6 +4881,8 @@ permissions: RrWw""") assert "TRANSP:TRANSPARENT" in answer assert "DESCRIPTION:BDAY=1970-01-01" in answer assert "CATEGORIES:Birthday" in answer + assert "CLASS:PRIVATE" in answer + assert "STATUS:CONFIRMED" in answer # content type must be adjusted assert 'Content-Type' in headers assert 'text/calendar' in headers['Content-Type']