From d0e6396cae43c17c6b0a51ddab83e9519908b055 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Schild?= Date: Tue, 11 Aug 2026 10:28:20 +0200 Subject: [PATCH 1/4] Add calendar (CalDAV) and contacts (CardDAV) support (issue #59) Calendars and address books are reachable below remote.php/dav/, but the library had no way to talk to them: the path resolver carried CALDAV and VCARD types that nothing used. Both protocols are structurally identical - a per-user home collection of collections holding text resources - so the shared plumbing lives in ADavCollectionHandler and the two connectors only supply the namespaces and element names that differ. Entries are exchanged as raw iCalendar/vCard text. Parsing them would mean taking on ical4j and ez-vcard as compile dependencies and tying our public API to their model classes, so that is left to the caller. Sardine models plain WebDAV properties and drops the calendar-data and address-data payloads, so the REPORT responses are read with a small StAX reader, hardened against XXE like XMLAnswerParser. Listing an entire collection is a PROPFIND to enumerate it plus one multiget REPORT for the payloads, rather than one GET per entry. DAV paths use the internal user id from the provisioning API rather than the login name, which the 2021 discussion on the issue flagged as a problem when the two differ. Co-Authored-By: Claude Opus 5 --- Changelog.md | 18 + README.md | 36 ++ pom.xml | 2 +- .../nextcloud/api/NextcloudConnector.java | 216 ++++++++ .../nextcloud/api/calendar/Calendar.java | 55 ++ .../nextcloud/api/calendar/CalendarEntry.java | 33 ++ .../nextcloud/api/calendar/Calendars.java | 235 ++++++++ .../nextcloud/api/contacts/AddressBook.java | 28 + .../nextcloud/api/contacts/AddressBooks.java | 197 +++++++ .../nextcloud/api/contacts/Contact.java | 31 ++ .../api/dav/ADavCollectionHandler.java | 513 ++++++++++++++++++ .../nextcloud/api/dav/DavCollection.java | 97 ++++ .../aarboard/nextcloud/api/dav/DavEntry.java | 91 ++++ .../aarboard/nextcloud/api/dav/DavMethod.java | 42 ++ .../nextcloud/api/dav/MultistatusParser.java | 171 ++++++ .../api/exception/NextcloudApiException.java | 9 + .../nextcloud/api/systemtags/SystemTags.java | 35 -- .../nextcloud/api/webdav/AWebdavHandler.java | 83 +++ .../nextcloud/api/TestAddressBooks.java | 169 ++++++ .../aarboard/nextcloud/api/TestCalendars.java | 207 +++++++ .../api/dav/MultistatusParserTest.java | 190 +++++++ 21 files changed, 2422 insertions(+), 36 deletions(-) create mode 100644 src/main/java/org/aarboard/nextcloud/api/calendar/Calendar.java create mode 100644 src/main/java/org/aarboard/nextcloud/api/calendar/CalendarEntry.java create mode 100644 src/main/java/org/aarboard/nextcloud/api/calendar/Calendars.java create mode 100644 src/main/java/org/aarboard/nextcloud/api/contacts/AddressBook.java create mode 100644 src/main/java/org/aarboard/nextcloud/api/contacts/AddressBooks.java create mode 100644 src/main/java/org/aarboard/nextcloud/api/contacts/Contact.java create mode 100644 src/main/java/org/aarboard/nextcloud/api/dav/ADavCollectionHandler.java create mode 100644 src/main/java/org/aarboard/nextcloud/api/dav/DavCollection.java create mode 100644 src/main/java/org/aarboard/nextcloud/api/dav/DavEntry.java create mode 100644 src/main/java/org/aarboard/nextcloud/api/dav/DavMethod.java create mode 100644 src/main/java/org/aarboard/nextcloud/api/dav/MultistatusParser.java create mode 100644 src/test/java/org/aarboard/nextcloud/api/TestAddressBooks.java create mode 100644 src/test/java/org/aarboard/nextcloud/api/TestCalendars.java create mode 100644 src/test/java/org/aarboard/nextcloud/api/dav/MultistatusParserTest.java diff --git a/Changelog.md b/Changelog.md index c94f4df..e1e2587 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,5 +1,23 @@ # Changelog for nextcloud api +## Version 14.3.0 (unreleased) +- Add calendar (CalDAV) and contacts (CardDAV) support (issue #59): + - Calendars: list calendars, read all entries of a calendar, query the events + overlapping a time range, read a single entry, create/replace and delete + entries, and create and delete calendars + - Address books: list address books, read all contacts, read a single + contact, create/replace and delete contacts, and create and delete address + books + - Entries are exchanged as raw iCalendar/vCard documents, so the library + gains no iCalendar or vCard dependency and callers stay free to parse them + with the library of their choice (e.g. ical4j or ez-vcard) + - Updates accept the etag of the entry they are based on, so a concurrent + change is reported instead of silently overwritten + - DAV paths use the internal user id rather than the login name, so they are + also correct when the two differ (external user backends) +- `NextcloudApiException` gained a `(String message, Throwable cause)` + constructor so failures can be reported with both context and cause + ## Version 14.2.1 - 2026-08-11 - Updated dependencies: diff --git a/README.md b/README.md index f514f1d..758715a 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Java api library to access nextcloud features from java applications - Management of groups - Folder management (Without access control) - List shares and create new file shares (No way to delete/update shares atm.) +- Calendars (CalDAV) and contacts (CardDAV), see below - Tested against nextCloud 31.0.0 server version, but should also work with older nextCloud and ownCloud systems ## Usage @@ -29,6 +30,41 @@ Java api library to access nextcloud features from java applications - Create a NextcloudConnector instance and provide your server settings and authentification - Now you can use the methods exposed to access your nextcloud instance +## Calendars and contacts + +Calendar entries and contacts are exchanged as raw iCalendar/vCard documents. +The library does not parse them, so it needs no iCalendar or vCard dependency +and you stay free to use the parser of your choice (for example +[ical4j](https://github.com/ical4j/ical4j) or +[ez-vcard](https://github.com/mangstadt/ez-vcard)). + +```java +try (NextcloudConnector nc = new NextcloudConnector("cloud.example.org", true, 443, "user", "password")) { + for (Calendar calendar : nc.listCalendars()) { + System.out.println(calendar.getName() + " -> " + calendar.getDisplayName()); + } + + // All entries of a calendar, or only the events in a time range + List all = nc.getCalendarEntries("personal"); + List thisWeek = nc.getCalendarEntriesInRange("personal", + Instant.now(), Instant.now().plus(7, ChronoUnit.DAYS)); + + String ics = thisWeek.get(0).getData(); // the iCalendar document + + // Store an entry, and update it only while it still carries this etag + String etag = nc.putCalendarEntry("personal", "my-event.ics", ics); + nc.putCalendarEntry("personal", "my-event.ics", changedIcs, etag); + + nc.deleteCalendarEntry("personal", "my-event.ics"); +} +``` + +Contacts work the same way via `listAddressBooks()`, `getContacts(book)`, +`getContact(book, name)`, `putContact(...)` and `deleteContact(...)`. +Calendars and address books can also be created and deleted with +`createCalendar(name, displayName, colour)` / `deleteCalendar(name)` and +`createAddressBook(name, displayName, description)` / `deleteAddressBook(name)`. + ## When you wish to contribute to the project [Infos for contributors](./README.developers.md) diff --git a/pom.xml b/pom.xml index 854fd3c..2dde0c0 100644 --- a/pom.xml +++ b/pom.xml @@ -3,7 +3,7 @@ 4.0.0 org.aarboard.nextcloud nextcloud-api - 14.2.2-SNAPSHOT + 14.3.0-SNAPSHOT jar diff --git a/src/main/java/org/aarboard/nextcloud/api/NextcloudConnector.java b/src/main/java/org/aarboard/nextcloud/api/NextcloudConnector.java index 25a9df9..786edfb 100644 --- a/src/main/java/org/aarboard/nextcloud/api/NextcloudConnector.java +++ b/src/main/java/org/aarboard/nextcloud/api/NextcloudConnector.java @@ -35,6 +35,12 @@ import org.aarboard.nextcloud.api.filesharing.RemoteShare; import org.aarboard.nextcloud.api.filesharing.SingleShareXMLAnswer; import org.aarboard.nextcloud.api.groupfolders.GroupFolderInfo; +import org.aarboard.nextcloud.api.calendar.Calendar; +import org.aarboard.nextcloud.api.calendar.CalendarEntry; +import org.aarboard.nextcloud.api.calendar.Calendars; +import org.aarboard.nextcloud.api.contacts.AddressBook; +import org.aarboard.nextcloud.api.contacts.AddressBooks; +import org.aarboard.nextcloud.api.contacts.Contact; import org.aarboard.nextcloud.api.groupfolders.GroupFolders; import org.aarboard.nextcloud.api.provisioning.*; import org.aarboard.nextcloud.api.systemtags.SystemTags; @@ -66,6 +72,8 @@ public class NextcloudConnector implements AutoCloseable { private final Files fl; private final GroupFolders gf; private final SystemTags st; + private final Calendars cal; + private final AddressBooks ab; /** * @@ -130,6 +138,8 @@ public NextcloudConnector(String originalServiceUrl, AuthenticationConfig authen fl = new Files(this.serverConfig); gf = new GroupFolders(this.serverConfig); st = new SystemTags(this.serverConfig); + cal = new Calendars(this.serverConfig); + ab = new AddressBooks(this.serverConfig); OPEN_INSTANCES.incrementAndGet(); } catch (MalformedURLException e) { @@ -154,6 +164,8 @@ public NextcloudConnector(String serverName, boolean useHTTPS, int port, fl = new Files(this.serverConfig); gf = new GroupFolders(this.serverConfig); st = new SystemTags(this.serverConfig); + cal = new Calendars(this.serverConfig); + ab = new AddressBooks(this.serverConfig); OPEN_INSTANCES.incrementAndGet(); } @@ -280,6 +292,210 @@ public boolean declinePendingRemoteShare(int remoteShareId) { return fc.declinePendingRemoteShare(remoteShareId); } + /** + * Lists the calendars of the authenticated user. + * + * @return the user's calendars + * @since 14.3 + */ + public java.util.List listCalendars() { + return cal.listCalendars(); + } + + /** + * Fetches every entry of a calendar as a raw iCalendar document. + * + * @param calendarName name of the calendar + * @return all entries of the calendar + * @since 14.3 + */ + public java.util.List getCalendarEntries(String calendarName) { + return cal.getCalendarEntries(calendarName); + } + + /** + * Fetches the events of a calendar overlapping a time range. + * + * @param calendarName name of the calendar + * @param from start of the range, inclusive + * @param to end of the range, exclusive + * @return the matching entries + * @since 14.3 + */ + public java.util.List getCalendarEntriesInRange(String calendarName, + java.time.Instant from, java.time.Instant to) { + return cal.getCalendarEntriesInRange(calendarName, from, to); + } + + /** + * 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..7880f54 --- /dev/null +++ b/src/main/java/org/aarboard/nextcloud/api/calendar/Calendars.java @@ -0,0 +1,235 @@ +/* + * 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, not expanded into one entry per occurrence. + * + * @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) { + 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 body = "" + + "" + + "" + + "" + + "" + + "" + + ""; + + 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..1bf6952 --- /dev/null +++ b/src/main/java/org/aarboard/nextcloud/api/dav/ADavCollectionHandler.java @@ -0,0 +1,513 @@ +/* + * 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_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); + + 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..5358cdf --- /dev/null +++ b/src/test/java/org/aarboard/nextcloud/api/TestCalendars.java @@ -0,0 +1,207 @@ +/* + * 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"; + + /** 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"; + } + + @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()); + } + + @Test + public void t05_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 t06_deleteEntry() { + if (serverName == null) { + return; + } + _nc.deleteCalendarEntry(CALENDAR, ENTRY); + + assertNull(_nc.getCalendarEntry(CALENDAR, ENTRY)); + assertTrue(_nc.getCalendarEntries(CALENDAR).isEmpty()); + } + + @Test + public void t07_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 t08_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 + } + } +} From 77b8b668ac3272b5e2733e56249096d77693bd0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Schild?= Date: Tue, 11 Aug 2026 10:36:05 +0200 Subject: [PATCH 2/4] Request resourcetype explicitly when listing DAV collections A PROPFIND naming specific properties returns only those, so resourcetype was never sent back, DavResource.getResourceTypes() was empty and the filter dropped every calendar and address book from the listing. Co-Authored-By: Claude Opus 5 --- .../aarboard/nextcloud/api/dav/ADavCollectionHandler.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/org/aarboard/nextcloud/api/dav/ADavCollectionHandler.java b/src/main/java/org/aarboard/nextcloud/api/dav/ADavCollectionHandler.java index 1bf6952..8b70877 100644 --- a/src/main/java/org/aarboard/nextcloud/api/dav/ADavCollectionHandler.java +++ b/src/main/java/org/aarboard/nextcloud/api/dav/ADavCollectionHandler.java @@ -64,6 +64,7 @@ public abstract class ADavCollectionHandler extends AWebdavHandler { 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 = @@ -147,6 +148,10 @@ protected List listCollections(Set extraProp 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(); From 19e13cf5b9c4d907649e4f96aa33a7d21cd0877e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Schild?= Date: Tue, 11 Aug 2026 10:50:31 +0200 Subject: [PATCH 3/4] Let the caller ask for expanded recurrences in the time-range query getCalendarEntriesInRange gained a boolean overload that adds the CalDAV expand element to the requested calendar-data, so the server resolves a recurring event into one VEVENT per occurrence in the range instead of returning the stored event with its RRULE. The expanded form is a computed view of that range and not the stored resource, so the javadoc warns against writing it back. Co-Authored-By: Claude Opus 5 --- Changelog.md | 3 + README.md | 6 ++ .../nextcloud/api/NextcloudConnector.java | 22 +++++- .../nextcloud/api/calendar/Calendars.java | 46 +++++++++++- .../aarboard/nextcloud/api/TestCalendars.java | 75 ++++++++++++++++++- 5 files changed, 143 insertions(+), 9 deletions(-) diff --git a/Changelog.md b/Changelog.md index e1e2587..94cb0a7 100644 --- a/Changelog.md +++ b/Changelog.md @@ -5,6 +5,9 @@ - Calendars: list calendars, read all entries of a calendar, query the events overlapping a time range, read a single entry, create/replace and delete entries, and create and delete calendars + - The time-range query optionally lets the server expand recurring events + into one occurrence per repetition (`getCalendarEntriesInRange(..., true)`) + instead of returning the stored event with its recurrence rule - Address books: list address books, read all contacts, read a single contact, create/replace and delete contacts, and create and delete address books diff --git a/README.md b/README.md index 758715a..838f9ed 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,12 @@ try (NextcloudConnector nc = new NextcloudConnector("cloud.example.org", true, 4 String ics = thisWeek.get(0).getData(); // the iCalendar document + // Pass true to have the server expand recurring events into one VEVENT per + // occurrence in the range, instead of one event carrying its RRULE. The + // expanded result is a computed view of that range, so don't write it back. + List occurrences = nc.getCalendarEntriesInRange("personal", + Instant.now(), Instant.now().plus(7, ChronoUnit.DAYS), true); + // Store an entry, and update it only while it still carries this etag String etag = nc.putCalendarEntry("personal", "my-event.ics", ics); nc.putCalendarEntry("personal", "my-event.ics", changedIcs, etag); diff --git a/src/main/java/org/aarboard/nextcloud/api/NextcloudConnector.java b/src/main/java/org/aarboard/nextcloud/api/NextcloudConnector.java index 786edfb..0974cdc 100644 --- a/src/main/java/org/aarboard/nextcloud/api/NextcloudConnector.java +++ b/src/main/java/org/aarboard/nextcloud/api/NextcloudConnector.java @@ -314,7 +314,8 @@ public java.util.List getCalendarEntries(String calendarName) { } /** - * Fetches the events of a calendar overlapping a time range. + * Fetches the events of a calendar overlapping a time range. Recurring + * events are returned once, as stored, carrying their recurrence rule. * * @param calendarName name of the calendar * @param from start of the range, inclusive @@ -327,6 +328,25 @@ public java.util.List getCalendarEntriesInRange(String calendarNa 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. * diff --git a/src/main/java/org/aarboard/nextcloud/api/calendar/Calendars.java b/src/main/java/org/aarboard/nextcloud/api/calendar/Calendars.java index 7880f54..132188a 100644 --- a/src/main/java/org/aarboard/nextcloud/api/calendar/Calendars.java +++ b/src/main/java/org/aarboard/nextcloud/api/calendar/Calendars.java @@ -118,7 +118,9 @@ public List getCalendarEntries(String calendarName) { /** * 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, not expanded into one entry per occurrence. + * 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 @@ -126,18 +128,54 @@ public List getCalendarEntries(String calendarName) { * @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 + "" + "" - + "" + + "" + "" + ""; diff --git a/src/test/java/org/aarboard/nextcloud/api/TestCalendars.java b/src/test/java/org/aarboard/nextcloud/api/TestCalendars.java index 5358cdf..d9ba50b 100644 --- a/src/test/java/org/aarboard/nextcloud/api/TestCalendars.java +++ b/src/test/java/org/aarboard/nextcloud/api/TestCalendars.java @@ -46,6 +46,7 @@ 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"); @@ -67,6 +68,22 @@ private static String iCalendar(String summary) { + "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(); @@ -147,8 +164,58 @@ public void t04_getEntriesInRange() { 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 t05_updateWithEtagPrecondition() { + public void t06_updateWithEtagPrecondition() { if (serverName == null) { return; } @@ -171,7 +238,7 @@ public void t05_updateWithEtagPrecondition() { } @Test - public void t06_deleteEntry() { + public void t07_deleteEntry() { if (serverName == null) { return; } @@ -182,7 +249,7 @@ public void t06_deleteEntry() { } @Test - public void t07_deleteCalendar() { + public void t08_deleteCalendar() { if (serverName == null) { return; } @@ -193,7 +260,7 @@ public void t07_deleteCalendar() { } @Test - public void t08_rejectsNameWithPathSeparator() { + public void t09_rejectsNameWithPathSeparator() { if (serverName == null) { return; } From 732a2a5b6d9128790b1c599d171289e657cb41f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Schild?= Date: Tue, 11 Aug 2026 10:57:53 +0200 Subject: [PATCH 4/4] README: document how to consume a pre-release snapshot Co-Authored-By: Claude Opus 5 --- README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/README.md b/README.md index 838f9ed..5c06314 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,29 @@ Java api library to access nextcloud features from java applications ``` +### Trying a pre-release snapshot + +Unreleased work is published as a `-SNAPSHOT` to the Central Portal snapshot +repository so it can be tried before a release. Snapshots are overwritten by +later builds and Sonatype removes them after about 90 days, so don't depend on +one from a production build. + +```xml + + + central-snapshots + https://central.sonatype.com/repository/maven-snapshots/ + true + + + + + org.aarboard.nextcloud + nextcloud-api + 14.3.0-SNAPSHOT + +``` + - The 14.x versions require Java 11+,as the jakarta.xml binding requires Java 11+ - The 13.x versions are now using the jakarta.xml binding stuff, to prevent problems with Java 11+ No API changes have been made in v13, but at some places the XML stuff is exposed