diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index 8b3f041b..5eb6d208 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -2324,7 +2324,7 @@ Supported placeholders (data used from VCARD) * `{nickname}`: nick name (RFC6350#6.2.3) Supported extra placeholders - * `{age}`: age, toggles to creation of single events instead using RRULE + * `{age}`: age, toggles to creation of single events instead using RRULE (only active if year + age_max >= current year) Fallback is supported if placeholders inside `[...|...]` (first successful resolved one is used) @@ -2346,7 +2346,7 @@ Global template for alarm trigger of conversion "bday" Default: `` -Supported format: `TIMEDELTA;DESCRIPTION` (separated by `|` if more alarms should be generated) +Supported format: `TIMEDELTA;DESCRIPTION` (separated by `$` if more alarms should be generated) Supported format for `TIMEDELTA`: `[+-]?[0-9]+[WDHM]` diff --git a/config b/config index 77f42526..904986a6 100644 --- a/config +++ b/config @@ -363,7 +363,7 @@ #conversion_bday_description_template = BDAY={year}-{month}-{day} # Global template of alarm trigger of conversion "bday" -# Example: "-15H;BDAY tomorrow|9H;BDAY today" +# Example: "-15H;BDAY tomorrow$9H;BDAY today" #conversion_bday_alarm_trigger_template = "" # Global categories of conversion "bday" diff --git a/radicale/item/__init__.py b/radicale/item/__init__.py index 4a43f787..565488a1 100644 --- a/radicale/item/__init__.py +++ b/radicale/item/__init__.py @@ -49,6 +49,9 @@ UID_SUFFIX = "-auto-converted-by-Radicale" VCF_TO_ICS_SUPPORTED_PLACEHOLDERS: list = ["fn", "n:f", "n:g", "n:a", "age", "nickname", "year", "month", "day"] +# List of BDAY years acting as flag for "no year specified" +VCF_TO_ICS_BDAY_NO_YEAR: list = ["1604"] + def read_components(s: str) -> List[vobject.base.Component]: """Wrapper for vobject.readComponents""" @@ -379,27 +382,37 @@ def verify(file: str, encoding: str): def replace_placeholders(text: str, placeholder_mapping: dict) -> str: + logger.trace("item/convert_vcf_to_ics: resolve placeholders: %r", text) + for placeholder in placeholder_mapping: text = text.replace(placeholder, placeholder_mapping[placeholder]) - # resolve {..|..} recursive - pattern = re.compile('(.*)(\\[)([^|]+)\\|(.+)(\\])(.*)') - logger.trace("item/convert_vcf_to_ics: resolve [..|..] starting with: %r", text) + logger.trace("item/convert_vcf_to_ics: resolve [..|..] in : %r", text) + + # resolve [..|..] recursive + pattern = re.compile('(.*)(\\[)([^|]+)\\|([^\\]]*)(\\])(.*)') while True: match = pattern.match(text) if not match: # nothing more todo break else: - if match[3].startswith('!') and match[3].endswith('!'): + logger.trace("item/convert_vcf_to_ics: resolve match : %r", match[0]) + # check for still unresolved placeholders + unresolved = False + for placeholder in VCF_TO_ICS_SUPPORTED_PLACEHOLDERS: + if "!" + placeholder + "!" in match[3]: + unresolved = True + break + if unresolved: # not resolved variable if '|' in match[4]: # further recursion required text = match[1] + match[2] + match[4] + match[5] + match[6] - logger.trace("item/convert_vcf_to_ics: resolve [..|..] match/replace/continue result: %r", text) + logger.trace("item/convert_vcf_to_ics: resolve continue : %r", text) else: text = match[1] + match[4] + match[6] - logger.trace("item/convert_vcf_to_ics: resolve [..|..] match/replace/final result: %r", text) + logger.trace("item/convert_vcf_to_ics: resolve final result: %r", text) break else: # resolved variable @@ -605,6 +618,13 @@ class Item: else: pass + vcard_has_year = True # default + vcard_age_exceed_max = False # default + + if str(match[1]) in VCF_TO_ICS_BDAY_NO_YEAR: + logger.trace("item/convert_vcf_to_ics: has 'no year' bday: %r -> %r", self.href, bday.value) + vcard_has_year = False + placeholder_mapping: dict = {} bdayS = match[1] + match[2] + match[3] @@ -616,6 +636,9 @@ class Item: placeholder_mapping['{month}'] = match[2] placeholder_mapping['{day}'] = match[3] + if not vcard_has_year: + placeholder_mapping['{year}'] = "????" + # create ICS if hasattr(self.vobject_item, "fn"): name = self.vobject_item.fn.value @@ -669,7 +692,6 @@ class Item: summary = ShareActions['config_default']['conversion_bday_summary_template'] else: summary = sharing.SHARING_BDAY_SUMMARY_TEMPLATE_DEFAULT # fallback - summary = replace_placeholders(summary, placeholder_mapping) # prepare DESCRIPTION if ShareActions is not None and 'config' in ShareActions and 'conversion_bday_description_template' in ShareActions['config']: @@ -678,7 +700,6 @@ class Item: description = ShareActions['config_default']['conversion_bday_description_template'] else: description = sharing.SHARING_BDAY_DESCRIPTION_TEMPLATE_DEFAULT # fallback - description = replace_placeholders(description, placeholder_mapping) # create CATEGORIES if ShareActions is not None and 'config' in ShareActions and 'conversion_bday_categories' in ShareActions['config']: @@ -698,14 +719,23 @@ class Item: vevent_enable_age = False age_max = 0 - if "{age}" in summary or "{age}" in description or "age" in alarm_trigger: + if vcard_has_year and ("{age}" in summary or "{age}" in description or "age" in alarm_trigger): if ShareActions is not None and 'config' in ShareActions and 'conversion_bday_age_max' in ShareActions['config']: age_max = ShareActions['config']['conversion_bday_age_max'] elif ShareActions is not None and 'config_default' in ShareActions and 'conversion_bday_age_max' in ShareActions['config_default']: age_max = ShareActions['config_default']['conversion_bday_age_max'] else: age_max = sharing.SHARING_BDAY_AGE_MAX_DEFAULT # fallback - vevent_enable_age = True + + # check for age_max in the past + currentDateTime = datetime.datetime.now() + date = currentDateTime.date() + if bdayY + age_max < date.year: + logger.trace("item/convert_vcf_to_ics: bdayY=%d + age_max=%d < current year %d -> disable age support", bdayY, age_max, date.year) + vcard_age_exceed_max = True + age_max = 0 + else: + vevent_enable_age = True # create UID if hasattr(self.vobject_item, "uid"): @@ -742,12 +772,16 @@ class Item: uid_value = uid vevent.add('uid').value = uid_value - # set SUMMARY + # set placehoder for "age" if vevent_enable_age: - summary_value = summary.replace("{age}", str(age)) - else: - summary_value = summary - vevent.add('summary').value = summary_value + placeholder_mapping['{age}'] = str(age) + elif not vcard_has_year: + placeholder_mapping['{age}'] = "??" + elif vcard_age_exceed_max: + placeholder_mapping['{age}'] = "!age!" + + # set SUMMARY + vevent.add('summary').value = replace_placeholders(summary, placeholder_mapping) # set CATEGORIES if categories is not None and categories != []: @@ -761,16 +795,14 @@ class Item: # set VALARM if alarm_trigger is not None and alarm_trigger != "": - for entry in alarm_trigger.split('|'): + for entry in alarm_trigger.split('$'): (trigger, alarm_description) = entry.split(';') - logger.trace("item/convert_vcf_to_ics: alarm trigger entry: %r (trigger=%r description=%r)", entry, trigger, description) + logger.trace("item/convert_vcf_to_ics: alarm trigger entry: %r (trigger=%r description=%r)", entry, trigger, alarm_description) td = trigger_to_timedelta(trigger) if td is not None: - alarm_description = replace_placeholders(alarm_description, placeholder_mapping) - alarm_description_value = alarm_description.replace("{age}", str(age)) valarm = vevent.add('valarm') valarm.add('action').value = "DISPLAY" - valarm.add('description').value = alarm_description_value + valarm.add('description').value = replace_placeholders(alarm_description, placeholder_mapping) valarm.add('trigger').value = td # set RRULE @@ -781,12 +813,8 @@ class Item: vevent.add('transp').value = "TRANSPARENT" # set DESCRIPTION - if vevent_enable_age: - description_value = description.replace("{age}", str(age)) - else: - description_value = description if description != "": - vevent.add('description').value = description_value + vevent.add('description').value = replace_placeholders(description, placeholder_mapping) # increase age age = age + 1 diff --git a/radicale/sharing/__init__.py b/radicale/sharing/__init__.py index fbbde5b5..ca4d331b 100644 --- a/radicale/sharing/__init__.py +++ b/radicale/sharing/__init__.py @@ -147,7 +147,7 @@ def check_template(data: Any) -> str: placeholder_mapping["{" + placeholder + "}"] = '!' + placeholder + '!' result = item.replace_placeholders(data, placeholder_mapping) - logger.trace("replace placeholders: %r -> %r", data, result) + logger.trace("replace placeholders result: %r -> %r", data, result) pattern = re.compile('.*{.*}.*') if pattern.search(result): raise ValueError("template contains unsupported placeholder {..}: %r" % result) @@ -163,7 +163,7 @@ def check_template_not_empty(data: Any) -> str: def check_template_alarm_trigger(data: Any) -> str: if data is not None and data != '': - for entry in data.split('|'): + for entry in data.split('$'): try: (trigger, alarm_description) = entry.split(';') except ValueError: diff --git a/radicale/sharing/csv.py b/radicale/sharing/csv.py index e1c97ed1..03d59ce1 100644 --- a/radicale/sharing/csv.py +++ b/radicale/sharing/csv.py @@ -444,6 +444,7 @@ class Sharing(sharing.BaseSharing): field = field.replace("': {", '": {') # replace for JSON entry/value ': { -> ": { field = field.replace("': ", '": ') # replace for JSON entry/value ': -> ":(whitespace) field = field.replace("', '", '", "') # replace for JSON delimiter ', ' -> ", " + field = field.replace(", '", ', "') # replace for JSON delimiter , ' -> , " logger.trace("json prep quote replacer match (after) : %s", field) try: row[fieldname] = json.loads(field) diff --git a/radicale/tests/static/contact4-with-bday-no-year.vcf b/radicale/tests/static/contact4-with-bday-no-year.vcf new file mode 100644 index 00000000..106983b0 --- /dev/null +++ b/radicale/tests/static/contact4-with-bday-no-year.vcf @@ -0,0 +1,9 @@ +BEGIN:VCARD +VERSION:3.0 +PRODID:Thunderbird CardBook V105.3//DE +UID:d0bd27f0-5c7c-4540-b9b6-91314b8361f1 +BDAY:16040101 +FN:Test bday without year Thunderbird CardBook +N:;;;; +REV:2026-06-10T04:26:53Z +END:VCARD diff --git a/radicale/tests/test_sharing.py b/radicale/tests/test_sharing.py index d44f53f5..f7b93313 100644 --- a/radicale/tests/test_sharing.py +++ b/radicale/tests/test_sharing.py @@ -5020,7 +5020,7 @@ permissions: RrWw""") self.configure({"sharing": { "conversion_bday_summary_template": "[{fn}|{n:f} {n:g} {n:a}|{nickname}] (BDAY)", "conversion_bday_description_template": "BDAY={year}-{month}-{day}", - "conversion_bday_alarm_trigger_template": "-15H;BDAY tomorrow|9H;BDAY today", + "conversion_bday_alarm_trigger_template": "-15H;BDAY tomorrow$9H;BDAY today", }}) # verify content as user @@ -5083,7 +5083,7 @@ permissions: RrWw""") assert "TRIGGER:-PT15H" in answer assert "TRIGGER:PT9H" in answer - self.configure({"sharing": {"conversion_bday_alarm_trigger_template": "-12H;Birthday tomorrow of {fn}|12H;Birthday today of {n:g} {n:f}"}}) + self.configure({"sharing": {"conversion_bday_alarm_trigger_template": "-12H;Birthday tomorrow of {fn}$12H;Birthday today of {n:g} {n:f}"}}) logging.info("\n*** GET collection user format: description -> ok") _, headers, answer = self.request("GET", path_shared_3, login="user:userpw") assert "DESCRIPTION:Birthday tomorrow of Test-FN-C3" in answer @@ -5300,14 +5300,15 @@ permissions: RrWw""") json_dict['PathOrToken'] = path_shared_r json_dict['Actions'] = {"config": { "conversion_bday_age_max": 5, - "conversion_bday_summary_template": "{fn} ({year}/{age})", + "conversion_bday_summary_template": "{fn} ({year}/[{age}|MAX-in-the-past])", }} _, headers, answer = self._sharing_api_json("map", "update", check=200, login="owner:ownerpw", json_dict=json_dict) - logging.info("\n*** GET collection user format: summary with age -> ok") + logging.info("\n*** GET collection user format: summary with age:5 -> ok") _, headers, answer = self.request("GET", path_shared_3, login="user:userpw") - assert "Test-FN-C3 (1990/0)" in answer - assert "Test-FN-C3 (1990/5)" in answer + assert "Test-FN-C3 (1990/MAX-in-the-past)" in answer + assert "Test-FN-C3 (1990/0)" not in answer + assert "Test-FN-C3 (1990/5)" not in answer assert "Test-FN-C3 (1990/6)" not in answer # update template @@ -5321,7 +5322,7 @@ permissions: RrWw""") }} _, headers, answer = self._sharing_api_json("map", "update", check=200, login="owner:ownerpw", json_dict=json_dict) - logging.info("\n*** GET collection user format: summary with age -> ok") + logging.info("\n*** GET collection user format: summary with age default -> ok") _, headers, answer = self.request("GET", path_shared_3, login="user:userpw") assert "Test-FN-C3 (1990/0)" in answer assert "Test-FN-C3 (1990/5)" in answer @@ -5416,20 +5417,21 @@ permissions: RrWw""") assert "SUMMARY:Test-FN (BDAY)" in answer self.configure({"sharing": { - "conversion_bday_summary_template": "[{fn}|{n:f} {n:g} {n:a}|{nickname}] ({age}. Birthday)", + "conversion_bday_summary_template": "[{fn}|{n:f} {n:g} {n:a}|{nickname}] ([{age}. |]Birthday)", + "conversion_bday_age_max": 99, }}) - logging.info("\n*** GET collection user format:text -> ok") + logging.info("\n*** GET collection user format:text (summary) (age:99) -> ok") _, headers, answer = self.request("GET", path_shared_2, login="user:userpw") assert "SUMMARY:Test-FN (0. Birthday)" in answer assert "SUMMARY:Test-FN (1. Birthday)" in answer assert "SUMMARY:Test-FN (99. Birthday)" in answer assert "SUMMARY:Test-FN (100. Birthday)" not in answer + logging.info("\n*** GET collection user format:text (no trigger) (age:99) -> ok") self.configure({"sharing": { "conversion_bday_summary_template": "[{fn}|{n:f} {n:g} {n:a}|{nickname}] (BDAY)", "conversion_bday_description_template": "AGE={age}", }}) - logging.info("\n*** GET collection user format:text -> ok") _, headers, answer = self.request("GET", path_shared_2, login="user:userpw") assert "SUMMARY:Test-FN (BDAY)" in answer assert "DESCRIPTION:AGE=0" in answer @@ -5437,12 +5439,12 @@ permissions: RrWw""") assert "DESCRIPTION:AGE=99" in answer assert "DESCRIPTION:AGE=100" not in answer + logging.info("\n*** GET collection user format:text (with trigger) -> ok") self.configure({"sharing": { "conversion_bday_summary_template": "[{fn}|{n:f} {n:g} {n:a}|{nickname}] (BDAY)", "conversion_bday_description_template": "BDAY={year}-{month}-{day}", - "conversion_bday_alarm_trigger_template": "-15H;alarm {fn} {age}. birthday", + "conversion_bday_alarm_trigger_template": "-15H;alarm {fn} [{age}. |]birthday", }}) - logging.info("\n*** GET collection user format:text -> ok") _, headers, answer = self.request("GET", path_shared_2, login="user:userpw") assert "SUMMARY:Test-FN (BDAY)" in answer assert "DESCRIPTION:BDAY=1970-01-01" in answer @@ -5452,7 +5454,44 @@ permissions: RrWw""") assert "DESCRIPTION:alarm Test-FN 100. birthday" not in answer # update template - logging.info("\n*** update map(bday) user/owner:r -> ok") + logging.info("\n*** update map(bday) user/owner:r (age:98) -> ok") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_r + json_dict['Actions'] = {"config": { + "conversion_bday_age_max": 98, + }} + _, headers, answer = self._sharing_api_json("map", "update", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + + logging.info("\n*** GET collection user format:text (age:98) -> ok") + _, headers, answer = self.request("GET", path_shared_2, login="user:userpw") + assert "SUMMARY:Test-FN (BDAY)" in answer + assert "DESCRIPTION:BDAY=1970-01-01" in answer + assert "DESCRIPTION:alarm Test-FN 0. birthday" in answer + assert "DESCRIPTION:alarm Test-FN 1. birthday" in answer + assert "DESCRIPTION:alarm Test-FN 98. birthday" in answer + assert "DESCRIPTION:alarm Test-FN 99. birthday" not in answer + assert "DESCRIPTION:alarm Test-FN 100. birthday" not in answer + + logging.info("\n*** GET collection user limit age (age:98) -> ok") + self.configure({"sharing": { + "conversion_bday_summary_template": "[{fn}|{n:f} {n:g} {n:a}|{nickname}] (BDAY)", + "conversion_bday_description_template": "AGE=[{age}|MAX-in-the-past]", + "conversion_bday_alarm_trigger_template": "", + }}) + _, headers, answer = self.request("GET", path_shared_2, login="user:userpw") + assert "SUMMARY:Test-FN (BDAY)" in answer + assert "DESCRIPTION:AGE=0" in answer + assert "DESCRIPTION:AGE=1" in answer + assert "DESCRIPTION:AGE=98" in answer + assert "DESCRIPTION:AGE=99" not in answer + assert "DESCRIPTION:AGE=100" not in answer + + # update template + logging.info("\n*** update map(bday) user/owner:r (age:5) -> ok") json_dict = {} json_dict['User'] = "user" json_dict['PathMapped'] = path_mapped @@ -5464,29 +5503,28 @@ permissions: RrWw""") answer_dict = json.loads(answer) assert answer_dict['Status'] == "success" - logging.info("\n*** GET collection user format:text -> ok") + logging.info("\n*** GET collection user format:text (age:5) -> ok") _, headers, answer = self.request("GET", path_shared_2, login="user:userpw") assert "SUMMARY:Test-FN (BDAY)" in answer - assert "DESCRIPTION:BDAY=1970-01-01" in answer - assert "DESCRIPTION:alarm Test-FN 0. birthday" in answer - assert "DESCRIPTION:alarm Test-FN 1. birthday" in answer - assert "DESCRIPTION:alarm Test-FN 5. birthday" in answer - assert "DESCRIPTION:alarm Test-FN 6. birthday" not in answer + assert "DESCRIPTION:AGE=MAX-in-the-past" in answer + assert "DESCRIPTION:alarm Test-FN 0. birthday" not in answer + assert "DESCRIPTION:alarm Test-FN 1. birthday" not in answer + assert "DESCRIPTION:alarm Test-FN 98. birthday" not in answer assert "DESCRIPTION:alarm Test-FN 99. birthday" not in answer assert "DESCRIPTION:alarm Test-FN 100. birthday" not in answer + logging.info("\n*** GET collection user limit age (age:5) -> ok") self.configure({"sharing": { "conversion_bday_summary_template": "[{fn}|{n:f} {n:g} {n:a}|{nickname}] (BDAY)", - "conversion_bday_description_template": "AGE={age}", + "conversion_bday_description_template": "AGE=[{age}|MAX-in-the-past|MAX-in-the-past]", "conversion_bday_alarm_trigger_template": "", }}) - logging.info("\n*** GET collection user limit age to 5 -> ok") _, headers, answer = self.request("GET", path_shared_2, login="user:userpw") assert "SUMMARY:Test-FN (BDAY)" in answer - assert "DESCRIPTION:AGE=0" in answer - assert "DESCRIPTION:AGE=1" in answer - assert "DESCRIPTION:AGE=5" in answer - assert "DESCRIPTION:AGE=6" not in answer + assert "DESCRIPTION:AGE=MAX-in-the-past" in answer + assert "DESCRIPTION:AGE=0" not in answer + assert "DESCRIPTION:AGE=1" not in answer + assert "DESCRIPTION:AGE=98" not in answer assert "DESCRIPTION:AGE=99" not in answer assert "DESCRIPTION:AGE=100" not in answer @@ -5554,6 +5592,103 @@ permissions: RrWw""") }} _, headers, answer = self._sharing_api_json("map", "update", check=400, login="owner:ownerpw", json_dict=json_dict) + def test_sharing_api_map_vcf_no_year_bday_age_template(self) -> None: + """share-by-map with conversion=bday template tests with age and VCF without year.""" + self.configure({"auth": {"type": "htpasswd", + "htpasswd_filename": self.htpasswd_file_path, + "htpasswd_encryption": "plain"}, + "sharing": { + "type": "csv", + "permit_create_map": True, + "permit_properties_overlay": "True", + "enforce_properties_overlay": "True", + "collection_by_map": "True"}, + "logging": {"request_header_on_debug": "False", + "response_content_on_debug": "True", + "response_header_on_debug": "True", + "request_content_on_debug": "True"}, + "rights": {"type": "owner_only"}}) + + json_dict: dict + + logging.info("\n*** prepare and test access") + + for db_type in list(filter(lambda item: item != "none", sharing.INTERNAL_TYPES)): + logging.info("\n*** test: %s", db_type) + self.configure({"sharing": {"type": db_type}}) + + path_mapped = "/owner/adressbook-" + db_type + ".vcf/" + path_shared_r = "/user/calendar-bday-abook-shared-by-owner-r-" + db_type + ".ics/" + self.create_addressbook(path_mapped, login="owner:ownerpw") + + contact2 = get_file_content("contact4-with-bday-no-year.vcf") + path2 = path_mapped + "/contact4-with-bday-no-year.vcf" + path_shared_2 = path_shared_r + "/contact4-with-bday-no-year.vcf" + self.put(path2, contact2, login="owner:ownerpw") + + # create map + logging.info("\n*** create map(bday) user/owner:r -> ok") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_r + json_dict['Conversion'] = "bday" + json_dict['Permissions'] = "rP" + json_dict['Enabled'] = True + json_dict['Enabled'] = True + json_dict['Hidden'] = False + json_dict['Properties'] = {"D:displayname": "Test-BDAY"} + _, headers, answer = self._sharing_api_json("map", "create", check=200, login="owner:ownerpw", json_dict=json_dict) + answer_dict = json.loads(answer) + assert answer_dict['Status'] == "success" + + # enable map by user + logging.info("\n*** enable map(bday) by user") + json_dict = {} + json_dict['User'] = "user" + json_dict['PathMapped'] = path_mapped + json_dict['PathOrToken'] = path_shared_r + _, headers, answer = self._sharing_api_json("map", "enable", check=200, login="user:userpw", json_dict=json_dict) + + self.configure({"sharing": { + "conversion_bday_summary_template": "[{fn}|{n:f} {n:g} {n:a}|{nickname}] (BDAY)", + "conversion_bday_description_template": "BDAY={year}-{month}-{day}", + }}) + + # verify content as user + logging.info("\n*** GET collection user format:default -> ok") + _, headers, answer = self.request("GET", path_shared_2, login="user:userpw") + assert "SUMMARY:Test bday without year Thunderbird CardBook (BDAY)" in answer + + self.configure({"sharing": { + "conversion_bday_summary_template": "[{fn}|{n:f} {n:g} {n:a}|{nickname}] ([{age}. |]Birthday)", + }}) + logging.info("\n*** GET collection user format:text -> ok") + _, headers, answer = self.request("GET", path_shared_2, login="user:userpw") + assert "(0. Birthday)" not in answer + + self.configure({"sharing": { + "conversion_bday_summary_template": "[{fn}|{n:f} {n:g} {n:a}|{nickname}] (BDAY)", + "conversion_bday_description_template": "AGE={age} BDAY={year}-{month}-{day}", + }}) + logging.info("\n*** GET collection user format:text -> ok") + _, headers, answer = self.request("GET", path_shared_2, login="user:userpw") + assert "SUMMARY:Test bday without year Thunderbird CardBook (BDAY)" in answer + assert "DESCRIPTION:AGE=0" not in answer + assert "DESCRIPTION:AGE=??" in answer + + self.configure({"sharing": { + "conversion_bday_summary_template": "[{fn}|{n:f} {n:g} {n:a}|{nickname}] (BDAY)", + "conversion_bday_description_template": "BDAY={year}-{month}-{day}", + "conversion_bday_alarm_trigger_template": "-15H;alarm {fn} [{age}. |]birthday", + }}) + logging.info("\n*** GET collection user format:text -> ok") + _, headers, answer = self.request("GET", path_shared_2, login="user:userpw") + assert "SUMMARY:Test bday without year Thunderbird CardBook (BDAY)" in answer + assert "DESCRIPTION:BDAY=????-01-01" in answer + assert "DESCRIPTION:alarm Test bday without year Thunderbird CardBook 0. birthday" not in answer + assert "DESCRIPTION:alarm Test bday without year Thunderbird CardBook ??. birthday" in answer + def test_sharing_api_map_vcf_bday_per_share_template(self) -> None: """share-by-map with conversion=bday template per share tests.""" self.configure({"auth": {"type": "htpasswd",