+
@@ -409,6 +411,7 @@ final class Plugin
const clearEditor=()=>{
s("cp-event-id").value="";
["cp-title","cp-description","cp-location","cp-category","cp-occurrence-key"].forEach(k=>s(k).value="");
+ s("cp-visibility").value="public";
s("cp-occurrence-key-iso").value="";
s("cp-all-day").checked=false;
s("cp-repeat-type").value="none";
@@ -452,6 +455,7 @@ final class Plugin
const openDetailsForItem=(it)=>{
s("cp-details-title").value=it.title||"";
+ s("cp-details-visibility").value=(it.visibility||"public")==="private"?"Private":"Public";
s("cp-details-category").value=it.category||"";
s("cp-details-location").value=it.location||"";
s("cp-details-start").value=detailsDateTime(it.occurrence_start||it.start_datetime,!!it.all_day_event);
@@ -478,6 +482,7 @@ final class Plugin
s("cp-editor-title").textContent="Edit Event";
s("cp-event-id").value=itemId(source);
s("cp-title").value=source.title||"";
+ s("cp-visibility").value=source.visibility==="private"?"private":"public";
s("cp-description").value=source.description||"";
s("cp-location").value=source.location||"";
s("cp-category").value=source.category||"";
@@ -532,6 +537,7 @@ final class Plugin
ok:true,
payload:{
title:title,
+ visibility:s("cp-visibility").value==="private"?"private":"public",
description:s("cp-description").value,
location:s("cp-location").value,
category:s("cp-category").value,
@@ -1592,10 +1598,16 @@ HTML
$date = (string) ($request->get_param('date') ?: gmdate('Y-m-d'));
$futureOnlyRaw = (string) ($request->get_param('future_only') ?? '');
$futureOnly = in_array(strtolower($futureOnlyRaw), ['1', 'true', 'yes', 'on'], true);
- $items = $this->eventService->listPublicOccurrences($view, $date, $futureOnly);
+ $includePrivateDetails = $this->canWriteCalendar($request);
+ $items = $this->eventService->listPublicOccurrences($view, $date, $futureOnly, !$includePrivateDetails);
return [
'data' => $items,
- 'meta' => ['count' => count($items), 'view' => $view, 'future_only' => $futureOnly],
+ 'meta' => [
+ 'count' => count($items),
+ 'view' => $view,
+ 'future_only' => $futureOnly,
+ 'redacted_private' => !$includePrivateDetails,
+ ],
];
},
]
@@ -1608,7 +1620,7 @@ HTML
'methods' => 'GET',
'permission_callback' => '__return_true',
'callback' => function (): array {
- $items = $this->eventService->listSidebarUpcoming(14);
+ $items = $this->eventService->listSidebarUpcoming(14, true);
return [
'data' => $items,
'meta' => ['count' => count($items), 'window_days' => 14],
@@ -1840,13 +1852,19 @@ HTML
[
'methods' => 'GET',
'permission_callback' => '__return_true',
- 'callback' => function (): array {
+ 'callback' => function ($request): array|\WP_Error {
$settings = $this->settingsService->getAll();
+ $icsMode = (string) ($settings['ics_access_mode'] ?? 'public_read');
+ $includePrivateDetails = $this->canWriteCalendar($request);
+ if ($icsMode === 'authenticated_read' && !$includePrivateDetails) {
+ return $this->error('auth_required', 'authentication required', 401);
+ }
$calendarName = (string) ($settings['caldav_calendar_name'] ?? 'Public Calendar');
$ics = $this->icsService->buildCalendar(
$this->eventService->listEvents(),
fn(int $eventId): array => $this->eventService->getDeletedOccurrenceKeys($eventId),
- $calendarName
+ $calendarName,
+ !$includePrivateDetails
);
return ['data' => $ics];
},
@@ -1964,9 +1982,11 @@ HTML
private function serveIcsResponse(): void
{
$settings = $this->settingsService->getAll();
+ $icsMode = (string) ($settings['ics_access_mode'] ?? 'public_read');
+ $includePrivateDetails = $this->canWriteCalendar(null);
if (
- (string) ($settings['ics_access_mode'] ?? 'public_read') === 'authenticated_read'
- && $this->resolveCalDavUserForRequest(null) === null
+ $icsMode === 'authenticated_read'
+ && !$includePrivateDetails
) {
http_response_code(401);
header('Content-Type: application/json; charset=utf-8');
@@ -1978,7 +1998,8 @@ HTML
$ics = $this->icsService->buildCalendar(
$this->eventService->listEvents(),
fn(int $eventId): array => $this->eventService->getDeletedOccurrenceKeys($eventId),
- $calendarName
+ $calendarName,
+ !$includePrivateDetails
);
$etag = '"' . substr(sha1($ics), 0, 16) . '"';
$lastModified = gmdate('D, d M Y H:i:s') . ' GMT';
@@ -2010,7 +2031,18 @@ HTML
$resourcePrefix = $collection;
if ($method === 'HEAD') {
- if ($path === $root || $path === $root . '/' || $path === $calendarsRoot || $path === rtrim($calendarsRoot, '/') || $path === $collection || $path === rtrim($collection, '/')) {
+ if (
+ $path === $root
+ || $path === $root . '/'
+ || $path === $principalCollection
+ || $path === rtrim($principalCollection, '/')
+ || $path === $principal
+ || $path === rtrim($principal, '/')
+ || $path === $calendarsRoot
+ || $path === rtrim($calendarsRoot, '/')
+ || $path === $collection
+ || $path === rtrim($collection, '/')
+ ) {
header('Allow: OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, HEAD');
header('DAV: 1, calendar-access');
http_response_code(200);
@@ -2030,6 +2062,26 @@ HTML
}
}
+ if ($method === 'GET') {
+ if (
+ $path === $root
+ || $path === $root . '/'
+ || $path === $principalCollection
+ || $path === rtrim($principalCollection, '/')
+ || $path === $principal
+ || $path === rtrim($principal, '/')
+ || $path === $calendarsRoot
+ || $path === rtrim($calendarsRoot, '/')
+ || $path === $collection
+ || $path === rtrim($collection, '/')
+ ) {
+ header('Allow: OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, HEAD');
+ header('DAV: 1, calendar-access');
+ http_response_code(200);
+ return;
+ }
+ }
+
if ($method === 'OPTIONS') {
header('Allow: OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, HEAD');
header('DAV: 1, calendar-access');
@@ -2079,7 +2131,7 @@ HTML
return;
}
- if ($method === 'REPORT' && $path === $collection) {
+ if ($method === 'REPORT' && ($path === $collection || $path === rtrim($collection, '/'))) {
$body = (string) file_get_contents('php://input');
header('Content-Type: application/xml; charset=utf-8');
http_response_code(207);
@@ -2269,7 +2321,15 @@ HTML
$items = [];
if (str_contains($bodyLower, 'sync-collection')) {
- $items = $this->calDavService->multiget($resources);
+ $clientSyncToken = $this->extractSyncCollectionToken($xmlBody);
+ // If client token is already current, no changes should be emitted.
+ if ($clientSyncToken !== '' && $clientSyncToken === $syncToken) {
+ $items = [];
+ } else {
+ // Fallback implementation: emit current objects for initial/out-of-date tokens.
+ // Avoid emitting historical tombstones because many clients treat large 404 sets as transient failures.
+ $items = $this->calDavService->multiget($resources);
+ }
} elseif (str_contains($bodyLower, 'calendar-query')) {
$items = $this->calDavService->multiget($resources);
if (preg_match('/start\\s*=\\s*"([0-9TzZ]+)"/i', $xmlBody, $s) && preg_match('/end\\s*=\\s*"([0-9TzZ]+)"/i', $xmlBody, $e)) {
@@ -2324,6 +2384,9 @@ HTML
foreach ($rows as $row) {
$seed .= (string) ($row['resource'] ?? '') . '|' . (string) ($row['etag'] ?? '') . ';';
}
+ foreach ($this->calDavService->listDeletedResources(1000) as $deletedResource) {
+ $seed .= 'deleted:' . $deletedResource . ';';
+ }
return 'urn:calendar-plugin:sync:' . sha1($seed);
}
@@ -2353,6 +2416,15 @@ HTML
return $this->icalToIso((string) $m[1]);
}
+ private function extractSyncCollectionToken(string $xmlBody): string
+ {
+ if (!preg_match('/<[^>]*sync-token[^>]*>(.*?)<\\/[^>]*sync-token>/is', $xmlBody, $m)) {
+ return '';
+ }
+ $token = trim(html_entity_decode((string) $m[1], ENT_QUOTES | ENT_XML1, 'UTF-8'));
+ return $token;
+ }
+
private function unwrapServiceResult(array $result): array|\WP_Error
{
if (isset($result['error']) && is_array($result['error'])) {
diff --git a/package/calendar-plugin-0.1.15.manifest.sha256 b/package/calendar-plugin-0.1.15.manifest.sha256
index fb05654..99df439 100644
--- a/package/calendar-plugin-0.1.15.manifest.sha256
+++ b/package/calendar-plugin-0.1.15.manifest.sha256
@@ -1,20 +1,20 @@
-ca4806cd3e7827b9666e6929cbcae8f3e2eba87e499f7dd7008d8edf2fcc774e staging/calendar-plugin/calendar-plugin.php
-1daa5861c0d10258c0d16c5c88c1a18fb3c8df7b590f7facfe7c28f678643bdb staging/calendar-plugin/src/Contracts/AuthAdapterInterface.php
-25cff4b0fc2ee292b53c152edd083c6af66200a25eec28f8dfce37d126a8892d staging/calendar-plugin/src/Contracts/DatabaseAdapterInterface.php
-4f0f4caa5ac98499854336f5b74af55ce889653f3956e5df10910f869a23fdba staging/calendar-plugin/src/Contracts/HttpAdapterInterface.php
-15e8f58c7360d6cd0c76c945abfeb026f8278d40b330e99b67955eb2f85f5563 staging/calendar-plugin/src/Contracts/OptionsAdapterInterface.php
-47d55e845b8a696c55fa86f597bb2760b6e35d974b70f365593d38697ef398c8 staging/calendar-plugin/src/Domain/CalDavService.php
-4dc3337761c97aa550896fcc377aaab8338c598f39e489742dacbfd20e1a71b1 staging/calendar-plugin/src/Domain/EventService.php
-412a22ecd910535c7ace2549a86eacf08cc9cd824f1767e60116d8593355f57f staging/calendar-plugin/src/Domain/IcsService.php
-5f7fb1f8c00136ad2c4a2dc8b909dabf6494a194b09aa73557a4bf68d73f4ed2 staging/calendar-plugin/src/Domain/RecurrenceExpander.php
-20ee26671fd934f36e97606cfd2ec1d5101ac8064594989bc955bf805fae2502 staging/calendar-plugin/src/Domain/SettingsService.php
-9956790a5d62f5798f3ec15fc507fb04f33e01fd27969db09e91a6d1b41cd33f staging/calendar-plugin/src/Domain/UserService.php
-5aaac066919b60461bde2e96cbfb4de66a5a28e89d2a61b78f0e2d346f23395b staging/calendar-plugin/src/Infrastructure/ServiceContainer.php
-70334df8ca06c8fe61d81f24cb0d8f19285180f9950054bd86af0083adf8b4c8 staging/calendar-plugin/src/Infrastructure/WordPress/MigrationManager.php
-8e6c95e9e1c051606e66d95cf0bcf92b2ca087bc491f32ab4921e0898cf77b81 staging/calendar-plugin/src/Infrastructure/WordPress/WordPressAuthAdapter.php
-68c0ca15ad2c8b6363a2578b85f8daf0d3a094e612a120a2cdd2a2bfd8fe5e3c staging/calendar-plugin/src/Infrastructure/WordPress/WordPressDatabaseAdapter.php
-8da85db3c1e69c2c5f01aaa2f558aa8f0323446d8af0aec5b34d4607cd4afe1b staging/calendar-plugin/src/Infrastructure/WordPress/WordPressHttpAdapter.php
-cf9fddcecb07af2c03ad2c0e448be12a6b45dd936efc8bc1fd46a52b4af864ea staging/calendar-plugin/src/Infrastructure/WordPress/WordPressOptionsAdapter.php
-ffc7c3eef8f3ed0873b5f614765219925d07c24ffd6ffa7422a3d862a7342450 staging/calendar-plugin/src/Plugin.php
-4e6930c79d9ce1045be6863ddd7546e19f7ef7e839d41a41014a3efb3183eaf0 staging/calendar-plugin/src/bootstrap.php
-893c6df62beed87a981d372c473e5012d1b5d1c254d23b39cda44ae8a08cd16c staging/calendar-plugin/uninstall.php
+ca4806cd3e7827b9666e6929cbcae8f3e2eba87e499f7dd7008d8edf2fcc774e ./calendar-plugin.php
+1daa5861c0d10258c0d16c5c88c1a18fb3c8df7b590f7facfe7c28f678643bdb ./src/Contracts/AuthAdapterInterface.php
+25cff4b0fc2ee292b53c152edd083c6af66200a25eec28f8dfce37d126a8892d ./src/Contracts/DatabaseAdapterInterface.php
+4f0f4caa5ac98499854336f5b74af55ce889653f3956e5df10910f869a23fdba ./src/Contracts/HttpAdapterInterface.php
+15e8f58c7360d6cd0c76c945abfeb026f8278d40b330e99b67955eb2f85f5563 ./src/Contracts/OptionsAdapterInterface.php
+da068811cd923bd6cf04a209b9b4eec55fa57aeedfccaa3250ebd37b906c6d04 ./src/Domain/CalDavService.php
+c8ab00f23e7cd198228734515c3ce9a2589b1ab6cb815307f3b8f7a3051e04d2 ./src/Domain/EventService.php
+f48006beb0c5c8d7a98e9f33f80a6a08fb92a999c28c937f9afd814e98de0a05 ./src/Domain/IcsService.php
+fd5377f7852b0f35550a453451d98882b8488b332d9a051dac603851c7b586d1 ./src/Domain/RecurrenceExpander.php
+20ee26671fd934f36e97606cfd2ec1d5101ac8064594989bc955bf805fae2502 ./src/Domain/SettingsService.php
+9956790a5d62f5798f3ec15fc507fb04f33e01fd27969db09e91a6d1b41cd33f ./src/Domain/UserService.php
+5aaac066919b60461bde2e96cbfb4de66a5a28e89d2a61b78f0e2d346f23395b ./src/Infrastructure/ServiceContainer.php
+e65577c707c5a66e2097faa7720170180c79a9d9df219f0e1061fdcec55be744 ./src/Infrastructure/WordPress/MigrationManager.php
+8e6c95e9e1c051606e66d95cf0bcf92b2ca087bc491f32ab4921e0898cf77b81 ./src/Infrastructure/WordPress/WordPressAuthAdapter.php
+68c0ca15ad2c8b6363a2578b85f8daf0d3a094e612a120a2cdd2a2bfd8fe5e3c ./src/Infrastructure/WordPress/WordPressDatabaseAdapter.php
+8da85db3c1e69c2c5f01aaa2f558aa8f0323446d8af0aec5b34d4607cd4afe1b ./src/Infrastructure/WordPress/WordPressHttpAdapter.php
+cf9fddcecb07af2c03ad2c0e448be12a6b45dd936efc8bc1fd46a52b4af864ea ./src/Infrastructure/WordPress/WordPressOptionsAdapter.php
+0cc96b252ac86e91a724e73beecfe6f57a1afdb6f5d9a26e44aed00a3f0e20f0 ./src/Plugin.php
+4e6930c79d9ce1045be6863ddd7546e19f7ef7e839d41a41014a3efb3183eaf0 ./src/bootstrap.php
+893c6df62beed87a981d372c473e5012d1b5d1c254d23b39cda44ae8a08cd16c ./uninstall.php
diff --git a/package/calendar-plugin-0.1.15.zip b/package/calendar-plugin-0.1.15.zip
index 1e69edd..d501ca0 100644
Binary files a/package/calendar-plugin-0.1.15.zip and b/package/calendar-plugin-0.1.15.zip differ
diff --git a/package/staging/calendar-plugin/src/Domain/CalDavService.php b/package/staging/calendar-plugin/src/Domain/CalDavService.php
index a6689df..a8f78ef 100644
--- a/package/staging/calendar-plugin/src/Domain/CalDavService.php
+++ b/package/staging/calendar-plugin/src/Domain/CalDavService.php
@@ -140,6 +140,14 @@ final class CalDavService
return $out;
}
+ public function listDeletedResources(int $limit = 500): array
+ {
+ $rows = $this->events->listCalDavTombstones($limit);
+ return array_values(array_filter(array_map(static function (array $row): string {
+ return trim((string) ($row['resource'] ?? ''));
+ }, $rows)));
+ }
+
public function resourceForEvent(array $event): string
{
$resource = trim((string) ($event['caldav_resource'] ?? ''));
diff --git a/package/staging/calendar-plugin/src/Domain/EventService.php b/package/staging/calendar-plugin/src/Domain/EventService.php
index 7e99a9b..5deb849 100644
--- a/package/staging/calendar-plugin/src/Domain/EventService.php
+++ b/package/staging/calendar-plugin/src/Domain/EventService.php
@@ -12,6 +12,7 @@ final class EventService
{
private readonly string $eventsTable;
private readonly string $exceptionsTable;
+ private readonly string $tombstonesTable;
public function __construct(private readonly DatabaseAdapterInterface $db, string $tableStem = 'cs_calendar')
{
@@ -19,6 +20,7 @@ final class EventService
$stem = trim($tableStem, '_');
$this->eventsTable = $prefix . $stem . '_events';
$this->exceptionsTable = $prefix . $stem . '_recurrence_exceptions';
+ $this->tombstonesTable = $prefix . $stem . '_caldav_tombstones';
}
public function listEvents(): array
@@ -75,6 +77,7 @@ final class EventService
$data = [
'uid' => $uid,
+ 'visibility' => $this->canonicalVisibility((string) ($payload['visibility'] ?? 'public')),
'title' => $title,
'description' => (string) ($payload['description'] ?? ''),
'location' => (string) ($payload['location'] ?? ''),
@@ -104,7 +107,12 @@ final class EventService
if ($inserted === false) {
throw new \RuntimeException('failed to create event');
}
- return (array) $this->getEvent($this->db->insertId());
+ $created = (array) $this->getEvent($this->db->insertId());
+ $resource = trim((string) ($created['caldav_resource'] ?? ''));
+ if ($resource !== '') {
+ $this->clearCalDavTombstone($resource);
+ }
+ return $created;
}
public function updateEvent(int $id, array $payload): ?array
@@ -148,6 +156,9 @@ final class EventService
$repeatNthWeekday
);
$data = [
+ 'visibility' => array_key_exists('visibility', $payload)
+ ? $this->canonicalVisibility((string) $payload['visibility'])
+ : $this->canonicalVisibility((string) ($existing['visibility'] ?? 'public')),
'title' => trim((string) ($payload['title'] ?? $existing['title'])),
'description' => (string) ($payload['description'] ?? $existing['description']),
'location' => (string) ($payload['location'] ?? $existing['location']),
@@ -179,13 +190,27 @@ final class EventService
];
$this->db->update($this->eventsTable, $data, ['id' => $id]);
- return $this->getEvent($id);
+ $updated = $this->getEvent($id);
+ if ($updated) {
+ $resource = trim((string) ($updated['caldav_resource'] ?? ''));
+ if ($resource !== '') {
+ $this->clearCalDavTombstone($resource);
+ }
+ }
+ return $updated;
}
public function deleteEvent(int $id): bool
{
+ $event = $this->getEvent($id);
$this->db->delete($this->exceptionsTable, ['event_id' => $id]);
$deleted = $this->db->delete($this->eventsTable, ['id' => $id]);
+ if ($deleted !== false && $event) {
+ $resource = trim((string) ($event['caldav_resource'] ?? ''));
+ if ($resource !== '') {
+ $this->recordCalDavTombstone($resource);
+ }
+ }
return $deleted !== false;
}
@@ -278,6 +303,7 @@ final class EventService
$event = [
'id' => 0,
'uid' => 'preview@calendar-plugin',
+ 'visibility' => $this->canonicalVisibility((string) ($payload['visibility'] ?? 'public')),
'title' => (string) ($payload['title'] ?? ''),
'description' => (string) ($payload['description'] ?? ''),
'location' => (string) ($payload['location'] ?? ''),
@@ -352,7 +378,7 @@ final class EventService
}
}
- public function listPublicOccurrences(string $view, string $dateAnchor, bool $futureOnly = false): array
+ public function listPublicOccurrences(string $view, string $dateAnchor, bool $futureOnly = false, bool $redactPrivate = true): array
{
$tz = new DateTimeZone('Europe/London');
$anchor = $this->safeDate($dateAnchor, $tz);
@@ -382,10 +408,14 @@ final class EventService
static fn(array $a, array $b): int => strcmp((string) $a['occurrence_start'], (string) $b['occurrence_start'])
);
+ if ($redactPrivate) {
+ return array_map([$this, 'redactOccurrenceForPublic'], $out);
+ }
+
return $out;
}
- public function listSidebarUpcoming(int $days = 14): array
+ public function listSidebarUpcoming(int $days = 14, bool $redactPrivate = true): array
{
$tz = new DateTimeZone('Europe/London');
$start = new DateTimeImmutable('today', $tz);
@@ -404,6 +434,10 @@ final class EventService
static fn(array $a, array $b): int => strcmp((string) $a['occurrence_start'], (string) $b['occurrence_start'])
);
+ if ($redactPrivate) {
+ return array_map([$this, 'redactOccurrenceForPublic'], $out);
+ }
+
return $out;
}
@@ -411,11 +445,31 @@ final class EventService
{
$events = $this->listEvents();
$count = count($events);
+ foreach ($events as $event) {
+ $resource = trim((string) ($event['caldav_resource'] ?? ''));
+ if ($resource !== '') {
+ $this->recordCalDavTombstone($resource);
+ }
+ }
$this->db->query("DELETE FROM {$this->exceptionsTable}");
$this->db->query("DELETE FROM {$this->eventsTable}");
return $count;
}
+ public function listCalDavTombstones(int $limit = 500): array
+ {
+ $limit = max(1, min($limit, 5000));
+ $rows = $this->db->getResults(
+ "SELECT resource, deleted_at FROM {$this->tombstonesTable} ORDER BY deleted_at DESC LIMIT {$limit}"
+ );
+ return array_map(static function (object $row): array {
+ return [
+ 'resource' => (string) ($row->resource ?? ''),
+ 'deleted_at' => (string) ($row->deleted_at ?? ''),
+ ];
+ }, $rows);
+ }
+
public function seedDefaultEvents(): int
{
$seed = [
@@ -494,6 +548,9 @@ final class EventService
return [
'id' => (int) $row->id,
'uid' => (string) $row->uid,
+ 'visibility' => property_exists($row, 'visibility')
+ ? $this->canonicalVisibility((string) ($row->visibility ?? 'public'))
+ : 'public',
'title' => (string) $row->title,
'description' => (string) $row->description,
'location' => (string) $row->location,
@@ -614,6 +671,26 @@ final class EventService
return in_array($v, ['none', 'count', 'until'], true) ? $v : 'none';
}
+ private function canonicalVisibility(string $value): string
+ {
+ $visibility = strtolower(trim($value));
+ return $visibility === 'private' ? 'private' : 'public';
+ }
+
+ private function redactOccurrenceForPublic(array $occurrence): array
+ {
+ if ($this->canonicalVisibility((string) ($occurrence['visibility'] ?? 'public')) !== 'private') {
+ return $occurrence;
+ }
+
+ $occurrence['title'] = 'Private Event';
+ $occurrence['description'] = '';
+ $occurrence['location'] = '';
+ $occurrence['category'] = '';
+
+ return $occurrence;
+ }
+
private function normalizeMonthlyAnchor(
string $startIso,
string $endIso,
@@ -706,4 +783,30 @@ final class EventService
}
return [$newYear, $newMonth];
}
+
+ private function clearCalDavTombstone(string $resource): void
+ {
+ $resource = trim($resource);
+ if ($resource === '') {
+ return;
+ }
+ $this->db->delete($this->tombstonesTable, ['resource' => $resource]);
+ }
+
+ private function recordCalDavTombstone(string $resource): void
+ {
+ $resource = trim($resource);
+ if ($resource === '') {
+ return;
+ }
+ $now = gmdate('c');
+ $this->db->delete($this->tombstonesTable, ['resource' => $resource]);
+ $this->db->insert(
+ $this->tombstonesTable,
+ [
+ 'resource' => $resource,
+ 'deleted_at' => $now,
+ ]
+ );
+ }
}
diff --git a/package/staging/calendar-plugin/src/Domain/IcsService.php b/package/staging/calendar-plugin/src/Domain/IcsService.php
index c974626..7927cdf 100644
--- a/package/staging/calendar-plugin/src/Domain/IcsService.php
+++ b/package/staging/calendar-plugin/src/Domain/IcsService.php
@@ -11,7 +11,12 @@ final class IcsService
{
private const PRODID = '-//Calendar Plugin//EN';
- public function buildCalendar(array $events, callable $deletedKeysProvider, string $calendarName = 'Calendar'): string
+ public function buildCalendar(
+ array $events,
+ callable $deletedKeysProvider,
+ string $calendarName = 'Calendar',
+ bool $redactPrivate = false
+ ): string
{
$lines = [
'BEGIN:VCALENDAR',
@@ -20,10 +25,35 @@ final class IcsService
'CALSCALE:GREGORIAN',
'X-WR-CALNAME:' . $this->escapeText($calendarName),
'X-WR-TIMEZONE:Europe/London',
+ 'BEGIN:VTIMEZONE',
+ 'TZID:Europe/London',
+ 'X-LIC-LOCATION:Europe/London',
+ 'BEGIN:DAYLIGHT',
+ 'TZOFFSETFROM:+0000',
+ 'TZOFFSETTO:+0100',
+ 'TZNAME:BST',
+ 'DTSTART:19700329T010000',
+ 'RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU',
+ 'END:DAYLIGHT',
+ 'BEGIN:STANDARD',
+ 'TZOFFSETFROM:+0100',
+ 'TZOFFSETTO:+0000',
+ 'TZNAME:GMT',
+ 'DTSTART:19701025T020000',
+ 'RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU',
+ 'END:STANDARD',
+ 'END:VTIMEZONE',
];
foreach ($events as $event) {
- $lines = array_merge($lines, $this->eventToLines($event, (array) $deletedKeysProvider((int) ($event['id'] ?? 0))));
+ $lines = array_merge(
+ $lines,
+ $this->eventToLines(
+ $event,
+ (array) $deletedKeysProvider((int) ($event['id'] ?? 0)),
+ $redactPrivate
+ )
+ );
}
$lines[] = 'END:VCALENDAR';
@@ -76,6 +106,11 @@ final class IcsService
'repeat_until' => null,
'timezone' => 'Europe/London',
];
+ if (isset($props['CLASS'][0])) {
+ $payload['visibility'] = strtoupper((string) $props['CLASS'][0]) === 'PRIVATE' ? 'private' : 'public';
+ } elseif (isset($props['X-CALENDARSERVER-ACCESS'][0])) {
+ $payload['visibility'] = strtoupper((string) $props['X-CALENDARSERVER-ACCESS'][0]) === 'PRIVATE' ? 'private' : 'public';
+ }
$rrule = (string) ($props['RRULE'][0] ?? '');
if ($rrule !== '') {
@@ -97,7 +132,7 @@ final class IcsService
return $payload;
}
- private function eventToLines(array $event, array $deletedKeys): array
+ private function eventToLines(array $event, array $deletedKeys, bool $redactPrivate): array
{
$uid = (string) ($event['uid'] ?? '');
$uid = $uid !== '' ? $uid : ('event-' . (string) ($event['id'] ?? 0) . '@calendar-plugin');
@@ -109,18 +144,26 @@ final class IcsService
}
$allDay = (bool) ($event['all_day_event'] ?? false);
+ $visibility = strtolower(trim((string) ($event['visibility'] ?? 'public'))) === 'private' ? 'private' : 'public';
+ $isRedactedPrivate = $redactPrivate && $visibility === 'private';
$updated = $this->toDateTime((string) ($event['updated_at'] ?? '')) ?? new DateTimeImmutable('now', new DateTimeZone('UTC'));
$lines = [
'BEGIN:VEVENT',
'UID:' . $this->escapeText($uid),
- 'SUMMARY:' . $this->escapeText((string) ($event['title'] ?? 'Untitled')),
- 'DESCRIPTION:' . $this->escapeText((string) ($event['description'] ?? '')),
- 'LOCATION:' . $this->escapeText((string) ($event['location'] ?? '')),
- 'CATEGORIES:' . $this->escapeText((string) ($event['category'] ?? '')),
+ 'SUMMARY:' . $this->escapeText($isRedactedPrivate ? 'Private Event' : (string) ($event['title'] ?? 'Untitled')),
'DTSTAMP:' . $this->toUtcIcs($updated),
'LAST-MODIFIED:' . $this->toUtcIcs($updated),
];
+ if (!$isRedactedPrivate) {
+ $icsVisibility = strtoupper($visibility === 'private' ? 'PRIVATE' : 'PUBLIC');
+ $lines[] = 'CLASS:' . $icsVisibility;
+ // Compatibility hint for clients that rely on CalendarServer-style access fields.
+ $lines[] = 'X-CALENDARSERVER-ACCESS:' . $icsVisibility;
+ $lines[] = 'DESCRIPTION:' . $this->escapeText((string) ($event['description'] ?? ''));
+ $lines[] = 'LOCATION:' . $this->escapeText((string) ($event['location'] ?? ''));
+ $lines[] = 'CATEGORIES:' . $this->escapeText((string) ($event['category'] ?? ''));
+ }
if ($allDay) {
$lines[] = 'DTSTART;VALUE=DATE:' . $start->setTimezone(new DateTimeZone('Europe/London'))->format('Ymd');
diff --git a/package/staging/calendar-plugin/src/Domain/RecurrenceExpander.php b/package/staging/calendar-plugin/src/Domain/RecurrenceExpander.php
index 71fe24e..0fd8bd8 100644
--- a/package/staging/calendar-plugin/src/Domain/RecurrenceExpander.php
+++ b/package/staging/calendar-plugin/src/Domain/RecurrenceExpander.php
@@ -148,6 +148,7 @@ final class RecurrenceExpander
return [
'event_id' => (int) ($event['id'] ?? 0),
'uid' => (string) ($event['uid'] ?? ''),
+ 'visibility' => (string) ($event['visibility'] ?? 'public'),
'title' => (string) ($event['title'] ?? ''),
'description' => (string) ($event['description'] ?? ''),
'location' => (string) ($event['location'] ?? ''),
diff --git a/package/staging/calendar-plugin/src/Infrastructure/WordPress/MigrationManager.php b/package/staging/calendar-plugin/src/Infrastructure/WordPress/MigrationManager.php
index cb2281c..6537806 100644
--- a/package/staging/calendar-plugin/src/Infrastructure/WordPress/MigrationManager.php
+++ b/package/staging/calendar-plugin/src/Infrastructure/WordPress/MigrationManager.php
@@ -10,7 +10,7 @@ use DateTimeZone;
final class MigrationManager
{
- private const SCHEMA_VERSION = '3';
+ private const SCHEMA_VERSION = '5';
private const STEM_OPTION = 'calendar_plugin_table_stem';
public function __construct(private readonly DatabaseAdapterInterface $db, private readonly string $tableStem = 'cs_calendar')
@@ -31,10 +31,12 @@ final class MigrationManager
$users = $prefix . $stem . '_users';
$tokens = $prefix . $stem . '_user_tokens';
$audit = $prefix . $stem . '_audit_log';
+ $tombstones = $prefix . $stem . '_caldav_tombstones';
$sqlEvents = "CREATE TABLE {$events} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
uid VARCHAR(191) NOT NULL,
+ visibility VARCHAR(16) NOT NULL DEFAULT 'public',
title TEXT NOT NULL,
description LONGTEXT NOT NULL,
location TEXT NOT NULL,
@@ -115,11 +117,18 @@ final class MigrationManager
KEY created_at (created_at)
) {$charsetCollate};";
+ $sqlTombstones = "CREATE TABLE {$tombstones} (
+ resource VARCHAR(191) NOT NULL,
+ deleted_at VARCHAR(32) NOT NULL,
+ PRIMARY KEY (resource)
+ ) {$charsetCollate};";
+
dbDelta($sqlEvents);
dbDelta($sqlExceptions);
dbDelta($sqlUsers);
dbDelta($sqlTokens);
dbDelta($sqlAudit);
+ dbDelta($sqlTombstones);
// Ensure every event has a stable CalDAV object resource name.
$this->db->query(
@@ -129,6 +138,13 @@ final class MigrationManager
AND uid IS NOT NULL
AND uid <> ''"
);
+ $this->db->query(
+ "UPDATE {$events}
+ SET visibility = 'public'
+ WHERE visibility IS NULL
+ OR visibility = ''
+ OR visibility NOT IN ('public', 'private')"
+ );
$this->normalizeEventDateTimesToLondon($events);
update_option(self::STEM_OPTION, $stem);
@@ -170,6 +186,7 @@ final class MigrationManager
$prefix . $stem . '_users',
$prefix . $stem . '_user_tokens',
$prefix . $stem . '_audit_log',
+ $prefix . $stem . '_caldav_tombstones',
];
foreach ($tables as $table) {
$sql = $this->db->prepare('SHOW TABLES LIKE %s', $table);
diff --git a/package/staging/calendar-plugin/src/Plugin.php b/package/staging/calendar-plugin/src/Plugin.php
index 42c77db..8b573ea 100644
--- a/package/staging/calendar-plugin/src/Plugin.php
+++ b/package/staging/calendar-plugin/src/Plugin.php
@@ -209,6 +209,7 @@ final class Plugin
+
@@ -256,6 +257,7 @@ final class Plugin
Event Details
+
@@ -409,6 +411,7 @@ final class Plugin
const clearEditor=()=>{
s("cp-event-id").value="";
["cp-title","cp-description","cp-location","cp-category","cp-occurrence-key"].forEach(k=>s(k).value="");
+ s("cp-visibility").value="public";
s("cp-occurrence-key-iso").value="";
s("cp-all-day").checked=false;
s("cp-repeat-type").value="none";
@@ -452,6 +455,7 @@ final class Plugin
const openDetailsForItem=(it)=>{
s("cp-details-title").value=it.title||"";
+ s("cp-details-visibility").value=(it.visibility||"public")==="private"?"Private":"Public";
s("cp-details-category").value=it.category||"";
s("cp-details-location").value=it.location||"";
s("cp-details-start").value=detailsDateTime(it.occurrence_start||it.start_datetime,!!it.all_day_event);
@@ -478,6 +482,7 @@ final class Plugin
s("cp-editor-title").textContent="Edit Event";
s("cp-event-id").value=itemId(source);
s("cp-title").value=source.title||"";
+ s("cp-visibility").value=source.visibility==="private"?"private":"public";
s("cp-description").value=source.description||"";
s("cp-location").value=source.location||"";
s("cp-category").value=source.category||"";
@@ -532,6 +537,7 @@ final class Plugin
ok:true,
payload:{
title:title,
+ visibility:s("cp-visibility").value==="private"?"private":"public",
description:s("cp-description").value,
location:s("cp-location").value,
category:s("cp-category").value,
@@ -1592,10 +1598,16 @@ HTML
$date = (string) ($request->get_param('date') ?: gmdate('Y-m-d'));
$futureOnlyRaw = (string) ($request->get_param('future_only') ?? '');
$futureOnly = in_array(strtolower($futureOnlyRaw), ['1', 'true', 'yes', 'on'], true);
- $items = $this->eventService->listPublicOccurrences($view, $date, $futureOnly);
+ $includePrivateDetails = $this->canWriteCalendar($request);
+ $items = $this->eventService->listPublicOccurrences($view, $date, $futureOnly, !$includePrivateDetails);
return [
'data' => $items,
- 'meta' => ['count' => count($items), 'view' => $view, 'future_only' => $futureOnly],
+ 'meta' => [
+ 'count' => count($items),
+ 'view' => $view,
+ 'future_only' => $futureOnly,
+ 'redacted_private' => !$includePrivateDetails,
+ ],
];
},
]
@@ -1608,7 +1620,7 @@ HTML
'methods' => 'GET',
'permission_callback' => '__return_true',
'callback' => function (): array {
- $items = $this->eventService->listSidebarUpcoming(14);
+ $items = $this->eventService->listSidebarUpcoming(14, true);
return [
'data' => $items,
'meta' => ['count' => count($items), 'window_days' => 14],
@@ -1840,13 +1852,19 @@ HTML
[
'methods' => 'GET',
'permission_callback' => '__return_true',
- 'callback' => function (): array {
+ 'callback' => function ($request): array|\WP_Error {
$settings = $this->settingsService->getAll();
+ $icsMode = (string) ($settings['ics_access_mode'] ?? 'public_read');
+ $includePrivateDetails = $this->canWriteCalendar($request);
+ if ($icsMode === 'authenticated_read' && !$includePrivateDetails) {
+ return $this->error('auth_required', 'authentication required', 401);
+ }
$calendarName = (string) ($settings['caldav_calendar_name'] ?? 'Public Calendar');
$ics = $this->icsService->buildCalendar(
$this->eventService->listEvents(),
fn(int $eventId): array => $this->eventService->getDeletedOccurrenceKeys($eventId),
- $calendarName
+ $calendarName,
+ !$includePrivateDetails
);
return ['data' => $ics];
},
@@ -1964,9 +1982,11 @@ HTML
private function serveIcsResponse(): void
{
$settings = $this->settingsService->getAll();
+ $icsMode = (string) ($settings['ics_access_mode'] ?? 'public_read');
+ $includePrivateDetails = $this->canWriteCalendar(null);
if (
- (string) ($settings['ics_access_mode'] ?? 'public_read') === 'authenticated_read'
- && $this->resolveCalDavUserForRequest(null) === null
+ $icsMode === 'authenticated_read'
+ && !$includePrivateDetails
) {
http_response_code(401);
header('Content-Type: application/json; charset=utf-8');
@@ -1978,7 +1998,8 @@ HTML
$ics = $this->icsService->buildCalendar(
$this->eventService->listEvents(),
fn(int $eventId): array => $this->eventService->getDeletedOccurrenceKeys($eventId),
- $calendarName
+ $calendarName,
+ !$includePrivateDetails
);
$etag = '"' . substr(sha1($ics), 0, 16) . '"';
$lastModified = gmdate('D, d M Y H:i:s') . ' GMT';
@@ -2010,7 +2031,18 @@ HTML
$resourcePrefix = $collection;
if ($method === 'HEAD') {
- if ($path === $root || $path === $root . '/' || $path === $calendarsRoot || $path === rtrim($calendarsRoot, '/') || $path === $collection || $path === rtrim($collection, '/')) {
+ if (
+ $path === $root
+ || $path === $root . '/'
+ || $path === $principalCollection
+ || $path === rtrim($principalCollection, '/')
+ || $path === $principal
+ || $path === rtrim($principal, '/')
+ || $path === $calendarsRoot
+ || $path === rtrim($calendarsRoot, '/')
+ || $path === $collection
+ || $path === rtrim($collection, '/')
+ ) {
header('Allow: OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, HEAD');
header('DAV: 1, calendar-access');
http_response_code(200);
@@ -2030,6 +2062,26 @@ HTML
}
}
+ if ($method === 'GET') {
+ if (
+ $path === $root
+ || $path === $root . '/'
+ || $path === $principalCollection
+ || $path === rtrim($principalCollection, '/')
+ || $path === $principal
+ || $path === rtrim($principal, '/')
+ || $path === $calendarsRoot
+ || $path === rtrim($calendarsRoot, '/')
+ || $path === $collection
+ || $path === rtrim($collection, '/')
+ ) {
+ header('Allow: OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, HEAD');
+ header('DAV: 1, calendar-access');
+ http_response_code(200);
+ return;
+ }
+ }
+
if ($method === 'OPTIONS') {
header('Allow: OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, HEAD');
header('DAV: 1, calendar-access');
@@ -2079,7 +2131,7 @@ HTML
return;
}
- if ($method === 'REPORT' && $path === $collection) {
+ if ($method === 'REPORT' && ($path === $collection || $path === rtrim($collection, '/'))) {
$body = (string) file_get_contents('php://input');
header('Content-Type: application/xml; charset=utf-8');
http_response_code(207);
@@ -2269,7 +2321,15 @@ HTML
$items = [];
if (str_contains($bodyLower, 'sync-collection')) {
- $items = $this->calDavService->multiget($resources);
+ $clientSyncToken = $this->extractSyncCollectionToken($xmlBody);
+ // If client token is already current, no changes should be emitted.
+ if ($clientSyncToken !== '' && $clientSyncToken === $syncToken) {
+ $items = [];
+ } else {
+ // Fallback implementation: emit current objects for initial/out-of-date tokens.
+ // Avoid emitting historical tombstones because many clients treat large 404 sets as transient failures.
+ $items = $this->calDavService->multiget($resources);
+ }
} elseif (str_contains($bodyLower, 'calendar-query')) {
$items = $this->calDavService->multiget($resources);
if (preg_match('/start\\s*=\\s*"([0-9TzZ]+)"/i', $xmlBody, $s) && preg_match('/end\\s*=\\s*"([0-9TzZ]+)"/i', $xmlBody, $e)) {
@@ -2324,6 +2384,9 @@ HTML
foreach ($rows as $row) {
$seed .= (string) ($row['resource'] ?? '') . '|' . (string) ($row['etag'] ?? '') . ';';
}
+ foreach ($this->calDavService->listDeletedResources(1000) as $deletedResource) {
+ $seed .= 'deleted:' . $deletedResource . ';';
+ }
return 'urn:calendar-plugin:sync:' . sha1($seed);
}
@@ -2353,6 +2416,15 @@ HTML
return $this->icalToIso((string) $m[1]);
}
+ private function extractSyncCollectionToken(string $xmlBody): string
+ {
+ if (!preg_match('/<[^>]*sync-token[^>]*>(.*?)<\\/[^>]*sync-token>/is', $xmlBody, $m)) {
+ return '';
+ }
+ $token = trim(html_entity_decode((string) $m[1], ENT_QUOTES | ENT_XML1, 'UTF-8'));
+ return $token;
+ }
+
private function unwrapServiceResult(array $result): array|\WP_Error
{
if (isset($result['error']) && is_array($result['error'])) {
diff --git a/requirements/caldav.md b/requirements/caldav.md
index 9284547..c5d7b5d 100644
--- a/requirements/caldav.md
+++ b/requirements/caldav.md
@@ -70,6 +70,7 @@ The endpoint must support these operations at minimum.
- `REPORT` (`calendar-query`): return events in collection, including time-range filtering.
- `REPORT` (`calendar-multiget`): fetch specific event resources by href.
- `GET`: retrieve individual event resource (`text/calendar`).
+- `GET` on CalDAV collection resources should return `200` (empty body acceptable) for client availability probes.
### Create and Update
- `PUT`: create new event resource or replace an existing event resource.
@@ -86,6 +87,8 @@ The endpoint must support these operations at minimum.
- `If-Match`/`If-None-Match` preconditions must be honored for safe updates/creates.
- `REPORT` (`sync-collection`) should be supported for incremental sync tokens.
- Sync token invalidation/rotation behavior must be deterministic and documented.
+- `sync-collection` no-change requests (client token equals current token) should return an empty change set (`207` with no changed/deleted `response` entries).
+- `sync-collection` should prefer stable incremental behavior over historical replay; unchanged resyncs must not emit large historical tombstone sets.
## iCalendar Representation Requirements
CalDAV event payloads must be standards-compatible `VCALENDAR` with `VEVENT` components.
diff --git a/requirements/caldav_endpoints.md b/requirements/caldav_endpoints.md
index f89084a..f11518b 100644
--- a/requirements/caldav_endpoints.md
+++ b/requirements/caldav_endpoints.md
@@ -53,6 +53,7 @@ URI rules:
### Calendar collection resources
- `OPTIONS`
- `PROPFIND`
+- `GET` (availability probe support; returns `200` with empty body on collection URL)
- `REPORT` (`calendar-query`, `calendar-multiget`, `sync-collection`)
### Event object resources
@@ -78,6 +79,9 @@ Calendar collection and principal responses must support, at minimum, these prop
- `calendar-query` with time-range filtering
- `calendar-multiget` by href set
- `sync-collection` for incremental changes since sync token
+- Collection `REPORT` handling must accept both canonical and non-canonical trailing-slash variants (for example `/caldav/calendars/public/` and `/caldav/calendars/public`).
+- For `sync-collection`, if client sync-token equals server sync-token, server should return `207` with no `D:response` change entries.
+- `sync-collection` responses must not emit large sets of historical `404` tombstones for unchanged state.
If a report is unsupported for a resource, server returns standards-appropriate error status with DAV error body.
@@ -115,4 +119,7 @@ Acceptance should verify:
- URI layout and discovery flows are stable.
- Required methods return expected statuses.
- REPORT responses include correct event sets.
+- Collection `GET` returns `200` for authenticated probe requests.
+- `sync-collection` no-change request (current token) returns `207` with zero change responses.
+- `sync-collection` works with and without trailing slash on collection URI.
- Conditional write and etag behavior prevents stale overwrite.
diff --git a/requirements/test_strategy.md b/requirements/test_strategy.md
index e0c04fe..89bd207 100644
--- a/requirements/test_strategy.md
+++ b/requirements/test_strategy.md
@@ -68,6 +68,10 @@ Must run:
- Recurrence exception behavior (single occurrence delete without split) is mandatory coverage.
- Authn/Authz paths for admin, API, and CalDAV roles are mandatory coverage.
- Error-model contract coverage is mandatory for API endpoints.
+- CalDAV client-compatibility regressions are mandatory coverage, including:
+ - collection `GET` availability probe compatibility
+ - collection `REPORT` handling with and without trailing slash
+ - `sync-collection` no-change stability (current token -> zero change entries)
## Pass/Fail Gates
- Any required suite failure blocks merge/release as applicable.
diff --git a/scripts/deploy_remote.sh b/scripts/deploy_remote.sh
index 58aac1f..5205be1 100755
--- a/scripts/deploy_remote.sh
+++ b/scripts/deploy_remote.sh
@@ -105,10 +105,12 @@ fi
"${SSH[@]}" "set -euo pipefail; rm -rf '${STAGE_DIR}/extracted'; mkdir -p '${STAGE_DIR}/extracted'; unzip -q '${STAGE_DIR}/${artifact_base}' -d '${STAGE_DIR}/extracted'; test -f '${STAGE_DIR}/extracted/calendar-plugin/calendar-plugin.php'"
"${SSH[@]}" "set -euo pipefail; rsync -a --delete '${STAGE_DIR}/extracted/calendar-plugin/' '${REMOTE_APP_DIR}/'"
"${SSH[@]}" "set -euo pipefail; chown -R www-data:www-data '${REMOTE_APP_DIR}'"
-"${SSH[@]}" "set -euo pipefail; '${REMOTE_WP_CLI}' --path='${WP_ROOT}' plugin activate calendar-plugin --allow-root >/dev/null 2>&1 || true"
+# Always cycle plugin activation so activation-hook migrations run on every deploy.
+"${SSH[@]}" "set -euo pipefail; '${REMOTE_WP_CLI}' --path='${WP_ROOT}' plugin deactivate calendar-plugin --allow-root >/dev/null 2>&1 || true; '${REMOTE_WP_CLI}' --path='${WP_ROOT}' plugin activate calendar-plugin --allow-root >/dev/null"
-# Exact-match style checksum dry-run check
-"${SSH[@]}" "set -euo pipefail; rsync -avznc --delete '${STAGE_DIR}/extracted/calendar-plugin/' '${REMOTE_APP_DIR}/' >/tmp/codex_rsync_check.out; if grep -Eq '^[^./]|^\./' /tmp/codex_rsync_check.out; then cat /tmp/codex_rsync_check.out; exit 1; fi"
+# Exact-match style checksum dry-run check for content drift.
+# Ignore directory metadata-only differences, which are expected after chown/remote extraction.
+"${SSH[@]}" "set -euo pipefail; rsync -rcn --delete --omit-dir-times --no-perms --no-owner --no-group --itemize-changes '${STAGE_DIR}/extracted/calendar-plugin/' '${REMOTE_APP_DIR}/' >/tmp/codex_rsync_check.out; if grep -Eq '^(>f|\\*deleting|cd|cL|cD|cS)' /tmp/codex_rsync_check.out; then cat /tmp/codex_rsync_check.out; exit 1; fi"
# Ownership sanity check: must be zero mismatches
non_owned="$("${SSH[@]}" "set -euo pipefail; find '${REMOTE_APP_DIR}' \( ! -user www-data -o ! -group www-data \) | wc -l")"
diff --git a/tests/api_test_cases.md b/tests/api_test_cases.md
index 03a977b..4f93ef4 100644
--- a/tests/api_test_cases.md
+++ b/tests/api_test_cases.md
@@ -142,6 +142,31 @@ Adjust paths to actual implementation while preserving case coverage.
- CalDAV link uses `/
/caldav/`.
- Link path changes match configured slug value.
+## CalDAV Sync Compatibility Tests
+
+### API-CALDAV-001 Collection URL Variant Compatibility
+- Method: `REPORT sync-collection` against both:
+ - `.../caldav/calendars/public/`
+ - `.../caldav/calendars/public`
+- Assertions:
+ - Both return `207`.
+ - Neither returns method errors due to trailing slash variant.
+
+### API-CALDAV-002 Collection Availability Probe
+- Method: `GET .../caldav/calendars/public/` (authenticated)
+- Assertions:
+ - Returns `200` (body may be empty).
+ - Does not force client into temporary unavailable state on probe.
+
+### API-CALDAV-003 No-Change Incremental Sync Stability
+- Method:
+ 1. run `REPORT sync-collection` to obtain sync token
+ 2. rerun `REPORT sync-collection` with returned token and no intervening changes
+- Assertions:
+ - Returns `207`
+ - Contains zero `` change entries
+ - Does not emit historical `404` tombstone floods for unchanged state
+
## Negative and Security Tests
### API-SEC-001 Unauthorized Access
diff --git a/tests/run_remote_tests.sh b/tests/run_remote_tests.sh
index b9a7ad6..381b237 100755
--- a/tests/run_remote_tests.sh
+++ b/tests/run_remote_tests.sh
@@ -12,8 +12,9 @@ fi
BASE_URL="${BASE_URL:-${WP_URL:-}}"
AUTH_USER="${CAL_TEST_USER:-adrians@chezstephens.org.uk}"
AUTH_PASS="${CAL_TEST_PASSWORD:-brillig1}"
-EXPECTED_TABLE_PREFIX="${CAL_TABLE_PREFIX_EXPECTED:-wp_cs_calendar}"
ENABLE_PREFIX_CHECK="${CAL_ENABLE_PREFIX_CHECK:-1}"
+CAL_URL_SLUG="${CAL_URL_SLUG:-}"
+CAL_CALDAV_PATH="${CAL_CALDAV_PATH:-}"
REMOTE_WP_PATH="${REMOTE_WP_PATH:-}"
if [[ -z "${REMOTE_WP_PATH}" ]] && [[ -n "${REMOTE_APP_DIR:-}" ]]; then
REMOTE_WP_PATH="$(dirname "$(dirname "${REMOTE_APP_DIR}")")"
@@ -24,6 +25,10 @@ fi
FAILURES=0
CREATED_EVENT_ID=""
CREATED_REC_EVENT_ID=""
+CREATED_PRIVATE_EVENT_ID=""
+CREATED_SYNC_DELETE_EVENT_ID=""
+DETECTED_URL_SLUG=""
+SSH_OK=0
usage() {
cat <<'TXT'
@@ -36,8 +41,9 @@ Env overrides:
BASE_URL
CAL_TEST_USER
CAL_TEST_PASSWORD
- CAL_TABLE_PREFIX_EXPECTED
CAL_ENABLE_PREFIX_CHECK
+ CAL_URL_SLUG
+ CAL_CALDAV_PATH
Defaults:
BASE_URL -> credentials/.env:WP_URL
@@ -110,6 +116,14 @@ cleanup() {
curl -sS -u "${AUTH_USER}:${AUTH_PASS}" -X DELETE \
"${BASE_URL}/wp-json/calendar/v1/events/${CREATED_REC_EVENT_ID}" >/dev/null || true
fi
+ if [[ -n "${CREATED_PRIVATE_EVENT_ID}" ]]; then
+ curl -sS -u "${AUTH_USER}:${AUTH_PASS}" -X DELETE \
+ "${BASE_URL}/wp-json/calendar/v1/events/${CREATED_PRIVATE_EVENT_ID}" >/dev/null || true
+ fi
+ if [[ -n "${CREATED_SYNC_DELETE_EVENT_ID}" ]]; then
+ curl -sS -u "${AUTH_USER}:${AUTH_PASS}" -X DELETE \
+ "${BASE_URL}/wp-json/calendar/v1/events/${CREATED_SYNC_DELETE_EVENT_ID}" >/dev/null || true
+ fi
}
trap cleanup EXIT
@@ -122,7 +136,11 @@ else
record_fail "health endpoint unreachable"
fi
-if [[ "${ENABLE_PREFIX_CHECK}" == "1" ]] && [[ -n "${REMOTE_HOST:-}" ]] && [[ -n "${REMOTE_USER:-}" ]] && [[ -n "${REMOTE_SSH_KEY_PATH:-}" ]] && [[ -n "${REMOTE_PORT:-}" ]] && [[ -n "${REMOTE_WP_CLI:-}" ]]; then
+if [[ -n "${REMOTE_HOST:-}" ]] && [[ -n "${REMOTE_USER:-}" ]] && [[ -n "${REMOTE_SSH_KEY_PATH:-}" ]] && [[ -n "${REMOTE_PORT:-}" ]] && [[ -n "${REMOTE_WP_CLI:-}" ]]; then
+ SSH_OK=1
+fi
+
+if [[ "${ENABLE_PREFIX_CHECK}" == "1" ]] && [[ "${SSH_OK}" == "1" ]]; then
step "table prefix configuration"
SSH_KEY_PATH="${REMOTE_SSH_KEY_PATH}"
if [[ "${SSH_KEY_PATH}" != /* ]]; then
@@ -131,16 +149,21 @@ if [[ "${ENABLE_PREFIX_CHECK}" == "1" ]] && [[ -n "${REMOTE_HOST:-}" ]] && [[ -n
SSH_PREFIX=(ssh -F /dev/null -i "${SSH_KEY_PATH}" -p "${REMOTE_PORT}" -o StrictHostKeyChecking=accept-new "${REMOTE_USER}@${REMOTE_HOST}")
if ! "${SSH_PREFIX[@]}" "echo ok" >/dev/null 2>&1; then
echo "[remote-tests] WARN: SSH unavailable, skipping table prefix check"
+ SSH_OK=0
else
+ DETECTED_URL_SLUG="$("${SSH_PREFIX[@]}" "cd ${REMOTE_WP_PATH} && ${REMOTE_WP_CLI} option get calendar_plugin_url_slug --allow-root 2>/dev/null || true" | tr -d '\r' | tail -n1)"
STEM="$("${SSH_PREFIX[@]}" "cd ${REMOTE_WP_PATH} && ${REMOTE_WP_CLI} option get calendar_plugin_table_stem --allow-root 2>/dev/null || true" | tr -d '\r' | tail -n1)"
- if [[ -n "${STEM}" ]] && [[ "${STEM}" != "cs_calendar" ]] && [[ "${STEM}" != "calendar" ]]; then
- record_fail "table stem option expected cs_calendar/calendar got '${STEM}'"
- fi
+ WP_DB_PREFIX="$("${SSH_PREFIX[@]}" "cd ${REMOTE_WP_PATH} && ${REMOTE_WP_CLI} eval 'global \$wpdb; echo \$wpdb->prefix;' --allow-root 2>/dev/null || true" | tr -d '\r' | tail -n1)"
TABLE_LIST="$("${SSH_PREFIX[@]}" "cd ${REMOTE_WP_PATH} && ${REMOTE_WP_CLI} db query \"SHOW TABLES;\" --allow-root --silent --skip-column-names 2>/dev/null || true" | tr -d '\r')"
- TABLE_EXISTS="$(printf '%s\n' "${TABLE_LIST}" | grep -Fx "${EXPECTED_TABLE_PREFIX}_events" | head -n1 || true)"
- LEGACY_EXISTS="$(printf '%s\n' "${TABLE_LIST}" | grep -Fx "wp_calendar_events" | head -n1 || true)"
- if [[ "${TABLE_EXISTS}" != "${EXPECTED_TABLE_PREFIX}_events" ]] && [[ "${LEGACY_EXISTS}" != "wp_calendar_events" ]]; then
- record_fail "expected table prefix '${EXPECTED_TABLE_PREFIX}' (or legacy wp_calendar) not found"
+ if [[ -n "${STEM}" ]] && [[ -n "${WP_DB_PREFIX}" ]]; then
+ EXPECTED_EVENTS_TABLE="${WP_DB_PREFIX}${STEM}_events"
+ if ! printf '%s\n' "${TABLE_LIST}" | grep -Fxq "${EXPECTED_EVENTS_TABLE}"; then
+ record_fail "table stem '${STEM}' does not match an existing events table (${EXPECTED_EVENTS_TABLE})"
+ fi
+ else
+ if ! printf '%s\n' "${TABLE_LIST}" | grep -Eq '_calendar_.*_events$|_calendar_events$'; then
+ record_fail "no calendar events table detected from SHOW TABLES output"
+ fi
fi
fi
fi
@@ -267,19 +290,217 @@ PY
fi
fi
+step "public privacy redaction"
+PRIV_UID="remote-private-redact-$(date +%s)@calendar-plugin"
+PRIV_JSON=$(cat <${PRE_DELETE_TOKEN}1"
+ SYNC_REPORT_HTTP="$(curl -sS -u "${AUTH_USER}:${AUTH_PASS}" -X REPORT -H 'Depth: 1' -H 'Content-Type: application/xml; charset=utf-8' \
+ --data "${SYNC_REPORT_BODY}" -o /tmp/remote_test_sync_report.xml -w '%{http_code}' \
+ "${CALDAV_COLLECTION_URL}" || true)"
+ if [[ "${SYNC_REPORT_HTTP}" != "207" ]]; then
+ record_fail "sync-collection report failed (http ${SYNC_REPORT_HTTP})"
+ else
+ SYNC_REPORT_HTTP_NOSLASH="$(curl -sS -u "${AUTH_USER}:${AUTH_PASS}" -X REPORT -H 'Depth: 1' -H 'Content-Type: application/xml; charset=utf-8' \
+ --data "${SYNC_REPORT_BODY}" -o /tmp/remote_test_sync_report_noslash.xml -w '%{http_code}' \
+ "${CALDAV_COLLECTION_URL%/}" || true)"
+ if [[ "${SYNC_REPORT_HTTP_NOSLASH}" != "207" ]]; then
+ record_fail "sync-collection report without trailing slash failed (http ${SYNC_REPORT_HTTP_NOSLASH})"
+ fi
+ if grep -Fq 'HTTP/1.1 404 Not Found' /tmp/remote_test_sync_report.xml; then
+ record_fail "sync-collection returned 404 entries for incremental sync"
+ fi
+ SYNC_TOKEN_1="$(python3 - <<'PY'
+import re
+xml=open('/tmp/remote_test_sync_report.xml', encoding='utf-8', errors='ignore').read()
+m=re.search(r'(.*?)', xml, re.S)
+print((m.group(1).strip() if m else ''))
+PY
+)"
+ if [[ -z "${SYNC_TOKEN_1}" ]]; then
+ record_fail "incremental sync-collection missing sync-token"
+ else
+ SYNC_REPORT_BODY_NOCHANGE="${SYNC_TOKEN_1}1"
+ SYNC_REPORT_NOCHANGE_HTTP="$(curl -sS -u "${AUTH_USER}:${AUTH_PASS}" -X REPORT -H 'Depth: 1' -H 'Content-Type: application/xml; charset=utf-8' \
+ --data "${SYNC_REPORT_BODY_NOCHANGE}" -o /tmp/remote_test_sync_report_nochange.xml -w '%{http_code}' \
+ "${CALDAV_COLLECTION_URL}" || true)"
+ if [[ "${SYNC_REPORT_NOCHANGE_HTTP}" != "207" ]]; then
+ record_fail "no-change sync-collection report failed (http ${SYNC_REPORT_NOCHANGE_HTTP})"
+ else
+ RESPONSE_COUNT_NOCHANGE="$(python3 - <<'PY'
+import re
+xml=open('/tmp/remote_test_sync_report_nochange.xml', encoding='utf-8', errors='ignore').read()
+print(len(re.findall(r'', xml)))
+PY
+)"
+ if [[ "${RESPONSE_COUNT_NOCHANGE}" != "0" ]]; then
+ record_fail "no-change sync-collection should return 0 responses, got ${RESPONSE_COUNT_NOCHANGE}"
+ fi
+ fi
+ fi
+ fi
+ fi
+ fi
+fi
printf '\n[remote-tests] completed with %d failure(s)\n' "${FAILURES}"
if [[ "${FAILURES}" -gt 0 ]]; then
diff --git a/tests/smoke_tests.md b/tests/smoke_tests.md
index 7905f33..a45ff25 100644
--- a/tests/smoke_tests.md
+++ b/tests/smoke_tests.md
@@ -68,6 +68,9 @@ Use a minimal subset:
- discoverable calendar collection href
- Authenticated `PROPFIND` on principal must return `calendar-home-set`.
- Authenticated `PROPFIND /caldav/calendars/` must include calendar collection metadata (``) and supported component set (`VEVENT`).
+- Authenticated `GET` on calendar collection (`.../calendars/public/`) must return `200` (availability probe compatibility).
+- Authenticated `REPORT sync-collection` must succeed for both trailing slash and no-trailing-slash collection URLs.
+- No-change `sync-collection` (current token) must return no change entries.
- Legacy local harness check is archived at `fixture-tests/fixture_caldav_client_compat_smoke.sh`.
### SMK-010 Lifecycle Controls (Staging Only)