Răsfoiți Sursa

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 1 lună în urmă
părinte
comite
1f76b08831

+ 21 - 1
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"))

+ 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
 
 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/", """\
+<?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:
         """Set/Remove a property and read it back."""
         self.mkcalendar("/calendar.ics/")

+ 11 - 0
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]))