|
| 1 | +"""CardDAV client for NextCloud contacts operations.""" |
| 2 | + |
| 3 | +import logging |
| 4 | +from .base import BaseNextcloudClient |
| 5 | +import xml.etree.ElementTree as ET |
| 6 | +from pythonvCard4.vcard import Contact |
| 7 | + |
| 8 | +logger = logging.getLogger(__name__) |
| 9 | + |
| 10 | + |
| 11 | +class ContactsClient(BaseNextcloudClient): |
| 12 | + """Client for NextCloud CardDAV contact operations.""" |
| 13 | + |
| 14 | + def _get_carddav_base_path(self) -> str: |
| 15 | + """Helper to get the base CardDAV path for contacts.""" |
| 16 | + return f"/remote.php/dav/addressbooks/users/{self.username}" |
| 17 | + |
| 18 | + async def list_addressbooks(self): |
| 19 | + """List all available addressbooks for the user.""" |
| 20 | + |
| 21 | + carddav_path = self._get_carddav_base_path() |
| 22 | + |
| 23 | + propfind_body = """<?xml version="1.0" encoding="utf-8"?> |
| 24 | + <d:propfind xmlns:d="DAV:" xmlns:cs="http://calendarserver.org/ns/"> |
| 25 | + <d:prop> |
| 26 | + <d:displayname/> |
| 27 | + <d:getctag /> |
| 28 | + </d:prop> |
| 29 | + </d:propfind>""" |
| 30 | + |
| 31 | + headers = { |
| 32 | + # "Depth": "0", |
| 33 | + "Content-Type": "application/xml", |
| 34 | + "Accept": "application/xml", |
| 35 | + } |
| 36 | + |
| 37 | + response = await self._make_request( |
| 38 | + "PROPFIND", carddav_path, content=propfind_body, headers=headers |
| 39 | + ) |
| 40 | + |
| 41 | + ns = {"d": "DAV:"} |
| 42 | + |
| 43 | + # logger.info(response.content) |
| 44 | + root = ET.fromstring(response.content) |
| 45 | + addressbooks = [] |
| 46 | + for response_elem in root.findall(".//d:response", ns): |
| 47 | + href = response_elem.find(".//d:href", ns) |
| 48 | + if href is None: |
| 49 | + continue |
| 50 | + |
| 51 | + href_text = href.text or "" |
| 52 | + if not href_text.endswith("/"): |
| 53 | + continue # Skip non-addressbook resources |
| 54 | + |
| 55 | + # Extract addressbook name from href |
| 56 | + addressbook_name = href_text.rstrip("/").split("/")[-1] |
| 57 | + if not addressbook_name or addressbook_name == self.username: |
| 58 | + continue |
| 59 | + |
| 60 | + # Get properties |
| 61 | + propstat = response_elem.find(".//d:propstat", ns) |
| 62 | + if propstat is None: |
| 63 | + continue |
| 64 | + |
| 65 | + prop = propstat.find(".//d:prop", ns) |
| 66 | + if prop is None: |
| 67 | + continue |
| 68 | + |
| 69 | + displayname_elem = prop.find(".//d:displayname", ns) |
| 70 | + displayname = ( |
| 71 | + displayname_elem.text |
| 72 | + if displayname_elem is not None |
| 73 | + else addressbook_name |
| 74 | + ) |
| 75 | + |
| 76 | + getctag_elem = prop.find(".//d:getctag", ns) |
| 77 | + getctag = getctag_elem.text if getctag_elem is not None else None |
| 78 | + |
| 79 | + addressbooks.append( |
| 80 | + { |
| 81 | + "name": addressbook_name, |
| 82 | + "display_name": displayname, |
| 83 | + "getctag": getctag, |
| 84 | + } |
| 85 | + ) |
| 86 | + |
| 87 | + logger.debug(f"Found {len(addressbooks)} addressbooks") |
| 88 | + return addressbooks |
| 89 | + |
| 90 | + async def create_addressbook(self, *, name: str, display_name: str): |
| 91 | + """Create a new addressbook.""" |
| 92 | + carddav_path = self._get_carddav_base_path() |
| 93 | + url = f"{carddav_path}/{name}/" |
| 94 | + |
| 95 | + prop_body = f"""<?xml version="1.0" encoding="utf-8"?> |
| 96 | + <d:mkcol xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:carddav"> |
| 97 | + <d:set> |
| 98 | + <d:prop> |
| 99 | + <d:resourcetype> |
| 100 | + <d:collection/> |
| 101 | + <c:addressbook/> |
| 102 | + </d:resourcetype> |
| 103 | + <d:displayname>{display_name}</d:displayname> |
| 104 | + </d:prop> |
| 105 | + </d:set> |
| 106 | + </d:mkcol>""" |
| 107 | + |
| 108 | + headers = { |
| 109 | + "Content-Type": "application/xml", |
| 110 | + } |
| 111 | + |
| 112 | + await self._make_request("MKCOL", url, content=prop_body, headers=headers) |
| 113 | + |
| 114 | + async def delete_addressbook(self, *, name: str): |
| 115 | + """Delete an addressbook.""" |
| 116 | + carddav_path = self._get_carddav_base_path() |
| 117 | + url = f"{carddav_path}/{name}/" |
| 118 | + await self._make_request("DELETE", url) |
| 119 | + |
| 120 | + async def create_contact(self, *, addressbook: str, uid: str, contact_data: dict): |
| 121 | + """Create a new contact.""" |
| 122 | + carddav_path = self._get_carddav_base_path() |
| 123 | + url = f"{carddav_path}/{addressbook}/{uid}.vcf" |
| 124 | + |
| 125 | + contact = Contact(fn=contact_data.get("fn"), uid=uid) |
| 126 | + if "email" in contact_data: |
| 127 | + contact.email = [{"value": contact_data["email"], "type": ["HOME"]}] |
| 128 | + if "tel" in contact_data: |
| 129 | + contact.tel = [{"value": contact_data["tel"], "type": ["HOME"]}] |
| 130 | + |
| 131 | + vcard = contact.to_vcard() |
| 132 | + |
| 133 | + headers = { |
| 134 | + "Content-Type": "text/vcard; charset=utf-8", |
| 135 | + "If-None-Match": "*", |
| 136 | + } |
| 137 | + |
| 138 | + await self._make_request("PUT", url, content=vcard, headers=headers) |
| 139 | + |
| 140 | + async def delete_contact(self, *, addressbook: str, uid: str): |
| 141 | + """Delete a contact.""" |
| 142 | + carddav_path = self._get_carddav_base_path() |
| 143 | + url = f"{carddav_path}/{addressbook}/{uid}.vcf" |
| 144 | + await self._make_request("DELETE", url) |
| 145 | + |
| 146 | + async def list_contacts(self, *, addressbook: str): |
| 147 | + """List all available contacts for addressbook.""" |
| 148 | + |
| 149 | + carddav_path = self._get_carddav_base_path() |
| 150 | + |
| 151 | + report_body = """<?xml version="1.0" encoding="utf-8"?> |
| 152 | + <card:addressbook-query xmlns:d="DAV:" xmlns:card="urn:ietf:params:xml:ns:carddav"> |
| 153 | + <d:prop> |
| 154 | + <d:getetag /> |
| 155 | + <card:address-data /> |
| 156 | + </d:prop> |
| 157 | + </card:addressbook-query>""" |
| 158 | + |
| 159 | + headers = { |
| 160 | + "Depth": "1", |
| 161 | + "Content-Type": "application/xml", |
| 162 | + "Accept": "application/xml", |
| 163 | + } |
| 164 | + |
| 165 | + response = await self._make_request( |
| 166 | + "REPORT", |
| 167 | + f"{carddav_path}/{addressbook}", |
| 168 | + content=report_body, |
| 169 | + headers=headers, |
| 170 | + ) |
| 171 | + |
| 172 | + ns = {"d": "DAV:", "card": "urn:ietf:params:xml:ns:carddav"} |
| 173 | + |
| 174 | + # logger.info(response.text) |
| 175 | + root = ET.fromstring(response.content) |
| 176 | + contacts = [] |
| 177 | + for response_elem in root.findall(".//d:response", ns): |
| 178 | + href = response_elem.find(".//d:href", ns) |
| 179 | + if href is None: |
| 180 | + logger.info("Skip missing href") |
| 181 | + continue |
| 182 | + |
| 183 | + href_text = href.text or "" |
| 184 | + # logger.info("Href text: %s", href_text) |
| 185 | + # if not href_text.endswith("/"): |
| 186 | + # logger.info("# Skip non-addressbook resources") |
| 187 | + # continue |
| 188 | + |
| 189 | + # Extract vcard id from href |
| 190 | + vcard_id = href_text.rstrip("/").split("/")[-1] |
| 191 | + if not vcard_id: |
| 192 | + logger.info("Skip missing vcard_id") |
| 193 | + continue |
| 194 | + vcard_id = vcard_id.replace(".vcf", "") |
| 195 | + |
| 196 | + # Get properties |
| 197 | + propstat = response_elem.find(".//d:propstat", ns) |
| 198 | + if propstat is None: |
| 199 | + logger.info("Skip missing propstat") |
| 200 | + continue |
| 201 | + |
| 202 | + prop = propstat.find(".//d:prop", ns) |
| 203 | + if prop is None: |
| 204 | + logger.info("Skip missing prop") |
| 205 | + continue |
| 206 | + |
| 207 | + getetag_elem = prop.find(".//d:getetag", ns) |
| 208 | + getetag = getetag_elem.text if getetag_elem is not None else None |
| 209 | + |
| 210 | + addressdata_elem = prop.find(".//card:address-data", ns) |
| 211 | + addressdata = ( |
| 212 | + addressdata_elem.text if addressdata_elem is not None else None |
| 213 | + ) |
| 214 | + if addressdata is None: |
| 215 | + logger.info("Skip missing addressdata") |
| 216 | + continue |
| 217 | + |
| 218 | + contact = Contact.from_vcard(addressdata) |
| 219 | + |
| 220 | + contacts.append( |
| 221 | + { |
| 222 | + "vcard_id": vcard_id, |
| 223 | + "getetag": getetag, |
| 224 | + "contact": { |
| 225 | + "fullname": contact.fn, |
| 226 | + "nickname": contact.nickname, |
| 227 | + "birthday": contact.bday, |
| 228 | + "email": contact.email, |
| 229 | + }, |
| 230 | + "addressdata": addressdata, |
| 231 | + } |
| 232 | + ) |
| 233 | + |
| 234 | + logger.debug(f"Found {len(contacts)} contacts") |
| 235 | + return contacts |
0 commit comments