radicale birthday calendars

radicale webhooks
This commit is contained in:
azykov@mail.ru
2026-05-07 11:38:10 +03:00
parent 8e23b06d69
commit 6d132ee3fb
13 changed files with 373 additions and 0 deletions
@@ -0,0 +1,65 @@
#!/usr/bin/env python3
from pprint import pprint
import vobject
from .types import Event
from .utils import parse_date
def read_contact_date_field(card, target: list[Event], field_name: str, target_field_name: str):
if field_name in card.contents:
for idx, component in enumerate(card.contents[field_name]): # datetime.date or string depending on format
field_id = f"{field_name}-{idx}"
omit_year = component.params.get("X-APPLE-OMIT-YEAR")
dt = parse_date(field_id, target_field_name, component.value, omit_year[0] if omit_year else False)
target.append(dt)
def read_contact_abdate_field(card, target: list[Event]):
grouped_fields = {}
field_name = "x-abdate"
field_name_2 = "x-ablabel"
if field_name in card.contents:
for component in card.contents[field_name]: # datetime.date or string depending on format
if component.group not in grouped_fields:
grouped_fields[component.group] = {}
grouped_fields[component.group]["value"] = component.value
if field_name_2 in card.contents:
for component in card.contents[field_name_2]: # datetime.date or string depending on format
if component.group not in grouped_fields:
grouped_fields[component.group] = {}
grouped_fields[component.group]["name"] = component.value
# if len(grouped_fields):
# pprint(card)
for group in grouped_fields:
f = grouped_fields[group]
# pprint(group)
# pprint(f)
if "name" in f and "value" in f:
target.append(parse_date(group, f["name"], f["value"]))
def read_contact(path: str) -> tuple[vobject.vCard, list[Event]]:
# print("---")
# with open(path, 'r') as file:
# print(file.read())
# print("---")
with open(path) as f:
card = vobject.readOne(f)
result = []
read_contact_date_field(card, result, "bday", "birthday")
read_contact_date_field(card, result, "anniversary", "anniversary")
read_contact_date_field(card, result, "x-anniversary", "anniversary")
# result["x-abdate"] = read_contact_date_field(card, "x-abdate") # need specific parsing. also see X-ABDATE;X-ABLABEL
read_contact_date_field(card, result, "x-anniversary", "anniversary")
read_contact_date_field(card, result, "x-evolution-anniversary", "anniversary") # also see X-EVOLUTION-SPOUSE
read_contact_abdate_field(card, result) # also see X-EVOLUTION-SPOUSE
return card, result
@@ -0,0 +1,41 @@
import vobject
def get_contact_name(card: vobject.vCard) -> str:
"""Extract best available display name from a vCard, never raises."""
# 1. FN — formatted name, most reliable
try:
fn = card.contents["fn"][0].value.strip()
if fn:
return fn
except (KeyError, IndexError, AttributeError):
pass
# 2. N — structured name: last, first, middle, prefix, suffix
try:
n = card.contents["n"][0].value
parts = [n.prefix, n.given, n.additional, n.family, n.suffix]
name = " ".join(p for p in parts if p and p.strip())
if name.strip():
return name.strip()
except (KeyError, IndexError, AttributeError):
pass
# 3. ORG — organization name as last resort
try:
org = card.contents["org"][0].value
# org can be a list ["Company", "Department"] or a string
if isinstance(org, list):
org = " / ".join(p for p in org if p and p.strip())
if org and org.strip():
return org.strip()
except (KeyError, IndexError, AttributeError):
pass
# 4. EMAIL
try:
return card.contents["email"][0].value.strip()
except (KeyError, IndexError, AttributeError):
pass
return "Unknown"
@@ -0,0 +1,27 @@
import vobject
from datetime import date, datetime
from pathlib import Path
from .types import Event
def create_event(name: str, base_id: str, eventDate: Event) -> tuple[str, str]:
year = eventDate.year or datetime.now().year
dt = date(year, eventDate.month, eventDate.day)
id = base_id
cal = vobject.iCalendar()
event = cal.add('vevent')
event.add('uid').value = id
event.add('summary').value = f"{name}, {eventDate.name}"
event.add('description').value = (f"{eventDate.year}-" if eventDate.year is not None else "") + f"{eventDate.month:02}-{eventDate.day:02}"
event.add('dtstart').value = dt
event.add('rrule').value = 'FREQ=YEARLY'
return id, cal.serialize()
def write_calendar_event(prefix: str, event: Event, dir: Path):
evtid, ics = create_event(prefix, f"{id}-{event.id}", event)
with open(dir / f"{evtid}.ics", 'w') as f:
f.write(ics)
@@ -0,0 +1,8 @@
from typing import NamedTuple, Optional
class Event(NamedTuple):
id: str
name: str
day: int
month: int
year: Optional[int] = None
@@ -0,0 +1,24 @@
import re
from datetime import date, datetime
from .types import Event
def parse_date(id: str, name: str, input: str, apple_omit_year: str | None = None) -> Event:
if isinstance(input, date):
return Event(id, name, input.day, input.month, input.year)
match = re.match(r"^(-|NaN)-(\d{2})-?(\d{2})$", input) # no-year format: --MMDD or --MM-DD. Also NaN-MM-DD spotted
if match:
return Event(id, name, int(match.group(3)), int(match.group(2)))
for fmt in ("%Y-%m-%d", "%Y%m%d"):
try:
dt = datetime.strptime(input, fmt).date()
if apple_omit_year == f"{dt.year}":
return Event(id, name, dt.day, dt.month)
else:
return Event(id, name, dt.day, dt.month, dt.year)
except ValueError:
continue
return None # cannot parse