getCalendarEntriesInRange(String calendarName,
+ java.time.Instant from, java.time.Instant to) {
+ return cal.getCalendarEntriesInRange(calendarName, from, to);
+ }
+
+ /**
+ * Fetches the events of a calendar overlapping a time range, optionally
+ * having the server expand recurring events into one occurrence each.
+ *
+ * An expanded result is a computed view of that range, not the stored
+ * resource, so it must not be written back.
+ *
+ * @param calendarName name of the calendar
+ * @param from start of the range, inclusive
+ * @param to end of the range, exclusive
+ * @param expandRecurrences whether recurring events should be expanded
+ * @return the matching entries
+ * @since 14.3
+ */
+ public java.util.List getCalendarEntriesInRange(String calendarName,
+ java.time.Instant from, java.time.Instant to, boolean expandRecurrences) {
+ return cal.getCalendarEntriesInRange(calendarName, from, to, expandRecurrences);
+ }
+
+ /**
+ * Fetches a single calendar entry.
+ *
+ * @param calendarName name of the calendar
+ * @param entryName name of the entry, or its href
+ * @return the entry, or {@code null} if it does not exist
+ * @since 14.3
+ */
+ public CalendarEntry getCalendarEntry(String calendarName, String entryName) {
+ return cal.getCalendarEntry(calendarName, entryName);
+ }
+
+ /**
+ * Creates a calendar entry, or replaces it if one of that name exists.
+ *
+ * @param calendarName name of the calendar
+ * @param entryName name of the entry, conventionally the event UID
+ * followed by {@code .ics}
+ * @param iCalendar the iCalendar document to store
+ * @return the etag of the stored entry
+ * @since 14.3
+ */
+ public String putCalendarEntry(String calendarName, String entryName, String iCalendar) {
+ return cal.putCalendarEntry(calendarName, entryName, iCalendar);
+ }
+
+ /**
+ * Replaces a calendar entry only while it still carries the given etag.
+ *
+ * @param calendarName name of the calendar
+ * @param entryName name of the entry
+ * @param iCalendar the iCalendar document to store
+ * @param etag the etag the stored entry must still have
+ * @return the etag of the stored entry
+ * @since 14.3
+ */
+ public String putCalendarEntry(String calendarName, String entryName, String iCalendar, String etag) {
+ return cal.putCalendarEntry(calendarName, entryName, iCalendar, etag);
+ }
+
+ /**
+ * Deletes a calendar entry.
+ *
+ * @param calendarName name of the calendar
+ * @param entryName name of the entry, or its href
+ * @since 14.3
+ */
+ public void deleteCalendarEntry(String calendarName, String entryName) {
+ cal.deleteCalendarEntry(calendarName, entryName);
+ }
+
+ /**
+ * Creates a calendar.
+ *
+ * @param calendarName name of the calendar as it appears in the URL
+ * @param displayName name shown in the UI, may be {@code null}
+ * @param color HTML colour code, e.g. {@code #FF0000}, may be
+ * {@code null}
+ * @since 14.3
+ */
+ public void createCalendar(String calendarName, String displayName, String color) {
+ cal.createCalendar(calendarName, displayName, color);
+ }
+
+ /**
+ * Deletes a calendar and all of its entries.
+ *
+ * @param calendarName name of the calendar
+ * @since 14.3
+ */
+ public void deleteCalendar(String calendarName) {
+ cal.deleteCalendar(calendarName);
+ }
+
+ /**
+ * Lists the address books of the authenticated user.
+ *
+ * @return the user's address books
+ * @since 14.3
+ */
+ public java.util.List listAddressBooks() {
+ return ab.listAddressBooks();
+ }
+
+ /**
+ * Fetches every contact of an address book as a raw vCard document.
+ *
+ * @param addressBookName name of the address book
+ * @return all contacts of the address book
+ * @since 14.3
+ */
+ public java.util.List getContacts(String addressBookName) {
+ return ab.getContacts(addressBookName);
+ }
+
+ /**
+ * Fetches a single contact.
+ *
+ * @param addressBookName name of the address book
+ * @param contactName name of the contact resource, or its href
+ * @return the contact, or {@code null} if it does not exist
+ * @since 14.3
+ */
+ public Contact getContact(String addressBookName, String contactName) {
+ return ab.getContact(addressBookName, contactName);
+ }
+
+ /**
+ * Creates a contact, or replaces it if one of that name exists.
+ *
+ * @param addressBookName name of the address book
+ * @param contactName name of the contact resource, conventionally the
+ * vCard UID followed by {@code .vcf}
+ * @param vCard the vCard document to store
+ * @return the etag of the stored contact
+ * @since 14.3
+ */
+ public String putContact(String addressBookName, String contactName, String vCard) {
+ return ab.putContact(addressBookName, contactName, vCard);
+ }
+
+ /**
+ * Replaces a contact only while it still carries the given etag.
+ *
+ * @param addressBookName name of the address book
+ * @param contactName name of the contact resource
+ * @param vCard the vCard document to store
+ * @param etag the etag the stored contact must still have
+ * @return the etag of the stored contact
+ * @since 14.3
+ */
+ public String putContact(String addressBookName, String contactName, String vCard, String etag) {
+ return ab.putContact(addressBookName, contactName, vCard, etag);
+ }
+
+ /**
+ * Deletes a contact.
+ *
+ * @param addressBookName name of the address book
+ * @param contactName name of the contact resource, or its href
+ * @since 14.3
+ */
+ public void deleteContact(String addressBookName, String contactName) {
+ ab.deleteContact(addressBookName, contactName);
+ }
+
+ /**
+ * Creates an address book.
+ *
+ * @param addressBookName name of the address book as it appears in the URL
+ * @param displayName name shown in the UI, may be {@code null}
+ * @param description description of the address book, may be
+ * {@code null}
+ * @since 14.3
+ */
+ public void createAddressBook(String addressBookName, String displayName, String description) {
+ ab.createAddressBook(addressBookName, displayName, description);
+ }
+
+ /**
+ * Deletes an address book and all of its contacts.
+ *
+ * @param addressBookName name of the address book
+ * @since 14.3
+ */
+ public void deleteAddressBook(String addressBookName) {
+ ab.deleteAddressBook(addressBookName);
+ }
+
/**
* @return all system tags on the server
*/
diff --git a/src/main/java/org/aarboard/nextcloud/api/calendar/Calendar.java b/src/main/java/org/aarboard/nextcloud/api/calendar/Calendar.java
new file mode 100644
index 0000000..5f71e2a
--- /dev/null
+++ b/src/main/java/org/aarboard/nextcloud/api/calendar/Calendar.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright (C) 2026 a.schild
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.aarboard.nextcloud.api.calendar;
+
+import org.aarboard.nextcloud.api.dav.DavCollection;
+
+/**
+ * A calendar collection of a Nextcloud user.
+ *
+ * @author a.schild
+ * @since 14.3
+ */
+public class Calendar extends DavCollection {
+
+ private String color;
+ private String order;
+
+ /**
+ * @return the calendar colour as an HTML colour code, e.g. {@code #FF0000},
+ * or {@code null} if the calendar has none
+ */
+ public String getColor() {
+ return color;
+ }
+
+ public void setColor(String color) {
+ this.color = color;
+ }
+
+ /**
+ * @return the sort order the clients display the calendar in, or
+ * {@code null} if unset
+ */
+ public String getOrder() {
+ return order;
+ }
+
+ public void setOrder(String order) {
+ this.order = order;
+ }
+}
diff --git a/src/main/java/org/aarboard/nextcloud/api/calendar/CalendarEntry.java b/src/main/java/org/aarboard/nextcloud/api/calendar/CalendarEntry.java
new file mode 100644
index 0000000..36b0dfa
--- /dev/null
+++ b/src/main/java/org/aarboard/nextcloud/api/calendar/CalendarEntry.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright (C) 2026 a.schild
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.aarboard.nextcloud.api.calendar;
+
+import org.aarboard.nextcloud.api.dav.DavEntry;
+
+/**
+ * A single resource of a calendar, holding one iCalendar document. Such a
+ * document contains the event, todo or journal itself plus, where applicable,
+ * its recurrence exceptions and the time zone definitions it refers to.
+ *
+ * {@link #getData()} returns the iCalendar text verbatim; parsing it is left to
+ * the caller, for example with ical4j.
+ *
+ * @author a.schild
+ * @since 14.3
+ */
+public class CalendarEntry extends DavEntry {
+}
diff --git a/src/main/java/org/aarboard/nextcloud/api/calendar/Calendars.java b/src/main/java/org/aarboard/nextcloud/api/calendar/Calendars.java
new file mode 100644
index 0000000..132188a
--- /dev/null
+++ b/src/main/java/org/aarboard/nextcloud/api/calendar/Calendars.java
@@ -0,0 +1,273 @@
+/*
+ * Copyright (C) 2026 a.schild
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.aarboard.nextcloud.api.calendar;
+
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.time.format.DateTimeFormatter;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import javax.xml.namespace.QName;
+import org.aarboard.nextcloud.api.ServerConfig;
+import org.aarboard.nextcloud.api.dav.ADavCollectionHandler;
+import org.aarboard.nextcloud.api.utils.ConnectorCommon;
+import org.apache.http.entity.ContentType;
+
+/**
+ * Access to the calendars of the authenticated user over
+ * CalDAV, below
+ * {@code remote.php/dav/calendars/}: listing calendars, reading and writing
+ * their entries, and creating or deleting calendars.
+ *
+ * Entries are exchanged as raw iCalendar documents, see {@link CalendarEntry}.
+ *
+ * @author a.schild
+ * @since 14.3
+ */
+public class Calendars extends ADavCollectionHandler {
+
+ private static final String CALENDAR_ROOT = "remote.php/dav/calendars/";
+
+ private static final QName RESOURCE_TYPE_CALENDAR = new QName(NS_CALDAV, "calendar");
+ private static final QName PROP_CALENDAR_DESCRIPTION = new QName(NS_CALDAV, "calendar-description", "c");
+ private static final QName PROP_CALENDAR_COLOR = new QName(NS_APPLE_ICAL, "calendar-color", "ic");
+ private static final QName PROP_CALENDAR_ORDER = new QName(NS_APPLE_ICAL, "calendar-order", "ic");
+
+ /** UTC in the basic format iCalendar and the CalDAV time-range filter use. */
+ private static final DateTimeFormatter UTC_TIMESTAMP =
+ DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'").withZone(ZoneOffset.UTC);
+
+ public Calendars(ServerConfig serverConfig) {
+ super(serverConfig);
+ }
+
+ @Override
+ protected String getHomeUrl() {
+ return buildDavUrl(CALENDAR_ROOT + encodeSegment(getCurrentUserId())) + "/";
+ }
+
+ @Override
+ protected QName getCollectionResourceType() {
+ return RESOURCE_TYPE_CALENDAR;
+ }
+
+ @Override
+ protected String getDataElementName() {
+ return "calendar-data";
+ }
+
+ @Override
+ protected String getReportNamespace() {
+ return NS_CALDAV;
+ }
+
+ @Override
+ protected String getMultigetElementName() {
+ return "calendar-multiget";
+ }
+
+ @Override
+ protected ContentType getEntryContentType() {
+ return ContentType.create("text/calendar", java.nio.charset.StandardCharsets.UTF_8);
+ }
+
+ /**
+ * Lists the calendars of the authenticated user. The scheduling inbox and
+ * outbox, the trash bin and subscribed calendars are not included.
+ *
+ * @return the user's calendars
+ */
+ public List listCalendars() {
+ Set props = new HashSet<>();
+ props.add(PROP_CALENDAR_DESCRIPTION);
+ props.add(PROP_CALENDAR_COLOR);
+ props.add(PROP_CALENDAR_ORDER);
+
+ return listCollections(props, Calendar::new, (calendar, resource) -> {
+ calendar.setDescription(resource.getCustomPropsNS().get(PROP_CALENDAR_DESCRIPTION));
+ calendar.setColor(resource.getCustomPropsNS().get(PROP_CALENDAR_COLOR));
+ calendar.setOrder(resource.getCustomPropsNS().get(PROP_CALENDAR_ORDER));
+ });
+ }
+
+ /**
+ * Fetches every entry of a calendar, iCalendar payload included.
+ *
+ * @param calendarName name of the calendar, see {@link Calendar#getName()}
+ * @return all entries of the calendar
+ */
+ public List getCalendarEntries(String calendarName) {
+ return getEntries(calendarName, CalendarEntry::new);
+ }
+
+ /**
+ * Fetches the events of a calendar overlapping a time range. Recurring
+ * events are matched on their expanded occurrences, but are returned as the
+ * stored iCalendar document, i.e. as a single entry carrying its recurrence
+ * rule. Use {@link #getCalendarEntriesInRange(String, Instant, Instant,
+ * boolean)} to have the server expand them instead.
+ *
+ * @param calendarName name of the calendar
+ * @param from start of the range, inclusive
+ * @param to end of the range, exclusive
+ * @return the matching entries
+ */
+ public List getCalendarEntriesInRange(String calendarName, Instant from, Instant to) {
+ return getCalendarEntriesInRange(calendarName, from, to, false);
+ }
+
+ /**
+ * Fetches the events of a calendar overlapping a time range, optionally
+ * expanding recurring events.
+ *
+ * With {@code expandRecurrences} set, the server resolves each recurring
+ * event into one {@code VEVENT} per occurrence falling in the range: the
+ * recurrence rule is gone, every occurrence carries its own
+ * {@code RECURRENCE-ID} and start/end, overridden occurrences are already
+ * applied, and all times are returned in UTC. That makes the result
+ * directly usable for displaying a period, but it is a computed view for
+ * this range only - it is not the stored resource, so it must not be
+ * written back with {@link #putCalendarEntry(String, String, String)}.
+ *
+ * Without it, each matching event is returned once, exactly as stored.
+ *
+ * @param calendarName name of the calendar
+ * @param from start of the range, inclusive
+ * @param to end of the range, exclusive
+ * @param expandRecurrences whether the server should expand recurring
+ * events into their individual occurrences
+ * @return the matching entries
+ * @since 14.3
+ */
+ public List getCalendarEntriesInRange(String calendarName, Instant from, Instant to,
+ boolean expandRecurrences) {
+ if (from == null || to == null) {
+ throw new IllegalArgumentException("Both range bounds must be set");
+ }
+ if (to.isBefore(from)) {
+ throw new IllegalArgumentException("The end of the range must not be before its start");
+ }
+ String start = UTC_TIMESTAMP.format(from);
+ String end = UTC_TIMESTAMP.format(to);
+
+ // The expand element is a child of calendar-data, the time-range in the
+ // filter only selects which resources match.
+ String calendarData = expandRecurrences
+ ? ""
+ : "";
+
+ String body = ""
+ + ""
+ + "" + calendarData + ""
+ + ""
+ + ""
+ + ""
+ + "";
+
+ return report(collectionUrl(calendarName), body, CalendarEntry::new);
+ }
+
+ /**
+ * Fetches a single calendar entry.
+ *
+ * @param calendarName name of the calendar
+ * @param entryName name of the entry, or its href
+ * @return the entry, or {@code null} if it does not exist
+ */
+ public CalendarEntry getCalendarEntry(String calendarName, String entryName) {
+ return getEntry(calendarName, entryName, CalendarEntry::new);
+ }
+
+ /**
+ * Creates a calendar entry, or replaces it if one of that name exists.
+ *
+ * @param calendarName name of the calendar
+ * @param entryName name of the entry, conventionally the UID of the
+ * event followed by {@code .ics}
+ * @param iCalendar the iCalendar document to store
+ * @return the etag of the stored entry, or {@code null} if the server did
+ * not return one
+ */
+ public String putCalendarEntry(String calendarName, String entryName, String iCalendar) {
+ return putEntry(calendarName, entryName, iCalendar, null);
+ }
+
+ /**
+ * Replaces a calendar entry only while it still carries the given etag, so
+ * that a change made in the meantime is not silently overwritten.
+ *
+ * @param calendarName name of the calendar
+ * @param entryName name of the entry
+ * @param iCalendar the iCalendar document to store
+ * @param etag the etag the stored entry must still have
+ * @return the etag of the stored entry
+ * @throws org.aarboard.nextcloud.api.exception.NextcloudApiException if the
+ * entry was modified in the meantime
+ */
+ public String putCalendarEntry(String calendarName, String entryName, String iCalendar, String etag) {
+ return putEntry(calendarName, entryName, iCalendar, etag);
+ }
+
+ /**
+ * Deletes a calendar entry.
+ *
+ * @param calendarName name of the calendar
+ * @param entryName name of the entry, or its href
+ */
+ public void deleteCalendarEntry(String calendarName, String entryName) {
+ deleteEntry(calendarName, entryName);
+ }
+
+ /**
+ * Creates a calendar.
+ *
+ * @param calendarName name of the calendar as it appears in the URL
+ * @param displayName name shown in the UI, may be {@code null}
+ * @param color HTML colour code, e.g. {@code #FF0000}, may be
+ * {@code null}
+ */
+ public void createCalendar(String calendarName, String displayName, String color) {
+ ConnectorCommon.requireValidPathSegment(calendarName);
+
+ StringBuilder props = new StringBuilder();
+ if (displayName != null) {
+ props.append("").append(xmlEscape(displayName)).append("");
+ }
+ if (color != null) {
+ props.append("").append(xmlEscape(color)).append("");
+ }
+
+ String body = ""
+ + ""
+ + (props.length() > 0 ? "" + props + "" : "")
+ + "";
+
+ executeWithBody("MKCALENDAR", collectionUrl(calendarName), body,
+ "Creating calendar " + calendarName, 201);
+ }
+
+ /**
+ * Deletes a calendar and all of its entries.
+ *
+ * @param calendarName name of the calendar
+ */
+ public void deleteCalendar(String calendarName) {
+ deleteCollection(calendarName);
+ }
+}
diff --git a/src/main/java/org/aarboard/nextcloud/api/contacts/AddressBook.java b/src/main/java/org/aarboard/nextcloud/api/contacts/AddressBook.java
new file mode 100644
index 0000000..e2055b7
--- /dev/null
+++ b/src/main/java/org/aarboard/nextcloud/api/contacts/AddressBook.java
@@ -0,0 +1,28 @@
+/*
+ * Copyright (C) 2026 a.schild
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.aarboard.nextcloud.api.contacts;
+
+import org.aarboard.nextcloud.api.dav.DavCollection;
+
+/**
+ * An address book of a Nextcloud user.
+ *
+ * @author a.schild
+ * @since 14.3
+ */
+public class AddressBook extends DavCollection {
+}
diff --git a/src/main/java/org/aarboard/nextcloud/api/contacts/AddressBooks.java b/src/main/java/org/aarboard/nextcloud/api/contacts/AddressBooks.java
new file mode 100644
index 0000000..34ed9b0
--- /dev/null
+++ b/src/main/java/org/aarboard/nextcloud/api/contacts/AddressBooks.java
@@ -0,0 +1,197 @@
+/*
+ * Copyright (C) 2026 a.schild
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.aarboard.nextcloud.api.contacts;
+
+import java.nio.charset.StandardCharsets;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import javax.xml.namespace.QName;
+import org.aarboard.nextcloud.api.ServerConfig;
+import org.aarboard.nextcloud.api.dav.ADavCollectionHandler;
+import org.aarboard.nextcloud.api.utils.ConnectorCommon;
+import org.apache.http.entity.ContentType;
+
+/**
+ * Access to the address books of the authenticated user over
+ * CardDAV, below
+ * {@code remote.php/dav/addressbooks/users/}: listing address books, reading and
+ * writing their contacts, and creating or deleting address books.
+ *
+ * Contacts are exchanged as raw vCard documents, see {@link Contact}.
+ *
+ * @author a.schild
+ * @since 14.3
+ */
+public class AddressBooks extends ADavCollectionHandler {
+
+ private static final String ADDRESSBOOK_ROOT = "remote.php/dav/addressbooks/users/";
+
+ private static final QName RESOURCE_TYPE_ADDRESSBOOK = new QName(NS_CARDDAV, "addressbook");
+ private static final QName PROP_ADDRESSBOOK_DESCRIPTION =
+ new QName(NS_CARDDAV, "addressbook-description", "card");
+
+ public AddressBooks(ServerConfig serverConfig) {
+ super(serverConfig);
+ }
+
+ @Override
+ protected String getHomeUrl() {
+ return buildDavUrl(ADDRESSBOOK_ROOT + encodeSegment(getCurrentUserId())) + "/";
+ }
+
+ @Override
+ protected QName getCollectionResourceType() {
+ return RESOURCE_TYPE_ADDRESSBOOK;
+ }
+
+ @Override
+ protected String getDataElementName() {
+ return "address-data";
+ }
+
+ @Override
+ protected String getReportNamespace() {
+ return NS_CARDDAV;
+ }
+
+ @Override
+ protected String getMultigetElementName() {
+ return "addressbook-multiget";
+ }
+
+ @Override
+ protected ContentType getEntryContentType() {
+ return ContentType.create("text/vcard", StandardCharsets.UTF_8);
+ }
+
+ /**
+ * Lists the address books of the authenticated user.
+ *
+ * @return the user's address books
+ */
+ public List listAddressBooks() {
+ Set props = new HashSet<>();
+ props.add(PROP_ADDRESSBOOK_DESCRIPTION);
+
+ return listCollections(props, AddressBook::new, (book, resource) ->
+ book.setDescription(resource.getCustomPropsNS().get(PROP_ADDRESSBOOK_DESCRIPTION)));
+ }
+
+ /**
+ * Fetches every contact of an address book, vCard payload included.
+ *
+ * @param addressBookName name of the address book, see
+ * {@link AddressBook#getName()}
+ * @return all contacts of the address book
+ */
+ public List getContacts(String addressBookName) {
+ return getEntries(addressBookName, Contact::new);
+ }
+
+ /**
+ * Fetches a single contact.
+ *
+ * @param addressBookName name of the address book
+ * @param contactName name of the contact resource, or its href
+ * @return the contact, or {@code null} if it does not exist
+ */
+ public Contact getContact(String addressBookName, String contactName) {
+ return getEntry(addressBookName, contactName, Contact::new);
+ }
+
+ /**
+ * Creates a contact, or replaces it if one of that name exists.
+ *
+ * @param addressBookName name of the address book
+ * @param contactName name of the contact resource, conventionally the
+ * UID of the vCard followed by {@code .vcf}
+ * @param vCard the vCard document to store
+ * @return the etag of the stored contact, or {@code null} if the server did
+ * not return one
+ */
+ public String putContact(String addressBookName, String contactName, String vCard) {
+ return putEntry(addressBookName, contactName, vCard, null);
+ }
+
+ /**
+ * Replaces a contact only while it still carries the given etag, so that a
+ * change made in the meantime is not silently overwritten.
+ *
+ * @param addressBookName name of the address book
+ * @param contactName name of the contact resource
+ * @param vCard the vCard document to store
+ * @param etag the etag the stored contact must still have
+ * @return the etag of the stored contact
+ * @throws org.aarboard.nextcloud.api.exception.NextcloudApiException if the
+ * contact was modified in the meantime
+ */
+ public String putContact(String addressBookName, String contactName, String vCard, String etag) {
+ return putEntry(addressBookName, contactName, vCard, etag);
+ }
+
+ /**
+ * Deletes a contact.
+ *
+ * @param addressBookName name of the address book
+ * @param contactName name of the contact resource, or its href
+ */
+ public void deleteContact(String addressBookName, String contactName) {
+ deleteEntry(addressBookName, contactName);
+ }
+
+ /**
+ * Creates an address book.
+ *
+ * @param addressBookName name of the address book as it appears in the URL
+ * @param displayName name shown in the UI, may be {@code null}
+ * @param description description of the address book, may be
+ * {@code null}
+ */
+ public void createAddressBook(String addressBookName, String displayName, String description) {
+ ConnectorCommon.requireValidPathSegment(addressBookName);
+
+ StringBuilder props = new StringBuilder();
+ props.append("");
+ if (displayName != null) {
+ props.append("").append(xmlEscape(displayName)).append("");
+ }
+ if (description != null) {
+ props.append("").append(xmlEscape(description))
+ .append("");
+ }
+
+ // Extended MKCOL (RFC 5689): a plain MKCOL would create an ordinary
+ // collection, the resourcetype in the body is what makes it an address book.
+ String body = ""
+ + ""
+ + "" + props + ""
+ + "";
+
+ executeWithBody("MKCOL", collectionUrl(addressBookName), body,
+ "Creating address book " + addressBookName, 201);
+ }
+
+ /**
+ * Deletes an address book and all of its contacts.
+ *
+ * @param addressBookName name of the address book
+ */
+ public void deleteAddressBook(String addressBookName) {
+ deleteCollection(addressBookName);
+ }
+}
diff --git a/src/main/java/org/aarboard/nextcloud/api/contacts/Contact.java b/src/main/java/org/aarboard/nextcloud/api/contacts/Contact.java
new file mode 100644
index 0000000..863f964
--- /dev/null
+++ b/src/main/java/org/aarboard/nextcloud/api/contacts/Contact.java
@@ -0,0 +1,31 @@
+/*
+ * Copyright (C) 2026 a.schild
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.aarboard.nextcloud.api.contacts;
+
+import org.aarboard.nextcloud.api.dav.DavEntry;
+
+/**
+ * A single resource of an address book, holding one vCard document.
+ *
+ * {@link #getData()} returns the vCard text verbatim; parsing it is left to the
+ * caller, for example with ez-vcard.
+ *
+ * @author a.schild
+ * @since 14.3
+ */
+public class Contact extends DavEntry {
+}
diff --git a/src/main/java/org/aarboard/nextcloud/api/dav/ADavCollectionHandler.java b/src/main/java/org/aarboard/nextcloud/api/dav/ADavCollectionHandler.java
new file mode 100644
index 0000000..8b70877
--- /dev/null
+++ b/src/main/java/org/aarboard/nextcloud/api/dav/ADavCollectionHandler.java
@@ -0,0 +1,518 @@
+/*
+ * Copyright (C) 2026 a.schild
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.aarboard.nextcloud.api.dav;
+
+import com.github.sardine.DavResource;
+import com.github.sardine.Sardine;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Set;
+import java.util.function.Supplier;
+import javax.xml.namespace.QName;
+import org.aarboard.nextcloud.api.ServerConfig;
+import org.aarboard.nextcloud.api.exception.NextcloudApiException;
+import org.aarboard.nextcloud.api.utils.ConnectorCommon;
+import org.aarboard.nextcloud.api.webdav.AWebdavHandler;
+import org.apache.http.Header;
+import org.apache.http.HttpEntity;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpDelete;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.methods.HttpPut;
+import org.apache.http.client.methods.HttpRequestBase;
+import org.apache.http.client.methods.HttpUriRequest;
+import org.apache.http.entity.ContentType;
+import org.apache.http.entity.StringEntity;
+import org.apache.http.impl.client.CloseableHttpClient;
+
+/**
+ * Shared plumbing for the CalDAV and CardDAV connectors. The two protocols are
+ * structurally identical - a per-user home collection holding collections which
+ * in turn hold text resources - so collection listing, entry retrieval and the
+ * write operations live here and the subclasses only supply the namespaces,
+ * element and content type names that differ.
+ *
+ * @author a.schild
+ * @since 14.3
+ */
+public abstract class ADavCollectionHandler extends AWebdavHandler {
+
+ protected static final String NS_DAV = "DAV:";
+ protected static final String NS_CALDAV = "urn:ietf:params:xml:ns:caldav";
+ protected static final String NS_CARDDAV = "urn:ietf:params:xml:ns:carddav";
+ protected static final String NS_CALENDARSERVER = "http://calendarserver.org/ns/";
+ protected static final String NS_APPLE_ICAL = "http://apple.com/ns/ical/";
+
+ protected static final QName PROP_DISPLAYNAME = new QName(NS_DAV, "displayname", "d");
+ protected static final QName PROP_RESOURCETYPE = new QName(NS_DAV, "resourcetype", "d");
+ protected static final QName PROP_CTAG = new QName(NS_CALENDARSERVER, "getctag", "cs");
+
+ private static final ContentType XML_CONTENT_TYPE =
+ ContentType.create("application/xml", StandardCharsets.UTF_8);
+
+ protected ADavCollectionHandler(ServerConfig serverConfig) {
+ super(serverConfig);
+ }
+
+ /**
+ * @return the URL of the per-user home collection, with a trailing slash,
+ * e.g. {@code https://host/remote.php/dav/calendars/user/}
+ */
+ protected abstract String getHomeUrl();
+
+ /**
+ * @return the resource type marking a collection this handler manages, e.g.
+ * {@code {urn:ietf:params:xml:ns:caldav}calendar}
+ */
+ protected abstract QName getCollectionResourceType();
+
+ /**
+ * @return local name of the element carrying the payload in a REPORT
+ * response, i.e. {@code calendar-data} or {@code address-data}
+ */
+ protected abstract String getDataElementName();
+
+ /**
+ * @return namespace of the payload element and of the multiget report
+ */
+ protected abstract String getReportNamespace();
+
+ /**
+ * @return name of the multiget report element, e.g.
+ * {@code calendar-multiget}
+ */
+ protected abstract String getMultigetElementName();
+
+ /**
+ * @return content type to send when storing an entry
+ */
+ protected abstract ContentType getEntryContentType();
+
+ /**
+ * Builds the URL of a collection below the home collection.
+ *
+ * @param collectionName name of the collection
+ * @return the collection URL, with a trailing slash
+ */
+ protected String collectionUrl(String collectionName) {
+ ConnectorCommon.requireValidPathSegment(collectionName);
+ return getHomeUrl() + encodeSegment(collectionName) + "/";
+ }
+
+ /**
+ * Builds the URL of a single entry inside a collection.
+ *
+ * @param collectionName name of the collection
+ * @param entryName name of the entry, or a full href of one - in which
+ * case the last path segment is used
+ * @return the entry URL
+ */
+ protected String entryUrl(String collectionName, String entryName) {
+ return collectionUrl(collectionName) + encodeSegment(lastSegment(entryName));
+ }
+
+ /**
+ * Lists the collections in the user's home collection, keeping only those
+ * carrying the resource type this handler manages. This filters out the
+ * scheduling inbox/outbox, the trash bin and subscriptions, which live in
+ * the same home collection.
+ *
+ * @param the concrete collection type
+ * @param extraProps additional properties to request
+ * @param factory creates a new, empty collection instance
+ * @param decorator fills the type specific properties of a collection
+ * @return the matching collections
+ */
+ protected List listCollections(Set extraProps,
+ Supplier factory, CollectionDecorator decorator) {
+ Set props = new java.util.HashSet<>(extraProps);
+ props.add(PROP_DISPLAYNAME);
+ props.add(PROP_CTAG);
+ // A PROPFIND naming specific properties returns only those, so the
+ // resource type has to be asked for explicitly - without it every
+ // collection would look like a non-calendar and be filtered out below.
+ props.add(PROP_RESOURCETYPE);
+
+ String homeUrl = getHomeUrl();
+ Sardine sardine = buildAuthSardine();
+ try {
+ List collections = new ArrayList<>();
+ for (DavResource resource : sardine.propfind(homeUrl, 1, props)) {
+ if (!resource.getResourceTypes().contains(getCollectionResourceType())) {
+ continue;
+ }
+ T collection = factory.get();
+ collection.setHref(resource.getPath());
+ collection.setName(lastSegment(resource.getPath()));
+ collection.setDisplayName(resource.getDisplayName() != null
+ ? resource.getDisplayName()
+ : resource.getCustomPropsNS().get(PROP_DISPLAYNAME));
+ collection.setCtag(resource.getCustomPropsNS().get(PROP_CTAG));
+ decorator.decorate(collection, resource);
+ collections.add(collection);
+ }
+ return collections;
+ } catch (IOException e) {
+ throw new NextcloudApiException(e);
+ } finally {
+ shutdownSardine(sardine);
+ }
+ }
+
+ /**
+ * Fetches every entry of a collection, payload included.
+ *
+ * This is done with a PROPFIND to enumerate the entries followed by a single
+ * multiget REPORT for their payloads, rather than one GET per entry.
+ *
+ * @param the concrete entry type
+ * @param collectionName name of the collection
+ * @param entryFactory creates a new, empty entry instance
+ * @return all entries of the collection
+ */
+ protected List getEntries(String collectionName, Supplier entryFactory) {
+ String collectionUrl = collectionUrl(collectionName);
+ List hrefs = listEntryHrefs(collectionUrl);
+ if (hrefs.isEmpty()) {
+ return new ArrayList<>();
+ }
+ return multiget(collectionUrl, hrefs, entryFactory);
+ }
+
+ /**
+ * Fetches a single entry.
+ *
+ * @param the concrete entry type
+ * @param collectionName name of the collection
+ * @param entryName name of the entry
+ * @param entryFactory creates a new, empty entry instance
+ * @return the entry, or {@code null} if it does not exist
+ */
+ protected T getEntry(String collectionName, String entryName,
+ Supplier entryFactory) {
+ String url = entryUrl(collectionName, entryName);
+ HttpGet get = new HttpGet(url);
+ get.setHeader("Authorization", authorizationHeader());
+
+ try (CloseableHttpClient client = buildSyncClient();
+ CloseableHttpResponse response = client.execute(get)) {
+ int status = response.getStatusLine().getStatusCode();
+ if (status == 404) {
+ return null;
+ }
+ if (status != 200) {
+ throw new NextcloudApiException("Fetching " + url + " failed with status " + status);
+ }
+ T entry = entryFactory.get();
+ entry.setHref(URI.create(url).getPath());
+ entry.setData(readBody(response.getEntity()));
+ Header etag = response.getFirstHeader("ETag");
+ if (etag != null) {
+ entry.setEtag(unquote(etag.getValue()));
+ }
+ return entry;
+ } catch (IOException e) {
+ throw new NextcloudApiException(e);
+ }
+ }
+
+ /**
+ * Creates or replaces an entry.
+ *
+ * @param collectionName name of the collection
+ * @param entryName name of the entry, including the file extension
+ * @param data the raw payload to store
+ * @param ifMatchEtag when set, the write only succeeds while the stored
+ * entry still carries this etag; when {@code null} the
+ * entry is written unconditionally
+ * @return the etag of the stored entry, or {@code null} if the server did
+ * not return one
+ */
+ protected String putEntry(String collectionName, String entryName, String data, String ifMatchEtag) {
+ if (data == null || data.isEmpty()) {
+ throw new IllegalArgumentException("Entry data must not be empty");
+ }
+ String url = entryUrl(collectionName, entryName);
+ HttpPut put = new HttpPut(url);
+ put.setHeader("Authorization", authorizationHeader());
+ if (ifMatchEtag != null) {
+ put.setHeader("If-Match", quote(ifMatchEtag));
+ }
+ put.setEntity(new StringEntity(data, getEntryContentType()));
+
+ try (CloseableHttpClient client = buildSyncClient();
+ CloseableHttpResponse response = client.execute(put)) {
+ int status = response.getStatusLine().getStatusCode();
+ if (status == 412) {
+ throw new NextcloudApiException("Entry " + entryName
+ + " was modified on the server, the If-Match precondition failed");
+ }
+ if (status != 201 && status != 204 && status != 200) {
+ throw new NextcloudApiException("Storing " + url + " failed with status " + status);
+ }
+ Header etag = response.getFirstHeader("ETag");
+ return etag != null ? unquote(etag.getValue()) : null;
+ } catch (IOException e) {
+ throw new NextcloudApiException(e);
+ }
+ }
+
+ /**
+ * Deletes an entry.
+ *
+ * @param collectionName name of the collection
+ * @param entryName name of the entry
+ */
+ protected void deleteEntry(String collectionName, String entryName) {
+ HttpDelete delete = new HttpDelete(entryUrl(collectionName, entryName));
+ delete.setHeader("Authorization", authorizationHeader());
+ execute(delete, "Deleting entry " + entryName, 204, 200);
+ }
+
+ /**
+ * Deletes a whole collection and everything in it.
+ *
+ * @param collectionName name of the collection
+ */
+ protected void deleteCollection(String collectionName) {
+ HttpDelete delete = new HttpDelete(collectionUrl(collectionName));
+ delete.setHeader("Authorization", authorizationHeader());
+ execute(delete, "Deleting collection " + collectionName, 204, 200);
+ }
+
+ /**
+ * Issues a request with a body and an XML content type, e.g. MKCALENDAR or
+ * an extended MKCOL.
+ *
+ * @param method the HTTP method name
+ * @param url the target URL
+ * @param body the request body
+ * @param description used in the error message
+ * @param expectedStatus the accepted status codes
+ */
+ protected void executeWithBody(String method, String url, String body, String description,
+ int... expectedStatus) {
+ DavMethod request = new DavMethod(method, url);
+ request.setHeader("Authorization", authorizationHeader());
+ request.setEntity(new StringEntity(body, XML_CONTENT_TYPE));
+ execute(request, description, expectedStatus);
+ }
+
+ private List listEntryHrefs(String collectionUrl) {
+ Sardine sardine = buildAuthSardine();
+ try {
+ List hrefs = new ArrayList<>();
+ String collectionPath = URI.create(collectionUrl).getPath();
+ for (DavResource resource : sardine.list(collectionUrl, 1)) {
+ String path = resource.getPath();
+ // The collection itself is part of a depth 1 listing.
+ if (path == null || samePath(path, collectionPath)) {
+ continue;
+ }
+ hrefs.add(path);
+ }
+ return hrefs;
+ } catch (IOException e) {
+ throw new NextcloudApiException(e);
+ } finally {
+ shutdownSardine(sardine);
+ }
+ }
+
+ private List multiget(String collectionUrl, List hrefs,
+ Supplier entryFactory) {
+ StringBuilder body = new StringBuilder();
+ body.append("")
+ .append("")
+ .append("");
+ for (String href : hrefs) {
+ body.append("").append(xmlEscape(href)).append("");
+ }
+ body.append("');
+
+ return report(collectionUrl, body.toString(), entryFactory);
+ }
+
+ /**
+ * Sends a REPORT request and parses the multistatus response.
+ *
+ * @param the concrete entry type
+ * @param url the target collection URL
+ * @param body the REPORT body
+ * @param entryFactory creates a new, empty entry instance
+ * @return the entries carried by the response
+ */
+ protected List report(String url, String body, Supplier entryFactory) {
+ DavMethod request = new DavMethod("REPORT", url);
+ request.setHeader("Authorization", authorizationHeader());
+ request.setHeader("Depth", "1");
+ request.setEntity(new StringEntity(body, XML_CONTENT_TYPE));
+
+ try (CloseableHttpClient client = buildSyncClient();
+ CloseableHttpResponse response = client.execute(request)) {
+ int status = response.getStatusLine().getStatusCode();
+ if (status != 207) {
+ throw new NextcloudApiException("REPORT on " + url + " failed with status " + status);
+ }
+ HttpEntity entity = response.getEntity();
+ if (entity == null) {
+ return Collections.emptyList();
+ }
+ try (InputStream in = entity.getContent()) {
+ return MultistatusParser.parse(in, getDataElementName(), entryFactory);
+ }
+ } catch (IOException e) {
+ throw new NextcloudApiException(e);
+ }
+ }
+
+ private void execute(HttpUriRequest request, String description, int... expectedStatus) {
+ try (CloseableHttpClient client = buildSyncClient();
+ CloseableHttpResponse response = client.execute(request)) {
+ int status = response.getStatusLine().getStatusCode();
+ for (int expected : expectedStatus) {
+ if (status == expected) {
+ return;
+ }
+ }
+ throw new NextcloudApiException(description + " failed with status " + status);
+ } catch (IOException e) {
+ throw new NextcloudApiException(e);
+ } finally {
+ if (request instanceof HttpRequestBase) {
+ ((HttpRequestBase) request).releaseConnection();
+ }
+ }
+ }
+
+ private static String readBody(HttpEntity entity) throws IOException {
+ if (entity == null) {
+ return null;
+ }
+ try (InputStream in = entity.getContent()) {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ byte[] buffer = new byte[FILE_BUFFER_SIZE];
+ int read;
+ while ((read = in.read(buffer)) != -1) {
+ out.write(buffer, 0, read);
+ }
+ return new String(out.toByteArray(), StandardCharsets.UTF_8);
+ }
+ }
+
+ private static boolean samePath(String left, String right) {
+ return stripTrailingSlash(left).equals(stripTrailingSlash(right));
+ }
+
+ private static String stripTrailingSlash(String value) {
+ return value.endsWith("/") ? value.substring(0, value.length() - 1) : value;
+ }
+
+ /**
+ * Returns the last path segment of a value, so that callers may pass either
+ * a bare entry name or a full href.
+ *
+ * @param value an entry name or href
+ * @return the last path segment
+ */
+ protected static String lastSegment(String value) {
+ if (value == null) {
+ throw new IllegalArgumentException("Name must not be null");
+ }
+ String trimmed = stripTrailingSlash(value);
+ int lastSlash = trimmed.lastIndexOf('/');
+ String segment = lastSlash >= 0 ? trimmed.substring(lastSlash + 1) : trimmed;
+ if (segment.isEmpty()) {
+ throw new IllegalArgumentException("Name must not be empty");
+ }
+ return segment;
+ }
+
+ /**
+ * Percent-encodes a single path segment. The segment has already been
+ * checked not to contain a path separator, so only the remaining reserved
+ * characters need escaping.
+ *
+ * @param segment the path segment
+ * @return the encoded segment
+ */
+ protected static String encodeSegment(String segment) {
+ StringBuilder encoded = new StringBuilder();
+ for (byte b : segment.getBytes(StandardCharsets.UTF_8)) {
+ int value = b & 0xFF;
+ if ((value >= 'a' && value <= 'z') || (value >= 'A' && value <= 'Z')
+ || (value >= '0' && value <= '9')
+ || value == '-' || value == '_' || value == '.' || value == '~') {
+ encoded.append((char) value);
+ } else {
+ encoded.append('%').append(String.format("%02X", value));
+ }
+ }
+ return encoded.toString();
+ }
+
+ /**
+ * Escapes the characters that may not appear in XML character data.
+ *
+ * @param value the raw value
+ * @return the escaped value
+ */
+ protected static String xmlEscape(String value) {
+ if (value == null) {
+ return "";
+ }
+ return value.replace("&", "&")
+ .replace("<", "<")
+ .replace(">", ">")
+ .replace("\"", """)
+ .replace("'", "'");
+ }
+
+ private static String quote(String etag) {
+ String value = etag.trim();
+ return value.startsWith("\"") || value.startsWith("W/") ? value : "\"" + value + "\"";
+ }
+
+ private static String unquote(String etag) {
+ String value = etag.trim();
+ if (value.startsWith("W/")) {
+ value = value.substring(2);
+ }
+ if (value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"")) {
+ value = value.substring(1, value.length() - 1);
+ }
+ return value;
+ }
+
+ /**
+ * Fills in the properties specific to a collection type.
+ *
+ * @param the concrete collection type
+ */
+ @FunctionalInterface
+ protected interface CollectionDecorator {
+ void decorate(T collection, DavResource resource);
+ }
+}
diff --git a/src/main/java/org/aarboard/nextcloud/api/dav/DavCollection.java b/src/main/java/org/aarboard/nextcloud/api/dav/DavCollection.java
new file mode 100644
index 0000000..8ec316b
--- /dev/null
+++ b/src/main/java/org/aarboard/nextcloud/api/dav/DavCollection.java
@@ -0,0 +1,97 @@
+/*
+ * Copyright (C) 2026 a.schild
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.aarboard.nextcloud.api.dav;
+
+/**
+ * Common metadata of a CalDAV/CardDAV collection (a calendar or an address
+ * book).
+ *
+ * @author a.schild
+ * @since 14.3
+ */
+public abstract class DavCollection {
+
+ private String name;
+ private String href;
+ private String displayName;
+ private String description;
+ private String ctag;
+
+ /**
+ * The collection name as it appears in the URL, for example
+ * {@code personal}. This is the identifier to pass to the entry methods.
+ *
+ * @return the collection name
+ */
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ /**
+ * @return the absolute path of the collection on the server
+ */
+ public String getHref() {
+ return href;
+ }
+
+ public void setHref(String href) {
+ this.href = href;
+ }
+
+ /**
+ * @return the human readable name shown in the Nextcloud UI, which may
+ * differ from {@link #getName()}
+ */
+ public String getDisplayName() {
+ return displayName;
+ }
+
+ public void setDisplayName(String displayName) {
+ this.displayName = displayName;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public void setDescription(String description) {
+ this.description = description;
+ }
+
+ /**
+ * The collection tag: it changes whenever anything inside the collection
+ * changes, so it can be used to skip a full sync when nothing has changed.
+ *
+ * @return the ctag of the collection
+ */
+ public String getCtag() {
+ return ctag;
+ }
+
+ public void setCtag(String ctag) {
+ this.ctag = ctag;
+ }
+
+ @Override
+ public String toString() {
+ return getClass().getSimpleName() + "{name=" + name + ", displayName=" + displayName + '}';
+ }
+}
diff --git a/src/main/java/org/aarboard/nextcloud/api/dav/DavEntry.java b/src/main/java/org/aarboard/nextcloud/api/dav/DavEntry.java
new file mode 100644
index 0000000..3dbda90
--- /dev/null
+++ b/src/main/java/org/aarboard/nextcloud/api/dav/DavEntry.java
@@ -0,0 +1,91 @@
+/*
+ * Copyright (C) 2026 a.schild
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.aarboard.nextcloud.api.dav;
+
+/**
+ * A single resource inside a CalDAV or CardDAV collection, carrying the raw
+ * payload as it is stored on the server.
+ *
+ * The library deliberately does not parse the iCalendar/vCard payload: it has no
+ * dependency on an iCalendar or vCard library, so consumers stay free to use
+ * whichever one they prefer (for example ical4j or ez-vcard) or none at all.
+ *
+ * @author a.schild
+ * @since 14.3
+ */
+public abstract class DavEntry {
+
+ private String href;
+ private String etag;
+ private String data;
+
+ /**
+ * @return the absolute path of the resource on the server, for example
+ * {@code /remote.php/dav/calendars/user/personal/abc123.ics}
+ */
+ public String getHref() {
+ return href;
+ }
+
+ public void setHref(String href) {
+ this.href = href;
+ }
+
+ /**
+ * The name of the resource within its collection, i.e. the last segment of
+ * the href. This is the value to pass back to the update and delete methods.
+ *
+ * @return the resource name, or {@code null} if no href is set
+ */
+ public String getName() {
+ if (href == null) {
+ return null;
+ }
+ String trimmed = href.endsWith("/") ? href.substring(0, href.length() - 1) : href;
+ int lastSlash = trimmed.lastIndexOf('/');
+ return lastSlash >= 0 ? trimmed.substring(lastSlash + 1) : trimmed;
+ }
+
+ /**
+ * @return the entity tag of this resource, usable as a precondition when
+ * updating it to avoid overwriting a concurrent change
+ */
+ public String getEtag() {
+ return etag;
+ }
+
+ public void setEtag(String etag) {
+ this.etag = etag;
+ }
+
+ /**
+ * @return the raw payload (an iCalendar or vCard document), or {@code null}
+ * if only the metadata was requested
+ */
+ public String getData() {
+ return data;
+ }
+
+ public void setData(String data) {
+ this.data = data;
+ }
+
+ @Override
+ public String toString() {
+ return getClass().getSimpleName() + "{href=" + href + ", etag=" + etag + '}';
+ }
+}
diff --git a/src/main/java/org/aarboard/nextcloud/api/dav/DavMethod.java b/src/main/java/org/aarboard/nextcloud/api/dav/DavMethod.java
new file mode 100644
index 0000000..328db36
--- /dev/null
+++ b/src/main/java/org/aarboard/nextcloud/api/dav/DavMethod.java
@@ -0,0 +1,42 @@
+/*
+ * Copyright (C) 2026 a.schild
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.aarboard.nextcloud.api.dav;
+
+import java.net.URI;
+import org.apache.http.client.methods.HttpEntityEnclosingRequestBase;
+
+/**
+ * A request with an arbitrary method name and a body, for the DAV verbs Apache
+ * HttpClient does not ship (REPORT, MKCALENDAR, extended MKCOL).
+ *
+ * @author a.schild
+ * @since 14.3
+ */
+class DavMethod extends HttpEntityEnclosingRequestBase {
+
+ private final String method;
+
+ DavMethod(String method, String uri) {
+ this.method = method;
+ setURI(URI.create(uri));
+ }
+
+ @Override
+ public String getMethod() {
+ return method;
+ }
+}
diff --git a/src/main/java/org/aarboard/nextcloud/api/dav/MultistatusParser.java b/src/main/java/org/aarboard/nextcloud/api/dav/MultistatusParser.java
new file mode 100644
index 0000000..71bf896
--- /dev/null
+++ b/src/main/java/org/aarboard/nextcloud/api/dav/MultistatusParser.java
@@ -0,0 +1,171 @@
+/*
+ * Copyright (C) 2026 a.schild
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.aarboard.nextcloud.api.dav;
+
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.Supplier;
+import javax.xml.stream.XMLInputFactory;
+import javax.xml.stream.XMLStreamConstants;
+import javax.xml.stream.XMLStreamException;
+import javax.xml.stream.XMLStreamReader;
+import org.aarboard.nextcloud.api.exception.NextcloudApiException;
+
+/**
+ * Minimal reader for the {@code DAV:multistatus} documents returned by the
+ * CalDAV/CardDAV {@code REPORT} requests.
+ *
+ * Sardine models plain WebDAV properties only and drops the
+ * {@code calendar-data} / {@code address-data} payload elements, so the REPORT
+ * responses are read here instead. Only the three elements the connectors need
+ * are extracted (href, getetag and the payload); everything else is skipped.
+ * Like {@code XMLAnswerParser}, the reader is hardened against XXE by
+ * disabling DTDs and external entities.
+ *
+ * @author a.schild
+ * @since 14.3
+ */
+public final class MultistatusParser {
+
+ private static final XMLInputFactory XML_INPUT_FACTORY = createHardenedInputFactory();
+
+ private static final String EL_RESPONSE = "response";
+ private static final String EL_HREF = "href";
+ private static final String EL_ETAG = "getetag";
+
+ private MultistatusParser() {
+ }
+
+ private static XMLInputFactory createHardenedInputFactory() {
+ XMLInputFactory factory = XMLInputFactory.newFactory();
+ factory.setProperty(XMLInputFactory.SUPPORT_DTD, false);
+ factory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false);
+ return factory;
+ }
+
+ /**
+ * Parses a multistatus document into entries.
+ *
+ * @param the concrete entry type
+ * @param in the response body
+ * @param dataElement local name of the element holding the payload, i.e.
+ * {@code calendar-data} or {@code address-data}
+ * @param entryFactory creates a new, empty entry instance
+ * @return one entry per {@code response} element that carried a payload
+ */
+ public static List parse(InputStream in, String dataElement,
+ Supplier entryFactory) {
+ List entries = new ArrayList<>();
+ try {
+ XMLStreamReader reader = XML_INPUT_FACTORY.createXMLStreamReader(in);
+ try {
+ T current = null;
+ while (reader.hasNext()) {
+ int event = reader.next();
+ if (event == XMLStreamConstants.START_ELEMENT) {
+ String local = reader.getLocalName();
+ if (EL_RESPONSE.equals(local)) {
+ current = entryFactory.get();
+ } else if (current == null) {
+ continue;
+ } else if (EL_HREF.equals(local)) {
+ // A response holds exactly one href, but the propstat
+ // sections can contain further ones (e.g. in principal
+ // properties); keep the first, which is the resource.
+ if (current.getHref() == null) {
+ current.setHref(reader.getElementText());
+ }
+ } else if (EL_ETAG.equals(local)) {
+ current.setEtag(unquote(reader.getElementText()));
+ } else if (dataElement.equals(local)) {
+ current.setData(reader.getElementText());
+ }
+ } else if (event == XMLStreamConstants.END_ELEMENT
+ && EL_RESPONSE.equals(reader.getLocalName())) {
+ // Responses without a payload are error entries (404/403
+ // propstat) and are not useful to the caller. Such a
+ // propstat still echoes the requested element, empty, so
+ // an empty payload counts as absent here.
+ if (current != null && current.getData() != null
+ && !current.getData().isEmpty()) {
+ entries.add(current);
+ }
+ current = null;
+ }
+ }
+ } finally {
+ reader.close();
+ }
+ } catch (XMLStreamException e) {
+ throw new NextcloudApiException("Could not parse the DAV multistatus response", e);
+ }
+ return entries;
+ }
+
+ /**
+ * Reads only the hrefs of a multistatus document, ignoring any payload.
+ *
+ * @param in the response body
+ * @return the href of every {@code response} element, in document order
+ */
+ public static List parseHrefs(InputStream in) {
+ List hrefs = new ArrayList<>();
+ try {
+ XMLStreamReader reader = XML_INPUT_FACTORY.createXMLStreamReader(in);
+ try {
+ boolean inResponse = false;
+ boolean hrefSeen = false;
+ while (reader.hasNext()) {
+ int event = reader.next();
+ if (event == XMLStreamConstants.START_ELEMENT) {
+ String local = reader.getLocalName();
+ if (EL_RESPONSE.equals(local)) {
+ inResponse = true;
+ hrefSeen = false;
+ } else if (inResponse && !hrefSeen && EL_HREF.equals(local)) {
+ hrefs.add(reader.getElementText());
+ hrefSeen = true;
+ }
+ } else if (event == XMLStreamConstants.END_ELEMENT
+ && EL_RESPONSE.equals(reader.getLocalName())) {
+ inResponse = false;
+ }
+ }
+ } finally {
+ reader.close();
+ }
+ } catch (XMLStreamException e) {
+ throw new NextcloudApiException("Could not parse the DAV multistatus response", e);
+ }
+ return hrefs;
+ }
+
+ private static String unquote(String etag) {
+ if (etag == null) {
+ return null;
+ }
+ String value = etag.trim();
+ if (value.startsWith("W/")) {
+ value = value.substring(2);
+ }
+ if (value.length() >= 2 && value.startsWith("\"") && value.endsWith("\"")) {
+ value = value.substring(1, value.length() - 1);
+ }
+ return value;
+ }
+}
diff --git a/src/main/java/org/aarboard/nextcloud/api/exception/NextcloudApiException.java b/src/main/java/org/aarboard/nextcloud/api/exception/NextcloudApiException.java
index 4c6a3d5..fa5222a 100644
--- a/src/main/java/org/aarboard/nextcloud/api/exception/NextcloudApiException.java
+++ b/src/main/java/org/aarboard/nextcloud/api/exception/NextcloudApiException.java
@@ -10,4 +10,13 @@ public NextcloudApiException(Throwable cause) {
public NextcloudApiException(String message) {
super(message);
}
+
+ /**
+ * @param message describes what failed
+ * @param cause the underlying failure
+ * @since 14.3
+ */
+ public NextcloudApiException(String message, Throwable cause) {
+ super(message, cause);
+ }
}
diff --git a/src/main/java/org/aarboard/nextcloud/api/systemtags/SystemTags.java b/src/main/java/org/aarboard/nextcloud/api/systemtags/SystemTags.java
index 9d5ff51..b7bc684 100644
--- a/src/main/java/org/aarboard/nextcloud/api/systemtags/SystemTags.java
+++ b/src/main/java/org/aarboard/nextcloud/api/systemtags/SystemTags.java
@@ -20,29 +20,22 @@
import com.github.sardine.DavResource;
import com.github.sardine.Sardine;
import java.io.IOException;
-import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
-import java.util.Base64;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
-import javax.net.ssl.SSLContext;
import javax.xml.namespace.QName;
import org.aarboard.nextcloud.api.ServerConfig;
import org.aarboard.nextcloud.api.exception.NextcloudApiException;
-import org.aarboard.nextcloud.api.utils.SslUtils;
import org.aarboard.nextcloud.api.webdav.AWebdavHandler;
import org.apache.http.Header;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpPost;
-import org.apache.http.conn.ssl.NoopHostnameVerifier;
import org.apache.http.entity.ContentType;
import org.apache.http.entity.StringEntity;
import org.apache.http.impl.client.CloseableHttpClient;
-import org.apache.http.impl.client.HttpClientBuilder;
-import org.apache.http.impl.client.HttpClients;
/**
* Access to Nextcloud system
@@ -212,32 +205,4 @@ private static boolean toBoolean(String value) {
return "true".equalsIgnoreCase(value) || "1".equals(value);
}
- private String authorizationHeader() {
- if (getServerConfig().getAuthenticationConfig().usesBasicAuthentication()) {
- String credentials = getServerConfig().getUserName() + ":"
- + getServerConfig().getAuthenticationConfig().getPassword();
- return "Basic " + Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8));
- }
- return "Bearer " + getServerConfig().getAuthenticationConfig().getBearerToken();
- }
-
- private CloseableHttpClient buildSyncClient() {
- HttpClientBuilder builder = HttpClients.custom();
- SSLContext sslContext = SslUtils.buildSslContext(getServerConfig());
- if (sslContext != null) {
- builder.setSSLContext(sslContext);
- if (SslUtils.isHostnameVerificationDisabled(getServerConfig())) {
- builder.setSSLHostnameVerifier(NoopHostnameVerifier.INSTANCE);
- }
- }
- return builder.build();
- }
-
- private void shutdownSardine(Sardine sardine) {
- try {
- sardine.shutdown();
- } catch (IOException e) {
- // best effort
- }
- }
}
diff --git a/src/main/java/org/aarboard/nextcloud/api/webdav/AWebdavHandler.java b/src/main/java/org/aarboard/nextcloud/api/webdav/AWebdavHandler.java
index e527d5e..3c5aafa 100644
--- a/src/main/java/org/aarboard/nextcloud/api/webdav/AWebdavHandler.java
+++ b/src/main/java/org/aarboard/nextcloud/api/webdav/AWebdavHandler.java
@@ -25,6 +25,7 @@
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
+import java.util.Base64;
import java.util.stream.Collectors;
import javax.net.ssl.HostnameVerifier;
import javax.net.ssl.SSLContext;
@@ -40,6 +41,9 @@
import org.apache.http.conn.socket.ConnectionSocketFactory;
import org.apache.http.conn.ssl.NoopHostnameVerifier;
import org.apache.http.conn.ssl.SSLConnectionSocketFactory;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClientBuilder;
+import org.apache.http.impl.client.HttpClients;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -62,6 +66,8 @@ public abstract class AWebdavHandler
private String nextcloudServerVersion;
+ private String currentUserId;
+
protected AWebdavHandler(ServerConfig serverConfig)
{
this.serverConfig = serverConfig;
@@ -191,6 +197,83 @@ protected ServerConfig getServerConfig()
return this.serverConfig;
}
+ /**
+ * Resolves the internal user id of the authenticated user, which is the one
+ * appearing in DAV paths. This is not necessarily the login name: with
+ * external user backends the two can differ, and the DAV endpoints only
+ * accept the internal id. The value is resolved once and then cached.
+ *
+ * @return the internal user id of the authenticated user
+ * @since 14.3
+ */
+ protected String getCurrentUserId()
+ {
+ if (null == this.currentUserId)
+ {
+ ProvisionConnector pc = new ProvisionConnector(this.serverConfig);
+ this.currentUserId = pc.getCurrentUser().getId();
+ }
+ return this.currentUserId;
+ }
+
+ /**
+ * Builds the value for the {@code Authorization} header matching the
+ * configured authentication method.
+ *
+ * @return the authorization header value
+ * @since 14.3
+ */
+ protected String authorizationHeader()
+ {
+ if (this.serverConfig.getAuthenticationConfig().usesBasicAuthentication())
+ {
+ String credentials = this.serverConfig.getUserName() + ":"
+ + this.serverConfig.getAuthenticationConfig().getPassword();
+ return "Basic " + Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8));
+ }
+ return "Bearer " + this.serverConfig.getAuthenticationConfig().getBearerToken();
+ }
+
+ /**
+ * Builds a synchronous HTTP client honouring the configured certificate
+ * trust settings. Used for the DAV verbs Sardine does not expose.
+ *
+ * @return a new client the caller is responsible for closing
+ * @since 14.3
+ */
+ protected CloseableHttpClient buildSyncClient()
+ {
+ HttpClientBuilder builder = HttpClients.custom();
+ SSLContext sslContext = SslUtils.buildSslContext(this.serverConfig);
+ if (sslContext != null)
+ {
+ builder.setSSLContext(sslContext);
+ if (SslUtils.isHostnameVerificationDisabled(this.serverConfig))
+ {
+ builder.setSSLHostnameVerifier(NoopHostnameVerifier.INSTANCE);
+ }
+ }
+ return builder.build();
+ }
+
+ /**
+ * Shuts a Sardine connector down, logging but not propagating failures.
+ *
+ * @param sardine the connector to shut down
+ * @since 14.3
+ */
+ protected void shutdownSardine(Sardine sardine)
+ {
+ try
+ {
+ sardine.shutdown();
+ }
+ catch (IOException ex)
+ {
+ LOG.warn(ERROR_CLOSING, ex);
+ }
+ }
+
protected String getWebdavPathPrefix()
{
if (resolver != null)
diff --git a/src/test/java/org/aarboard/nextcloud/api/TestAddressBooks.java b/src/test/java/org/aarboard/nextcloud/api/TestAddressBooks.java
new file mode 100644
index 0000000..3699841
--- /dev/null
+++ b/src/test/java/org/aarboard/nextcloud/api/TestAddressBooks.java
@@ -0,0 +1,169 @@
+/*
+ * Copyright (C) 2026 a.schild
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.aarboard.nextcloud.api;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import java.util.List;
+import org.aarboard.nextcloud.api.contacts.AddressBook;
+import org.aarboard.nextcloud.api.contacts.Contact;
+import org.aarboard.nextcloud.api.exception.NextcloudApiException;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.FixMethodOrder;
+import org.junit.Test;
+import org.junit.runners.MethodSorters;
+
+/**
+ * Integration tests for the CardDAV support (issue #59).
+ *
+ * @author a.schild
+ */
+@FixMethodOrder(MethodSorters.NAME_ASCENDING)
+public class TestAddressBooks {
+
+ private static final String ADDRESSBOOK = "api-test-addressbook";
+ private static final String DISPLAY_NAME = "API test address book";
+ private static final String DESCRIPTION = "Created by the integration tests";
+ private static final String CONTACT = "api-test-contact-1.vcf";
+
+ private static String serverName = null;
+ private static NextcloudConnector _nc = null;
+
+ private static String vCard(String fullName) {
+ return "BEGIN:VCARD\r\n"
+ + "VERSION:3.0\r\n"
+ + "UID:api-test-contact-1\r\n"
+ + "FN:" + fullName + "\r\n"
+ + "N:Doe;Jane;;;\r\n"
+ + "EMAIL:jane.doe@example.org\r\n"
+ + "END:VCARD\r\n";
+ }
+
+ @BeforeClass
+ public static void setUp() {
+ TestHelper th = new TestHelper();
+ serverName = th.getServerName();
+ if (serverName != null) {
+ _nc = new NextcloudConnector(serverName, th.getServerPort() == 443, th.getServerPort(),
+ th.getUserName(), th.getPassword());
+ _nc.createAddressBook(ADDRESSBOOK, DISPLAY_NAME, DESCRIPTION);
+ }
+ }
+
+ @AfterClass
+ public static void tearDown() throws Exception {
+ if (_nc != null) {
+ try {
+ _nc.deleteAddressBook(ADDRESSBOOK);
+ } catch (NextcloudApiException e) {
+ // the test that deletes it may already have run
+ }
+ _nc.close();
+ }
+ }
+
+ @Test
+ public void t01_listAddressBooks() {
+ if (serverName == null) {
+ return;
+ }
+ List books = _nc.listAddressBooks();
+
+ AddressBook created = books.stream()
+ .filter(b -> ADDRESSBOOK.equals(b.getName()))
+ .findFirst().orElse(null);
+ assertNotNull("Created address book not found in the listing", created);
+ assertEquals(DISPLAY_NAME, created.getDisplayName());
+ assertEquals(DESCRIPTION, created.getDescription());
+ }
+
+ @Test
+ public void t02_putAndGetContact() {
+ if (serverName == null) {
+ return;
+ }
+ _nc.putContact(ADDRESSBOOK, CONTACT, vCard("Jane Doe"));
+
+ Contact contact = _nc.getContact(ADDRESSBOOK, CONTACT);
+ assertNotNull(contact);
+ assertEquals(CONTACT, contact.getName());
+ assertNotNull("A stored contact must have an etag", contact.getEtag());
+ assertTrue(contact.getData().contains("FN:Jane Doe"));
+ }
+
+ @Test
+ public void t03_getContacts() {
+ if (serverName == null) {
+ return;
+ }
+ List contacts = _nc.getContacts(ADDRESSBOOK);
+ assertEquals(1, contacts.size());
+ assertEquals(CONTACT, contacts.get(0).getName());
+ assertTrue(contacts.get(0).getData().contains("EMAIL:jane.doe@example.org"));
+ }
+
+ @Test
+ public void t04_updateContact() {
+ if (serverName == null) {
+ return;
+ }
+ _nc.putContact(ADDRESSBOOK, CONTACT, vCard("Jane Renamed"));
+
+ assertTrue(_nc.getContact(ADDRESSBOOK, CONTACT).getData().contains("FN:Jane Renamed"));
+ assertEquals("Updating must not create a second contact", 1, _nc.getContacts(ADDRESSBOOK).size());
+ }
+
+ @Test
+ public void t05_deleteContact() {
+ if (serverName == null) {
+ return;
+ }
+ _nc.deleteContact(ADDRESSBOOK, CONTACT);
+
+ assertNull(_nc.getContact(ADDRESSBOOK, CONTACT));
+ assertTrue(_nc.getContacts(ADDRESSBOOK).isEmpty());
+ }
+
+ @Test
+ public void t06_deleteAddressBook() {
+ if (serverName == null) {
+ return;
+ }
+ _nc.deleteAddressBook(ADDRESSBOOK);
+
+ assertTrue("The deleted address book must be gone from the listing",
+ _nc.listAddressBooks().stream().noneMatch(b -> ADDRESSBOOK.equals(b.getName())));
+ }
+
+ @Test
+ public void t07_rejectsNameWithPathSeparator() {
+ if (serverName == null) {
+ return;
+ }
+ try {
+ _nc.getContacts("../other");
+ fail("Expected a path separator in an address book name to be rejected");
+ } catch (IllegalArgumentException expected) {
+ // expected
+ }
+ }
+}
diff --git a/src/test/java/org/aarboard/nextcloud/api/TestCalendars.java b/src/test/java/org/aarboard/nextcloud/api/TestCalendars.java
new file mode 100644
index 0000000..d9ba50b
--- /dev/null
+++ b/src/test/java/org/aarboard/nextcloud/api/TestCalendars.java
@@ -0,0 +1,274 @@
+/*
+ * Copyright (C) 2026 a.schild
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.aarboard.nextcloud.api;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.List;
+import org.aarboard.nextcloud.api.calendar.Calendar;
+import org.aarboard.nextcloud.api.calendar.CalendarEntry;
+import org.aarboard.nextcloud.api.exception.NextcloudApiException;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+import org.junit.FixMethodOrder;
+import org.junit.Test;
+import org.junit.runners.MethodSorters;
+
+/**
+ * Integration tests for the CalDAV support (issue #59).
+ *
+ * @author a.schild
+ */
+@FixMethodOrder(MethodSorters.NAME_ASCENDING)
+public class TestCalendars {
+
+ private static final String CALENDAR = "api-test-calendar";
+ private static final String DISPLAY_NAME = "API test calendar";
+ private static final String ENTRY = "api-test-event-1.ics";
+ private static final String RECURRING_ENTRY = "api-test-event-recurring.ics";
+
+ /** The event starts here, so a range around it must match and one after it must not. */
+ private static final Instant EVENT_START = Instant.parse("2026-08-12T09:00:00Z");
+
+ private static String serverName = null;
+ private static NextcloudConnector _nc = null;
+
+ private static String iCalendar(String summary) {
+ return "BEGIN:VCALENDAR\r\n"
+ + "VERSION:2.0\r\n"
+ + "PRODID:-//nextcloud-java-api//integration test//EN\r\n"
+ + "BEGIN:VEVENT\r\n"
+ + "UID:api-test-event-1\r\n"
+ + "DTSTAMP:20260811T080000Z\r\n"
+ + "DTSTART:20260812T090000Z\r\n"
+ + "DTEND:20260812T100000Z\r\n"
+ + "SUMMARY:" + summary + "\r\n"
+ + "END:VEVENT\r\n"
+ + "END:VCALENDAR\r\n";
+ }
+
+ /** A daily event with exactly three occurrences, starting at EVENT_START. */
+ private static String recurringICalendar() {
+ return "BEGIN:VCALENDAR\r\n"
+ + "VERSION:2.0\r\n"
+ + "PRODID:-//nextcloud-java-api//integration test//EN\r\n"
+ + "BEGIN:VEVENT\r\n"
+ + "UID:api-test-event-recurring\r\n"
+ + "DTSTAMP:20260811T080000Z\r\n"
+ + "DTSTART:20260812T090000Z\r\n"
+ + "DTEND:20260812T100000Z\r\n"
+ + "RRULE:FREQ=DAILY;COUNT=3\r\n"
+ + "SUMMARY:API test recurring event\r\n"
+ + "END:VEVENT\r\n"
+ + "END:VCALENDAR\r\n";
+ }
+
+ @BeforeClass
+ public static void setUp() {
+ TestHelper th = new TestHelper();
+ serverName = th.getServerName();
+ if (serverName != null) {
+ _nc = new NextcloudConnector(serverName, th.getServerPort() == 443, th.getServerPort(),
+ th.getUserName(), th.getPassword());
+ _nc.createCalendar(CALENDAR, DISPLAY_NAME, "#FF0000");
+ }
+ }
+
+ @AfterClass
+ public static void tearDown() throws Exception {
+ if (_nc != null) {
+ try {
+ _nc.deleteCalendar(CALENDAR);
+ } catch (NextcloudApiException e) {
+ // the test that deletes it may already have run
+ }
+ _nc.close();
+ }
+ }
+
+ @Test
+ public void t01_listCalendars() {
+ if (serverName == null) {
+ return;
+ }
+ List calendars = _nc.listCalendars();
+ assertFalse("The user should have at least the calendar we created", calendars.isEmpty());
+
+ Calendar created = calendars.stream()
+ .filter(c -> CALENDAR.equals(c.getName()))
+ .findFirst().orElse(null);
+ assertNotNull("Created calendar not found in the listing", created);
+ assertEquals(DISPLAY_NAME, created.getDisplayName());
+ assertNotNull("Calendar should expose a ctag", created.getCtag());
+ }
+
+ @Test
+ public void t02_putAndGetEntry() {
+ if (serverName == null) {
+ return;
+ }
+ _nc.putCalendarEntry(CALENDAR, ENTRY, iCalendar("API test event"));
+
+ CalendarEntry entry = _nc.getCalendarEntry(CALENDAR, ENTRY);
+ assertNotNull(entry);
+ assertEquals(ENTRY, entry.getName());
+ assertNotNull("A stored entry must have an etag", entry.getEtag());
+ assertTrue(entry.getData().contains("SUMMARY:API test event"));
+ assertTrue(entry.getData().contains("UID:api-test-event-1"));
+ }
+
+ @Test
+ public void t03_getEntries() {
+ if (serverName == null) {
+ return;
+ }
+ List entries = _nc.getCalendarEntries(CALENDAR);
+ assertEquals(1, entries.size());
+ assertEquals(ENTRY, entries.get(0).getName());
+ assertTrue(entries.get(0).getData().contains("UID:api-test-event-1"));
+ }
+
+ @Test
+ public void t04_getEntriesInRange() {
+ if (serverName == null) {
+ return;
+ }
+ List hit = _nc.getCalendarEntriesInRange(CALENDAR,
+ EVENT_START.minus(1, ChronoUnit.DAYS), EVENT_START.plus(1, ChronoUnit.DAYS));
+ assertEquals("The event should be in a range around it", 1, hit.size());
+ assertTrue(hit.get(0).getData().contains("UID:api-test-event-1"));
+
+ List miss = _nc.getCalendarEntriesInRange(CALENDAR,
+ EVENT_START.plus(30, ChronoUnit.DAYS), EVENT_START.plus(60, ChronoUnit.DAYS));
+ assertTrue("No event should be in a range after it", miss.isEmpty());
+ }
+
+ /**
+ * A daily event repeating three times must come back as one stored resource
+ * when recurrences are not expanded, and as three occurrences when they are.
+ */
+ @Test
+ public void t05_expandRecurringEvent() {
+ if (serverName == null) {
+ return;
+ }
+ _nc.putCalendarEntry(CALENDAR, RECURRING_ENTRY, recurringICalendar());
+ try {
+ Instant from = EVENT_START.minus(1, ChronoUnit.DAYS);
+ Instant to = EVENT_START.plus(10, ChronoUnit.DAYS);
+
+ List stored = _nc.getCalendarEntriesInRange(CALENDAR, from, to, false);
+ CalendarEntry storedRecurring = stored.stream()
+ .filter(e -> RECURRING_ENTRY.equals(e.getName()))
+ .findFirst().orElse(null);
+ assertNotNull("The recurring event should match the range", storedRecurring);
+ assertTrue("Unexpanded, the recurrence rule must still be there",
+ storedRecurring.getData().contains("RRULE:"));
+ assertEquals("Unexpanded, the event is a single resource",
+ 1, countOccurrences(storedRecurring.getData(), "BEGIN:VEVENT"));
+
+ List expanded = _nc.getCalendarEntriesInRange(CALENDAR, from, to, true);
+ CalendarEntry expandedRecurring = expanded.stream()
+ .filter(e -> RECURRING_ENTRY.equals(e.getName()))
+ .findFirst().orElse(null);
+ assertNotNull("The recurring event should also match when expanding", expandedRecurring);
+ assertEquals("Expanded, each occurrence is its own VEVENT",
+ 3, countOccurrences(expandedRecurring.getData(), "BEGIN:VEVENT"));
+ assertTrue("Expanded occurrences carry a RECURRENCE-ID",
+ expandedRecurring.getData().contains("RECURRENCE-ID"));
+ assertFalse("Expanded, the recurrence rule is resolved away",
+ expandedRecurring.getData().contains("RRULE:"));
+ } finally {
+ _nc.deleteCalendarEntry(CALENDAR, RECURRING_ENTRY);
+ }
+ }
+
+ private static int countOccurrences(String haystack, String needle) {
+ int count = 0;
+ int index = haystack.indexOf(needle);
+ while (index >= 0) {
+ count++;
+ index = haystack.indexOf(needle, index + needle.length());
+ }
+ return count;
+ }
+
+ @Test
+ public void t06_updateWithEtagPrecondition() {
+ if (serverName == null) {
+ return;
+ }
+ CalendarEntry entry = _nc.getCalendarEntry(CALENDAR, ENTRY);
+ String etag = entry.getEtag();
+
+ // Updating with the current etag succeeds
+ _nc.putCalendarEntry(CALENDAR, ENTRY, iCalendar("Updated summary"), etag);
+ assertTrue(_nc.getCalendarEntry(CALENDAR, ENTRY).getData().contains("SUMMARY:Updated summary"));
+
+ // Re-using the now stale etag must be refused instead of overwriting
+ try {
+ _nc.putCalendarEntry(CALENDAR, ENTRY, iCalendar("Should not be stored"), etag);
+ fail("Expected the stale If-Match precondition to fail");
+ } catch (NextcloudApiException expected) {
+ // expected
+ }
+ assertTrue("The refused update must not have been stored",
+ _nc.getCalendarEntry(CALENDAR, ENTRY).getData().contains("SUMMARY:Updated summary"));
+ }
+
+ @Test
+ public void t07_deleteEntry() {
+ if (serverName == null) {
+ return;
+ }
+ _nc.deleteCalendarEntry(CALENDAR, ENTRY);
+
+ assertNull(_nc.getCalendarEntry(CALENDAR, ENTRY));
+ assertTrue(_nc.getCalendarEntries(CALENDAR).isEmpty());
+ }
+
+ @Test
+ public void t08_deleteCalendar() {
+ if (serverName == null) {
+ return;
+ }
+ _nc.deleteCalendar(CALENDAR);
+
+ assertTrue("The deleted calendar must be gone from the listing",
+ _nc.listCalendars().stream().noneMatch(c -> CALENDAR.equals(c.getName())));
+ }
+
+ @Test
+ public void t09_rejectsNameWithPathSeparator() {
+ if (serverName == null) {
+ return;
+ }
+ try {
+ _nc.getCalendarEntries("../../etc");
+ fail("Expected a path separator in a calendar name to be rejected");
+ } catch (IllegalArgumentException expected) {
+ // expected
+ }
+ }
+}
diff --git a/src/test/java/org/aarboard/nextcloud/api/dav/MultistatusParserTest.java b/src/test/java/org/aarboard/nextcloud/api/dav/MultistatusParserTest.java
new file mode 100644
index 0000000..f7cafd1
--- /dev/null
+++ b/src/test/java/org/aarboard/nextcloud/api/dav/MultistatusParserTest.java
@@ -0,0 +1,190 @@
+/*
+ * Copyright (C) 2026 a.schild
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+package org.aarboard.nextcloud.api.dav;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import org.aarboard.nextcloud.api.calendar.CalendarEntry;
+import org.aarboard.nextcloud.api.contacts.Contact;
+import org.aarboard.nextcloud.api.exception.NextcloudApiException;
+import org.junit.Test;
+
+/**
+ * Unit tests for the CalDAV/CardDAV multistatus reader. These need no server.
+ *
+ * @author a.schild
+ */
+public class MultistatusParserTest {
+
+ private static InputStream stream(String xml) {
+ return new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8));
+ }
+
+ @Test
+ public void testParsesCalendarMultistatus() {
+ String xml = ""
+ + ""
+ + ""
+ + "/remote.php/dav/calendars/user/personal/event1.ics"
+ + ""
+ + ""abc123""
+ + "BEGIN:VCALENDAR\nUID:event1\nEND:VCALENDAR"
+ + "HTTP/1.1 200 OK"
+ + ""
+ + ""
+ + "/remote.php/dav/calendars/user/personal/event2.ics"
+ + ""
+ + "W/"def456""
+ + "BEGIN:VCALENDAR\nUID:event2\nEND:VCALENDAR"
+ + "HTTP/1.1 200 OK"
+ + ""
+ + "";
+
+ List entries = MultistatusParser.parse(stream(xml), "calendar-data",
+ CalendarEntry::new);
+
+ assertEquals(2, entries.size());
+ assertEquals("/remote.php/dav/calendars/user/personal/event1.ics", entries.get(0).getHref());
+ assertEquals("event1.ics", entries.get(0).getName());
+ assertEquals("abc123", entries.get(0).getEtag());
+ assertTrue(entries.get(0).getData().contains("UID:event1"));
+ // A weak etag keeps its value but loses the W/ marker and the quotes
+ assertEquals("def456", entries.get(1).getEtag());
+ }
+
+ @Test
+ public void testParsesAddressbookMultistatus() {
+ String xml = ""
+ + ""
+ + ""
+ + "/remote.php/dav/addressbooks/users/user/contacts/c1.vcf"
+ + ""
+ + ""e1""
+ + "BEGIN:VCARD\nFN:Jane Doe\nEND:VCARD"
+ + "HTTP/1.1 200 OK"
+ + ""
+ + "";
+
+ List contacts = MultistatusParser.parse(stream(xml), "address-data", Contact::new);
+
+ assertEquals(1, contacts.size());
+ assertEquals("c1.vcf", contacts.get(0).getName());
+ assertTrue(contacts.get(0).getData().contains("FN:Jane Doe"));
+ }
+
+ /**
+ * A multiget for a href that no longer exists yields a 404 propstat without
+ * a payload. Such a response carries no data for the caller, so it must not
+ * turn into an entry with a null payload.
+ */
+ @Test
+ public void testSkipsResponsesWithoutPayload() {
+ String xml = ""
+ + ""
+ + ""
+ + "/remote.php/dav/calendars/user/personal/gone.ics"
+ + ""
+ + "HTTP/1.1 404 Not Found"
+ + ""
+ + ""
+ + "/remote.php/dav/calendars/user/personal/here.ics"
+ + ""
+ + ""e""
+ + "BEGIN:VCALENDAR\nEND:VCALENDAR"
+ + "HTTP/1.1 200 OK"
+ + ""
+ + "";
+
+ List entries = MultistatusParser.parse(stream(xml), "calendar-data",
+ CalendarEntry::new);
+
+ assertEquals(1, entries.size());
+ assertEquals("here.ics", entries.get(0).getName());
+ }
+
+ @Test
+ public void testEmptyMultistatusYieldsNoEntries() {
+ String xml = "";
+
+ assertTrue(MultistatusParser.parse(stream(xml), "calendar-data", CalendarEntry::new).isEmpty());
+ }
+
+ @Test
+ public void testParsesHrefs() {
+ String xml = ""
+ + ""
+ + "/dav/calendars/user/personal/"
+ + "/dav/calendars/user/personal/a.ics"
+ + "";
+
+ List hrefs = MultistatusParser.parseHrefs(stream(xml));
+
+ assertEquals(2, hrefs.size());
+ assertEquals("/dav/calendars/user/personal/", hrefs.get(0));
+ assertEquals("/dav/calendars/user/personal/a.ics", hrefs.get(1));
+ }
+
+ /**
+ * The reader must not resolve external entities, so that a malicious or
+ * compromised server cannot use a DAV response to read local files.
+ */
+ @Test
+ public void testRejectsExternalEntities() {
+ String xml = ""
+ + "]>"
+ + ""
+ + "/a.ics"
+ + "&xxe;"
+ + "";
+
+ try {
+ List entries = MultistatusParser.parse(stream(xml), "calendar-data",
+ CalendarEntry::new);
+ // Some StAX implementations report the disallowed DTD by failing, others
+ // by simply not expanding the entity. Never leak the file content.
+ for (CalendarEntry entry : entries) {
+ assertTrue("External entity was expanded",
+ entry.getData() == null || !entry.getData().contains("root:"));
+ }
+ } catch (NextcloudApiException expected) {
+ // DTD support is disabled, which is the outcome we want
+ }
+ }
+
+ @Test
+ public void testEntryNameOfNullHrefIsNull() {
+ CalendarEntry entry = new CalendarEntry();
+ assertNull(entry.getName());
+ }
+
+ @Test
+ public void testMalformedXmlIsWrapped() {
+ try {
+ MultistatusParser.parse(stream(""), "calendar-data", CalendarEntry::new);
+ fail("Expected a NextcloudApiException for malformed XML");
+ } catch (NextcloudApiException expected) {
+ // expected
+ }
+ }
+}