Merge pull request #2158 from pbiering/sharing-bday-template-age-check

Sharing bday template age check
This commit is contained in:
Peter Bieringer
2026-06-11 18:54:16 +03:00
committed by GitHub
7 changed files with 228 additions and 55 deletions

View File

@@ -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]`

2
config
View File

@@ -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"

View File

@@ -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

View File

@@ -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:

View File

@@ -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)

View File

@@ -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

View File

@@ -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",