From 4cc1f96adab7040f51892edaee40ac3d05833578 Mon Sep 17 00:00:00 2001 From: Peter Bieringer Date: Fri, 13 Mar 2026 21:34:46 +0100 Subject: [PATCH] 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