Browse Source

Add CardDAV supported-address-data, update vCards to 4.0 (#1948)

* Add CardDAV supported-address-data, update vCards to 4.0

- radicale/app/propfind.py: Add CS:getctag and CR:supported-address-data
  properties to VADDRESSBOOK collections in allprop responses; implement
  CR:supported-address-data handler that advertises vCard 4.0 as preferred
  format with 3.0 fallback per RFC 6352 section 6.2.2

- radicale/tests/static/contact1.vcf: Update from vCard 3.0 to 4.0 format

- radicale/tests/static/contact_multiple.vcf: Update both contact entries
  from vCard 3.0 to 4.0 format

- radicale/tests/static/contact_photo_with_data_uri.vcf: Update from vCard
  3.0 to 4.0 format; change PHOTO property from 3.0 syntax with ENCODING=b
  and TYPE parameters to 4.0 data URI syntax

* Conditionally offer vCard 4.0 based on vobject version

- Add vobject_supports_vcard4() helper function in utils.py
- Modify propfind.py to only advertise vCard 4.0 if vobject >= 1.0.0
- Add vCard 3.0 static test files for fallback testing
- Add tests for both vCard 3.0 and 4.0 contacts (v4 tests skipped if
  vobject < 1.0.0)
- Add propfind tests for CR:supported-address-data property

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Default vCard fixtures to v3.0, add explicit v4 files

- contact1.vcf: Change VERSION from 4.0 to 3.0 to make vCard 3.0 the default
  test fixture format, since vCard 3.0 is more widely supported

- contact1_v3.vcf: Delete file as contact1.vcf now serves as the v3.0 fixture

- contact1_v4.vcf: Add new file with VERSION 4.0 for explicit vCard 4.0
  testing with vobject >= 1.0.0

- contact_multiple.vcf: Change VERSION from 4.0 to 3.0 for both contacts to
  align with new default

- contact_multiple_v3.vcf: Delete file as contact_multiple.vcf now serves as
  the v3.0 fixture

- contact_multiple_v4.vcf: Add new file with VERSION 4.0 for both contacts

- contact_photo_with_data_uri.vcf: Change VERSION from 4.0 to 3.0 and update
  PHOTO property to use v3.0 format with ENCODING=b;TYPE=png parameters

- contact_photo_with_data_uri_v3.vcf: Delete file as
  contact_photo_with_data_uri.vcf now serves as the v3.0 fixture

- contact_photo_with_data_uri_v4.vcf: Add new file with VERSION 4.0 and v4.0
  PHOTO data URI format

- test_base.py: Update test methods to use renamed fixture files, with v3.0
  tests using default fixtures and v4.0 tests using explicit _v4.vcf files

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
John Wiegley 2 months ago
parent
commit
1f76b08831

+ 21 - 1
radicale/app/propfind.py

@@ -26,7 +26,8 @@ import xml.etree.ElementTree as ET
 from http import client
 from http import client
 from typing import Dict, Iterable, Iterator, List, Optional, Sequence, Tuple
 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.app.base import Access, ApplicationBase
 from radicale.log import logger
 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("CS:getctag"))
                 props.append(
                 props.append(
                     xmlutils.make_clark("C:supported-calendar-component-set"))
                     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()
             meta = collection.get_meta()
             for tag in meta:
             for tag in meta:
@@ -188,6 +193,21 @@ def xml_propfind_response(
                     element.append(comp)
                     element.append(comp)
             else:
             else:
                 is404 = True
                 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"):
         elif tag == xmlutils.make_clark("D:current-user-principal"):
             if user:
             if user:
                 child_element = ET.Element(xmlutils.make_clark("D:href"))
                 child_element = ET.Element(xmlutils.make_clark("D:href"))

+ 7 - 0
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

+ 12 - 0
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

+ 8 - 0
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

+ 91 - 1
radicale/tests/test_base.py

@@ -27,9 +27,10 @@ import posixpath
 from typing import Any, Callable, ClassVar, Iterable, List, Optional, Tuple
 from typing import Any, Callable, ClassVar, Iterable, List, Optional, Tuple
 
 
 import defusedxml.ElementTree as DefusedET
 import defusedxml.ElementTree as DefusedET
+import pytest
 import vobject
 import vobject
 
 
-from radicale import storage, xmlutils
+from radicale import storage, utils, xmlutils
 from radicale.tests import RESPONSES, BaseTest
 from radicale.tests import RESPONSES, BaseTest
 from radicale.tests.helpers import get_file_content
 from radicale.tests.helpers import get_file_content
 
 
@@ -274,6 +275,48 @@ permissions: RrWw""")
         path = "/contacts.vcf/contact.vcf"
         path = "/contacts.vcf/contact.vcf"
         self.put(path, contact, check=400)
         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:
     def test_update_event(self) -> None:
         """Update an event."""
         """Update an event."""
         self.mkcalendar("/calendar.ics/")
         self.mkcalendar("/calendar.ics/")
@@ -744,6 +787,53 @@ permissions: RrWw""")
         status, prop = response["CS:getctag"]
         status, prop = response["CS:getctag"]
         assert status == 200 and prop.text
         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/", """\
+<?xml version="1.0"?>
+<propfind xmlns="DAV:" xmlns:CR="urn:ietf:params:xml:ns:carddav">
+  <prop>
+    <CR:supported-address-data />
+  </prop>
+</propfind>""")
+        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/", """\
+<?xml version="1.0"?>
+<propfind xmlns="DAV:" xmlns:CR="urn:ietf:params:xml:ns:carddav">
+  <prop>
+    <CR:supported-address-data />
+  </prop>
+</propfind>""")
+        response = responses["/calendar.ics/"]
+        assert not isinstance(response, int)
+        status, prop = response["CR:supported-address-data"]
+        assert status == 404
+
     def test_proppatch(self) -> None:
     def test_proppatch(self) -> None:
         """Set/Remove a property and read it back."""
         """Set/Remove a property and read it back."""
         self.mkcalendar("/calendar.ics/")
         self.mkcalendar("/calendar.ics/")

+ 11 - 0
radicale/utils.py

@@ -88,6 +88,17 @@ def package_version(name):
     return metadata.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():
 def packages_version():
     versions = []
     versions = []
     versions.append("python=%s.%s.%s" % (sys.version_info[0], sys.version_info[1], sys.version_info[2]))
     versions.append("python=%s.%s.%s" % (sys.version_info[0], sys.version_info[1], sys.version_info[2]))