diff --git a/radicale/app/propfind.py b/radicale/app/propfind.py
index 3976af91..62af2949 100644
--- a/radicale/app/propfind.py
+++ b/radicale/app/propfind.py
@@ -26,7 +26,8 @@ import xml.etree.ElementTree as ET
from http import client
from typing import Dict, Iterable, Iterator, List, Optional, Sequence, Tuple
-from radicale import httputils, pathutils, rights, storage, types, xmlutils
+from radicale import (httputils, pathutils, rights, storage, types, utils,
+ xmlutils)
from radicale.app.base import Access, ApplicationBase
from radicale.log import logger
@@ -135,6 +136,10 @@ def xml_propfind_response(
props.append(xmlutils.make_clark("CS:getctag"))
props.append(
xmlutils.make_clark("C:supported-calendar-component-set"))
+ if collection.tag == "VADDRESSBOOK":
+ props.append(xmlutils.make_clark("CS:getctag"))
+ props.append(
+ xmlutils.make_clark("CR:supported-address-data"))
meta = collection.get_meta()
for tag in meta:
@@ -188,6 +193,21 @@ def xml_propfind_response(
element.append(comp)
else:
is404 = True
+ elif tag == xmlutils.make_clark("CR:supported-address-data"):
+ if is_collection and is_leaf and collection.tag == "VADDRESSBOOK":
+ # 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")
+ if utils.vobject_supports_vcard4()
+ else ("3.0",))
+ for version in versions:
+ address_data_type = ET.Element(
+ xmlutils.make_clark("CR:address-data-type"))
+ address_data_type.set("content-type", "text/vcard")
+ address_data_type.set("version", version)
+ element.append(address_data_type)
+ else:
+ is404 = True
elif tag == xmlutils.make_clark("D:current-user-principal"):
if user:
child_element = ET.Element(xmlutils.make_clark("D:href"))
diff --git a/radicale/tests/static/contact1_v4.vcf b/radicale/tests/static/contact1_v4.vcf
new file mode 100644
index 00000000..5ddb2312
--- /dev/null
+++ b/radicale/tests/static/contact1_v4.vcf
@@ -0,0 +1,7 @@
+BEGIN:VCARD
+VERSION:4.0
+UID:contact1
+N:Contact;;;;
+FN:Contact
+NICKNAME:test
+END:VCARD
diff --git a/radicale/tests/static/contact_multiple_v4.vcf b/radicale/tests/static/contact_multiple_v4.vcf
new file mode 100644
index 00000000..e153ba52
--- /dev/null
+++ b/radicale/tests/static/contact_multiple_v4.vcf
@@ -0,0 +1,12 @@
+BEGIN:VCARD
+VERSION:4.0
+UID:contact1
+N:Contact1;;;;
+FN:Contact1
+END:VCARD
+BEGIN:VCARD
+VERSION:4.0
+UID:contact2
+N:Contact2;;;;
+FN:Contact2
+END:VCARD
diff --git a/radicale/tests/static/contact_photo_with_data_uri_v4.vcf b/radicale/tests/static/contact_photo_with_data_uri_v4.vcf
new file mode 100644
index 00000000..18a2dad3
--- /dev/null
+++ b/radicale/tests/static/contact_photo_with_data_uri_v4.vcf
@@ -0,0 +1,8 @@
+BEGIN:VCARD
+VERSION:4.0
+UID:contact
+N:Contact;;;;
+FN:Contact
+NICKNAME:test
+PHOTO:data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAAD0lEQVQIHQEEAPv/AP///wX+Av4DfRnGAAAAAElFTkSuQmCC
+END:VCARD
diff --git a/radicale/tests/test_base.py b/radicale/tests/test_base.py
index c1d4e5ab..8d8e0def 100644
--- a/radicale/tests/test_base.py
+++ b/radicale/tests/test_base.py
@@ -27,9 +27,10 @@ import posixpath
from typing import Any, Callable, ClassVar, Iterable, List, Optional, Tuple
import defusedxml.ElementTree as DefusedET
+import pytest
import vobject
-from radicale import storage, xmlutils
+from radicale import storage, utils, xmlutils
from radicale.tests import RESPONSES, BaseTest
from radicale.tests.helpers import get_file_content
@@ -274,6 +275,48 @@ permissions: RrWw""")
path = "/contacts.vcf/contact.vcf"
self.put(path, contact, check=400)
+ def test_add_contact_v3(self) -> None:
+ """Add a vCard 3.0 contact."""
+ self.create_addressbook("/contacts.vcf/")
+ contact = get_file_content("contact1.vcf")
+ path = "/contacts.vcf/contact.vcf"
+ self.put(path, contact)
+ _, headers, answer = self.request("GET", path, check=200)
+ assert "ETag" in headers
+ assert headers["Content-Type"] == "text/vcard; charset=utf-8"
+ assert "VCARD" in answer
+ assert "UID:contact1" in answer
+ assert "VERSION:3.0" in answer
+
+ @pytest.mark.skipif(not utils.vobject_supports_vcard4(),
+ reason="vobject < 1.0.0 does not support vCard 4.0")
+ def test_add_contact_v4(self) -> None:
+ """Add a vCard 4.0 contact (requires vobject >= 1.0.0)."""
+ self.create_addressbook("/contacts.vcf/")
+ contact = get_file_content("contact1_v4.vcf")
+ path = "/contacts.vcf/contact.vcf"
+ self.put(path, contact)
+ _, headers, answer = self.request("GET", path, check=200)
+ assert "ETag" in headers
+ assert headers["Content-Type"] == "text/vcard; charset=utf-8"
+ assert "VCARD" in answer
+ assert "UID:contact1" in answer
+ assert "VERSION:4.0" in answer
+
+ def test_add_contact_photo_with_data_uri_v3(self) -> None:
+ """Test vCard 3.0 PHOTO format"""
+ self.create_addressbook("/contacts.vcf/")
+ contact = get_file_content("contact_photo_with_data_uri.vcf")
+ self.put("/contacts.vcf/contact.vcf", contact)
+
+ @pytest.mark.skipif(not utils.vobject_supports_vcard4(),
+ reason="vobject < 1.0.0 does not support vCard 4.0")
+ def test_add_contact_photo_with_data_uri_v4(self) -> None:
+ """Test vCard 4.0 PHOTO data URI format (requires vobject >= 1.0.0)"""
+ self.create_addressbook("/contacts.vcf/")
+ contact = get_file_content("contact_photo_with_data_uri_v4.vcf")
+ self.put("/contacts.vcf/contact.vcf", contact)
+
def test_update_event(self) -> None:
"""Update an event."""
self.mkcalendar("/calendar.ics/")
@@ -744,6 +787,53 @@ permissions: RrWw""")
status, prop = response["CS:getctag"]
assert status == 200 and prop.text
+ def test_propfind_supported_address_data(self) -> None:
+ """Read property CR:supported-address-data on addressbook"""
+ self.create_addressbook("/addressbook.vcf/")
+ contact = get_file_content("contact1.vcf")
+ self.put("/addressbook.vcf/contact.vcf", contact)
+ _, responses = self.propfind("/addressbook.vcf/", """\
+
+
+
+
+
+""")
+ response = responses["/addressbook.vcf/"]
+ assert not isinstance(response, int)
+ status, prop = response["CR:supported-address-data"]
+ assert status == 200
+ # Should have at least one address-data-type element
+ address_data_types = prop.findall(
+ xmlutils.make_clark("CR:address-data-type"))
+ assert len(address_data_types) >= 1
+ # Check that 3.0 is always supported
+ versions = [e.get("version") for e in address_data_types]
+ assert "3.0" in versions
+ # Check content-type is text/vcard for all
+ for e in address_data_types:
+ assert e.get("content-type") == "text/vcard"
+ # If vobject >= 1.0.0, should also support 4.0
+ if utils.vobject_supports_vcard4():
+ assert "4.0" in versions
+ # vCard 4.0 should be listed first (preferred)
+ assert versions[0] == "4.0"
+
+ def test_propfind_supported_address_data_on_calendar(self) -> None:
+ """Read property CR:supported-address-data on calendar (should 404)"""
+ self.mkcalendar("/calendar.ics/")
+ _, responses = self.propfind("/calendar.ics/", """\
+
+
+
+
+
+""")
+ response = responses["/calendar.ics/"]
+ assert not isinstance(response, int)
+ status, prop = response["CR:supported-address-data"]
+ assert status == 404
+
def test_proppatch(self) -> None:
"""Set/Remove a property and read it back."""
self.mkcalendar("/calendar.ics/")
diff --git a/radicale/utils.py b/radicale/utils.py
index e2e01903..152f384e 100644
--- a/radicale/utils.py
+++ b/radicale/utils.py
@@ -88,6 +88,17 @@ def package_version(name):
return metadata.version(name)
+def vobject_supports_vcard4() -> bool:
+ """Check if vobject supports vCard 4.0 (requires version >= 1.0.0)."""
+ try:
+ version = package_version("vobject")
+ parts = version.split(".")
+ major = int(parts[0])
+ return major >= 1
+ except Exception:
+ return False
+
+
def packages_version():
versions = []
versions.append("python=%s.%s.%s" % (sys.version_info[0], sys.version_info[1], sys.version_info[2]))