From 14172cf3c833afec564a657195e75473d13232c6 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 15 Mar 2026 18:10:07 +0100 Subject: [PATCH 01/46] sharing/update: fix for self-owned --- radicale/sharing/__init__.py | 38 +++++++++++++++++++++++++----------- 1 file changed, 27 insertions(+), 11 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 6a6ee954..eeb6ad24 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -1023,17 +1023,33 @@ class BaseSharing: logger.warning(api_info + ": access to %r not allowed for user %r", PathMapped, user) return httputils.NOT_ALLOWED - result = self.database_update_sharing( - ShareType=ShareType, - PathMapped=PathMapped, - Permissions=Permissions, - EnabledByOwner=Enabled, - HiddenByOwner=Hidden, - PathOrToken=PathOrToken, - OwnerOrUser=user, - User=User, - Timestamp=Timestamp, - Properties=Properties) + if user == share['User']: + # self-owned share + result = self.database_update_sharing( + ShareType=ShareType, + PathMapped=PathMapped, + Permissions=Permissions, + EnabledByOwner=Enabled, + EnabledByUser=Enabled, + HiddenByOwner=Hidden, + HiddenByUser=Hidden, + PathOrToken=PathOrToken, + OwnerOrUser=user, + User=User, + Timestamp=Timestamp, + Properties=Properties) + else: + result = self.database_update_sharing( + ShareType=ShareType, + PathMapped=PathMapped, + Permissions=Permissions, + EnabledByOwner=Enabled, + HiddenByOwner=Hidden, + PathOrToken=PathOrToken, + OwnerOrUser=user, + User=User, + Timestamp=Timestamp, + Properties=Properties) elif user == share['User']: # User is only allowed to update Properties From aafc09d81c315fa5e2d3e807e08528c5bc0c3744 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 15 Mar 2026 12:40:02 +0100 Subject: [PATCH 02/46] sharing/propfind: feed ShareType (rev-5) --- radicale/app/propfind.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index 4af86d2b..0378c1e9 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -491,7 +491,10 @@ class ApplicationPartPropfind(ApplicationBase): # put item back items_iter = itertools.chain([item], items_iter) for item, permission in list(self._collect_allowed_items(items_iter, user)): - allowed_items.append((item, permission, None)) + if share: + allowed_items.append((item, permission, share['ShareType'])) + else: + allowed_items.append((item, permission, None)) if self._sharing._enabled: if http_depth == "1": if logger.isEnabledFor(logging.DEBUG): From 20838efb54bf126986fc5f9bdc5f77950ae7eeef Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 13 Mar 2026 18:19:14 +0100 Subject: [PATCH 03/46] sharing/bday: doc --- DOCUMENTATION.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index d1864280..66d2c8c3 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -2120,6 +2120,14 @@ Share collection by map Default: `false` +##### collection_by_bday + +_(>= 3.8.0)_ + +Share collection by bday (conversion on-the-fly) + +Default: `false` + ##### permit_create_token _(>= 3.7.0)_ @@ -2142,6 +2150,17 @@ Default: `false` * If `False` it can be explicitly granted by *rights* permissions: `M` * If `True` it can be explicitly forbidden by *rights* permissions: `m` +##### permit_create_bday + +_(>= 3.8.0)_ + +Permit create of bday-based sharing + +Default: `false` + +* If `False` it can be explicitly granted by *rights* permissions: `B` +* If `True` it can be explicitly forbidden by *rights* permissions: `b` + ##### permit_properties_overlay _(>= 3.7.0)_ From 8b7b060fe9af22bfe688a79454a1f54bd9104f01 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 13 Mar 2026 18:19:41 +0100 Subject: [PATCH 04/46] sharing/bday: config parser --- radicale/config.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/radicale/config.py b/radicale/config.py index 0d50dc7b..81504364 100644 --- a/radicale/config.py +++ b/radicale/config.py @@ -487,6 +487,10 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([ "value": "false", "help": "enable sharing of collection by map", "type": bool}), + ("collection_by_bday", { + "value": "false", + "help": "enable sharing of collection by bday (conversion on-the-fly)", + "type": bool}), ("permit_create_token", { "value": "false", "help": "permit create of token-based sharing", @@ -495,6 +499,10 @@ DEFAULT_CONFIG_SCHEMA: types.CONFIG_SCHEMA = OrderedDict([ "value": "false", "help": "permit create of map-based sharing", "type": bool}), + ("permit_create_bday", { + "value": "false", + "help": "permit create of bday-based sharing", + "type": bool}), ("permit_properties_overlay", { "value": "false", "help": "permit properties overlay", From bd93e0b81dd93ab33a6010ef03314be02dde0649 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 13 Mar 2026 18:20:07 +0100 Subject: [PATCH 05/46] sharing/bday: config example --- config | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/config b/config index 399d6d9e..98e13af2 100644 --- a/config +++ b/config @@ -320,6 +320,9 @@ # Share collection by map #collection_by_map = false +# Share collection by bday (conversion on-the-fly) +#collection_by_bday = false + # Permit create of token-based sharing # If False it can be explicitly granted by permissions: t # If True it can be explicitly forbidden by permissions: T @@ -330,6 +333,11 @@ # If True it can be explicitly forbidden by permissions: M #permit_create_map = false +# Permit create of bday-based sharing +# If False it can be explicitly granted by permissions: b +# If True it can be explicitly forbidden by permissions: B +#permit_create_bday = false + # Permit properties overlay # If False it can be explicitly granted by share permissions: P # If True it can be explicitly forbidden by share permissions: p From bc1c5dbe368da7a37db07e2c9434528d5f26560d Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 13 Mar 2026 18:25:05 +0100 Subject: [PATCH 06/46] sharing/bday: tests --- radicale/tests/test_sharing.py | 132 +++++++++++++++++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 9de2734d..3b7cec74 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -256,6 +256,7 @@ class TestSharingApiSanity(BaseTest): "htpasswd_encryption": "plain"}, "sharing": { "collection_by_map": "True", + "collection_by_bday": "True", "collection_by_token": "True"}, "rights": {"type": "owner_only"}}) @@ -333,13 +334,16 @@ class TestSharingApiSanity(BaseTest): # path with valid API and hook and all enabled self.configure({"sharing": { "collection_by_map": "True", + "collection_by_bday": "True", "collection_by_token": "True"} }) for sharetype in sharing.SHARE_TYPES: path = "/.sharing/v1/" + sharetype + "/" + action # invalid API + logging.info("\n*** check hook -> 404 (invalid)") _, headers, _ = self.request("POST", path + "NA", check=404, login="owner:ownerpw") # valid API + logging.info("\n*** check hook -> 400 (valid but no data)") _, headers, _ = self.request("POST", path, check=400, login="owner:ownerpw") logging.info("\n*** check API hook: info/token -> 200") @@ -353,8 +357,10 @@ class TestSharingApiSanity(BaseTest): # When turning on permission to create self.configure({"sharing": { "collection_by_map": "True", + "collection_by_bday": "True", "collection_by_token": "True", "permit_create_map": "True", + "permit_create_bday": "True", "permit_create_token": "True"} }) logging.info("\n*** check API hook: info/all") @@ -363,8 +369,10 @@ class TestSharingApiSanity(BaseTest): answer_dict = json.loads(answer) assert answer_dict['FeatureEnabledCollectionByMap'] is True, f'FeatureEnabledCollectionByMap {db_type}' assert answer_dict['FeatureEnabledCollectionByToken'] is True, f'FeatureEnabledCollectionByToken {db_type}' + assert answer_dict['FeatureEnabledCollectionByBday'] is True, f'FeatureEnabledCollectionByBday {db_type}' assert answer_dict['PermittedCreateCollectionByMap'] is True, f'PermittedCreateCollectionByMap {db_type}' assert answer_dict['PermittedCreateCollectionByToken'] is True, f'PermittedCreateCollectionByToken {db_type}' + assert answer_dict['PermittedCreateCollectionByBday'] is True, f'PermittedCreateCollectionByBday {db_type}' def test_sharing_api_list_with_auth(self) -> None: """POST/list with authentication.""" @@ -374,8 +382,10 @@ class TestSharingApiSanity(BaseTest): "sharing": { "type": "csv", "permit_create_map": True, + "permit_create_bday": True, "permit_create_token": True, "collection_by_map": "True", + "collection_by_bday": "True", "collection_by_token": "True"}, "logging": {"request_header_on_debug": "true", "request_content_on_debug": "True"}, @@ -478,6 +488,7 @@ class TestSharingApiSanity(BaseTest): "permit_create_map": True, "permit_create_token": True, "collection_by_map": "True", + "collection_by_bday": "True", "collection_by_token": "True"}, "logging": {"request_header_on_debug": "False", "response_content_on_debug": "True", @@ -4170,3 +4181,124 @@ permissions: RrWw""") assert answer_dict['Lines'] == 1 assert 'ICAL:calendar-color' not in answer_dict['Content'][0]['Properties'] assert 'C:calendar-description' not in answer_dict['Content'][0]['Properties'] + + def test_sharing_api_bday_basic(self) -> None: + """share-by-map API usage tests related to partial overlay.""" + self.configure({"auth": {"type": "htpasswd", + "htpasswd_filename": self.htpasswd_file_path, + "htpasswd_encryption": "plain"}, + "sharing": { + "type": "csv", + "permit_create_bday": True, + "permit_properties_overlay": "True", + "enforce_properties_overlay": "True", + "collection_by_bday": "True"}, + "logging": {"request_header_on_debug": "False", + "response_content_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") + + contact = get_file_content("contact1.vcf") + path = path_mapped + "/contact1.vcf" + self.put(path, contact, login="owner:ownerpw") + + contact = get_file_content("contact2-with-bday.vcf") + path = path_mapped + "/contact2-with-bday.vcf" + self.put(path, contact, login="owner:ownerpw") + + contact = get_file_content("contact3-with-bday.vcf") + path = path_mapped + "/contact3-with-bday.vcf" + self.put(path, contact, login="owner:ownerpw") + + # check PROPFIND as owner + logging.info("\n*** PROPFIND collection owner -> ok") + _, responses = self.propfind(path_mapped, """\ + + + +""", login="owner:ownerpw") + logging.info("response: %r", responses) + response = responses[path_mapped] + assert not isinstance(response, int) + assert "CR:supported-address-data" in response + + # execute GET as owner + logging.info("\n*** GET VCF collection owner -> ok") + _, answer = self.get(path_mapped, login="owner:ownerpw") + assert "contact1" in answer + assert "contact2" in answer + + # create map + logging.info("\n*** create bday user/owner:r -> ok") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_r + json_dict['Permissions'] = "r" + json_dict['Enabled'] = True + json_dict['Hidden'] = False + _, headers, answer = self._sharing_api_json("bday", "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 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("bday", "enable", check=200, login="user:userpw", json_dict=json_dict) + + # check PROPFIND item as user + logging.info("\n*** PROPFIND item as user -> calendar") + response = self._propfind_allprop(path_shared_r, login="user:userpw") + logging.debug("response: %r", response) + assert "CR:supported-address-data" not in response + assert "D:sync-token" not in response + assert "C:supported-calendar-component-set" in response + assert "D:current-user-privilege-set" in response + + # check PROPFIND/priviledges item as user + logging.info("\n*** PROPFIND/priviledges item as user -> calendar") + priviledges_list = self._propfind_priviledges(path_shared_r, login="user:userpw") + assert "D:read" in priviledges_list + assert "D:write-content" not in priviledges_list + assert "D:write-properties" in priviledges_list + assert "D:write" not in priviledges_list + assert "D:all" not in priviledges_list + + # verify content as user + logging.info("\n*** GET collection user -> ok") + _, answer = self.get(path_shared_r, login="user:userpw") + assert "BEGIN:VCARD" not in answer + assert "BEGIN:VCALENDAR" in answer + assert "RRULE:FREQ=YEARLY" in answer + assert "DTSTART;VALUE=DATE:19700101" in answer + assert "DTEND;VALUE=DATE:19700102" in answer + assert "TRANSP:TRANSPARENT" in answer + + # verify report as user + logging.info("\n*** REPORT collection user -> ok") + _, responses = self.report(path_shared_r, """\ + + + + + +""", login="user:userpw") + logging.debug("resonses: %r", responses) + assert path_shared_r + "contact2-with-bday.ics" in responses + assert path_shared_r + "contact3-with-bday.ics" in responses + assert path_shared_r + "contact1.ics" not in responses From 127f2d1a36ad0257603c8b65198b6a3326db015e Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 13 Mar 2026 18:26:07 +0100 Subject: [PATCH 07/46] sharing/bday: extend permission list --- radicale/rights/__init__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/radicale/rights/__init__.py b/radicale/rights/__init__.py index 2e51fe0b..a195e901 100644 --- a/radicale/rights/__init__.py +++ b/radicale/rights/__init__.py @@ -35,6 +35,8 @@ Permissions: - t: deny create of token-based sharing of collection in case permit_create_token=True (>= 3.7.0) - M: permit create of map-based sharing of collection in case permit_create_map=False (>= 3.7.0) - m: deny create of map-based sharing of collection in case permit_create_map=True (>= 3.7.0) + - B: permit create of bday-based sharing of collection in case permit_create_bday=False (>= 3.8.0) + - b: deny create of bday-based sharing of collection in case permit_create_bday=True (>= 3.8.0) Permissions only supported so far in share permissions: - P: permit properties overlay in case permit_properties_overlay=False (>= 3.7.0) @@ -53,7 +55,7 @@ from radicale import config, utils INTERNAL_TYPES: Sequence[str] = ("authenticated", "owner_write", "owner_only", "from_file") -INTERNAL_PERMISSIONS: str = "RriWwDdOoTtMmPpEe" +INTERNAL_PERMISSIONS: str = "RriWwDdOoTtMmPpEeBb" def load(configuration: "config.Configuration") -> "BaseRights": From 79061b04fc16273a2ecc76a6dca6363eb202cbac Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 13 Mar 2026 18:27:00 +0100 Subject: [PATCH 08/46] sharing/bday: add hook for conversion --- radicale/storage/__init__.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/radicale/storage/__init__.py b/radicale/storage/__init__.py index ba4d1038..1e0b16bf 100644 --- a/radicale/storage/__init__.py +++ b/radicale/storage/__init__.py @@ -25,6 +25,7 @@ Take a look at the class ``BaseCollection`` if you want to implement your own. """ import json +import logging import xml.etree.ElementTree as ET from hashlib import sha256 from typing import (Callable, ContextManager, Dict, Iterable, Iterator, List, @@ -223,7 +224,7 @@ class BaseCollection: """Get the HTTP-datetime of when the collection was modified.""" raise NotImplementedError - def serialize(self) -> str: + def serialize(self, vcf_to_ics: bool = False) -> str: """Get the unicode string representing the whole collection.""" if self.tag == "VCALENDAR": in_vcalendar = False @@ -282,7 +283,23 @@ class BaseCollection: vtimezones + components + template[template_insert_pos:]) if self.tag == "VADDRESSBOOK": - return "".join((item.serialize() for item in self.get_all())) + if vcf_to_ics: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/storage: convert VCF to ICS") + + items = [] + for item in self.get_all(): + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/storage/convert VCF to ICS: %r:", item) + item_ics = item.convert_vcf_to_ics() + if item_ics is None: + continue + else: + pass + items.append(item_ics.vobject_item) + return "".join((item.serialize() for item in items)) + else: + return "".join((item.serialize() for item in self.get_all())) return "" From fe4fc5324b86c81723de4d85e4bba4618826f3e0 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 13 Mar 2026 18:27:28 +0100 Subject: [PATCH 09/46] sharing/bday: add support --- radicale/sharing/csv.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/radicale/sharing/csv.py b/radicale/sharing/csv.py index 9b609a52..c7ce48ed 100644 --- a/radicale/sharing/csv.py +++ b/radicale/sharing/csv.py @@ -241,6 +241,17 @@ class Sharing(sharing.BaseSharing): # must be unique systemwide logger.error("sharing/map/create: entry already exists: PathMapped=%r User=%r", PathMapped, User) return {"status": "conflict"} + elif ShareType == "bday": + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/bday/create: PathOrToken=%r Owner=%r PathMapped=%r User=%r Permissions=%r", PathOrToken, Owner, PathMapped, User, Permissions) + # check for duplicate map entry + for row in self._sharing_cache: + if row['ShareType'] != "bday": + continue + if row['PathMapped'] == PathMapped and row['User'] == User and row['PathOrToken'] == PathOrToken: + # must be unique systemwide + logger.error("sharing/bday/create: entry already exists: PathMapped=%r User=%r", PathMapped, User) + return {"status": "conflict"} else: return {"status": "error"} From 22554f8b4ef47e30b93c8470c806a36f05695bf1 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 13 Mar 2026 21:16:26 +0100 Subject: [PATCH 10/46] sharing/bday/get: add on-the-fly conversion hook --- radicale/app/get.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/radicale/app/get.py b/radicale/app/get.py index fa4158ad..e42f27ff 100644 --- a/radicale/app/get.py +++ b/radicale/app/get.py @@ -117,5 +117,9 @@ class ApplicationPartGet(ApplicationBase): "ETag": item.etag} if content_disposition: headers["Content-Disposition"] = content_disposition - answer = item.serialize() + if isinstance(item, storage.BaseCollection) and self._sharing._enabled and share and share['ShareType'] == "bday": + # convert VCF to ICS + answer = item.serialize(vcf_to_ics=True) + else: + answer = item.serialize() return client.OK, headers, answer, None From c5623bc7c719b5274446001f7384f6cfa79934d8 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 13 Mar 2026 21:19:58 +0100 Subject: [PATCH 11/46] sharing/bday/propfind: add mapping/handling support --- radicale/app/propfind.py | 41 +++++++++++++++++++++++++++++++--------- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index 0378c1e9..a0ce858b 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -127,6 +127,10 @@ def xml_propfind_response( href.text = xmlutils.make_href(base_prefix, uri) response.append(href) + share_bday_automap = False + if share and share['ShareType'] == "bday": + share_bday_automap = True + if propname or allprop: props = [] # Should list all properties that can be retrieved by the code below @@ -155,12 +159,16 @@ def xml_propfind_response( if is_collection: if is_leaf: props.append(xmlutils.make_clark("D:displayname")) - props.append(xmlutils.make_clark("D:sync-token")) - if collection.tag == "VCALENDAR": + if share_bday_automap: + # bday share has no sync-token support + pass + else: + props.append(xmlutils.make_clark("D:sync-token")) + if collection.tag == "VCALENDAR" or share_bday_automap: props.append(xmlutils.make_clark("CS:getctag")) props.append( xmlutils.make_clark("C:supported-calendar-component-set")) - if collection.tag == "VADDRESSBOOK": + if collection.tag == "VADDRESSBOOK" and not share_bday_automap: props.append(xmlutils.make_clark("CS:getctag")) props.append( xmlutils.make_clark("CR:supported-address-data")) @@ -205,6 +213,8 @@ def xml_propfind_response( if share: # backmap child_element.text = child_element.text.replace(share['PathMapped'], share['PathOrToken']) + if share_bday_automap: + child_element.text = child_element.text.rstrip(".vcf") + ".ics" element.append(child_element) elif tag == xmlutils.make_clark("C:supported-calendar-component-set"): human_tag = xmlutils.make_human_tag(tag) @@ -214,6 +224,9 @@ def xml_propfind_response( components = components_text.split(",") else: components = ["VTODO", "VEVENT", "VJOURNAL"] + if share_bday_automap: + # enforce VEVENT-only + components = ["VEVENT"] for component in components: comp = ET.Element(xmlutils.make_clark("C:comp")) comp.set("name", component) @@ -221,7 +234,7 @@ def xml_propfind_response( else: is404 = True elif tag == xmlutils.make_clark("CR:supported-address-data"): - if is_collection and is_leaf and collection.tag == "VADDRESSBOOK": + if is_collection and is_leaf and collection.tag == "VADDRESSBOOK" and not share_bday_automap: # Advertise supported vCard versions per RFC 6352 section 6.2.2 # vCard 4.0 requires vobject >= 1.0.0 versions: Sequence[str] = (("4.0", "3.0") @@ -243,6 +256,8 @@ def xml_propfind_response( if share: # backmap child_element.text = child_element.text.replace(share['Owner'], share['User']) + if share_bday_automap: + child_element.text = child_element.text.rstrip(".vcf") + ".ics" element.append(child_element) else: element.append(ET.Element( @@ -254,7 +269,8 @@ def xml_propfind_response( if "P" in share['Permissions']: privileges.append("D:write-properties") if "w" in share['Permissions']: - privileges.append("D:write-content") + if not share_bday_automap: + privileges.append("D:write-content") elif write: privileges.append("D:all") privileges.append("D:write") @@ -272,10 +288,10 @@ def xml_propfind_response( "D:principal-property-search"] if is_collection and is_leaf: reports.append("D:sync-collection") - if collection.tag == "VADDRESSBOOK": + if collection.tag == "VADDRESSBOOK" and not share_bday_automap: reports.append("CR:addressbook-multiget") reports.append("CR:addressbook-query") - elif collection.tag == "VCALENDAR": + elif collection.tag == "VCALENDAR" or share_bday_automap: reports.append("C:calendar-multiget") reports.append("C:calendar-query") for human_tag in reports: @@ -306,6 +322,9 @@ def xml_propfind_response( if is_leaf: element.text = xmlutils.MIMETYPES[ collection.tag] + if share_bday_automap: + # overwrite + element.text = xmlutils.MIMETYPES["VCALENDAR"] else: is404 = True elif tag == xmlutils.make_clark("D:resourcetype"): @@ -314,11 +333,11 @@ def xml_propfind_response( xmlutils.make_clark("D:principal")) element.append(child_element) if is_leaf: - if collection.tag == "VADDRESSBOOK": + if collection.tag == "VADDRESSBOOK" and not share_bday_automap: child_element = ET.Element( xmlutils.make_clark("CR:addressbook")) element.append(child_element) - elif collection.tag == "VCALENDAR": + elif collection.tag == "VCALENDAR" or share_bday_automap: child_element = ET.Element( xmlutils.make_clark("C:calendar")) element.append(child_element) @@ -335,6 +354,8 @@ def xml_propfind_response( displayname = share['Properties']["D:displayname"] if displayname is not None: element.text = displayname + if share_bday_automap: + element.text += " (BDAY)" else: is404 = True elif tag == xmlutils.make_clark("RADICALE:getcontentcount"): @@ -351,6 +372,8 @@ def xml_propfind_response( displayname = collection.path if displayname is not None: element.text = displayname + if share_bday_automap: + element.text += " (BDAY)" else: is404 = True elif tag == xmlutils.make_clark("CS:getctag"): From 03b5ed162ffa5c14e1370bcc1e6918a9a09270f7 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 13 Mar 2026 21:20:30 +0100 Subject: [PATCH 12/46] sharing/bday/report: add support --- radicale/app/report.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/radicale/app/report.py b/radicale/app/report.py index ef18cfd8..bf035e91 100644 --- a/radicale/app/report.py +++ b/radicale/app/report.py @@ -259,6 +259,20 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element], collection_tag = collection.tag # !!! Don't access storage after this !!! unlock_storage_fn() + if share and share['ShareType'] == "bday": + collection_tag = "VCALENDAR" + # autoconvert + retrieved_items_vcf_to_ics = [] + for item, flag in retrieved_items: + item_ics = item.convert_vcf_to_ics() + if item_ics is None: + continue + else: + pass + item_ics.href = item.href + retrieved_items_vcf_to_ics.append((item_ics, flag)) + retrieved_items = retrieved_items_vcf_to_ics + logging.debug("TRACE/REPORT/retrieved_items: %r", retrieved_items) n_vevents = 0 while retrieved_items: @@ -712,12 +726,20 @@ def xml_item_response(base_prefix: str, href: str, found_props: Sequence[ET.Element] = (), not_found_props: Sequence[ET.Element] = (), found_item: bool = True, share: Union[dict, None] = None) -> ET.Element: + response = ET.Element(xmlutils.make_clark("D:response")) + share_bday_automap = False + if share and share['ShareType'] == "bday": + share_bday_automap = True + href_element = ET.Element(xmlutils.make_clark("D:href")) href_element.text = xmlutils.make_href(base_prefix, href) if share: + # backmap href_element.text = href_element.text.replace(share['PathMapped'], share['PathOrToken']) + if share_bday_automap: + href_element.text = href_element.text.rstrip(".vcf") + ".ics" response.append(href_element) if found_item: From 4cc1f96adab7040f51892edaee40ac3d05833578 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 13 Mar 2026 21:34:46 +0100 Subject: [PATCH 13/46] sharing/bday/item: add conversion function --- radicale/item/__init__.py | 119 +++++++++++++++++++++++++++++++++++++- 1 file changed, 118 insertions(+), 1 deletion(-) diff --git a/radicale/item/__init__.py b/radicale/item/__init__.py index 48c0bdaa..7349c1c7 100644 --- a/radicale/item/__init__.py +++ b/radicale/item/__init__.py @@ -26,6 +26,7 @@ Module for address books and calendar entries (see ``Item``). import binascii import contextlib +import logging import math import os import re @@ -33,7 +34,7 @@ from datetime import datetime, timedelta from hashlib import sha256 from itertools import chain from typing import (Any, Callable, List, MutableMapping, Optional, Sequence, - Tuple) + Tuple, Union) import vobject @@ -42,6 +43,11 @@ from radicale import pathutils, utils from radicale.item import filter as radicale_filter from radicale.log import logger +# Product ID for auto-conversion +PRODID_CONVERTED = u"-//Radicale//NONSGML " + utils.package_version("radicale") + "//EN (auto-converted)" +PRODID_SUFFIX = " (auto-converted by Radicale " + utils.package_version("radicale") + ")" +UID_SUFFIX = "-auto-converted-by-Radicale" + def read_components(s: str) -> List[vobject.base.Component]: """Wrapper for vobject.readComponents""" @@ -498,3 +504,114 @@ class Item: self.time_range self.component_name self._vobject_item = orig_vobject_item + + def convert_vcf_to_ics(self) -> Union["Item" | None]: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/item/convert_vcf_to_ics: convert VCF to ICS (href): %r", self.href) + logger.debug("TRACE/item/convert_vcf_to_ics: convert VCF to ICS (vobject): %r", self.vobject_item) + if self.vobject_item.name != "VCARD": + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/item/convert_vcf_to_ics: item is not a VCARD (skip): %r", self.href) + return None + else: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/item/convert_vcf_to_ics: item is a VCARD (ok): %r", self.href) + if not hasattr(self.vobject_item, "bday"): + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/item/convert_vcf_to_ics: miss bday (skip): %r", self.href) + return None + else: + pass + + bday = self.vobject_item.bday + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/item/convert_vcf_to_ics: has bday (ok): %r -> %r", self.href, bday.value) + + pattern = re.compile('^([0-9]{4})-?([0-9]{2})-?([0-9]{2})$') + match = pattern.match(bday.value) + if not match: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/item/convert_vcf_to_ics: has unsupported bday: %r -> %r", self.href, bday.value) + return None + else: + pass + + bdayY = int(match[1]) + bdayM = int(match[2]) + bdayD = int(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 + elif hasattr(self.vobject_item, "nickname"): + name = self.vobject_item.nickname.value + else: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/item/convert_vcf_to_ics: has bday but neither FN or N or NICKNAME (skip): %r", self.href) + return None + + # create VCALENDAR + item_ics = vobject.newFromBehavior('vcalendar') + + # set PRODID + if hasattr(self.vobject_item, "prodid"): + item_ics.add('prodid').value = self.vobject_item.prodid.value + PRODID_SUFFIX + else: + item_ics.add('prodid').value = PRODID_CONVERTED + + # create EVENT + item_ics.add('vevent') + + # set DTSTART + date = datetime(bdayY, bdayM, bdayD) + dtstart = date.strftime('%Y%m%d') + item_ics.vevent.add('dtstart;VALUE=DATE').value = dtstart + + # calculate and set DTEND + date += timedelta(days=1) + item_ics.vevent.add('dtend;VALUE=DATE').value = date.strftime('%Y%m%d') + + # 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' + dtstart + 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 + item_ics.vevent.add('summary').value = name + " (BDAY)" + + # set RRULE + item_ics.vevent.add('rrule').value = "FREQ=YEARLY" + + # add transparency + item_ics.vevent.add('transp').value = "TRANSPARENT" + + href = self.href + if href is not None: + href = href.rstrip(".vcf") + ".ics" + + etag = self.etag + # replace 14 leading chars of etag "" by special format bda0YYYYMMDD00 + etag = '"bda0' + dtstart + '00' + etag[15:] + + item_new: Item = Item( + collection=self.collection, + etag=etag, + href=href, + vobject_item=item_ics) + + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/storage: item generated/vobject: %r", item_ics.serialize()) + logger.debug("TRACE/storage: item orig /etag : %r", self.etag) + logger.debug("TRACE/storage: item generated/etag : %r", item_new.etag) + logger.debug("TRACE/storage: item generated/href : %r", item_new.href) + + return item_new From bf3684165709d26b924ca6653f29d712bc61c3bd Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 13 Mar 2026 21:38:26 +0100 Subject: [PATCH 14/46] sharing/bday: add api+resolver support --- radicale/sharing/__init__.py | 112 ++++++++++++++++++++++++++++------- 1 file changed, 91 insertions(+), 21 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index eeb6ad24..a02f874a 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -67,11 +67,12 @@ DB_TYPES_V1: dict[str, type] = { DB_FIELDS_V1_USER_PERMITTED: Sequence[str] = ('EnabledByUser', 'HiddenByUser', 'Properties') -SHARE_TYPES: Sequence[str] = ('token', 'map', 'all') +SHARE_TYPES: Sequence[str] = ('token', 'map', 'bday', 'all') -SHARE_TYPES_V1: Sequence[str] = ('token', 'map') +SHARE_TYPES_V1: Sequence[str] = ('token', 'map', 'bday') # token: share by secret token (does not require authentication) # map : share by mapping collection of one user to another as virtual +# bday : share by mapping addressbook-collection of one user to another as virtual calendar-collection # all : only supported for "list" and "info" API_HOOKS_V1: Sequence[str] = ('list', 'create', 'delete', 'update', 'hide', 'unhide', 'enable', 'disable', 'info') @@ -93,8 +94,10 @@ API_TYPES_V1: dict[str, type] = { "Lines": int, "FeatureEnabledCollectionByMap": bool, "FeatureEnabledCollectionByToken": bool, + "FeatureEnabledCollectionByBday": bool, "PermittedCreateCollectionByMap": bool, "PermittedCreateCollectionByToken": bool, + "PermittedCreateCollectionByBday": bool, "ShareType": str, "PathOrToken": str, "PathMapped": str, @@ -142,16 +145,20 @@ class BaseSharing: # Sharing self.sharing_collection_by_map = configuration.get("sharing", "collection_by_map") self.sharing_collection_by_token = configuration.get("sharing", "collection_by_token") + self.sharing_collection_by_bday = configuration.get("sharing", "collection_by_bday") self.permit_create_token = configuration.get("sharing", "permit_create_token") self.permit_create_map = configuration.get("sharing", "permit_create_map") + self.permit_create_bday = configuration.get("sharing", "permit_create_bday") self.default_permissions_create_token = configuration.get("sharing", "default_permissions_create_token") 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") logger.info("sharing.collection_by_map : %s", self.sharing_collection_by_map) logger.info("sharing.collection_by_token: %s", self.sharing_collection_by_token) + logger.info("sharing.collection_by_bday : %s", self.sharing_collection_by_bday) logger.info("sharing.permit_create_token: %s", self.permit_create_token) logger.info("sharing.permit_create_map : %s", self.permit_create_map) + logger.info("sharing.permit_create_bday : %s", self.permit_create_bday) logger.info("sharing.default_permissions_create_token: %r", self.default_permissions_create_token) logger.info("sharing.default_permissions_create_map : %r", self.default_permissions_create_map) logger.info("sharing.permit_properties_overlay: %s", self.permit_properties_overlay) @@ -161,7 +168,7 @@ class BaseSharing: self.sharing_db_type = configuration.get("sharing", "type") logger.info("sharing.database_type: %s", self.sharing_db_type) - if ((self.sharing_collection_by_map is False) and (self.sharing_collection_by_token is False)): + if ((self.sharing_collection_by_map is False) and (self.sharing_collection_by_token is False) and (self.sharing_collection_by_bday is False)): logger.info("sharing disabled as no feature is enabled") self._enabled = False return @@ -366,6 +373,13 @@ class BaseSharing: if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/map: not active") + if self.sharing_collection_by_bday: + if share is None: + share = self.sharing_collection_by_bday_resolver(path, user) + else: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/bday: not active") + if share is not None: if self.permit_properties_overlay: if share['Permissions'] and "p" not in share['Permissions']: @@ -466,6 +480,43 @@ class BaseSharing: logger.debug("TRACE/sharing/map: not active") return None + # resolves a bday "path" to a share + def sharing_collection_by_bday_resolver(self, path: str, user: str) -> Union[dict, None]: + """ returning dict with PathMapped, Owner, Permissions or None if invalid""" + if self.sharing_collection_by_bday: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/bday/resolver: check path: %r", path) + result = self.database_get_sharing( + ShareType="bday", + PathOrToken=path, + User=user) + if result: + pass + else: + # fallback to parent path + parent_path = pathutils.parent_path(path) + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/bday/resolver: check parent path: %r", parent_path) + result = self.database_get_sharing( + ShareType="bday", + PathOrToken=parent_path, + User=user) + if result: + result['PathMapped'] = path.replace(parent_path, result['PathMapped']) + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/bday/resolver: PathMapped=%r Permissions=%r by parent_path=%r", result['PathMapped'], result['Permissions'], parent_path) + else: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/bday: not found") + return None + + logger.info("Sharing/%s: resolved path %r->%r, user %r->%r, permissions %r", "bday", path, result['PathMapped'], user, result['Owner'], result['Permissions']) + return result + else: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/bday: not active") + return None + # *** POST API *** def post(self, environ: types.WSGIEnviron, base_prefix: str, path: str, user: str) -> types.WSGIResponse: # Late import to avoid circular dependency in config @@ -480,28 +531,28 @@ class BaseSharing: ``user`` is empty for anonymous users. Request: - action: (token|map/list + action: (token|map|bday)/list PathOrToken: (optional for filter) - action: (token|map)/create + action: (token|map|bday)/create PathMapped: (mandatory) Permissions: (default: r) token -> returns - map + map|bday PathOrToken: (mandatory) User: (mandatory) - action: (token|map)/update + action: (token|map|bday)/update - action: (token|map)/(delete|disable|enable|hide|unhide) + action: (token|map|bday)/(delete|disable|enable|hide|unhide) PathOrToken: (mandatory) token - map - PathMapped: (mandator) + map|bday + PathMapped: (mandatory) User: Response: output format depending on ACCEPT header @@ -561,6 +612,11 @@ class BaseSharing: logger.warning(api_info + ": not enabled by config (collection_by_map)") return httputils.NOT_FOUND + if not self.sharing_collection_by_bday and ShareType == "bday": + # API "token" is not enabled + logger.warning(api_info + ": not enabled by config (collection_by_bday)") + return httputils.NOT_FOUND + # check for valid API hooks if action not in API_HOOKS_V1: if logger.isEnabledFor(logging.DEBUG): @@ -756,7 +812,7 @@ class BaseSharing: answer['ApiVersion'] = 1 Timestamp = int((datetime.now() - datetime(1970, 1, 1)).total_seconds()) - if not self.sharing_collection_by_map and not self.sharing_collection_by_token: + if not self.sharing_collection_by_map and not self.sharing_collection_by_token and not self.sharing_collection_by_bday: if not action == 'info': # API is not enabled logger.warning(api_info + ": API is not enabled") @@ -881,7 +937,7 @@ class BaseSharing: if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/" + api_info + ": result=%r", result) - elif ShareType == "map": + elif ShareType in ["map", "bday"]: # check preconditions if PathOrToken is None: return httputils.bad_request("Missing PathOrToken") @@ -911,14 +967,25 @@ class BaseSharing: logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r", PathMapped, user) return httputils.NOT_ALLOWED - if self.permit_create_map is False: - if "m" not in access.permissions: - logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r (permit=False but explicit grant misses 'm')", PathMapped, user) - return httputils.NOT_ALLOWED - else: - if "M" in access.permissions: - logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r (permit=True but denied by 'M')", PathMapped, user) - return httputils.NOT_ALLOWED + if ShareType == "map": + if self.permit_create_map is False: + if "m" not in access.permissions: + logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r (permit=False but explicit grant misses 'm')", PathMapped, user) + return httputils.NOT_ALLOWED + else: + if "M" in access.permissions: + logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r (permit=True but denied by 'M')", PathMapped, user) + return httputils.NOT_ALLOWED + + elif ShareType == "bday": + if self.permit_create_bday is False: + if "b" not in access.permissions: + logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r (permit=False but explicit grant misses 'b')", PathMapped, user) + return httputils.NOT_ALLOWED + else: + if "B" in access.permissions: + logger.warning(api_info + ": access to PathMapped=%r not allowed for owner %r (permit=True but denied by 'B')", PathMapped, user) + return httputils.NOT_ALLOWED access = Access(self._rights, User, PathOrToken) if not access.check("r"): @@ -1149,13 +1216,16 @@ class BaseSharing: if ShareType in ["all", "token"]: answer['FeatureEnabledCollectionByToken'] = self.sharing_collection_by_token answer['PermittedCreateCollectionByToken'] = self.permit_create_token + if ShareType in ["all", "bday"]: + answer['FeatureEnabledCollectionByBday'] = self.sharing_collection_by_bday + answer['PermittedCreateCollectionByBday'] = self.permit_create_bday # action: TOGGLE elif action in API_SHARE_TOGGLES_V1: if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/sharing/API/POST/" + action) - if ShareType not in ["token", "map"]: + if ShareType not in ["token", "map", "bday"]: logger.warning(api_info + ": unsupported for ShareType=%r", ShareType) return httputils.bad_request("Invalid share type") From f11b4f3c4aabbcbd2cd29e49792ba1490e532276 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 13 Mar 2026 21:41:21 +0100 Subject: [PATCH 15/46] sharing/bday: add additional test cases --- radicale/tests/static/contact2-with-bday.vcf | 9 +++++++++ radicale/tests/static/contact3-with-bday.vcf | 9 +++++++++ 2 files changed, 18 insertions(+) create mode 100644 radicale/tests/static/contact2-with-bday.vcf create mode 100644 radicale/tests/static/contact3-with-bday.vcf diff --git a/radicale/tests/static/contact2-with-bday.vcf b/radicale/tests/static/contact2-with-bday.vcf new file mode 100644 index 00000000..daff0d92 --- /dev/null +++ b/radicale/tests/static/contact2-with-bday.vcf @@ -0,0 +1,9 @@ +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;;; +FN:Test-FN +NICKNAME:Test-NICKNAME +BDAY:1970-01-01 +END:VCARD diff --git a/radicale/tests/static/contact3-with-bday.vcf b/radicale/tests/static/contact3-with-bday.vcf new file mode 100644 index 00000000..9a40865c --- /dev/null +++ b/radicale/tests/static/contact3-with-bday.vcf @@ -0,0 +1,9 @@ +BEGIN:VCARD +VERSION:3.0 +PRODID:+//Manual/0.0.1 +UID:05cf4901-e581-44ee-bb0a-1e1a875da44a +N:Test;N;C3;; +FN:Test-FN-C3 +NICKNAME:Test-NICKNAME-C3 +BDAY:1990-01-01 +END:VCARD From 31f3e83ce731cb6eedb274d1fc6357f55f4e7d28 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Mar 2026 13:51:29 +0100 Subject: [PATCH 16/46] sharing/bday: upadate doc --- SHARING.md | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/SHARING.md b/SHARING.md index df9853f2..732d57a7 100644 --- a/SHARING.md +++ b/SHARING.md @@ -122,11 +122,12 @@ Types of supported sharing configuration: * `ShareType`: type of share * `token`: token-based share (do not require user authentication) * `map`: map-based share (requires user authentication) + * `bday`: map-based share (requires user authentication) with on-the-fly auto-conversion * `PathOrToken`: token or "virtual" collection, has to be unique (PRIMARY KEY) * `PathMapped`: target collection * `Owner`: owner of the share * `User`: user of the share - * `Permissions`: effective permission of the share + * `Permissions`: effective permission of the share (*bday* is always read-only) * `EnabledByOwner`: control by owner * `EnabledByUser`: control by user * `HiddenByOwner`: control by owner @@ -157,10 +158,15 @@ File-based configuration store is using encoded `PathOrToken` as filename for ea Map-based sharing can be accessed as usual after authentication and authorization. + * *map* is a standard sharing of one collection to another user + * *bday* is special sharing auto-mapping on-the-fly a VADDRESSBOOK to a VCALENDAR of all entries containing a `BDAY` + #### Permission Control * `permit_create_map` * supported *rights* permissions: `Mm` + * `permit_create_bday` + * supported *rights* permissions: `Bb` #### Workflow @@ -263,11 +269,13 @@ curl -u user:pass --silent -H "accept: application/json" -d "" http://localhost: "FeatureEnabledCollectionByMap": true, "PermittedCreateCollectionByMap": true, "FeatureEnabledCollectionByToken": true, - "PermittedCreateCollectionByToken": true + "PermittedCreateCollectionByToken": true, + "FeatureEnabledCollectionByBday": true, + "PermittedCreateCollectionByBday": true } ``` -##### API Hook "(token|map)/create" +##### API Hook "(token|map|bday)/create" * Authorization * Authenticated user is `Owner` @@ -316,7 +324,7 @@ curl -u user:pass -H "Content-Type: application/json" -d '{ "PathMapped": "/user {"ApiVersion": 1, "Status": "success", "PathOrToken": "v1/aMsmGqOsRwSH-2-6tEa8EMr4RMYzMU7WvPmjnp5qDnw="} ``` -###### API Hook "map/create" +###### API Hook "(map|bday)/create" Create a share by mapping a collection of an `Owner` to an `User`. @@ -325,8 +333,13 @@ Create a share by mapping a collection of an `Owner` to an `User`. * `PathMapped` is not existing already as a share target for same `User` * Authenticated user as `Owner` has at least read access to `PathMapped` * Provided `User` has at least read access to `PathOrToken` - * Global permitted by `permit_create_map = True` or `rights` permission `m` - * Global denied by `permit_create_map = False` or `rights` permission `M` + * *map* + * Global permitted by `permit_create_map = True` or `rights` permission `m` + * Global denied by `permit_create_map = False` or `rights` permission `M` + * *bday* + * Global permitted by `permit_create_map = True` or `rights` permission `b` + * Global denied by `permit_create_map = False` or `rights` permission `B` + * `PathMapped` is a VCALENDAR collection * Input @@ -358,7 +371,7 @@ curl -u owner:pass -H "Content-Type: application/json" -d '{ "PathOrToken": "/us {"ApiVersion": 1, "Status": "success"} ``` -##### API Hook "(map|token|all)/list" +##### API Hook "(all|token|map|bday)/list" List shares (optional with filter) either owned or assigned as user. @@ -439,7 +452,7 @@ curl -s -H "Content-Type: application/json" -u user:pass -d "{}" http://localhos ``` -##### API Hook "(map|token)/delete" +##### API Hook "(token|map|bday)/delete" Delete a share selected by `PathOrToken`. @@ -472,7 +485,7 @@ curl -u user:pass -H "Content-Type: application/json" -d '{ "PathOrToken": "v1/D {"ApiVersion": 1, "Status": "success"} ``` -##### API Hook "(token|map)/update" +##### API Hook "(token|map|bday)/update" Update a share selected by `PathOrToken`. @@ -512,7 +525,7 @@ curl -u user:pass -H "Content-Type: application/json" -d '{ "PathOrToken": "/use {"ApiVersion": 1, "Status": "success"} ``` -##### API Hooks "(map|token)/(enable|disable|hide|unhide)" +##### API Hooks "(token|map|bday)/(enable|disable|hide|unhide)" Toggle enable|disable|hide|unhide of `Owner` or `User` of a share selected by `PathOrToken` From f0f70e4ae8e21acd37b145a87616f5e67f9be626 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Mar 2026 13:51:57 +0100 Subject: [PATCH 17/46] sharing/bday: add missing extensions --- radicale/sharing/__init__.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index a02f874a..530f524d 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -348,6 +348,20 @@ class BaseSharing: HiddenByOwner=Hidden, HiddenByUser=Hidden) + if not self.sharing_collection_by_bday: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/sharing/bday: not active") + else: + # retrieve collections depending on filter + sharing_collection_list += self.database_list_sharing( + ShareType="bday", + OwnerOrUser=User, + User=User, + EnabledByOwner=Enabled, + EnabledByUser=Enabled, + HiddenByOwner=Hidden, + HiddenByUser=Hidden) + return sharing_collection_list # resolves a path to a share @@ -1045,7 +1059,7 @@ class BaseSharing: if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/" + api_info + ": start") - if ShareType not in ["token", "map"]: + if ShareType not in ["token", "map", "bday"]: logger.warning(api_info + ": unsupported for ShareType=%r", ShareType) return httputils.bad_request("Invalid share type") @@ -1171,7 +1185,7 @@ class BaseSharing: if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/" + api_info + ": start") - if ShareType not in ["token", "map"]: + if ShareType not in ["token", "map", "bday"]: logger.warning(api_info + ": unsupported for ShareType=%r", ShareType) return httputils.bad_request("Invalid share type") From 3502d366b4742089c8f14d23521d634016130a75 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Mar 2026 17:19:35 +0100 Subject: [PATCH 18/46] sharing/bday: add complex test case --- radicale/tests/test_sharing.py | 146 +++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 3b7cec74..e50bfc04 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -4302,3 +4302,149 @@ permissions: RrWw""") assert path_shared_r + "contact2-with-bday.ics" in responses assert path_shared_r + "contact3-with-bday.ics" in responses assert path_shared_r + "contact1.ics" not in responses + + def test_sharing_api_bday_complex(self) -> None: + """share-by-map API usage tests related to partial overlay.""" + self.configure({"auth": {"type": "htpasswd", + "htpasswd_filename": self.htpasswd_file_path, + "htpasswd_encryption": "plain"}, + "sharing": { + "type": "csv", + "permit_create_bday": True, + "permit_create_map": True, + "permit_properties_overlay": "True", + "enforce_properties_overlay": "True", + "collection_by_map": "True", + "collection_by_bday": "True"}, + "logging": {"request_header_on_debug": "False", + "response_content_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_user = "/user/" + path_shared_bday = path_user + "calendar-bday-abook-shared-by-owner-r-" + db_type + ".ics/" + path_shared_map = path_user + "abook-shared-by-owner-r-" + db_type + ".vcf/" + self.create_addressbook(path_mapped, login="owner:ownerpw") + + contact = get_file_content("contact1.vcf") + path = path_mapped + "/contact1.vcf" + self.put(path, contact, login="owner:ownerpw") + + contact = get_file_content("contact2-with-bday.vcf") + path = path_mapped + "/contact2-with-bday.vcf" + self.put(path, contact, login="owner:ownerpw") + + contact = get_file_content("contact3-with-bday.vcf") + path = path_mapped + "/contact3-with-bday.vcf" + self.put(path, contact, login="owner:ownerpw") + + # check PROPFIND as owner + logging.info("\n*** PROPFIND collection owner -> ok") + _, responses = self.propfind(path_mapped, """\ + + + +""", login="owner:ownerpw") + logging.info("response: %r", responses) + response = responses[path_mapped] + assert not isinstance(response, int) + assert "CR:supported-address-data" in response + + # execute GET as owner + logging.info("\n*** GET VCF collection owner -> ok") + _, answer = self.get(path_mapped, login="owner:ownerpw") + assert "contact1" in answer + assert "contact2" in answer + + # create bday + logging.info("\n*** create bday user/owner:r -> ok") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_bday + json_dict['Permissions'] = "r" + json_dict['Enabled'] = True + json_dict['Hidden'] = False + _, headers, answer = self._sharing_api_json("bday", "create", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + + # enable bday by user + logging.info("\n*** enable bday by user") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_bday + _, headers, answer = self._sharing_api_json("bday", "enable", check=200, login="user:userpw", json_dict=json_dict) + + # unhide bday by user + logging.info("\n*** unhide bday by user") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_bday + _, headers, answer = self._sharing_api_json("bday", "unhide", check=200, login="user:userpw", json_dict=json_dict) + + # create map + logging.info("\n*** create map user/owner:r -> ok") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_map + json_dict['Permissions'] = "r" + json_dict['Enabled'] = True + json_dict['Hidden'] = False + _, 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 by user") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_map + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) + + # unhide map by user + logging.info("\n*** unhide map by user") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_map + _, headers, answer = self._sharing_api_json("map", "unhide", check=200, login="user:userpw", json_dict=json_dict) + + # check PROPFIND item as user + logging.info("\n*** PROPFIND all as user") + _, responses = self.propfind(path_user, """\ + + + + + + + + + + + + + + +""", login="user:userpw", HTTP_DEPTH="1") + #logging.debug("responses: %r", responses) + response = responses[path_shared_map] + logging.debug("response %r: %r", path_shared_map, response) + assert "C:supported-calendar-component-set" in response + response = responses[path_shared_bday] + logging.debug("response %r: %r", path_shared_bday, response) + assert "C:supported-calendar-component-set" in response From 4476edd5862bdd573cb3f47ee1459c619a004fe9 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Mar 2026 17:47:06 +0100 Subject: [PATCH 19/46] sharing/bday: extend collection list --- radicale/sharing/__init__.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index 530f524d..7deefa09 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -354,13 +354,13 @@ class BaseSharing: else: # retrieve collections depending on filter sharing_collection_list += self.database_list_sharing( - ShareType="bday", - OwnerOrUser=User, - User=User, - EnabledByOwner=Enabled, - EnabledByUser=Enabled, - HiddenByOwner=Hidden, - HiddenByUser=Hidden) + ShareType="bday", + OwnerOrUser=User, + User=User, + EnabledByOwner=Enabled, + EnabledByUser=Enabled, + HiddenByOwner=Hidden, + HiddenByUser=Hidden) return sharing_collection_list From f5822d1af87f0645fb3776378af7a1ae14e79f5d Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Mar 2026 18:38:25 +0100 Subject: [PATCH 20/46] sharing/bday: no sync support --- radicale/app/propfind.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index a0ce858b..15d90993 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -287,7 +287,8 @@ def xml_propfind_response( "D:principal-search-property-set", "D:principal-property-search"] if is_collection and is_leaf: - reports.append("D:sync-collection") + if not share_bday_automap: + reports.append("D:sync-collection") if collection.tag == "VADDRESSBOOK" and not share_bday_automap: reports.append("CR:addressbook-multiget") reports.append("CR:addressbook-query") From 6870da7e71e3fb7d19e1fa40f42d54a35b76a9ff Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Mar 2026 18:39:21 +0100 Subject: [PATCH 21/46] sharing/bday: report --- radicale/app/report.py | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/radicale/app/report.py b/radicale/app/report.py index bf035e91..ed720ec6 100644 --- a/radicale/app/report.py +++ b/radicale/app/report.py @@ -159,6 +159,13 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element], """ if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/REPORT/xml_report: base_prefix=%r path=%r", base_prefix, path) + + share_bday_automap = False + if share and share['ShareType'] == "bday": + share_bday_automap = True + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/REPORT/xml_report(1): share=%r", share) + multistatus = ET.Element(xmlutils.make_clark("D:multistatus")) if xml_request is None: return client.MULTI_STATUS, multistatus @@ -173,12 +180,9 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element], logger.warning("Unsupported REPORT method %r on %r requested", xmlutils.make_human_tag(root.tag), path) return client.MULTI_STATUS, multistatus - if (root.tag == xmlutils.make_clark("C:calendar-multiget") and - collection.tag != "VCALENDAR" or - root.tag == xmlutils.make_clark("CR:addressbook-multiget") and - collection.tag != "VADDRESSBOOK" or - root.tag == xmlutils.make_clark("D:sync-collection") and - collection.tag not in ("VADDRESSBOOK", "VCALENDAR")): + if ((root.tag == xmlutils.make_clark("C:calendar-multiget") and collection.tag != "VCALENDAR" and not share_bday_automap) or + (root.tag == xmlutils.make_clark("CR:addressbook-multiget") and collection.tag != "VADDRESSBOOK") or + (root.tag == xmlutils.make_clark("D:sync-collection") and collection.tag not in ("VADDRESSBOOK", "VCALENDAR"))): logger.warning("Invalid REPORT method %r on %r requested", xmlutils.make_human_tag(root.tag), path) return client.FORBIDDEN, xmlutils.webdav_error("D:supported-report") @@ -274,6 +278,9 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element], retrieved_items = retrieved_items_vcf_to_ics logging.debug("TRACE/REPORT/retrieved_items: %r", retrieved_items) + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/REPORT/xml_report(2): share=%r", share) + n_vevents = 0 while retrieved_items: # ``item.vobject_item`` might be accessed during filtering. @@ -729,12 +736,17 @@ def xml_item_response(base_prefix: str, href: str, response = ET.Element(xmlutils.make_clark("D:response")) + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/REPORT/xml_item_response: found=%s share=%r", found_item, share) + share_bday_automap = False if share and share['ShareType'] == "bday": share_bday_automap = True href_element = ET.Element(xmlutils.make_clark("D:href")) href_element.text = xmlutils.make_href(base_prefix, href) + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/REPORT/xml_report: href=%r", href_element.text) if share: # backmap href_element.text = href_element.text.replace(share['PathMapped'], share['PathOrToken']) From 5fb3234eea1f48b6de375d83ccd3f12cf5a7376c Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Mar 2026 18:39:42 +0100 Subject: [PATCH 22/46] sharing/bday: extend tests --- radicale/tests/test_sharing.py | 40 ++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index e50bfc04..35b98203 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -4303,6 +4303,46 @@ permissions: RrWw""") assert path_shared_r + "contact3-with-bday.ics" in responses assert path_shared_r + "contact1.ics" not in responses + # get elements as user + logging.info("\n*** REPORT collection entries user -> ok") + _, responses = self.report(path_shared_r, """\ + + + + + + + """ + path_shared_r + "contact2-with-bday.ics" + """ + """ + path_shared_r + "contact3-with-bday.ics" + """ +""", login="user:userpw") + logging.debug("resonses: %r", responses) + assert path_shared_r + "contact2-with-bday.ics" in responses + assert path_shared_r + "contact3-with-bday.ics" in responses + assert path_shared_r + "contact1.ics" not in responses + + # timerange filter elements as user + logging.info("\n*** REPORT collection entries with timerange user -> ok") + _, responses = self.report(path_shared_r, """\ + + + + + + + + + + + + + + +""", login="user:userpw") + logging.debug("resonses: %r", responses) + assert path_shared_r + "contact2-with-bday.ics" in responses + assert path_shared_r + "contact3-with-bday.ics" in responses + assert path_shared_r + "contact1.ics" not in responses + def test_sharing_api_bday_complex(self) -> None: """share-by-map API usage tests related to partial overlay.""" self.configure({"auth": {"type": "htpasswd", From c3f0ba235312bc96f4a52dd7686fcca14dcd3da4 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Mar 2026 20:22:28 +0100 Subject: [PATCH 23/46] sharing/bday/report: backmap suffix --- radicale/app/report.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/radicale/app/report.py b/radicale/app/report.py index ed720ec6..0543b71c 100644 --- a/radicale/app/report.py +++ b/radicale/app/report.py @@ -793,6 +793,9 @@ def retrieve_items( if share: # map back to owner hreference = hreference.replace(share['PathOrToken'], share['PathMapped']) + if share['ShareType'] == "bday": + if not hreference.endswith('/'): + hreference = hreference.rstrip(".ics") + ".vcf" try: name = pathutils.name_from_path(hreference, collection) except ValueError as e: From 582dd05a7fbd36a5c31016ffd8278809ba049c64 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Mar 2026 21:08:20 +0100 Subject: [PATCH 24/46] sharing/bday: fix ics generation --- radicale/item/__init__.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/radicale/item/__init__.py b/radicale/item/__init__.py index 7349c1c7..628003b3 100644 --- a/radicale/item/__init__.py +++ b/radicale/item/__init__.py @@ -567,11 +567,15 @@ class Item: # set DTSTART date = datetime(bdayY, bdayM, bdayD) dtstart = date.strftime('%Y%m%d') - item_ics.vevent.add('dtstart;VALUE=DATE').value = dtstart + item_ics.vevent.add('dtstart').value = dtstart + item_ics.vevent.dtstart.value_param = "DATE" + item_ics.vevent.dtstart.isNative = False # calculate and set DTEND date += timedelta(days=1) - item_ics.vevent.add('dtend;VALUE=DATE').value = date.strftime('%Y%m%d') + item_ics.vevent.add('dtend').value = date.strftime('%Y%m%d') + item_ics.vevent.dtend.value_param = "DATE" + item_ics.vevent.dtend.isNative = False # set UID if hasattr(self.vobject_item, "uid"): From ea6027cf314350ce712e1f09340bea778b1e504a Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Mar 2026 22:19:02 +0100 Subject: [PATCH 25/46] sharing/bday: align test case --- radicale/tests/test_sharing.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 35b98203..5d6b41d1 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -4285,8 +4285,8 @@ permissions: RrWw""") assert "BEGIN:VCARD" not in answer assert "BEGIN:VCALENDAR" in answer assert "RRULE:FREQ=YEARLY" in answer - assert "DTSTART;VALUE=DATE:19700101" in answer - assert "DTEND;VALUE=DATE:19700102" in answer + assert "DTSTART;VALUE=DATE:1970-01-01" in answer + assert "DTEND;VALUE=DATE:1970-01-02" in answer assert "TRANSP:TRANSPARENT" in answer # verify report as user From 07e8c24bfa40ecbc6d23de42cf0b12ed23828a3a Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Mar 2026 22:20:28 +0100 Subject: [PATCH 26/46] generic/datetime --- radicale/item/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/radicale/item/__init__.py b/radicale/item/__init__.py index 628003b3..8615182d 100644 --- a/radicale/item/__init__.py +++ b/radicale/item/__init__.py @@ -26,11 +26,11 @@ Module for address books and calendar entries (see ``Item``). import binascii import contextlib +import datetime import logging import math import os import re -from datetime import datetime, timedelta from hashlib import sha256 from itertools import chain from typing import (Any, Callable, List, MutableMapping, Optional, Sequence, @@ -165,7 +165,7 @@ def check_and_sanitize_items( # an event with DTEND and DURATION:PT0S if (hasattr(component, "dtend") and hasattr(component, "duration") and - component.duration.value == timedelta(0)): + component.duration.value == datetime.timedelta(0)): logger.debug("Quirks: Removing zero duration from %s in " "object %r", component_name, component_uid) del component.duration @@ -320,7 +320,7 @@ def find_time_range(vobject_item: vobject.base.Component, tag: str return radicale_filter.TIMESTAMP_MIN, radicale_filter.TIMESTAMP_MAX start = end = None - def range_fn(range_start: datetime, range_end: datetime, + def range_fn(range_start: datetime.datetime, range_end: datetime.datetime, is_recurrence: bool) -> bool: nonlocal start, end if start is None or range_start < start: @@ -329,7 +329,7 @@ def find_time_range(vobject_item: vobject.base.Component, tag: str end = range_end return False - def infinity_fn(range_start: datetime) -> bool: + def infinity_fn(range_start: datetime.datetime) -> bool: nonlocal start, end if start is None or range_start < start: start = range_start From d0d77c239483a10046570d9971a81559427778b5 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Mar 2026 22:24:05 +0100 Subject: [PATCH 27/46] sharing/bday: flake8 --- radicale/app/report.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/radicale/app/report.py b/radicale/app/report.py index 0543b71c..c2184f4c 100644 --- a/radicale/app/report.py +++ b/radicale/app/report.py @@ -181,8 +181,8 @@ def xml_report(base_prefix: str, path: str, xml_request: Optional[ET.Element], xmlutils.make_human_tag(root.tag), path) return client.MULTI_STATUS, multistatus if ((root.tag == xmlutils.make_clark("C:calendar-multiget") and collection.tag != "VCALENDAR" and not share_bday_automap) or - (root.tag == xmlutils.make_clark("CR:addressbook-multiget") and collection.tag != "VADDRESSBOOK") or - (root.tag == xmlutils.make_clark("D:sync-collection") and collection.tag not in ("VADDRESSBOOK", "VCALENDAR"))): + (root.tag == xmlutils.make_clark("CR:addressbook-multiget") and collection.tag != "VADDRESSBOOK") or + (root.tag == xmlutils.make_clark("D:sync-collection") and collection.tag not in ("VADDRESSBOOK", "VCALENDAR"))): logger.warning("Invalid REPORT method %r on %r requested", xmlutils.make_human_tag(root.tag), path) return client.FORBIDDEN, xmlutils.webdav_error("D:supported-report") From eb22addbdd42a8a35e4c51d7b84b3c7c1faefa7b Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Mar 2026 22:32:34 +0100 Subject: [PATCH 28/46] sharing/bday: test fix --- radicale/tests/test_sharing.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 5d6b41d1..0e4e774c 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -4481,10 +4481,12 @@ permissions: RrWw""") """, login="user:userpw", HTTP_DEPTH="1") - #logging.debug("responses: %r", responses) + # logging.debug("responses: %r", responses) response = responses[path_shared_map] + assert not isinstance(response, int) logging.debug("response %r: %r", path_shared_map, response) assert "C:supported-calendar-component-set" in response response = responses[path_shared_bday] + assert not isinstance(response, int) logging.debug("response %r: %r", path_shared_bday, response) assert "C:supported-calendar-component-set" in response From cda4a44fe833447d12ef94cddb01400d93c61941 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sat, 14 Mar 2026 22:32:58 +0100 Subject: [PATCH 29/46] sharing/bday: ics generation fix --- radicale/item/__init__.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/radicale/item/__init__.py b/radicale/item/__init__.py index 8615182d..29d2c98b 100644 --- a/radicale/item/__init__.py +++ b/radicale/item/__init__.py @@ -536,6 +536,7 @@ class Item: else: pass + bdayS = match[1] + match[2] + match[3] bdayY = int(match[1]) bdayM = int(match[2]) bdayD = int(match[3]) @@ -565,15 +566,14 @@ class Item: item_ics.add('vevent') # set DTSTART - date = datetime(bdayY, bdayM, bdayD) - dtstart = date.strftime('%Y%m%d') + dtstart = datetime.date(bdayY, bdayM, bdayD) item_ics.vevent.add('dtstart').value = dtstart item_ics.vevent.dtstart.value_param = "DATE" item_ics.vevent.dtstart.isNative = False # calculate and set DTEND - date += timedelta(days=1) - item_ics.vevent.add('dtend').value = date.strftime('%Y%m%d') + dtend = dtstart + datetime.timedelta(days=1) + item_ics.vevent.add('dtend').value = dtend item_ics.vevent.dtend.value_param = "DATE" item_ics.vevent.dtend.isNative = False @@ -583,7 +583,7 @@ class Item: 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' + dtstart + match[2] + 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: @@ -604,7 +604,7 @@ class Item: etag = self.etag # replace 14 leading chars of etag "" by special format bda0YYYYMMDD00 - etag = '"bda0' + dtstart + '00' + etag[15:] + etag = '"bda0' + bdayS + '00' + etag[15:] item_new: Item = Item( collection=self.collection, From 29e03840b99e6c42a47eed82fa3cc119646a5e4a Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 15 Mar 2026 11:06:52 +0100 Subject: [PATCH 30/46] sharing/bday: add support for contentlength+size --- radicale/app/propfind.py | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index 15d90993..af4b5e58 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -305,7 +305,18 @@ def xml_propfind_response( element.append(supported_report) elif tag == xmlutils.make_clark("D:getcontentlength"): if not is_collection or is_leaf: - element.text = str(len(item.serialize().encode(encoding))) + if collection.tag == "VADDRESSBOOK" and share_bday_automap: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/PROPFIND/xml_propfind_response/getcontentcount: start bday automap handling") + length = 0 + for entry in item.get_all(): + item_ics = entry.convert_vcf_to_ics() + if item_ics is None: + continue + length += len(item_ics.vobject_item.serialize().encode(encoding)) + element.text = str(length) + else: + element.text = str(len(item.serialize().encode(encoding))) else: is404 = True elif tag == xmlutils.make_clark("D:owner"): @@ -362,7 +373,18 @@ def xml_propfind_response( elif tag == xmlutils.make_clark("RADICALE:getcontentcount"): # Only for internal use by the web interface if isinstance(item, storage.BaseCollection) and not collection.is_principal: - element.text = str(sum(1 for x in item.get_all())) + if collection.tag == "VADDRESSBOOK" and share_bday_automap: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/PROPFIND/xml_propfind_response/getcontentcount: start bday automap handling") + items = [] + for entry in item.get_all(): + item_ics = entry.convert_vcf_to_ics() + if item_ics is None: + continue + items.append(item_ics.vobject_item) + element.text = str(sum(1 for x in items)) + else: + element.text = str(sum(1 for x in item.get_all())) else: is404 = True elif tag == xmlutils.make_clark("D:displayname"): From 54e379ba0b35c02efff3492ad65b382f1ac171cc Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 15 Mar 2026 11:18:55 +0100 Subject: [PATCH 31/46] sharing/bday: test support for contentlength+size --- radicale/tests/test_sharing.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 0e4e774c..62d2d175 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -4490,3 +4490,27 @@ permissions: RrWw""") assert not isinstance(response, int) logging.debug("response %r: %r", path_shared_bday, response) assert "C:supported-calendar-component-set" in response + + # check PROPFIND item as user + logging.info("\n*** PROPFIND all as user (reduced)") + _, responses = self.propfind(path_user, """\ + + + + + + + + + +""", login="user:userpw", HTTP_DEPTH="1") + # logging.debug("responses: %r", responses) + response = responses[path_shared_bday] + assert not isinstance(response, int) + logging.debug("response %r: %r", path_shared_bday, response) + assert "D:getcontentlength" in response + assert "RADICALE:getcontentcount" in response + status, prop = response["D:getcontentlength"] + assert int(str(prop.text)) > 600 + status, prop = response["RADICALE:getcontentcount"] + assert int(str(prop.text)) == 2 From cd0e875ba91e6b5052f6d91af835f3d8f152bce7 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 15 Mar 2026 11:19:17 +0100 Subject: [PATCH 32/46] sharing/bday: fix for mypy --- radicale/app/propfind.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index af4b5e58..995add83 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -305,9 +305,9 @@ def xml_propfind_response( element.append(supported_report) elif tag == xmlutils.make_clark("D:getcontentlength"): if not is_collection or is_leaf: - if collection.tag == "VADDRESSBOOK" and share_bday_automap: + if collection.tag == "VADDRESSBOOK" and share_bday_automap and isinstance(item, storage.BaseCollection): if logger.isEnabledFor(logging.DEBUG): - logger.debug("TRACE/PROPFIND/xml_propfind_response/getcontentcount: start bday automap handling") + logger.debug("TRACE/PROPFIND/xml_propfind_response/getcontentlength: start bday automap handling") length = 0 for entry in item.get_all(): item_ics = entry.convert_vcf_to_ics() From 541e6bc57f3690bb463dc21b8d99ca6a98d17129 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 15 Mar 2026 12:58:00 +0100 Subject: [PATCH 33/46] sharing/bday: fix date format --- radicale/item/__init__.py | 4 ---- radicale/tests/test_sharing.py | 4 ++-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/radicale/item/__init__.py b/radicale/item/__init__.py index 29d2c98b..9c7bba00 100644 --- a/radicale/item/__init__.py +++ b/radicale/item/__init__.py @@ -568,14 +568,10 @@ class Item: # set DTSTART dtstart = datetime.date(bdayY, bdayM, bdayD) item_ics.vevent.add('dtstart').value = dtstart - item_ics.vevent.dtstart.value_param = "DATE" - item_ics.vevent.dtstart.isNative = False # calculate and set DTEND dtend = dtstart + datetime.timedelta(days=1) item_ics.vevent.add('dtend').value = dtend - item_ics.vevent.dtend.value_param = "DATE" - item_ics.vevent.dtend.isNative = False # set UID if hasattr(self.vobject_item, "uid"): diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 62d2d175..55541188 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -4285,8 +4285,8 @@ permissions: RrWw""") assert "BEGIN:VCARD" not in answer assert "BEGIN:VCALENDAR" in answer assert "RRULE:FREQ=YEARLY" in answer - assert "DTSTART;VALUE=DATE:1970-01-01" in answer - assert "DTEND;VALUE=DATE:1970-01-02" in answer + assert "DTSTART;VALUE=DATE:19700101" in answer + assert "DTEND;VALUE=DATE:19700102" in answer assert "TRANSP:TRANSPARENT" in answer # verify report as user From e5c4c4ff1a0163a9fa6b4f5e68670c1eac4b20e9 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 15 Mar 2026 12:58:41 +0100 Subject: [PATCH 34/46] sharing/bday: fix backmapping --- radicale/app/propfind.py | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index 995add83..138584ec 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -112,25 +112,30 @@ def xml_propfind_response( logger.debug("TRACE/PROPFIND/xml_propfind: sharetype=%r item.path=%r", sharetype, uri) for entry in shares: if logger.isEnabledFor(logging.DEBUG): - logger.debug("TRACE/PROPFIND/xml_propfind: entry=%r", entry) + logger.debug("TRACE/PROPFIND/xml_propfind: check entry=%r", entry) if entry is not None: - if shares[entry]['PathMapped'] == uri: + if logger.isEnabledFor(logging.DEBUG): + logger.debug("TRACE/PROPFIND/xml_propfind: PathMapped=%r uri=%r", shares[entry]['PathMapped'], uri) + if uri.startswith(shares[entry]['PathMapped']): if sharetype is None or shares[entry]['ShareType'] == sharetype: share = shares[entry] if logger.isEnabledFor(logging.DEBUG): - logger.debug("TRACE/PROPFIND/xml_propfind: share=%r", share) + logger.debug("TRACE/PROPFIND/xml_propfind: found share=%r", share) break - if share: - # backmap - uri = uri.replace(share['PathMapped'], share['PathOrToken']) - href.text = xmlutils.make_href(base_prefix, uri) - response.append(href) - share_bday_automap = False if share and share['ShareType'] == "bday": share_bday_automap = True + if share: + # backmap + uri = uri.replace(share['PathMapped'], share['PathOrToken']) + if share_bday_automap and not uri.endswith("/"): + uri = uri.rstrip(".vcf") + ".ics" + + href.text = xmlutils.make_href(base_prefix, uri) + response.append(href) + if propname or allprop: props = [] # Should list all properties that can be retrieved by the code below @@ -431,6 +436,9 @@ def xml_propfind_response( elif tag == xmlutils.make_clark("D:getcontenttype"): assert not isinstance(item, storage.BaseCollection) element.text = xmlutils.get_content_type(item, encoding) + if share_bday_automap: + # overwrite + element.text = xmlutils.MIMETYPES["VCALENDAR"] elif tag == xmlutils.make_clark("D:resourcetype"): # resourcetype must be returned empty for non-collection elements pass From 545074a32e8cd55250cddf0e0dccf8f8614187b9 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 15 Mar 2026 12:59:04 +0100 Subject: [PATCH 35/46] sharing/bday: blacklist items having no bday --- radicale/app/propfind.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index 138584ec..65e697d1 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -546,6 +546,9 @@ class ApplicationPartPropfind(ApplicationBase): items_iter = itertools.chain([item], items_iter) for item, permission in list(self._collect_allowed_items(items_iter, user)): if share: + if share['ShareType'] == "bday" and not isinstance(item, storage.BaseCollection): + if not item.convert_vcf_to_ics(): + continue allowed_items.append((item, permission, share['ShareType'])) else: allowed_items.append((item, permission, None)) From df8124ec9afe57c874e056640a8f9735030aef0e Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 15 Mar 2026 12:59:29 +0100 Subject: [PATCH 36/46] sharing/bday: extend testcase --- radicale/tests/test_sharing.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 55541188..ef595f41 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -4343,6 +4343,25 @@ permissions: RrWw""") assert path_shared_r + "contact3-with-bday.ics" in responses assert path_shared_r + "contact1.ics" not in responses + logging.info("\n*** PROPFIND collection entries user -> ok") + _, responses = self.propfind(path_shared_r, """\ + + + + + + + +""", login="user:userpw", HTTP_DEPTH="1") + logging.debug("resonses: %r", responses) + assert path_shared_r + "contact2-with-bday.ics" in responses + assert path_shared_r + "contact3-with-bday.ics" in responses + assert path_shared_r + "contact1.ics" not in responses + response = responses[path_shared_r + "contact2-with-bday.ics"] + assert not isinstance(response, int) + status, prop = response["D:getcontenttype"] + assert "vcard" not in prop.text + def test_sharing_api_bday_complex(self) -> None: """share-by-map API usage tests related to partial overlay.""" self.configure({"auth": {"type": "htpasswd", From b9490b8bc705c458d2a9554e9d52ee8844c2f1cb Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 15 Mar 2026 13:09:30 +0100 Subject: [PATCH 37/46] sharing/bday: bugfix --- radicale/app/propfind.py | 2 +- radicale/tests/test_sharing.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index 65e697d1..9afc0c33 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -545,7 +545,7 @@ class ApplicationPartPropfind(ApplicationBase): # put item back items_iter = itertools.chain([item], items_iter) for item, permission in list(self._collect_allowed_items(items_iter, user)): - if share: + if self._sharing._enabled and share: if share['ShareType'] == "bday" and not isinstance(item, storage.BaseCollection): if not item.convert_vcf_to_ics(): continue diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index ef595f41..42d4cab9 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -4360,7 +4360,7 @@ permissions: RrWw""") response = responses[path_shared_r + "contact2-with-bday.ics"] assert not isinstance(response, int) status, prop = response["D:getcontenttype"] - assert "vcard" not in prop.text + assert "text/calendar" in str(prop.text) def test_sharing_api_bday_complex(self) -> None: """share-by-map API usage tests related to partial overlay.""" From fb7abd6a9afebf319d08df22d144c8e902a5b2ac Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 15 Mar 2026 18:07:25 +0100 Subject: [PATCH 38/46] sharing/bday: add hints to doc --- SHARING.md | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/SHARING.md b/SHARING.md index 732d57a7..2eb81dbd 100644 --- a/SHARING.md +++ b/SHARING.md @@ -637,3 +637,37 @@ curl -u user:pass -d "$xml_ppc" -X PROPPATCH http://localhost:5232/user/cal1-fro ## Retrieve collection color of share by user (color set) curl -u user:pass -d "$xml_pfc" -X PROPFIND http://localhost:5232/user/cal1-from-owner/ ``` + +## Virtual "bday" collection + +Owner can create for itself or for particular user a virtual bday collection from an existing addressbook. + + +### Examples + +Preconditions: + + * Collection with type *adressbook* is existing + * Config options enabled in section `sharing`: + * `collection_by_bday` + * `permit_create_bday` + +#### Examples using API + + * Create + +```bash +## Create sharing of type "bday" +curl -u owner:ownerpw -d "PathOrToken=/owner/bday-of-addressbook/" -d "PathMapped=/owner/addressbook/" -d "User=owner" http://localhost:5232/.sharing/v1/bday/create + + +## Enable +curl -u owner:ownerpw -d "PathOrToken=/owner/bday-of-addressbook/" http://localhost:5232/.sharing/v1/bday/enable + +## Unhide +curl -u owner:ownerpw -d "PathOrToken=/owner/bday-of-addressbook/" http://localhost:5232/.sharing/v1/bday/unhide +``` + + * Check + +Use e.g. WebUI, an additional (virtual) calendar collection appears From 3deeb7a16c33caa623d69dda548b2fb593ca8b16 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 15 Mar 2026 18:10:51 +0100 Subject: [PATCH 39/46] sharing/bday: add additional test cases --- radicale/tests/test_sharing.py | 142 ++++++++++++++++++++++++++++++++- 1 file changed, 140 insertions(+), 2 deletions(-) diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index 42d4cab9..4ec1aee6 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -4183,7 +4183,7 @@ permissions: RrWw""") assert 'C:calendar-description' not in answer_dict['Content'][0]['Properties'] def test_sharing_api_bday_basic(self) -> None: - """share-by-map API usage tests related to partial overlay.""" + """share-by-bday basic tests.""" self.configure({"auth": {"type": "htpasswd", "htpasswd_filename": self.htpasswd_file_path, "htpasswd_encryption": "plain"}, @@ -4363,7 +4363,7 @@ permissions: RrWw""") assert "text/calendar" in str(prop.text) def test_sharing_api_bday_complex(self) -> None: - """share-by-map API usage tests related to partial overlay.""" + """share-by-bday complex tests.""" self.configure({"auth": {"type": "htpasswd", "htpasswd_filename": self.htpasswd_file_path, "htpasswd_encryption": "plain"}, @@ -4533,3 +4533,141 @@ permissions: RrWw""") assert int(str(prop.text)) > 600 status, prop = response["RADICALE:getcontentcount"] assert int(str(prop.text)) == 2 + + def test_sharing_api_bday_self(self) -> None: + """share-by-bday to self tests.""" + self.configure({"auth": {"type": "htpasswd", + "htpasswd_filename": self.htpasswd_file_path, + "htpasswd_encryption": "plain"}, + "sharing": { + "type": "csv", + "permit_create_bday": True, + "permit_properties_overlay": "True", + "enforce_properties_overlay": "True", + "collection_by_bday": "True"}, + "logging": {"request_header_on_debug": "False", + "response_content_on_debug": "True", + "request_content_on_debug": "True"}, + "rights": {"type": "owner_only"}}) + + json_dict: dict + + logging.info("\n*** prepare and test access") + + path_owner = "/owner/" + + 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 = path_owner + "abook-" + db_type + ".vcf/" + path_shared = path_owner + "cal-bday-abook-" + db_type + ".ics/" + self.create_addressbook(path_mapped, login="owner:ownerpw") + + contact = get_file_content("contact1.vcf") + path = path_mapped + "/contact1.vcf" + self.put(path, contact, login="owner:ownerpw") + + contact = get_file_content("contact2-with-bday.vcf") + path = path_mapped + "/contact2-with-bday.vcf" + self.put(path, contact, login="owner:ownerpw") + + contact = get_file_content("contact3-with-bday.vcf") + path = path_mapped + "/contact3-with-bday.vcf" + self.put(path, contact, login="owner:ownerpw") + + # check PROPFIND as owner + logging.info("\n*** PROPFIND collection owner -> ok") + _, responses = self.propfind(path_mapped, """\ + + + +""", login="owner:ownerpw") + logging.info("response: %r", responses) + response = responses[path_mapped] + assert not isinstance(response, int) + assert "CR:supported-address-data" in response + + # execute GET as owner + logging.info("\n*** GET VCF collection owner -> ok") + _, answer = self.get(path_mapped, login="owner:ownerpw") + assert "contact1" in answer + assert "contact2" in answer + assert "NICKNAME-C3" in answer + + # create map + logging.info("\n*** create bday owner to itself -> ok") + json_dict = {} + json_dict['User'] = "owner" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared + json_dict['Enabled'] = False + json_dict['Hidden'] = True + _, headers, answer = self._sharing_api_json("bday", "create", check=200, login="owner:ownerpw", json_dict=json_dict) + + # check PROPFIND item as owner + logging.info("\n*** PROPFIND all as owner") + _, responses = self.propfind(path_owner, """\ + + + + + + + + + + + + + + +""", login="owner:ownerpw", HTTP_DEPTH="1") + # logging.debug("responses: %r", responses) + response = responses[path_mapped] + assert not isinstance(response, int) + logging.debug("response %r: %r", path_mapped, response) + assert "C:supported-calendar-component-set" in response + assert path_shared not in responses + + # enable + unhide + logging.info("\n*** enable+unhide bday owner to itself -> ok") + json_dict = {} + json_dict['PathOrToken'] = path_shared + json_dict['Enabled'] = True + json_dict['Hidden'] = False + _, headers, answer = self._sharing_api_json("bday", "update", check=200, login="owner:ownerpw", json_dict=json_dict) + + # check PROPFIND item as owner + logging.info("\n*** PROPFIND all as owner") + _, responses = self.propfind(path_owner, """\ + + + + + + + + + + + + + + +""", login="owner:ownerpw", HTTP_DEPTH="1") + # logging.debug("responses: %r", responses) + response = responses[path_mapped] + assert not isinstance(response, int) + logging.debug("response %r: %r", path_mapped, response) + assert "C:supported-calendar-component-set" in response + assert path_shared in responses + + # check PROPFIND item as owner + logging.info("\n*** PROPFIND item as owner -> calendar") + response = self._propfind_allprop(path_shared, login="owner:ownerpw") + logging.debug("response: %r", response) + assert "CR:supported-address-data" not in response + assert "D:sync-token" not in response + assert "C:supported-calendar-component-set" in response + assert "D:current-user-privilege-set" in response From f3426b186bb287bf4be38539d12ecd7664988520 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 15 Mar 2026 18:11:07 +0100 Subject: [PATCH 40/46] sharing/propfind: fix backmapping --- radicale/app/propfind.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py index 9afc0c33..66edf99a 100644 --- a/radicale/app/propfind.py +++ b/radicale/app/propfind.py @@ -117,7 +117,7 @@ def xml_propfind_response( if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/PROPFIND/xml_propfind: PathMapped=%r uri=%r", shares[entry]['PathMapped'], uri) if uri.startswith(shares[entry]['PathMapped']): - if sharetype is None or shares[entry]['ShareType'] == sharetype: + if sharetype is not None and shares[entry]['ShareType'] == sharetype: share = shares[entry] if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/PROPFIND/xml_propfind: found share=%r", share) From 955f16e2979ca4ba14e4b002d8eee3cbdc0b6b13 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 15 Mar 2026 21:42:49 +0100 Subject: [PATCH 41/46] sharing/doc: replace hardcoded example credentials by placeholders --- SHARING.md | 50 +++++++++++++++++++++++++------------------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/SHARING.md b/SHARING.md index 2eb81dbd..29fa5d8d 100644 --- a/SHARING.md +++ b/SHARING.md @@ -249,7 +249,7 @@ Shows what kind of ShareTypes are supported * form->text ```bash -curl -u user:pass -H "accept: text/plain" -d "" http://localhost:5232/.sharing/v1/all/info +curl -u $user:$userpw -H "accept: text/plain" -d "" http://localhost:5232/.sharing/v1/all/info ApiVersion=1 Status='success' FeatureEnabledCollectionByMap=True @@ -262,7 +262,7 @@ PermittedCreateCollectionByToken=True ``` bash -curl -u user:pass --silent -H "accept: application/json" -d "" http://localhost:5232/.sharing/v1/all/info | jq +curl -u $user:$userpw --silent -H "accept: application/json" -d "" http://localhost:5232/.sharing/v1/all/info | jq { "ApiVersion": 1, "Status": "success", @@ -311,7 +311,7 @@ Create a share by mapping a collection of an `Owner` to a token. * form->text ```bash -curl -u user:pass -d "PathMapped=/user/testcalendar1/" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/token/create +curl -u $user:$userpw -d "PathMapped=/user/testcalendar1/" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/token/create ApiVersion=1 Status='success' PathOrToken='v1/VQR7AmsVRi2ZlFj_JwGpFx-ES5Goyku-gP_YkLh1zUw=' @@ -320,7 +320,7 @@ PathOrToken='v1/VQR7AmsVRi2ZlFj_JwGpFx-ES5Goyku-gP_YkLh1zUw=' * json->json ```bash -curl -u user:pass -H "Content-Type: application/json" -d '{ "PathMapped": "/user/testcalendar1/", "Enabled": true, "Hidden": false}' http://localhost:5232/.sharing/v1/token/create +curl -u $user:$userpw -H "Content-Type: application/json" -d '{ "PathMapped": "/user/testcalendar1/", "Enabled": true, "Hidden": false}' http://localhost:5232/.sharing/v1/token/create {"ApiVersion": 1, "Status": "success", "PathOrToken": "v1/aMsmGqOsRwSH-2-6tEa8EMr4RMYzMU7WvPmjnp5qDnw="} ``` @@ -359,7 +359,7 @@ Create a share by mapping a collection of an `Owner` to an `User`. * form->text ```bash -curl -u owner:pass -d "PathOrToken=/user/cal1-from-owner/" -d "PathMapped=/owner/testcalendar1/" -d "User=user" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/map/create +curl -u $owner:$ownerpw -d "PathOrToken=/user/cal1-from-owner/" -d "PathMapped=/owner/testcalendar1/" -d "User=user" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/map/create ApiVersion=1 Status='success' ``` @@ -367,7 +367,7 @@ Status='success' * json->json ```bash -curl -u owner:pass -H "Content-Type: application/json" -d '{ "PathOrToken": "/user/cal1-from-owner/", "PathMapped": "/owner/testcalendar1/", "User" : "user", "Enabled": true, "Hidden": false}' http://localhost:5232/.sharing/v1/map/create +curl -u $owner:$ownerpw -H "Content-Type: application/json" -d '{ "PathOrToken": "/user/cal1-from-owner/", "PathMapped": "/owner/testcalendar1/", "User" : "user", "Enabled": true, "Hidden": false}' http://localhost:5232/.sharing/v1/map/create {"ApiVersion": 1, "Status": "success"} ``` @@ -392,7 +392,7 @@ List shares (optional with filter) either owned or assigned as user. * form->text ("all") ```bash -curl -u user:pass -d "" http://localhost:5232/.sharing/v1/map/list://localhost:5232/.sharing/v1/map/list +curl -u $user:$userpw -d "" http://localhost:5232/.sharing/v1/map/list://localhost:5232/.sharing/v1/map/list ApiVersion=1 Lines=1 Status='success' @@ -403,7 +403,7 @@ Content[0]="map;/user/cal1-from-owner/;/owner/testcalendar1/;owner;user;r;True;T * form->csv ("map" only) ```bash -curl -H "accept: text/csv" -u user:pass -d "" http://localhost:5232/.sharing/v1/map/list://localhost:5232/.sharing/v1/map/list +curl -H "accept: text/csv" -u $user:$userpw -d "" http://localhost:5232/.sharing/v1/map/list://localhost:5232/.sharing/v1/map/list ShareType;PathOrToken;PathMapped;Owner;User;Permissions;EnabledByOwner;EnabledByUser;HiddenByOwner;HiddenByUser;TimestampCreated;TimestampUpdated;Properties map;/user/cal1-from-owner/;/owner/testcalendar1/;owner;user;r;True;False;False;True;1772747277;1772747277; ``` @@ -411,7 +411,7 @@ map;/user/cal1-from-owner/;/owner/testcalendar1/;owner;user;r;True;False;False;T * json->json ("all"), parsed with `jq` ```bash -curl -s -H "Content-Type: application/json" -u user:pass -d "{}" http://localhost:5232/.sharing/v1/all/list | jq +curl -s -H "Content-Type: application/json" -u $user:$userpw -d "{}" http://localhost:5232/.sharing/v1/all/list | jq { "ApiVersion": 1, "Lines": 2, @@ -473,7 +473,7 @@ Delete a share selected by `PathOrToken`. * form->text ```bash -curl -u owner:pass -d "PathOrToken=/user/cal1-from-owner/" http://localhost:5232/.sharing/v1/map/delete +curl -u $owner:$ownerpw -d "PathOrToken=/user/cal1-from-owner/" http://localhost:5232/.sharing/v1/map/delete ApiVersion=1 Status='success' ``` @@ -481,7 +481,7 @@ Status='success' * json->json ```bash -curl -u user:pass -H "Content-Type: application/json" -d '{ "PathOrToken": "v1/DUSl_J5rRlWx3fy8YRXpH22FFllplkOTpcSwfGtpvkc="}' http://localhost:5232/.sharing/v1/token/delete +curl -u $user:$userpw -H "Content-Type: application/json" -d '{ "PathOrToken": "v1/DUSl_J5rRlWx3fy8YRXpH22FFllplkOTpcSwfGtpvkc="}' http://localhost:5232/.sharing/v1/token/delete {"ApiVersion": 1, "Status": "success"} ``` @@ -513,7 +513,7 @@ Execute delete+create in case `PathOrToken` needs to be changed. * form->text ```bash -curl -u user:pass -d "PathOrToken=/user/cal1-from-owner/" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/map/update +curl -u $user:$userpw -d "PathOrToken=/user/cal1-from-owner/" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/map/update ApiVersion=1 Status='success' ``` @@ -521,7 +521,7 @@ Status='success' * json->json ```bash -curl -u user:pass -H "Content-Type: application/json" -d '{ "PathOrToken": "/user/cal1-from-owner/", "Enabled": true, "Hidden": false}' http://localhost:5232/.sharing/v1/map/update +curl -u $user:$userpw -H "Content-Type: application/json" -d '{ "PathOrToken": "/user/cal1-from-owner/", "Enabled": true, "Hidden": false}' http://localhost:5232/.sharing/v1/map/update {"ApiVersion": 1, "Status": "success"} ``` @@ -544,7 +544,7 @@ Toggle enable|disable|hide|unhide of `Owner` or `User` of a share selected by `P * form->text ```bash -curl -u user:pass -d "PathOrToken=/user/cal1-from-owner/" http://localhost:5232/.sharing/v1/map/enable +curl -u $user:$userpw -d "PathOrToken=/user/cal1-from-owner/" http://localhost:5232/.sharing/v1/map/enable ApiVersion=1 Status='success' ```bash @@ -552,7 +552,7 @@ Status='success' * json->json ```bash -curl -u user:pass -H "Content-Type: application/json" -d '{ "PathOrToken": "/user/cal1-from-owner/"}' http://localhost:5232/.sharing/v1/map/unhide +curl -u $user:$userpw -H "Content-Type: application/json" -d '{ "PathOrToken": "/user/cal1-from-owner/"}' http://localhost:5232/.sharing/v1/map/unhide {"ApiVersion": 1, "Status": "success"} ``` @@ -608,34 +608,34 @@ xml_ppc=' ```bash ## Retrieve collection color of owner (no color set) -curl -u owner:pass -d "$xml_pfc" -X PROPFIND http://localhost:5232/owner/testcalendar1/ +curl -u $owner:$ownerpw -d "$xml_pfc" -X PROPFIND http://localhost:5232/owner/testcalendar1/ ## Create read-only share for user -curl -u owner:pass -d "PathOrToken=/user/cal1-from-owner/" -d "PathMapped=/owner/testcalendar1/" -d "User=user" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/map/create +curl -u $owner:$ownerpw -d "PathOrToken=/user/cal1-from-owner/" -d "PathMapped=/owner/testcalendar1/" -d "User=user" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/map/create ## Accept (enable+unhide) share by user -curl -u user:pass -d "PathOrToken=/user/cal1-from-owner/" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/map/update +curl -u $user:$userpw -d "PathOrToken=/user/cal1-from-owner/" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/map/update ## Retrieve collection color of share by user (no color set) -curl -u user:pass -d "$xml_pfc" -X PROPFIND http://localhost:5232/user/cal1-from-owner/ +curl -u $user:$userpw -d "$xml_pfc" -X PROPFIND http://localhost:5232/user/cal1-from-owner/ ## Set property overlay by user -curl -u user:pass -d "PathOrToken=/user/cal1-from-owner/" -d 'Properties="ICAL:calendar-color"="#CCCCCC"' http://localhost:5232/.sharing/v1/map/update +curl -u $user:$userpw -d "PathOrToken=/user/cal1-from-owner/" -d 'Properties="ICAL:calendar-color"="#CCCCCC"' http://localhost:5232/.sharing/v1/map/update ## Retrieve collection color of share by user (color set) -curl -u user:pass -d "$xml_pfc" -X PROPFIND http://localhost:5232/user/cal1-from-owner/ +curl -u $user:$userpw -d "$xml_pfc" -X PROPFIND http://localhost:5232/user/cal1-from-owner/ ## Delete property overlay by user -curl -u user:pass -d "PathOrToken=/user/cal1-from-owner/" -d 'Properties=' http://localhost:5232/.sharing/v1/map/update +curl -u $user:$userpw -d "PathOrToken=/user/cal1-from-owner/" -d 'Properties=' http://localhost:5232/.sharing/v1/map/update ## Retrieve collection color of share by user (no color set) -curl -u user:pass -d "$xml_pfc" -X PROPFIND http://localhost:5232/user/cal1-from-owner/ +curl -u $user:$userpw -d "$xml_pfc" -X PROPFIND http://localhost:5232/user/cal1-from-owner/ ## Add property overlay by user using PROPPATCH -curl -u user:pass -d "$xml_ppc" -X PROPPATCH http://localhost:5232/user/cal1-from-owner/ +curl -u $user:$userpw -d "$xml_ppc" -X PROPPATCH http://localhost:5232/user/cal1-from-owner/ ## Retrieve collection color of share by user (color set) -curl -u user:pass -d "$xml_pfc" -X PROPFIND http://localhost:5232/user/cal1-from-owner/ +curl -u $user:$userpw -d "$xml_pfc" -X PROPFIND http://localhost:5232/user/cal1-from-owner/ ``` ## Virtual "bday" collection From 98a00930526bd9c2782c9a77f829e110e87d4feb Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 15 Mar 2026 21:51:45 +0100 Subject: [PATCH 42/46] sharing/bday: update version --- DOCUMENTATION.md | 6 ++++-- radicale/rights/__init__.py | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 66d2c8c3..02567aa5 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -2122,7 +2122,7 @@ Default: `false` ##### collection_by_bday -_(>= 3.8.0)_ +_(>= 3.7.0)_ Share collection by bday (conversion on-the-fly) @@ -2152,7 +2152,7 @@ Default: `false` ##### permit_create_bday -_(>= 3.8.0)_ +_(>= 3.7.0)_ Permit create of bday-based sharing @@ -2427,6 +2427,8 @@ The following `permissions` are recognized: * **t:** deny create of token-based sharing of collection in case `permit_create_token=True` _(>= 3.7.0)_ * **M:** permit create of map-based sharing of collection in case `permit_create_map= False` _(>= 3.7.0)_ * **m:** deny create of map-based sharing of collection in case `permit_create_map=True` _(>= 3.7.0)_ +* **B:** permit create of bday-based sharing of addressbook collection in case `permit_create_bday= False` _(>= 3.7.0)_ +* **b:** deny create of bday-based sharing of addressbook collection in case `permit_create_bday=True` _(>= 3.7.0)_ ### Storage diff --git a/radicale/rights/__init__.py b/radicale/rights/__init__.py index a195e901..7a2d881e 100644 --- a/radicale/rights/__init__.py +++ b/radicale/rights/__init__.py @@ -35,8 +35,8 @@ Permissions: - t: deny create of token-based sharing of collection in case permit_create_token=True (>= 3.7.0) - M: permit create of map-based sharing of collection in case permit_create_map=False (>= 3.7.0) - m: deny create of map-based sharing of collection in case permit_create_map=True (>= 3.7.0) - - B: permit create of bday-based sharing of collection in case permit_create_bday=False (>= 3.8.0) - - b: deny create of bday-based sharing of collection in case permit_create_bday=True (>= 3.8.0) + - B: permit create of bday-based sharing of addressbook collection in case permit_create_bday=False (>= 3.7.0) + - b: deny create of bday-based sharing of addressbook collection in case permit_create_bday=True (>= 3.7.0) Permissions only supported so far in share permissions: - P: permit properties overlay in case permit_properties_overlay=False (>= 3.7.0) From 9781281b5e203d6a2bdb30f17995ee62c0062e0e Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Sun, 15 Mar 2026 22:03:41 +0100 Subject: [PATCH 43/46] sharing/bday: fix a leftover union --- 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 9c7bba00..d6e8a474 100644 --- a/radicale/item/__init__.py +++ b/radicale/item/__init__.py @@ -505,7 +505,7 @@ class Item: self.component_name self._vobject_item = orig_vobject_item - def convert_vcf_to_ics(self) -> Union["Item" | None]: + def convert_vcf_to_ics(self) -> Union["Item", None]: if logger.isEnabledFor(logging.DEBUG): logger.debug("TRACE/item/convert_vcf_to_ics: convert VCF to ICS (href): %r", self.href) logger.debug("TRACE/item/convert_vcf_to_ics: convert VCF to ICS (vobject): %r", self.vobject_item) From fb422ad7a9d26589e31b790daed77468cabfaa33 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Mon, 16 Mar 2026 06:18:40 +0100 Subject: [PATCH 44/46] sharing/doc: use only hardcoded example user --- SHARING.md | 56 +++++++++++++++++++++++++++--------------------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/SHARING.md b/SHARING.md index 29fa5d8d..368e6d1e 100644 --- a/SHARING.md +++ b/SHARING.md @@ -249,7 +249,7 @@ Shows what kind of ShareTypes are supported * form->text ```bash -curl -u $user:$userpw -H "accept: text/plain" -d "" http://localhost:5232/.sharing/v1/all/info +curl -u user:$userpw -H "accept: text/plain" -d "" http://localhost:5232/.sharing/v1/all/info ApiVersion=1 Status='success' FeatureEnabledCollectionByMap=True @@ -262,7 +262,7 @@ PermittedCreateCollectionByToken=True ``` bash -curl -u $user:$userpw --silent -H "accept: application/json" -d "" http://localhost:5232/.sharing/v1/all/info | jq +curl -u user:$userpw --silent -H "accept: application/json" -d "" http://localhost:5232/.sharing/v1/all/info | jq { "ApiVersion": 1, "Status": "success", @@ -311,7 +311,7 @@ Create a share by mapping a collection of an `Owner` to a token. * form->text ```bash -curl -u $user:$userpw -d "PathMapped=/user/testcalendar1/" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/token/create +curl -u user:$userpw -d "PathMapped=/user/testcalendar1/" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/token/create ApiVersion=1 Status='success' PathOrToken='v1/VQR7AmsVRi2ZlFj_JwGpFx-ES5Goyku-gP_YkLh1zUw=' @@ -320,7 +320,7 @@ PathOrToken='v1/VQR7AmsVRi2ZlFj_JwGpFx-ES5Goyku-gP_YkLh1zUw=' * json->json ```bash -curl -u $user:$userpw -H "Content-Type: application/json" -d '{ "PathMapped": "/user/testcalendar1/", "Enabled": true, "Hidden": false}' http://localhost:5232/.sharing/v1/token/create +curl -u user:$userpw -H "Content-Type: application/json" -d '{ "PathMapped": "/user/testcalendar1/", "Enabled": true, "Hidden": false}' http://localhost:5232/.sharing/v1/token/create {"ApiVersion": 1, "Status": "success", "PathOrToken": "v1/aMsmGqOsRwSH-2-6tEa8EMr4RMYzMU7WvPmjnp5qDnw="} ``` @@ -359,7 +359,7 @@ Create a share by mapping a collection of an `Owner` to an `User`. * form->text ```bash -curl -u $owner:$ownerpw -d "PathOrToken=/user/cal1-from-owner/" -d "PathMapped=/owner/testcalendar1/" -d "User=user" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/map/create +curl -u owner:$ownerpw -d "PathOrToken=/user/cal1-from-owner/" -d "PathMapped=/owner/testcalendar1/" -d "User=user" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/map/create ApiVersion=1 Status='success' ``` @@ -367,7 +367,7 @@ Status='success' * json->json ```bash -curl -u $owner:$ownerpw -H "Content-Type: application/json" -d '{ "PathOrToken": "/user/cal1-from-owner/", "PathMapped": "/owner/testcalendar1/", "User" : "user", "Enabled": true, "Hidden": false}' http://localhost:5232/.sharing/v1/map/create +curl -u owner:$ownerpw -H "Content-Type: application/json" -d '{ "PathOrToken": "/user/cal1-from-owner/", "PathMapped": "/owner/testcalendar1/", "User" : "user", "Enabled": true, "Hidden": false}' http://localhost:5232/.sharing/v1/map/create {"ApiVersion": 1, "Status": "success"} ``` @@ -392,7 +392,7 @@ List shares (optional with filter) either owned or assigned as user. * form->text ("all") ```bash -curl -u $user:$userpw -d "" http://localhost:5232/.sharing/v1/map/list://localhost:5232/.sharing/v1/map/list +curl -u user:$userpw -d "" http://localhost:5232/.sharing/v1/map/list://localhost:5232/.sharing/v1/map/list ApiVersion=1 Lines=1 Status='success' @@ -403,7 +403,7 @@ Content[0]="map;/user/cal1-from-owner/;/owner/testcalendar1/;owner;user;r;True;T * form->csv ("map" only) ```bash -curl -H "accept: text/csv" -u $user:$userpw -d "" http://localhost:5232/.sharing/v1/map/list://localhost:5232/.sharing/v1/map/list +curl -H "accept: text/csv" -u user:$userpw -d "" http://localhost:5232/.sharing/v1/map/list://localhost:5232/.sharing/v1/map/list ShareType;PathOrToken;PathMapped;Owner;User;Permissions;EnabledByOwner;EnabledByUser;HiddenByOwner;HiddenByUser;TimestampCreated;TimestampUpdated;Properties map;/user/cal1-from-owner/;/owner/testcalendar1/;owner;user;r;True;False;False;True;1772747277;1772747277; ``` @@ -411,7 +411,7 @@ map;/user/cal1-from-owner/;/owner/testcalendar1/;owner;user;r;True;False;False;T * json->json ("all"), parsed with `jq` ```bash -curl -s -H "Content-Type: application/json" -u $user:$userpw -d "{}" http://localhost:5232/.sharing/v1/all/list | jq +curl -s -H "Content-Type: application/json" -u user:$userpw -d "{}" http://localhost:5232/.sharing/v1/all/list | jq { "ApiVersion": 1, "Lines": 2, @@ -473,7 +473,7 @@ Delete a share selected by `PathOrToken`. * form->text ```bash -curl -u $owner:$ownerpw -d "PathOrToken=/user/cal1-from-owner/" http://localhost:5232/.sharing/v1/map/delete +curl -u owner:$ownerpw -d "PathOrToken=/user/cal1-from-owner/" http://localhost:5232/.sharing/v1/map/delete ApiVersion=1 Status='success' ``` @@ -481,7 +481,7 @@ Status='success' * json->json ```bash -curl -u $user:$userpw -H "Content-Type: application/json" -d '{ "PathOrToken": "v1/DUSl_J5rRlWx3fy8YRXpH22FFllplkOTpcSwfGtpvkc="}' http://localhost:5232/.sharing/v1/token/delete +curl -u user:$userpw -H "Content-Type: application/json" -d '{ "PathOrToken": "v1/DUSl_J5rRlWx3fy8YRXpH22FFllplkOTpcSwfGtpvkc="}' http://localhost:5232/.sharing/v1/token/delete {"ApiVersion": 1, "Status": "success"} ``` @@ -513,7 +513,7 @@ Execute delete+create in case `PathOrToken` needs to be changed. * form->text ```bash -curl -u $user:$userpw -d "PathOrToken=/user/cal1-from-owner/" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/map/update +curl -u user:$userpw -d "PathOrToken=/user/cal1-from-owner/" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/map/update ApiVersion=1 Status='success' ``` @@ -521,7 +521,7 @@ Status='success' * json->json ```bash -curl -u $user:$userpw -H "Content-Type: application/json" -d '{ "PathOrToken": "/user/cal1-from-owner/", "Enabled": true, "Hidden": false}' http://localhost:5232/.sharing/v1/map/update +curl -u user:$userpw -H "Content-Type: application/json" -d '{ "PathOrToken": "/user/cal1-from-owner/", "Enabled": true, "Hidden": false}' http://localhost:5232/.sharing/v1/map/update {"ApiVersion": 1, "Status": "success"} ``` @@ -544,7 +544,7 @@ Toggle enable|disable|hide|unhide of `Owner` or `User` of a share selected by `P * form->text ```bash -curl -u $user:$userpw -d "PathOrToken=/user/cal1-from-owner/" http://localhost:5232/.sharing/v1/map/enable +curl -u user:$userpw -d "PathOrToken=/user/cal1-from-owner/" http://localhost:5232/.sharing/v1/map/enable ApiVersion=1 Status='success' ```bash @@ -552,7 +552,7 @@ Status='success' * json->json ```bash -curl -u $user:$userpw -H "Content-Type: application/json" -d '{ "PathOrToken": "/user/cal1-from-owner/"}' http://localhost:5232/.sharing/v1/map/unhide +curl -u user:$userpw -H "Content-Type: application/json" -d '{ "PathOrToken": "/user/cal1-from-owner/"}' http://localhost:5232/.sharing/v1/map/unhide {"ApiVersion": 1, "Status": "success"} ``` @@ -608,34 +608,34 @@ xml_ppc=' ```bash ## Retrieve collection color of owner (no color set) -curl -u $owner:$ownerpw -d "$xml_pfc" -X PROPFIND http://localhost:5232/owner/testcalendar1/ +curl -u owner:$ownerpw -d "$xml_pfc" -X PROPFIND http://localhost:5232/owner/testcalendar1/ ## Create read-only share for user -curl -u $owner:$ownerpw -d "PathOrToken=/user/cal1-from-owner/" -d "PathMapped=/owner/testcalendar1/" -d "User=user" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/map/create +curl -u owner:$ownerpw -d "PathOrToken=/user/cal1-from-owner/" -d "PathMapped=/owner/testcalendar1/" -d "User=user" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/map/create ## Accept (enable+unhide) share by user -curl -u $user:$userpw -d "PathOrToken=/user/cal1-from-owner/" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/map/update +curl -u user:$userpw -d "PathOrToken=/user/cal1-from-owner/" -d "Enabled=True" -d "Hidden=False" http://localhost:5232/.sharing/v1/map/update ## Retrieve collection color of share by user (no color set) -curl -u $user:$userpw -d "$xml_pfc" -X PROPFIND http://localhost:5232/user/cal1-from-owner/ +curl -u user:$userpw -d "$xml_pfc" -X PROPFIND http://localhost:5232/user/cal1-from-owner/ ## Set property overlay by user -curl -u $user:$userpw -d "PathOrToken=/user/cal1-from-owner/" -d 'Properties="ICAL:calendar-color"="#CCCCCC"' http://localhost:5232/.sharing/v1/map/update +curl -u user:$userpw -d "PathOrToken=/user/cal1-from-owner/" -d 'Properties="ICAL:calendar-color"="#CCCCCC"' http://localhost:5232/.sharing/v1/map/update ## Retrieve collection color of share by user (color set) -curl -u $user:$userpw -d "$xml_pfc" -X PROPFIND http://localhost:5232/user/cal1-from-owner/ +curl -u user:$userpw -d "$xml_pfc" -X PROPFIND http://localhost:5232/user/cal1-from-owner/ ## Delete property overlay by user -curl -u $user:$userpw -d "PathOrToken=/user/cal1-from-owner/" -d 'Properties=' http://localhost:5232/.sharing/v1/map/update +curl -u user:$userpw -d "PathOrToken=/user/cal1-from-owner/" -d 'Properties=' http://localhost:5232/.sharing/v1/map/update ## Retrieve collection color of share by user (no color set) -curl -u $user:$userpw -d "$xml_pfc" -X PROPFIND http://localhost:5232/user/cal1-from-owner/ +url -u user:$userpw -d "$xml_pfc" -X PROPFIND http://localhost:5232/user/cal1-from-owner/ ## Add property overlay by user using PROPPATCH -curl -u $user:$userpw -d "$xml_ppc" -X PROPPATCH http://localhost:5232/user/cal1-from-owner/ +curl -u user:$userpw -d "$xml_ppc" -X PROPPATCH http://localhost:5232/user/cal1-from-owner/ ## Retrieve collection color of share by user (color set) -curl -u $user:$userpw -d "$xml_pfc" -X PROPFIND http://localhost:5232/user/cal1-from-owner/ +curl -u user:$userpw -d "$xml_pfc" -X PROPFIND http://localhost:5232/user/cal1-from-owner/ ``` ## Virtual "bday" collection @@ -658,14 +658,14 @@ Preconditions: ```bash ## Create sharing of type "bday" -curl -u owner:ownerpw -d "PathOrToken=/owner/bday-of-addressbook/" -d "PathMapped=/owner/addressbook/" -d "User=owner" http://localhost:5232/.sharing/v1/bday/create +curl -u owner:$ownerpw -d "PathOrToken=/owner/bday-of-addressbook/" -d "PathMapped=/owner/addressbook/" -d "User=owner" http://localhost:5232/.sharing/v1/bday/create ## Enable -curl -u owner:ownerpw -d "PathOrToken=/owner/bday-of-addressbook/" http://localhost:5232/.sharing/v1/bday/enable +curl -u owner:$ownerpw -d "PathOrToken=/owner/bday-of-addressbook/" http://localhost:5232/.sharing/v1/bday/enable ## Unhide -curl -u owner:ownerpw -d "PathOrToken=/owner/bday-of-addressbook/" http://localhost:5232/.sharing/v1/bday/unhide +curl -u owner:$ownerpw -d "PathOrToken=/owner/bday-of-addressbook/" http://localhost:5232/.sharing/v1/bday/unhide ``` * Check From e10b03aa3928465ee6fc11a3dfba91db205816aa Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Mon, 16 Mar 2026 06:21:10 +0100 Subject: [PATCH 45/46] workflow: update versions for nodejs24 on lint --- .github/workflows/test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 445383db..5f20090e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -213,8 +213,8 @@ jobs: name: Lint runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 with: python-version: '3.14' - name: Install tox From 171b401b867e3a963e60ac0b93199a62715c181d Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Mon, 16 Mar 2026 06:27:23 +0100 Subject: [PATCH 46/46] workflow: update versions for nodejs24 of all --- .github/workflows/test.yml | 41 +++++++++++++++++--------------------- 1 file changed, 18 insertions(+), 23 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5f20090e..39b61cf8 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,8 +12,8 @@ jobs: python-version: ['3.14'] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Install Test dependencies @@ -32,7 +32,6 @@ jobs: container: image: i386/python:${{ matrix.python-version }} volumes: - - /home/runner/work/_temp:/__e/node20 - /home/runner/work/_temp:/__e/node24 steps: - name: Update system @@ -41,10 +40,6 @@ jobs: - name: Fix checkout and upload-artifact run: | apt-get install -y nodejs npm - mkdir -p /__e/node20/bin - ln -sf $(which node) /__e/node20/bin/node - ln -sf $(which npm) /__e/node20/bin/npm - ln -sf $(which npx) /__e/node20/bin/npx mkdir -p /__e/node24/bin ln -sf $(which node) /__e/node24/bin/node ln -sf $(which npm) /__e/node24/bin/npm @@ -52,8 +47,8 @@ jobs: - name: Install git run: | apt-get install -y git - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 - name: Install Python dependencies run: | apt-get install -y tox rust-all cargo @@ -76,8 +71,8 @@ jobs: python-version: ['3.9'] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Install Test dependencies @@ -94,8 +89,8 @@ jobs: python-version: ['3.14'] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Install Test dependencies @@ -112,8 +107,8 @@ jobs: python-version: ['3.9'] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Install Test dependencies @@ -137,8 +132,8 @@ jobs: python-version: 'pypy-3.11' runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - name: Install Test dependencies @@ -152,8 +147,8 @@ jobs: if: github.event_name == 'push' runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 with: python-version: '3.14' - name: Install Test dependencies @@ -191,8 +186,8 @@ jobs: runs-on: ubuntu-latest needs: test-ubuntu-python-newest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v5 + - uses: actions/setup-node@v5 with: node-version: 24 - name: JS Type Check @@ -227,8 +222,8 @@ jobs: runs-on: ubuntu-latest needs: test-ubuntu-python-newest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v5 + - uses: actions/setup-python@v6 with: python-version: '3.12' - name: Install uv