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>
This commit is contained in:
@@ -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
radicale/tests/static/contact1_v4.vcf
Normal file
7
radicale/tests/static/contact1_v4.vcf
Normal file
@@ -0,0 +1,7 @@
|
||||
BEGIN:VCARD
|
||||
VERSION:4.0
|
||||
UID:contact1
|
||||
N:Contact;;;;
|
||||
FN:Contact
|
||||
NICKNAME:test
|
||||
END:VCARD
|
||||
12
radicale/tests/static/contact_multiple_v4.vcf
Normal file
12
radicale/tests/static/contact_multiple_v4.vcf
Normal file
@@ -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
radicale/tests/static/contact_photo_with_data_uri_v4.vcf
Normal file
8
radicale/tests/static/contact_photo_with_data_uri_v4.vcf
Normal file
@@ -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
|
||||
@@ -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/")
|
||||
|
||||
@@ -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]))
|
||||
|
||||
Reference in New Issue
Block a user