Compare commits

..

3 Commits

42 changed files with 1561 additions and 188 deletions

View File

@ -1,11 +1,13 @@
# Calendar plugin for wordpress site
Status as of 2026-03-31 when codex credit ran out:
1. Removal of plugin did not work. Need to set all files owner to www-data.
2. Deletion of event in ui doesn't delete event in thunderbird
3. Cannot subscribe to an empty calendar in thunderbird
4. Click inside month cell doesn't add event.
5. Login pane hidden under website hero/banner image
1. Removal of plugin did not work. Need to set all files owner to www-data. - done
2. Deletion of event in ui doesn't delete event in thunderbird - done
3. Cannot subscribe to an empty calendar in thunderbird - ok
4. Click inside month cell doesn't add event. - cannot reproduce
5. Login pane hidden under website hero/banner image - done
6. Updating an events description via caldav creates weird sequences, e.g.
a space ends up as: text/html,%C2%A0":
a space ends up as: text/html,%C2%A0":
7. In a private window, Click on event from not-logged-in calendar page shows event as a dialog box, it should show it as a panel with a subset of the edit event panel - i.e. the title, category, location, start and end and description. -
8.

View File

@ -3,7 +3,7 @@
* Plugin Name: Calendar Plugin
* Plugin URI: https://chezstephens.org.uk
* Description: Provides a single shared calendar for WordPress with public display, authenticated event editing, user approval workflow, ICS publishing, and CalDAV read/write sync. Supports recurring events, single-occurrence exceptions, admin setup and diagnostics pages, and shortcode rendering for full calendar and upcoming-events sidebar views.
* Version: 0.1.15
* Version: 1.0.1
* Requires at least: 6.0
* Requires PHP: 8.1
* Author: Adrian Stephens (with AI assistance)

View File

@ -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'] ?? ''));

View File

@ -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,
]
);
}
}

View File

@ -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');
@ -303,7 +346,7 @@ final class IcsService
if (!$in) {
continue;
}
[$left, $value] = array_pad(explode(':', $line, 2), 2, '');
[$left, $value] = $this->splitContentLine($line);
if ($left === '') {
continue;
}
@ -321,6 +364,23 @@ final class IcsService
return $in ? $props : null;
}
private function splitContentLine(string $line): array
{
$inQuotes = false;
$len = strlen($line);
for ($i = 0; $i < $len; $i++) {
$ch = $line[$i];
if ($ch === '"') {
$inQuotes = !$inQuotes;
continue;
}
if ($ch === ':' && !$inQuotes) {
return [substr($line, 0, $i), substr($line, $i + 1)];
}
}
return [$line, ''];
}
private function parseIcsDateTime(string $value, bool $dateOnly): ?string
{
$value = trim($value);

View File

@ -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'] ?? ''),

View File

@ -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);

View File

@ -180,10 +180,10 @@ final class Plugin
<p id="cp-status" style="margin:10px 0 8px 0;"></p>
<h3 id="cp-events-title" style="margin:0 0 8px 0;">Events</h3>
<div id="cp-view-panel"></div>
<ul id="cp-public-list" style="margin-top:8px;"></ul>
<ul id="cp-public-list" style="margin-top:8px;list-style:none;padding-left:0;"></ul>
<div id="cp-auth-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.35);z-index:1000;">
<div style="max-width:560px;margin:8vh auto;background:#fff;border-radius:8px;padding:12px;">
<div id="cp-auth-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.35);z-index:2147483000;align-items:center;justify-content:center;padding:12px;box-sizing:border-box;">
<div style="width:min(560px,100%);max-height:92vh;overflow:auto;background:#fff;border-radius:8px;padding:12px;">
<h3 style="margin:0 0 8px 0;">Account Login</h3>
<p id="cp-auth-status" style="margin:0 0 8px 0;"></p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;">
@ -203,12 +203,13 @@ final class Plugin
</div>
</div>
<div id="cp-editor-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.35);z-index:1000;">
<div id="cp-editor-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.35);z-index:2147483000;">
<div style="max-width:900px;margin:3vh auto;background:#fff;border-radius:8px;padding:12px;max-height:94vh;overflow:auto;">
<h3 id="cp-editor-title" style="margin:0 0 8px 0;">Create Event</h3>
<input id="cp-event-id" type="hidden" />
<div style="display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;">
<div style="grid-column:1 / span 4;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Title</label><input id="cp-title" type="text" style="width:100%;border:0;outline:none;" /></div>
<div style="grid-column:1 / span 4;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Visibility</label><select id="cp-visibility" style="width:100%;border:0;outline:none;background:#fff;"><option value="public">Public</option><option value="private">Private</option></select></div>
<div style="grid-column:1 / span 2;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Category</label><input id="cp-category" type="text" style="width:100%;border:0;outline:none;" /></div>
<div style="grid-column:3 / span 2;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Location</label><input id="cp-location" type="text" style="width:100%;border:0;outline:none;" /></div>
<div style="grid-column:1 / span 4;"><label style="display:inline-flex;align-items:center;gap:6px;"><input id="cp-all-day" type="checkbox" /> All Day Event</label></div>
@ -251,11 +252,12 @@ final class Plugin
</div>
</div>
<div id="cp-details-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.35);z-index:1000;">
<div id="cp-details-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.35);z-index:2147483000;">
<div style="max-width:900px;margin:4vh auto;background:#fff;border-radius:8px;padding:12px;max-height:90vh;overflow:auto;">
<h3 style="margin:0 0 8px 0;">Event Details</h3>
<div style="display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;">
<div style="grid-column:1 / span 4;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Title</label><input id="cp-details-title" type="text" readonly style="width:100%;border:0;outline:none;background:#fff;" /></div>
<div style="grid-column:1 / span 4;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Visibility</label><input id="cp-details-visibility" type="text" readonly style="width:100%;border:0;outline:none;background:#fff;" /></div>
<div style="grid-column:1 / span 2;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Category</label><input id="cp-details-category" type="text" readonly style="width:100%;border:0;outline:none;background:#fff;" /></div>
<div style="grid-column:3 / span 2;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Location</label><input id="cp-details-location" type="text" readonly style="width:100%;border:0;outline:none;background:#fff;" /></div>
<div style="grid-column:1 / span 2;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Start</label><input id="cp-details-start" type="text" readonly style="width:100%;border:0;outline:none;background:#fff;" /></div>
@ -285,6 +287,35 @@ final class Plugin
const esc=(v)=>{const d=document.createElement("div"); d.textContent=v==null?"":String(v); return d.innerHTML;};
const localYmd=(d)=>{const p=(n)=>String(n).padStart(2,"0"); return d.getFullYear()+"-"+p(d.getMonth()+1)+"-"+p(d.getDate());};
const dmy=(d)=>{const p=(n)=>String(n).padStart(2,"0"); return p(d.getDate())+"/"+p(d.getMonth()+1)+"/"+d.getFullYear();};
const longDate=(iso)=>{
if(!iso){return "";}
const d=new Date(iso);
if(Number.isNaN(d.getTime())){return ymd(iso);}
return d.toLocaleDateString("en-GB",{day:"numeric",month:"long",year:"numeric"});
};
const timeValue=(iso)=>{
if(!iso){return "";}
const d=new Date(iso);
if(Number.isNaN(d.getTime())){return "";}
let h=d.getHours();
const m=d.getMinutes();
const mer=h>=12?"pm":"am";
h=h%12;
if(h===0){h=12;}
if(m===0){return `${h}${mer}`;}
return `${h}.${String(m).padStart(2,"0")}${mer}`;
};
const timeRange=(startIso,endIso)=>{
const s=new Date(startIso);
const e=new Date(endIso);
if(Number.isNaN(s.getTime()) || Number.isNaN(e.getTime())){return "";}
const sm=s.getHours()>=12?"pm":"am";
const em=e.getHours()>=12?"pm":"am";
let sv=timeValue(startIso);
const ev=timeValue(endIso);
if(sm===em){sv=sv.replace(/(am|pm)$/,"");}
return `${sv}${ev}`;
};
const itemId=(it)=>String(it.event_id||it.id||"");
const futureWrap=()=>s("cp-future-wrap");
const monthNames=["January","February","March","April","May","June","July","August","September","October","November","December"];
@ -409,6 +440,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 +484,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 +511,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 +566,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,
@ -626,23 +661,23 @@ final class Plugin
const rowsForDay=(items)=>{
const t=theme();
return items.map(it=>`<div data-id="${esc(itemId(it))}" style="cursor:pointer;margin:2px 0;padding:3px 4px;border:1px solid ${t.border};background:${t.cellBg};border-left:3px solid ${t.accent};border-radius:4px;color:${t.text};">${esc(it.all_day_event?"All-day":hm(it.occurrence_start))} ${esc(it.title||"")}</div>`).join("");
return items.map(it=>`<div data-cp-id="${esc(itemId(it))}" style="cursor:pointer;margin:2px 0;padding:3px 4px;border:1px solid ${t.border};background:${t.cellBg};border-left:3px solid ${t.accent};border-radius:4px;color:${t.text};">${esc(it.all_day_event?"All-day":hm(it.occurrence_start))} ${esc(it.title||"")}</div>`).join("");
};
const bindClicks=()=>{
Array.from(document.querySelectorAll("#cp-view-panel [data-id], #cp-public-list [data-id]")).forEach(el=>{
Array.from(document.querySelectorAll("#cp-view-panel [data-cp-id], #cp-public-list [data-cp-id]")).forEach(el=>{
el.addEventListener("click",async()=>{
const id=el.getAttribute("data-id")||"";
const id=el.getAttribute("data-cp-id")||"";
const it=lastItems.find(x=>String(itemId(x))===String(id))||null;
if(it){await openEditorForItem(it);}
});
});
};
const bindCreateClicks=()=>{
Array.from(document.querySelectorAll("#cp-view-panel [data-create-date]")).forEach(el=>{
Array.from(document.querySelectorAll("#cp-view-panel [data-cp-create-date]")).forEach(el=>{
el.addEventListener("click",(ev)=>{
if(ev.target && ev.target.closest("[data-id]")){return;}
const dateYmd=el.getAttribute("data-create-date")||"";
if(ev.target && ev.target.closest("[data-cp-id]")){return;}
const dateYmd=el.getAttribute("data-cp-create-date")||"";
if(dateYmd){openEditorCreateAt(dateYmd);}
});
});
@ -662,14 +697,19 @@ final class Plugin
panel.innerHTML="";
(items||[]).slice(0,300).forEach(it=>{
const li=document.createElement("li");
li.setAttribute("data-id", itemId(it));
li.setAttribute("data-cp-id", itemId(it));
li.style.cursor="pointer";
li.style.background=t.cellBg;
li.style.border=`1px solid ${t.border}`;
li.style.margin="4px 0";
li.style.padding="6px 8px";
li.style.borderRadius="4px";
li.textContent=ymd(it.occurrence_start)+" "+(it.all_day_event?"All-day":(hm(it.occurrence_start)+""+hm(it.occurrence_end)))+" "+(it.description||it.title||"");
const dateLabel=longDate(it.occurrence_start||it.start_datetime);
const timeLabel=it.all_day_event?"All day":timeRange(it.occurrence_start,it.occurrence_end);
const title=it.title||"";
const desc=String(it.description||"").trim();
const headline=[dateLabel,timeLabel,title].filter(Boolean).join(", ");
li.innerHTML=`<div>${esc(headline)}</div>${(desc!=="" && desc!==title)?`<div style="color:${t.mutedText};margin-top:2px;">${esc(desc)}</div>`:""}`;
ul.appendChild(li);
});
bindClicks();
@ -702,7 +742,7 @@ final class Plugin
const d=new Date(start); d.setDate(start.getDate()+i);
const k=localYmd(d);
const ev=(map[k]||[]).sort((a,b)=>String(a.occurrence_start).localeCompare(String(b.occurrence_start)));
cells+=`<td data-create-date="${k}" style="cursor:pointer;vertical-align:top;border:1px solid ${t.border};padding:4px;background:${t.cellBg};"><div style="color:${t.mutedText};"><strong>${esc(k)}</strong></div>${rowsForDay(ev)}</td>`;
cells+=`<td data-cp-create-date="${k}" style="cursor:pointer;vertical-align:top;border:1px solid ${t.border};padding:4px;background:${t.cellBg};"><div style="color:${t.mutedText};"><strong>${esc(k)}</strong></div>${rowsForDay(ev)}</td>`;
}
panel.innerHTML=`<table style="width:100%;border-collapse:collapse;"><tr><th style="width:65px;border:1px solid ${t.border};background:${t.headBg};">Time</th>${dow.map(n=>`<th style="border:1px solid ${t.border};background:${t.headBg};">${n}</th>`).join("")}</tr><tr><td style="border:1px solid ${t.border};vertical-align:top;padding:4px;background:${t.mutedBg};color:${t.mutedText};">00:00<br>06:00<br>12:00<br>18:00</td>${cells}</tr></table>`;
bindClicks();
@ -726,7 +766,7 @@ final class Plugin
const ev=(map[k]||[]).sort((a,b)=>String(a.occurrence_start).localeCompare(String(b.occurrence_start)));
const inMonth=d.getMonth()===anchor.getMonth();
if(inMonth){rowHasInMonth=true;}
tds+=`<td data-create-date="${k}" style="cursor:pointer;vertical-align:top;border:1px solid ${t.border};padding:4px;min-height:80px;opacity:${inMonth?1:0.55};background:${inMonth?t.cellBg:t.mutedBg};"><div style="color:${t.mutedText};"><strong>${esc(k.slice(8,10))}</strong></div>${rowsForDay(ev.slice(0,6))}</td>`;
tds+=`<td data-cp-create-date="${k}" style="cursor:pointer;vertical-align:top;border:1px solid ${t.border};padding:4px;min-height:80px;opacity:${inMonth?1:0.55};background:${inMonth?t.cellBg:t.mutedBg};"><div style="color:${t.mutedText};"><strong>${esc(k.slice(8,10))}</strong></div>${rowsForDay(ev.slice(0,6))}</td>`;
}
rowParts.push({html:`<tr>${tds}</tr>`,has:rowHasInMonth});
}
@ -751,15 +791,15 @@ final class Plugin
const d=new Date(start); d.setDate(start.getDate()+i);
const k=localYmd(d);
const same=d.getMonth()===m;
g+=`<div data-day="${k}" style="cursor:pointer;padding:2px;border:1px solid ${t.border};text-align:center;opacity:${same?1:0.35};font-weight:${has(k)?700:400};background:${same?t.cellBg:t.mutedBg};">${d.getDate()}</div>`;
g+=`<div data-cp-day="${k}" style="cursor:pointer;padding:2px;border:1px solid ${t.border};text-align:center;opacity:${same?1:0.35};font-weight:${has(k)?700:400};background:${same?t.cellBg:t.mutedBg};">${d.getDate()}</div>`;
}
out+=`<div style="border:1px solid ${t.border};padding:4px;background:${t.cellBg};"><div style="color:${t.mutedText};"><strong>${mon[m]}</strong></div><div style="display:grid;grid-template-columns:repeat(7,1fr);gap:2px;">${g}</div></div>`;
}
out+='</div>';
panel.innerHTML=out;
Array.from(panel.querySelectorAll("[data-day]")).forEach(el=>{
Array.from(panel.querySelectorAll("[data-cp-day]")).forEach(el=>{
el.addEventListener("click",()=>{
s("cp-date").value=el.getAttribute("data-day")||s("cp-date").value;
s("cp-date").value=el.getAttribute("data-cp-day")||s("cp-date").value;
s("cp-view").value="week";
loadPublic();
});
@ -779,7 +819,7 @@ final class Plugin
futureWrap().style.display=s("cp-view").value==="list"?"flex":"none";
};
s("cp-open-login-btn").onclick=()=>{setAuthStatus("",false); s("cp-auth-modal").style.display="block";};
s("cp-open-login-btn").onclick=()=>{setAuthStatus("",false); s("cp-auth-modal").style.display="flex";};
s("cp-close-login-btn").onclick=()=>{s("cp-auth-modal").style.display="none";};
s("cp-logout-btn").onclick=async()=>{
await api("/users/logout",{method:"POST"});
@ -926,7 +966,7 @@ final class Plugin
const tokenFromUrl=(new URLSearchParams(window.location.search)).get("calendar_verify_token");
if(tokenFromUrl){
s("cp-verify-token").value=tokenFromUrl;
s("cp-auth-modal").style.display="block";
s("cp-auth-modal").style.display="flex";
setAuthStatus("Verification token loaded from link. Press Verify Email.",false);
}
@ -973,18 +1013,19 @@ HTML
}
$rows = [];
$sidebarTz = new \DateTimeZone('Europe/London');
foreach ($items as $item) {
$start = (string) ($item['occurrence_start'] ?? '');
$end = (string) ($item['occurrence_end'] ?? '');
$startTs = strtotime($start);
$endTs = strtotime($end);
$dateLabel = $startTs !== false ? date('j F Y', $startTs) : substr($start, 0, 10);
$startDt = $this->toSidebarDateTime($start, $sidebarTz);
$endDt = $this->toSidebarDateTime($end, $sidebarTz);
$dateLabel = $startDt !== null ? $startDt->format('j F Y') : substr($start, 0, 10);
$timeLabel = '';
if ($startTs !== false && $endTs !== false) {
$startTime = date('H:i', $startTs);
$endTime = date('H:i', $endTs);
if ($startDt !== null && $endDt !== null) {
$startTime = $startDt->format('H:i');
$endTime = $endDt->format('H:i');
if ($startTime !== '00:00' || $endTime !== '00:00') {
$timeLabel = $this->formatSidebarTimeRange($startTs, $endTs);
$timeLabel = $this->formatSidebarTimeRange($startDt, $endDt);
}
}
$title = trim((string) ($item['title'] ?? ''));
@ -1004,12 +1045,24 @@ HTML
return '<div class="calendar-plugin-shell" data-mode="sidebar">' . implode('', $rows) . '</div>';
}
private function formatSidebarTimeRange(int $startTs, int $endTs): string
private function toSidebarDateTime(string $value, \DateTimeZone $timezone): ?\DateTimeImmutable
{
$startMeridiem = strtolower(date('a', $startTs));
$endMeridiem = strtolower(date('a', $endTs));
$startLabel = $this->formatSidebarTimeValue($startTs);
$endLabel = $this->formatSidebarTimeValue($endTs);
if ($value === '') {
return null;
}
try {
return (new \DateTimeImmutable($value))->setTimezone($timezone);
} catch (\Throwable) {
return null;
}
}
private function formatSidebarTimeRange(\DateTimeImmutable $startDt, \DateTimeImmutable $endDt): string
{
$startMeridiem = strtolower($startDt->format('a'));
$endMeridiem = strtolower($endDt->format('a'));
$startLabel = $this->formatSidebarTimeValue($startDt);
$endLabel = $this->formatSidebarTimeValue($endDt);
if ($startMeridiem === $endMeridiem) {
$startLabel = preg_replace('/(am|pm)$/', '', $startLabel) ?: $startLabel;
return $startLabel . '' . $endLabel;
@ -1017,11 +1070,11 @@ HTML
return $startLabel . '' . $endLabel;
}
private function formatSidebarTimeValue(int $ts): string
private function formatSidebarTimeValue(\DateTimeImmutable $dt): string
{
$hour = (int) date('G', $ts);
$minute = (int) date('i', $ts);
$meridiem = strtolower(date('a', $ts));
$hour = (int) $dt->format('G');
$minute = (int) $dt->format('i');
$meridiem = strtolower($dt->format('a'));
$hour12 = $hour % 12;
if ($hour12 === 0) {
$hour12 = 12;
@ -1369,7 +1422,7 @@ HTML
return [
'status' => 'ok',
'plugin' => 'calendar-plugin',
'version' => '0.1.15',
'version' => '1.0.1',
'db_prefix' => $this->db->getPrefix(),
];
},
@ -1592,10 +1645,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 +1667,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 +1899,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 +2029,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 +2045,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';
@ -1993,12 +2061,20 @@ HTML
private function serveCalDavPath(string $path, string $method): void
{
$userAgent = (string) ($_SERVER['HTTP_USER_AGENT'] ?? '');
$caldavUser = $this->resolveCalDavUserForRequest(null);
$this->caldavTrace('request', [
'method' => $method,
'path' => $path,
'user_agent' => $userAgent,
'authorized' => $caldavUser !== null,
]);
if ($caldavUser === null) {
http_response_code(401);
header('WWW-Authenticate: Basic realm="Calendar CalDAV"');
header('Content-Type: application/xml; charset=utf-8');
echo '<error><message>auth required</message></error>';
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 401, 'reason' => 'auth_required']);
return;
}
@ -2010,10 +2086,22 @@ 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);
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 200, 'kind' => 'collection_head']);
return;
}
if (str_starts_with($path, $resourcePrefix) && str_ends_with($path, '.ics')) {
@ -2021,11 +2109,34 @@ HTML
$obj = $this->calDavService->getObject($resource);
if ($obj === null) {
http_response_code(404);
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 404, 'resource' => $resource]);
return;
}
header('Content-Type: text/calendar; charset=utf-8');
header('ETag: ' . (string) ($obj['etag'] ?? ''));
http_response_code(200);
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 200, 'resource' => $resource]);
return;
}
}
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);
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 200, 'kind' => 'collection_get']);
return;
}
}
@ -2034,6 +2145,7 @@ HTML
header('Allow: OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, HEAD');
header('DAV: 1, calendar-access');
http_response_code(200);
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 200, 'kind' => 'options']);
return;
}
@ -2042,18 +2154,22 @@ HTML
http_response_code(207);
if ($path === $root || $path === $root . '/') {
echo $this->caldavPropfindRootXml($root, $principal, $calendarsRoot, $collection);
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 207, 'kind' => 'propfind_root']);
return;
}
if ($path === $principalCollection || $path === rtrim($principalCollection, '/')) {
echo $this->caldavPropfindPrincipalCollectionXml($principalCollection, $principal);
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 207, 'kind' => 'propfind_principal_collection']);
return;
}
if ($path === $principal || $path === rtrim($principal, '/')) {
echo $this->caldavPropfindPrincipalXml($principal, $calendarsRoot);
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 207, 'kind' => 'propfind_principal']);
return;
}
if ($path === $calendarsRoot || $path === rtrim($calendarsRoot, '/')) {
echo $this->caldavPropfindCalendarsRootXml($calendarsRoot, $collection);
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 207, 'kind' => 'propfind_calendars_root']);
return;
}
if ($path === rtrim($collection, '/')) {
@ -2061,6 +2177,7 @@ HTML
}
if ($path === $collection) {
echo $this->caldavPropfindCollectionXml($collection, $this->caldavSyncToken(), true);
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 207, 'kind' => 'propfind_collection']);
return;
}
if (str_starts_with($path, $resourcePrefix) && str_ends_with($path, '.ics')) {
@ -2069,21 +2186,33 @@ HTML
if ($obj === null) {
http_response_code(404);
echo '<error><message>not found</message></error>';
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 404, 'resource' => $resource, 'kind' => 'propfind_object']);
return;
}
echo $this->caldavPropfindObjectXml($collection . $resource, (string) ($obj['etag'] ?? ''));
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 207, 'resource' => $resource, 'kind' => 'propfind_object']);
return;
}
http_response_code(404);
echo '<error><message>not found</message></error>';
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 404, 'kind' => 'propfind_not_found']);
return;
}
if ($method === 'REPORT' && $path === $collection) {
if ($method === 'REPORT' && ($path === $collection || $path === rtrim($collection, '/'))) {
$body = (string) file_get_contents('php://input');
$reportType = $this->caldavReportType($body);
header('Content-Type: application/xml; charset=utf-8');
http_response_code(207);
echo $this->caldavReportXml($collection, $body, $this->caldavSyncToken());
$this->caldavTrace('response', [
'method' => $method,
'path' => $path,
'status' => 207,
'kind' => 'report',
'report_type' => $reportType,
'body_bytes' => strlen($body),
]);
return;
}
@ -2093,12 +2222,14 @@ HTML
$obj = $this->calDavService->getObject($resource);
if ($obj === null) {
http_response_code(404);
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 404, 'resource' => $resource, 'kind' => 'object_get']);
return;
}
header('Content-Type: text/calendar; charset=utf-8');
header('ETag: ' . (string) ($obj['etag'] ?? ''));
http_response_code(200);
echo (string) ($obj['ics'] ?? '');
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 200, 'resource' => $resource, 'kind' => 'object_get']);
return;
}
if ($method === 'PUT') {
@ -2117,6 +2248,14 @@ HTML
http_response_code((int) ($error['status'] ?? 500));
header('Content-Type: application/xml; charset=utf-8');
echo '<error><message>' . esc_html((string) ($error['message'] ?? 'error')) . '</message></error>';
$this->caldavTrace('response', [
'method' => $method,
'path' => $path,
'status' => (int) ($error['status'] ?? 500),
'resource' => $resource,
'kind' => 'object_put',
'body_bytes' => strlen($raw),
]);
return;
}
$status = (int) ($result['status'] ?? 204);
@ -2125,6 +2264,14 @@ HTML
header('ETag: ' . (string) $event['etag']);
}
http_response_code($status);
$this->caldavTrace('response', [
'method' => $method,
'path' => $path,
'status' => $status,
'resource' => $resource,
'kind' => 'object_put',
'body_bytes' => strlen($raw),
]);
return;
}
if ($method === 'DELETE') {
@ -2132,15 +2279,24 @@ HTML
if (isset($result['error'])) {
$error = (array) $result['error'];
http_response_code((int) ($error['status'] ?? 500));
$this->caldavTrace('response', [
'method' => $method,
'path' => $path,
'status' => (int) ($error['status'] ?? 500),
'resource' => $resource,
'kind' => 'object_delete',
]);
return;
}
http_response_code(204);
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 204, 'resource' => $resource, 'kind' => 'object_delete']);
return;
}
}
http_response_code(405);
header('Allow: OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, HEAD');
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 405, 'reason' => 'method_not_allowed']);
}
private function caldavPropfindRootXml(string $root, string $principal, string $calendarsRoot, string $collection): string
@ -2267,9 +2423,30 @@ HTML
$bodyLower = strtolower($xmlBody);
$resources = array_map(static fn(array $r): string => (string) ($r['resource'] ?? ''), $this->calDavService->listResources());
$items = [];
$reportType = $this->caldavReportType($xmlBody);
$includeDeleted = false;
$deletedCount = 0;
$clientSyncToken = '';
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 {
// Emit current objects for initial/out-of-date tokens.
$items = $this->calDavService->multiget($resources);
// For incremental syncs, include a bounded set of deleted hrefs so clients can remove local copies.
if ($clientSyncToken !== '') {
$includeDeleted = true;
// Keep incremental deletes bounded to avoid overwhelming strict clients.
$deletedResources = $this->calDavService->listDeletedResources(20);
$deletedCount = count($deletedResources);
foreach ($deletedResources as $deletedResource) {
$items[] = ['resource' => $deletedResource, 'status' => 404];
}
}
}
} 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)) {
@ -2299,17 +2476,38 @@ HTML
}
$responses = '';
$okCount = 0;
$notFoundCount = 0;
foreach ($items as $item) {
$status = (int) ($item['status'] ?? 404);
$resource = (string) ($item['resource'] ?? '');
$responses .= '<D:response><D:href>' . htmlspecialchars($collection . $resource, ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</D:href><D:propstat><D:prop>';
$responses .= '<D:response><D:href>' . htmlspecialchars($collection . $resource, ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</D:href>';
if ($status === 200) {
$okCount++;
$responses .= '<D:propstat><D:prop>';
$responses .= '<D:getetag>' . htmlspecialchars((string) ($item['etag'] ?? ''), ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</D:getetag>'
. '<C:calendar-data xmlns:C="urn:ietf:params:xml:ns:caldav">' . htmlspecialchars((string) ($item['ics'] ?? ''), ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</C:calendar-data>';
$responses .= '</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat>';
} else {
$notFoundCount++;
// For sync-collection deletions, clients expect bare status (no propstat block).
if ($reportType === 'sync-collection') {
$responses .= '<D:status>HTTP/1.1 404 Not Found</D:status>';
} else {
$responses .= '<D:propstat><D:prop></D:prop><D:status>HTTP/1.1 404 Not Found</D:status></D:propstat>';
}
}
$responses .= '</D:prop><D:status>HTTP/1.1 ' . $status . ($status === 200 ? ' OK' : ' Not Found')
. '</D:status></D:propstat></D:response>';
$responses .= '</D:response>';
}
$this->caldavTrace('report', [
'report_type' => $reportType,
'client_sync_token_present' => $clientSyncToken !== '',
'client_sync_token_matches' => $clientSyncToken !== '' && $clientSyncToken === $syncToken,
'include_deleted' => $includeDeleted,
'deleted_included_count' => $deletedCount,
'response_ok_count' => $okCount,
'response_not_found_count' => $notFoundCount,
]);
return '<?xml version="1.0" encoding="utf-8"?><D:multistatus xmlns:D="DAV:"><D:sync-token>'
. htmlspecialchars($syncToken, ENT_XML1 | ENT_QUOTES, 'UTF-8')
. '</D:sync-token>'
@ -2324,6 +2522,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 +2554,43 @@ 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 caldavReportType(string $xmlBody): string
{
$bodyLower = strtolower($xmlBody);
if (str_contains($bodyLower, 'sync-collection')) {
return 'sync-collection';
}
if (str_contains($bodyLower, 'calendar-query')) {
return 'calendar-query';
}
if (str_contains($bodyLower, 'calendar-multiget')) {
return 'calendar-multiget';
}
return 'other';
}
private function caldavTrace(string $event, array $context = []): void
{
if (!$this->isDiagnosticsEnabled()) {
return;
}
$payload = [
'event' => $event,
'at' => gmdate('c'),
'context' => $context,
];
error_log('[calendar-plugin][caldav] ' . wp_json_encode($payload));
}
private function unwrapServiceResult(array $result): array|\WP_Error
{
if (isset($result['error']) && is_array($result['error'])) {

View File

@ -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
7c740beff8c22271e1d3e578368c57b9d4b9ebdc42852f0fc967441d39c87d7b ./src/Plugin.php
4e6930c79d9ce1045be6863ddd7546e19f7ef7e839d41a41014a3efb3183eaf0 ./src/bootstrap.php
893c6df62beed87a981d372c473e5012d1b5d1c254d23b39cda44ae8a08cd16c ./uninstall.php

Binary file not shown.

View File

@ -0,0 +1,20 @@
af5fe54d7e0ded93bddd52890eb2d7f78ae378e3c8008dafabb527d152336f5d ./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
8af4c2cb42b8ec24b0cce507d9e0911063c8a4caa5023216125fdc5988bc5eaa ./src/Plugin.php
4e6930c79d9ce1045be6863ddd7546e19f7ef7e839d41a41014a3efb3183eaf0 ./src/bootstrap.php
893c6df62beed87a981d372c473e5012d1b5d1c254d23b39cda44ae8a08cd16c ./uninstall.php

Binary file not shown.

View File

@ -0,0 +1,20 @@
9557a73776dcd4d43288d6415589555eaa537eee22d13592678699bc4cd02efc ./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
83e082e7afd6504c89241a11a2fcfdbd685985083a932fa30bbfc2df7518c3c7 ./src/Plugin.php
4e6930c79d9ce1045be6863ddd7546e19f7ef7e839d41a41014a3efb3183eaf0 ./src/bootstrap.php
893c6df62beed87a981d372c473e5012d1b5d1c254d23b39cda44ae8a08cd16c ./uninstall.php

Binary file not shown.

View File

@ -0,0 +1,20 @@
a3e0e5bbbf679c4f2f98de78149fa56365f29f4b170b8d2d7e8cafa98a730e43 ./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
db3af8bbd3c7bb7a84e2309ea5ba5c350cb02c72ac4849778d6c219686c36d8f ./src/Plugin.php
4e6930c79d9ce1045be6863ddd7546e19f7ef7e839d41a41014a3efb3183eaf0 ./src/bootstrap.php
893c6df62beed87a981d372c473e5012d1b5d1c254d23b39cda44ae8a08cd16c ./uninstall.php

Binary file not shown.

View File

@ -0,0 +1,20 @@
c1a836ec784d11ede886a399b128ed803c0fbaec60458e1fb6b72f6223745ee8 ./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
37b8f3077dcf9c0eb40dbcfefa7a5d8cebfe616051d827ad6ae3bb5a5fbccec7 ./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
3f3dd111db8a73fc2dfb8e8ec23c04cc15b805d0e9fe3006a63f4e656a80ff1d ./src/Plugin.php
4e6930c79d9ce1045be6863ddd7546e19f7ef7e839d41a41014a3efb3183eaf0 ./src/bootstrap.php
893c6df62beed87a981d372c473e5012d1b5d1c254d23b39cda44ae8a08cd16c ./uninstall.php

Binary file not shown.

View File

@ -0,0 +1,20 @@
67c918ffb3c537d09017ee1c0650d4007ec376f2b75632bb366e5f370b3671a3 ./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
37b8f3077dcf9c0eb40dbcfefa7a5d8cebfe616051d827ad6ae3bb5a5fbccec7 ./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
598ef126274bf5b19bf47e18c617f26dfa95639f73602d5113e3a50ef12359c1 ./src/Plugin.php
4e6930c79d9ce1045be6863ddd7546e19f7ef7e839d41a41014a3efb3183eaf0 ./src/bootstrap.php
893c6df62beed87a981d372c473e5012d1b5d1c254d23b39cda44ae8a08cd16c ./uninstall.php

Binary file not shown.

View File

@ -0,0 +1,20 @@
93108c80fea01a4d1de36fbf3b02148c060051ef4c6ac85e34076061e9b222ac ./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
37b8f3077dcf9c0eb40dbcfefa7a5d8cebfe616051d827ad6ae3bb5a5fbccec7 ./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
b9d407b4415d3fd1db145b9ba0b0f950e2993a72828783caf94d0d537fe077ff ./src/Plugin.php
4e6930c79d9ce1045be6863ddd7546e19f7ef7e839d41a41014a3efb3183eaf0 ./src/bootstrap.php
893c6df62beed87a981d372c473e5012d1b5d1c254d23b39cda44ae8a08cd16c ./uninstall.php

Binary file not shown.

View File

@ -0,0 +1,20 @@
042b7c45db8136ba535ea1d892a02826fb75a8607fc7f07984b9f7692dc47040 ./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
37b8f3077dcf9c0eb40dbcfefa7a5d8cebfe616051d827ad6ae3bb5a5fbccec7 ./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
d268d1b18384c399bd5cd5d31f96b26d271c559d656ef447428594f76078ff46 ./src/Plugin.php
4e6930c79d9ce1045be6863ddd7546e19f7ef7e839d41a41014a3efb3183eaf0 ./src/bootstrap.php
893c6df62beed87a981d372c473e5012d1b5d1c254d23b39cda44ae8a08cd16c ./uninstall.php

Binary file not shown.

View File

@ -3,7 +3,7 @@
* Plugin Name: Calendar Plugin
* Plugin URI: https://chezstephens.org.uk
* Description: Provides a single shared calendar for WordPress with public display, authenticated event editing, user approval workflow, ICS publishing, and CalDAV read/write sync. Supports recurring events, single-occurrence exceptions, admin setup and diagnostics pages, and shortcode rendering for full calendar and upcoming-events sidebar views.
* Version: 0.1.15
* Version: 1.0.1
* Requires at least: 6.0
* Requires PHP: 8.1
* Author: Adrian Stephens (with AI assistance)

View File

@ -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'] ?? ''));

View File

@ -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,
]
);
}
}

View File

@ -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');
@ -303,7 +346,7 @@ final class IcsService
if (!$in) {
continue;
}
[$left, $value] = array_pad(explode(':', $line, 2), 2, '');
[$left, $value] = $this->splitContentLine($line);
if ($left === '') {
continue;
}
@ -321,6 +364,23 @@ final class IcsService
return $in ? $props : null;
}
private function splitContentLine(string $line): array
{
$inQuotes = false;
$len = strlen($line);
for ($i = 0; $i < $len; $i++) {
$ch = $line[$i];
if ($ch === '"') {
$inQuotes = !$inQuotes;
continue;
}
if ($ch === ':' && !$inQuotes) {
return [substr($line, 0, $i), substr($line, $i + 1)];
}
}
return [$line, ''];
}
private function parseIcsDateTime(string $value, bool $dateOnly): ?string
{
$value = trim($value);

View File

@ -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'] ?? ''),

View File

@ -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);

View File

@ -180,10 +180,10 @@ final class Plugin
<p id="cp-status" style="margin:10px 0 8px 0;"></p>
<h3 id="cp-events-title" style="margin:0 0 8px 0;">Events</h3>
<div id="cp-view-panel"></div>
<ul id="cp-public-list" style="margin-top:8px;"></ul>
<ul id="cp-public-list" style="margin-top:8px;list-style:none;padding-left:0;"></ul>
<div id="cp-auth-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.35);z-index:1000;">
<div style="max-width:560px;margin:8vh auto;background:#fff;border-radius:8px;padding:12px;">
<div id="cp-auth-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.35);z-index:2147483000;align-items:center;justify-content:center;padding:12px;box-sizing:border-box;">
<div style="width:min(560px,100%);max-height:92vh;overflow:auto;background:#fff;border-radius:8px;padding:12px;">
<h3 style="margin:0 0 8px 0;">Account Login</h3>
<p id="cp-auth-status" style="margin:0 0 8px 0;"></p>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;">
@ -203,12 +203,13 @@ final class Plugin
</div>
</div>
<div id="cp-editor-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.35);z-index:1000;">
<div id="cp-editor-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.35);z-index:2147483000;">
<div style="max-width:900px;margin:3vh auto;background:#fff;border-radius:8px;padding:12px;max-height:94vh;overflow:auto;">
<h3 id="cp-editor-title" style="margin:0 0 8px 0;">Create Event</h3>
<input id="cp-event-id" type="hidden" />
<div style="display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;">
<div style="grid-column:1 / span 4;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Title</label><input id="cp-title" type="text" style="width:100%;border:0;outline:none;" /></div>
<div style="grid-column:1 / span 4;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Visibility</label><select id="cp-visibility" style="width:100%;border:0;outline:none;background:#fff;"><option value="public">Public</option><option value="private">Private</option></select></div>
<div style="grid-column:1 / span 2;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Category</label><input id="cp-category" type="text" style="width:100%;border:0;outline:none;" /></div>
<div style="grid-column:3 / span 2;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Location</label><input id="cp-location" type="text" style="width:100%;border:0;outline:none;" /></div>
<div style="grid-column:1 / span 4;"><label style="display:inline-flex;align-items:center;gap:6px;"><input id="cp-all-day" type="checkbox" /> All Day Event</label></div>
@ -251,11 +252,12 @@ final class Plugin
</div>
</div>
<div id="cp-details-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.35);z-index:1000;">
<div id="cp-details-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.35);z-index:2147483000;">
<div style="max-width:900px;margin:4vh auto;background:#fff;border-radius:8px;padding:12px;max-height:90vh;overflow:auto;">
<h3 style="margin:0 0 8px 0;">Event Details</h3>
<div style="display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;">
<div style="grid-column:1 / span 4;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Title</label><input id="cp-details-title" type="text" readonly style="width:100%;border:0;outline:none;background:#fff;" /></div>
<div style="grid-column:1 / span 4;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Visibility</label><input id="cp-details-visibility" type="text" readonly style="width:100%;border:0;outline:none;background:#fff;" /></div>
<div style="grid-column:1 / span 2;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Category</label><input id="cp-details-category" type="text" readonly style="width:100%;border:0;outline:none;background:#fff;" /></div>
<div style="grid-column:3 / span 2;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Location</label><input id="cp-details-location" type="text" readonly style="width:100%;border:0;outline:none;background:#fff;" /></div>
<div style="grid-column:1 / span 2;position:relative;border:1px solid #c7c7c7;border-radius:6px;padding:14px 8px 6px 8px;background:#fff;"><label style="position:absolute;top:-8px;left:8px;font-size:11px;background:#fff;padding:0 4px;color:#666;">Start</label><input id="cp-details-start" type="text" readonly style="width:100%;border:0;outline:none;background:#fff;" /></div>
@ -285,6 +287,35 @@ final class Plugin
const esc=(v)=>{const d=document.createElement("div"); d.textContent=v==null?"":String(v); return d.innerHTML;};
const localYmd=(d)=>{const p=(n)=>String(n).padStart(2,"0"); return d.getFullYear()+"-"+p(d.getMonth()+1)+"-"+p(d.getDate());};
const dmy=(d)=>{const p=(n)=>String(n).padStart(2,"0"); return p(d.getDate())+"/"+p(d.getMonth()+1)+"/"+d.getFullYear();};
const longDate=(iso)=>{
if(!iso){return "";}
const d=new Date(iso);
if(Number.isNaN(d.getTime())){return ymd(iso);}
return d.toLocaleDateString("en-GB",{day:"numeric",month:"long",year:"numeric"});
};
const timeValue=(iso)=>{
if(!iso){return "";}
const d=new Date(iso);
if(Number.isNaN(d.getTime())){return "";}
let h=d.getHours();
const m=d.getMinutes();
const mer=h>=12?"pm":"am";
h=h%12;
if(h===0){h=12;}
if(m===0){return `${h}${mer}`;}
return `${h}.${String(m).padStart(2,"0")}${mer}`;
};
const timeRange=(startIso,endIso)=>{
const s=new Date(startIso);
const e=new Date(endIso);
if(Number.isNaN(s.getTime()) || Number.isNaN(e.getTime())){return "";}
const sm=s.getHours()>=12?"pm":"am";
const em=e.getHours()>=12?"pm":"am";
let sv=timeValue(startIso);
const ev=timeValue(endIso);
if(sm===em){sv=sv.replace(/(am|pm)$/,"");}
return `${sv}${ev}`;
};
const itemId=(it)=>String(it.event_id||it.id||"");
const futureWrap=()=>s("cp-future-wrap");
const monthNames=["January","February","March","April","May","June","July","August","September","October","November","December"];
@ -409,6 +440,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 +484,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 +511,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 +566,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,
@ -626,23 +661,23 @@ final class Plugin
const rowsForDay=(items)=>{
const t=theme();
return items.map(it=>`<div data-id="${esc(itemId(it))}" style="cursor:pointer;margin:2px 0;padding:3px 4px;border:1px solid ${t.border};background:${t.cellBg};border-left:3px solid ${t.accent};border-radius:4px;color:${t.text};">${esc(it.all_day_event?"All-day":hm(it.occurrence_start))} ${esc(it.title||"")}</div>`).join("");
return items.map(it=>`<div data-cp-id="${esc(itemId(it))}" style="cursor:pointer;margin:2px 0;padding:3px 4px;border:1px solid ${t.border};background:${t.cellBg};border-left:3px solid ${t.accent};border-radius:4px;color:${t.text};">${esc(it.all_day_event?"All-day":hm(it.occurrence_start))} ${esc(it.title||"")}</div>`).join("");
};
const bindClicks=()=>{
Array.from(document.querySelectorAll("#cp-view-panel [data-id], #cp-public-list [data-id]")).forEach(el=>{
Array.from(document.querySelectorAll("#cp-view-panel [data-cp-id], #cp-public-list [data-cp-id]")).forEach(el=>{
el.addEventListener("click",async()=>{
const id=el.getAttribute("data-id")||"";
const id=el.getAttribute("data-cp-id")||"";
const it=lastItems.find(x=>String(itemId(x))===String(id))||null;
if(it){await openEditorForItem(it);}
});
});
};
const bindCreateClicks=()=>{
Array.from(document.querySelectorAll("#cp-view-panel [data-create-date]")).forEach(el=>{
Array.from(document.querySelectorAll("#cp-view-panel [data-cp-create-date]")).forEach(el=>{
el.addEventListener("click",(ev)=>{
if(ev.target && ev.target.closest("[data-id]")){return;}
const dateYmd=el.getAttribute("data-create-date")||"";
if(ev.target && ev.target.closest("[data-cp-id]")){return;}
const dateYmd=el.getAttribute("data-cp-create-date")||"";
if(dateYmd){openEditorCreateAt(dateYmd);}
});
});
@ -662,14 +697,19 @@ final class Plugin
panel.innerHTML="";
(items||[]).slice(0,300).forEach(it=>{
const li=document.createElement("li");
li.setAttribute("data-id", itemId(it));
li.setAttribute("data-cp-id", itemId(it));
li.style.cursor="pointer";
li.style.background=t.cellBg;
li.style.border=`1px solid ${t.border}`;
li.style.margin="4px 0";
li.style.padding="6px 8px";
li.style.borderRadius="4px";
li.textContent=ymd(it.occurrence_start)+" "+(it.all_day_event?"All-day":(hm(it.occurrence_start)+""+hm(it.occurrence_end)))+" "+(it.description||it.title||"");
const dateLabel=longDate(it.occurrence_start||it.start_datetime);
const timeLabel=it.all_day_event?"All day":timeRange(it.occurrence_start,it.occurrence_end);
const title=it.title||"";
const desc=String(it.description||"").trim();
const headline=[dateLabel,timeLabel,title].filter(Boolean).join(", ");
li.innerHTML=`<div>${esc(headline)}</div>${(desc!=="" && desc!==title)?`<div style="color:${t.mutedText};margin-top:2px;">${esc(desc)}</div>`:""}`;
ul.appendChild(li);
});
bindClicks();
@ -702,7 +742,7 @@ final class Plugin
const d=new Date(start); d.setDate(start.getDate()+i);
const k=localYmd(d);
const ev=(map[k]||[]).sort((a,b)=>String(a.occurrence_start).localeCompare(String(b.occurrence_start)));
cells+=`<td data-create-date="${k}" style="cursor:pointer;vertical-align:top;border:1px solid ${t.border};padding:4px;background:${t.cellBg};"><div style="color:${t.mutedText};"><strong>${esc(k)}</strong></div>${rowsForDay(ev)}</td>`;
cells+=`<td data-cp-create-date="${k}" style="cursor:pointer;vertical-align:top;border:1px solid ${t.border};padding:4px;background:${t.cellBg};"><div style="color:${t.mutedText};"><strong>${esc(k)}</strong></div>${rowsForDay(ev)}</td>`;
}
panel.innerHTML=`<table style="width:100%;border-collapse:collapse;"><tr><th style="width:65px;border:1px solid ${t.border};background:${t.headBg};">Time</th>${dow.map(n=>`<th style="border:1px solid ${t.border};background:${t.headBg};">${n}</th>`).join("")}</tr><tr><td style="border:1px solid ${t.border};vertical-align:top;padding:4px;background:${t.mutedBg};color:${t.mutedText};">00:00<br>06:00<br>12:00<br>18:00</td>${cells}</tr></table>`;
bindClicks();
@ -726,7 +766,7 @@ final class Plugin
const ev=(map[k]||[]).sort((a,b)=>String(a.occurrence_start).localeCompare(String(b.occurrence_start)));
const inMonth=d.getMonth()===anchor.getMonth();
if(inMonth){rowHasInMonth=true;}
tds+=`<td data-create-date="${k}" style="cursor:pointer;vertical-align:top;border:1px solid ${t.border};padding:4px;min-height:80px;opacity:${inMonth?1:0.55};background:${inMonth?t.cellBg:t.mutedBg};"><div style="color:${t.mutedText};"><strong>${esc(k.slice(8,10))}</strong></div>${rowsForDay(ev.slice(0,6))}</td>`;
tds+=`<td data-cp-create-date="${k}" style="cursor:pointer;vertical-align:top;border:1px solid ${t.border};padding:4px;min-height:80px;opacity:${inMonth?1:0.55};background:${inMonth?t.cellBg:t.mutedBg};"><div style="color:${t.mutedText};"><strong>${esc(k.slice(8,10))}</strong></div>${rowsForDay(ev.slice(0,6))}</td>`;
}
rowParts.push({html:`<tr>${tds}</tr>`,has:rowHasInMonth});
}
@ -751,15 +791,15 @@ final class Plugin
const d=new Date(start); d.setDate(start.getDate()+i);
const k=localYmd(d);
const same=d.getMonth()===m;
g+=`<div data-day="${k}" style="cursor:pointer;padding:2px;border:1px solid ${t.border};text-align:center;opacity:${same?1:0.35};font-weight:${has(k)?700:400};background:${same?t.cellBg:t.mutedBg};">${d.getDate()}</div>`;
g+=`<div data-cp-day="${k}" style="cursor:pointer;padding:2px;border:1px solid ${t.border};text-align:center;opacity:${same?1:0.35};font-weight:${has(k)?700:400};background:${same?t.cellBg:t.mutedBg};">${d.getDate()}</div>`;
}
out+=`<div style="border:1px solid ${t.border};padding:4px;background:${t.cellBg};"><div style="color:${t.mutedText};"><strong>${mon[m]}</strong></div><div style="display:grid;grid-template-columns:repeat(7,1fr);gap:2px;">${g}</div></div>`;
}
out+='</div>';
panel.innerHTML=out;
Array.from(panel.querySelectorAll("[data-day]")).forEach(el=>{
Array.from(panel.querySelectorAll("[data-cp-day]")).forEach(el=>{
el.addEventListener("click",()=>{
s("cp-date").value=el.getAttribute("data-day")||s("cp-date").value;
s("cp-date").value=el.getAttribute("data-cp-day")||s("cp-date").value;
s("cp-view").value="week";
loadPublic();
});
@ -779,7 +819,7 @@ final class Plugin
futureWrap().style.display=s("cp-view").value==="list"?"flex":"none";
};
s("cp-open-login-btn").onclick=()=>{setAuthStatus("",false); s("cp-auth-modal").style.display="block";};
s("cp-open-login-btn").onclick=()=>{setAuthStatus("",false); s("cp-auth-modal").style.display="flex";};
s("cp-close-login-btn").onclick=()=>{s("cp-auth-modal").style.display="none";};
s("cp-logout-btn").onclick=async()=>{
await api("/users/logout",{method:"POST"});
@ -926,7 +966,7 @@ final class Plugin
const tokenFromUrl=(new URLSearchParams(window.location.search)).get("calendar_verify_token");
if(tokenFromUrl){
s("cp-verify-token").value=tokenFromUrl;
s("cp-auth-modal").style.display="block";
s("cp-auth-modal").style.display="flex";
setAuthStatus("Verification token loaded from link. Press Verify Email.",false);
}
@ -973,18 +1013,19 @@ HTML
}
$rows = [];
$sidebarTz = new \DateTimeZone('Europe/London');
foreach ($items as $item) {
$start = (string) ($item['occurrence_start'] ?? '');
$end = (string) ($item['occurrence_end'] ?? '');
$startTs = strtotime($start);
$endTs = strtotime($end);
$dateLabel = $startTs !== false ? date('j F Y', $startTs) : substr($start, 0, 10);
$startDt = $this->toSidebarDateTime($start, $sidebarTz);
$endDt = $this->toSidebarDateTime($end, $sidebarTz);
$dateLabel = $startDt !== null ? $startDt->format('j F Y') : substr($start, 0, 10);
$timeLabel = '';
if ($startTs !== false && $endTs !== false) {
$startTime = date('H:i', $startTs);
$endTime = date('H:i', $endTs);
if ($startDt !== null && $endDt !== null) {
$startTime = $startDt->format('H:i');
$endTime = $endDt->format('H:i');
if ($startTime !== '00:00' || $endTime !== '00:00') {
$timeLabel = $this->formatSidebarTimeRange($startTs, $endTs);
$timeLabel = $this->formatSidebarTimeRange($startDt, $endDt);
}
}
$title = trim((string) ($item['title'] ?? ''));
@ -1004,12 +1045,24 @@ HTML
return '<div class="calendar-plugin-shell" data-mode="sidebar">' . implode('', $rows) . '</div>';
}
private function formatSidebarTimeRange(int $startTs, int $endTs): string
private function toSidebarDateTime(string $value, \DateTimeZone $timezone): ?\DateTimeImmutable
{
$startMeridiem = strtolower(date('a', $startTs));
$endMeridiem = strtolower(date('a', $endTs));
$startLabel = $this->formatSidebarTimeValue($startTs);
$endLabel = $this->formatSidebarTimeValue($endTs);
if ($value === '') {
return null;
}
try {
return (new \DateTimeImmutable($value))->setTimezone($timezone);
} catch (\Throwable) {
return null;
}
}
private function formatSidebarTimeRange(\DateTimeImmutable $startDt, \DateTimeImmutable $endDt): string
{
$startMeridiem = strtolower($startDt->format('a'));
$endMeridiem = strtolower($endDt->format('a'));
$startLabel = $this->formatSidebarTimeValue($startDt);
$endLabel = $this->formatSidebarTimeValue($endDt);
if ($startMeridiem === $endMeridiem) {
$startLabel = preg_replace('/(am|pm)$/', '', $startLabel) ?: $startLabel;
return $startLabel . '' . $endLabel;
@ -1017,11 +1070,11 @@ HTML
return $startLabel . '' . $endLabel;
}
private function formatSidebarTimeValue(int $ts): string
private function formatSidebarTimeValue(\DateTimeImmutable $dt): string
{
$hour = (int) date('G', $ts);
$minute = (int) date('i', $ts);
$meridiem = strtolower(date('a', $ts));
$hour = (int) $dt->format('G');
$minute = (int) $dt->format('i');
$meridiem = strtolower($dt->format('a'));
$hour12 = $hour % 12;
if ($hour12 === 0) {
$hour12 = 12;
@ -1369,7 +1422,7 @@ HTML
return [
'status' => 'ok',
'plugin' => 'calendar-plugin',
'version' => '0.1.15',
'version' => '1.0.1',
'db_prefix' => $this->db->getPrefix(),
];
},
@ -1592,10 +1645,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 +1667,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 +1899,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 +2029,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 +2045,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';
@ -1993,12 +2061,20 @@ HTML
private function serveCalDavPath(string $path, string $method): void
{
$userAgent = (string) ($_SERVER['HTTP_USER_AGENT'] ?? '');
$caldavUser = $this->resolveCalDavUserForRequest(null);
$this->caldavTrace('request', [
'method' => $method,
'path' => $path,
'user_agent' => $userAgent,
'authorized' => $caldavUser !== null,
]);
if ($caldavUser === null) {
http_response_code(401);
header('WWW-Authenticate: Basic realm="Calendar CalDAV"');
header('Content-Type: application/xml; charset=utf-8');
echo '<error><message>auth required</message></error>';
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 401, 'reason' => 'auth_required']);
return;
}
@ -2010,10 +2086,22 @@ 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);
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 200, 'kind' => 'collection_head']);
return;
}
if (str_starts_with($path, $resourcePrefix) && str_ends_with($path, '.ics')) {
@ -2021,11 +2109,34 @@ HTML
$obj = $this->calDavService->getObject($resource);
if ($obj === null) {
http_response_code(404);
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 404, 'resource' => $resource]);
return;
}
header('Content-Type: text/calendar; charset=utf-8');
header('ETag: ' . (string) ($obj['etag'] ?? ''));
http_response_code(200);
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 200, 'resource' => $resource]);
return;
}
}
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);
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 200, 'kind' => 'collection_get']);
return;
}
}
@ -2034,6 +2145,7 @@ HTML
header('Allow: OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, HEAD');
header('DAV: 1, calendar-access');
http_response_code(200);
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 200, 'kind' => 'options']);
return;
}
@ -2042,18 +2154,22 @@ HTML
http_response_code(207);
if ($path === $root || $path === $root . '/') {
echo $this->caldavPropfindRootXml($root, $principal, $calendarsRoot, $collection);
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 207, 'kind' => 'propfind_root']);
return;
}
if ($path === $principalCollection || $path === rtrim($principalCollection, '/')) {
echo $this->caldavPropfindPrincipalCollectionXml($principalCollection, $principal);
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 207, 'kind' => 'propfind_principal_collection']);
return;
}
if ($path === $principal || $path === rtrim($principal, '/')) {
echo $this->caldavPropfindPrincipalXml($principal, $calendarsRoot);
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 207, 'kind' => 'propfind_principal']);
return;
}
if ($path === $calendarsRoot || $path === rtrim($calendarsRoot, '/')) {
echo $this->caldavPropfindCalendarsRootXml($calendarsRoot, $collection);
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 207, 'kind' => 'propfind_calendars_root']);
return;
}
if ($path === rtrim($collection, '/')) {
@ -2061,6 +2177,7 @@ HTML
}
if ($path === $collection) {
echo $this->caldavPropfindCollectionXml($collection, $this->caldavSyncToken(), true);
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 207, 'kind' => 'propfind_collection']);
return;
}
if (str_starts_with($path, $resourcePrefix) && str_ends_with($path, '.ics')) {
@ -2069,21 +2186,33 @@ HTML
if ($obj === null) {
http_response_code(404);
echo '<error><message>not found</message></error>';
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 404, 'resource' => $resource, 'kind' => 'propfind_object']);
return;
}
echo $this->caldavPropfindObjectXml($collection . $resource, (string) ($obj['etag'] ?? ''));
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 207, 'resource' => $resource, 'kind' => 'propfind_object']);
return;
}
http_response_code(404);
echo '<error><message>not found</message></error>';
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 404, 'kind' => 'propfind_not_found']);
return;
}
if ($method === 'REPORT' && $path === $collection) {
if ($method === 'REPORT' && ($path === $collection || $path === rtrim($collection, '/'))) {
$body = (string) file_get_contents('php://input');
$reportType = $this->caldavReportType($body);
header('Content-Type: application/xml; charset=utf-8');
http_response_code(207);
echo $this->caldavReportXml($collection, $body, $this->caldavSyncToken());
$this->caldavTrace('response', [
'method' => $method,
'path' => $path,
'status' => 207,
'kind' => 'report',
'report_type' => $reportType,
'body_bytes' => strlen($body),
]);
return;
}
@ -2093,12 +2222,14 @@ HTML
$obj = $this->calDavService->getObject($resource);
if ($obj === null) {
http_response_code(404);
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 404, 'resource' => $resource, 'kind' => 'object_get']);
return;
}
header('Content-Type: text/calendar; charset=utf-8');
header('ETag: ' . (string) ($obj['etag'] ?? ''));
http_response_code(200);
echo (string) ($obj['ics'] ?? '');
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 200, 'resource' => $resource, 'kind' => 'object_get']);
return;
}
if ($method === 'PUT') {
@ -2117,6 +2248,14 @@ HTML
http_response_code((int) ($error['status'] ?? 500));
header('Content-Type: application/xml; charset=utf-8');
echo '<error><message>' . esc_html((string) ($error['message'] ?? 'error')) . '</message></error>';
$this->caldavTrace('response', [
'method' => $method,
'path' => $path,
'status' => (int) ($error['status'] ?? 500),
'resource' => $resource,
'kind' => 'object_put',
'body_bytes' => strlen($raw),
]);
return;
}
$status = (int) ($result['status'] ?? 204);
@ -2125,6 +2264,14 @@ HTML
header('ETag: ' . (string) $event['etag']);
}
http_response_code($status);
$this->caldavTrace('response', [
'method' => $method,
'path' => $path,
'status' => $status,
'resource' => $resource,
'kind' => 'object_put',
'body_bytes' => strlen($raw),
]);
return;
}
if ($method === 'DELETE') {
@ -2132,15 +2279,24 @@ HTML
if (isset($result['error'])) {
$error = (array) $result['error'];
http_response_code((int) ($error['status'] ?? 500));
$this->caldavTrace('response', [
'method' => $method,
'path' => $path,
'status' => (int) ($error['status'] ?? 500),
'resource' => $resource,
'kind' => 'object_delete',
]);
return;
}
http_response_code(204);
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 204, 'resource' => $resource, 'kind' => 'object_delete']);
return;
}
}
http_response_code(405);
header('Allow: OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, HEAD');
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 405, 'reason' => 'method_not_allowed']);
}
private function caldavPropfindRootXml(string $root, string $principal, string $calendarsRoot, string $collection): string
@ -2267,9 +2423,30 @@ HTML
$bodyLower = strtolower($xmlBody);
$resources = array_map(static fn(array $r): string => (string) ($r['resource'] ?? ''), $this->calDavService->listResources());
$items = [];
$reportType = $this->caldavReportType($xmlBody);
$includeDeleted = false;
$deletedCount = 0;
$clientSyncToken = '';
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 {
// Emit current objects for initial/out-of-date tokens.
$items = $this->calDavService->multiget($resources);
// For incremental syncs, include a bounded set of deleted hrefs so clients can remove local copies.
if ($clientSyncToken !== '') {
$includeDeleted = true;
// Keep incremental deletes bounded to avoid overwhelming strict clients.
$deletedResources = $this->calDavService->listDeletedResources(20);
$deletedCount = count($deletedResources);
foreach ($deletedResources as $deletedResource) {
$items[] = ['resource' => $deletedResource, 'status' => 404];
}
}
}
} 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)) {
@ -2299,17 +2476,38 @@ HTML
}
$responses = '';
$okCount = 0;
$notFoundCount = 0;
foreach ($items as $item) {
$status = (int) ($item['status'] ?? 404);
$resource = (string) ($item['resource'] ?? '');
$responses .= '<D:response><D:href>' . htmlspecialchars($collection . $resource, ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</D:href><D:propstat><D:prop>';
$responses .= '<D:response><D:href>' . htmlspecialchars($collection . $resource, ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</D:href>';
if ($status === 200) {
$okCount++;
$responses .= '<D:propstat><D:prop>';
$responses .= '<D:getetag>' . htmlspecialchars((string) ($item['etag'] ?? ''), ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</D:getetag>'
. '<C:calendar-data xmlns:C="urn:ietf:params:xml:ns:caldav">' . htmlspecialchars((string) ($item['ics'] ?? ''), ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</C:calendar-data>';
$responses .= '</D:prop><D:status>HTTP/1.1 200 OK</D:status></D:propstat>';
} else {
$notFoundCount++;
// For sync-collection deletions, clients expect bare status (no propstat block).
if ($reportType === 'sync-collection') {
$responses .= '<D:status>HTTP/1.1 404 Not Found</D:status>';
} else {
$responses .= '<D:propstat><D:prop></D:prop><D:status>HTTP/1.1 404 Not Found</D:status></D:propstat>';
}
}
$responses .= '</D:prop><D:status>HTTP/1.1 ' . $status . ($status === 200 ? ' OK' : ' Not Found')
. '</D:status></D:propstat></D:response>';
$responses .= '</D:response>';
}
$this->caldavTrace('report', [
'report_type' => $reportType,
'client_sync_token_present' => $clientSyncToken !== '',
'client_sync_token_matches' => $clientSyncToken !== '' && $clientSyncToken === $syncToken,
'include_deleted' => $includeDeleted,
'deleted_included_count' => $deletedCount,
'response_ok_count' => $okCount,
'response_not_found_count' => $notFoundCount,
]);
return '<?xml version="1.0" encoding="utf-8"?><D:multistatus xmlns:D="DAV:"><D:sync-token>'
. htmlspecialchars($syncToken, ENT_XML1 | ENT_QUOTES, 'UTF-8')
. '</D:sync-token>'
@ -2324,6 +2522,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 +2554,43 @@ 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 caldavReportType(string $xmlBody): string
{
$bodyLower = strtolower($xmlBody);
if (str_contains($bodyLower, 'sync-collection')) {
return 'sync-collection';
}
if (str_contains($bodyLower, 'calendar-query')) {
return 'calendar-query';
}
if (str_contains($bodyLower, 'calendar-multiget')) {
return 'calendar-multiget';
}
return 'other';
}
private function caldavTrace(string $event, array $context = []): void
{
if (!$this->isDiagnosticsEnabled()) {
return;
}
$payload = [
'event' => $event,
'at' => gmdate('c'),
'context' => $context,
];
error_log('[calendar-plugin][caldav] ' . wp_json_encode($payload));
}
private function unwrapServiceResult(array $result): array|\WP_Error
{
if (isset($result['error']) && is_array($result['error'])) {

View File

@ -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.
@ -101,6 +104,7 @@ Minimum mapping expectations:
- Last modification timestamp -> `DTSTAMP` (and `LAST-MODIFIED` when available)
- Recurrence rules -> `RRULE`
- Recurrence exceptions -> `EXDATE` and/or additional `VEVENT` with matching `UID` plus `RECURRENCE-ID`
- Parsing must correctly handle quoted property parameters containing `:` (for example `DESCRIPTION;ALTREP="data:text/html,..."`) so field values are not polluted by parameter content.
Privacy visibility mapping:

View File

@ -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.

View File

@ -44,11 +44,16 @@ Before deployment:
## Deployment Procedure Requirements
Required high-level procedure:
1. Transfer approved artifact to remote host staging area.
2. Extract artifact to a clean temporary directory on remote host.
3. Validate extracted plugin directory structure.
4. Synchronize extracted plugin directory to deploy directory.
5. Run post-deploy verification checks.
1. Build a fresh deploy artifact as part of deploy execution (package step) using the current repository state.
2. Transfer approved artifact to remote host staging area.
3. Extract artifact to a clean temporary directory on remote host.
4. Validate extracted plugin directory structure.
5. Synchronize extracted plugin directory to deploy directory.
6. Run post-deploy verification checks.
Additional policy requirement:
- Deploy flow should not bypass packaging by deploying an arbitrary stale artifact path; deployment must use the freshly built artifact for that deploy run.
- Deployment tooling may support an explicit version override for controlled releases (for example `1.0.0`); when used, that explicit version must be the packaged and deployed artifact version for that run.
## Exact-Match Validation (Required)
After deployment, deployed plugin files must exactly match the approved artifact contents (excluding allowed mutable runtime files if any are explicitly listed).

View File

@ -61,8 +61,10 @@ Required steps:
1. Create/clean a staging folder under `package/`.
2. Copy approved runtime files from `code/` into staging.
3. Apply exclusion rules to remove non-runtime artifacts.
4. Create a versioned zip archive in `package/`.
5. Record artifact name and version in release notes/changelog.
4. Increment plugin patch version (`X.Y.Z -> X.Y.(Z+1)`) for each package build unless an explicit version override is provided.
5. Keep runtime-visible version fields synchronized for the package (plugin header version and health/API version metadata).
6. Create a versioned zip archive in `package/`.
7. Record artifact name and version in release notes/changelog.
Artifact naming requirement:

View File

@ -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.

View File

@ -83,6 +83,15 @@ Minimum display requirements:
- Category (if provided)
- Description excerpt/summary (if configured for display)
List-view formatting requirements:
- In `list` view, each row headline must present date, time range (or all-day marker), then title in readable natural-language order.
- Date formatting in `list` view should use long-form style (for example `5 April 2026`) rather than compact numeric-only format.
- Time range formatting in `list` view should be compact and human-readable (for example `910am`).
- Title must be sourced from event title data and must not be replaced by description text.
- If description is shown in `list` view, it should appear as secondary text below the headline.
- `list` view rows must not display default browser list bullets.
Privacy display rules:
- Public events render full details per normal display rules.
@ -119,6 +128,9 @@ Requirements:
- Single-occurrence selection must be user-friendly via a date list or compact calendar selection.
- The compact selection mode should support a 3-month grid with previous/next navigation.
- The compact selection grid must remain readable in modal layout.
- Login, Event Details, and Event Editor overlays must render above site/theme chrome (for example header/banner artwork) and remain interactable without requiring page scroll workarounds.
- Login overlay should be centered within the viewport on desktop and mobile.
- Event click interactions must use in-page overlays and must not fall back to browser-native dialog boxes.
## Login and Access Modes
- `/calendar` must support two user modes: public (not logged in) and logged-in.
@ -131,6 +143,8 @@ Requirements:
- Login dialog must support password-reset request initiation.
- Logged-in but non-approved users remain read-only.
- Approved users can perform event CRUD.
- Event-click behavior must be consistent between normal and private/incognito browser sessions.
- In public mode, clicking an event must open the Event Details overlay (not the Event Editor overlay).
## ICS Link in Web UI
The UI must include a user-visible link to an ICS representation of calendar data.
@ -162,3 +176,5 @@ Acceptance should verify:
- Empty-state behavior is clear and user-friendly.
- ICS link is present and returns valid calendar payload.
- Privacy redaction behavior is correct in public views, sidebar, and logged-in views.
- In both normal and private/incognito sessions, clicking an event in public mode opens the same Event Details overlay.
- Login/Event Details/Event Editor overlays remain above site header/banner layers and are fully usable without scrolling to bypass theme artwork.

View File

@ -11,6 +11,7 @@ fi
ARTIFACT=""
WP_ROOT="${REMOTE_WP_PATH:-/var/www/wordpress}"
VERSION_OVERRIDE=""
while [[ $# -gt 0 ]]; do
case "$1" in
@ -22,17 +23,24 @@ while [[ $# -gt 0 ]]; do
WP_ROOT="${2:-}"
shift 2
;;
--version)
VERSION_OVERRIDE="${2:-}"
shift 2
;;
--help|-h)
cat <<'USAGE'
Deploy a plugin artifact to remote WordPress.
Usage:
scripts/deploy_remote.sh [--artifact /abs/or/relative/path.zip] [--wp-root /var/www/wordpress]
scripts/deploy_remote.sh [--wp-root /var/www/wordpress] [--version X.Y.Z]
Defaults:
- Artifact: latest ./package/calendar-plugin-*.zip
- Always builds a fresh package first (which auto-bumps patch version)
- Remote host settings from credentials/.env
Notes:
- Use `--version` for explicit release version packaging (for example `1.0.0`).
This script enforces ownership:
- chown -R www-data:www-data <remote plugin dir>
USAGE
@ -45,10 +53,20 @@ USAGE
esac
done
if [[ -z "${ARTIFACT}" ]]; then
ARTIFACT="$(ls -1 "${ROOT_DIR}/package/calendar-plugin-"*.zip 2>/dev/null | sort -V | tail -n1 || true)"
if [[ -n "${ARTIFACT}" ]]; then
echo "[deploy] --artifact is not supported; deploy always builds a fresh package with bumped patch version" >&2
exit 2
fi
if [[ -n "${VERSION_OVERRIDE}" ]]; then
echo "[deploy] packaging before deployment (explicit version ${VERSION_OVERRIDE})"
"${ROOT_DIR}/scripts/package_plugin.sh" --version "${VERSION_OVERRIDE}"
else
echo "[deploy] packaging before deployment (auto patch bump)"
"${ROOT_DIR}/scripts/package_plugin.sh"
fi
ARTIFACT="$(ls -1 "${ROOT_DIR}/package/calendar-plugin-"*.zip 2>/dev/null | sort -V | tail -n1 || true)"
if [[ -z "${ARTIFACT}" ]]; then
echo "[deploy] no artifact found; run scripts/package_plugin.sh first" >&2
exit 1
@ -105,10 +123,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")"

View File

@ -36,15 +36,25 @@ USAGE
esac
done
if [[ -z "${VERSION}" ]]; then
VERSION="$(sed -n 's/^ \* Version: \(.*\)$/\1/p' "${ROOT_DIR}/code/calendar-plugin.php" | head -n1 | tr -d '[:space:]')"
fi
CURRENT_VERSION="$(sed -n 's/^ \* Version: \(.*\)$/\1/p' "${ROOT_DIR}/code/calendar-plugin.php" | head -n1 | tr -d '[:space:]')"
if [[ -z "${VERSION}" ]]; then
if [[ -z "${CURRENT_VERSION}" ]]; then
echo "[package] unable to detect plugin version from code/calendar-plugin.php" >&2
exit 1
fi
if [[ -z "${VERSION}" ]]; then
if [[ "${CURRENT_VERSION}" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
VERSION="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.$((BASH_REMATCH[3] + 1))"
else
echo "[package] current version is not semantic (X.Y.Z): ${CURRENT_VERSION}" >&2
exit 1
fi
fi
sed -Ei "s/^ \* Version: .*/ * Version: ${VERSION}/" "${ROOT_DIR}/code/calendar-plugin.php"
sed -Ei "s/('version' => ')[^']+(',)/\1${VERSION}\2/" "${ROOT_DIR}/code/src/Plugin.php"
SLUG="calendar-plugin"
PACKAGE_DIR="${ROOT_DIR}/package"
STAGING_DIR="${PACKAGE_DIR}/staging/${SLUG}"
@ -67,5 +77,6 @@ rsync -a --delete "${ROOT_DIR}/code/" "${STAGING_DIR}/"
find . -type f -print0 | sort -z | xargs -0 sha256sum
) > "${MANIFEST}"
echo "[package] version: ${CURRENT_VERSION} -> ${VERSION}"
echo "[package] created artifact: ${ARTIFACT}"
echo "[package] created manifest: ${MANIFEST}"

View File

@ -142,6 +142,31 @@ Adjust paths to actual implementation while preserving case coverage.
- CalDAV link uses `/<url_slug>/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 `<D:response>` change entries
- Does not emit historical `404` tombstone floods for unchanged state
## Negative and Security Tests
### API-SEC-001 Unauthorized Access

View File

@ -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,275 @@ PY
fi
fi
step "public privacy redaction"
PRIV_UID="remote-private-redact-$(date +%s)@calendar-plugin"
PRIV_JSON=$(cat <<JSON
{"uid":"${PRIV_UID}","title":"Remote Private Leak Check","visibility":"private","description":"SHOULD_NOT_LEAK","location":"SECRET_ROOM","category":"SECRET_CAT","start_datetime":"2026-04-22T10:00:00+01:00","end_datetime":"2026-04-22T11:00:00+01:00","repeat_type":"none","repeat_interval":1,"repeat_range_mode":"none"}
JSON
)
PRIV_HTTP="$(curl -sS -u "${AUTH_USER}:${AUTH_PASS}" -H 'Content-Type: application/json' \
-d "${PRIV_JSON}" -o /tmp/remote_test_private_create.json -w '%{http_code}' \
"${BASE_URL}/wp-json/calendar/v1/events" || true)"
if [[ "${PRIV_HTTP}" != "200" ]]; then
record_fail "private event create failed (http ${PRIV_HTTP})"
else
CREATED_PRIVATE_UID="$(python3 - <<'PY'
import json
p=json.load(open('/tmp/remote_test_private_create.json'))
print(p.get('data', {}).get('uid', ''))
PY
)"
CREATED_PRIVATE_EVENT_ID="$(python3 - <<'PY'
import json
p=json.load(open('/tmp/remote_test_private_create.json'))
print(p.get('data', {}).get('id', ''))
PY
)"
PUB_HTTP="$(curl -sS \
-o /tmp/remote_test_private_public.json -w '%{http_code}' \
"${BASE_URL}/wp-json/calendar/v1/public/events?view=month&date=2026-04-01" || true)"
if [[ "${PUB_HTTP}" != "200" ]]; then
record_fail "public events read for privacy check failed (http ${PUB_HTTP})"
else
if ! python3 - "${CREATED_PRIVATE_UID}" <<'PY'
import json
import sys
uid=sys.argv[1]
payload=json.load(open('/tmp/remote_test_private_public.json'))
items=payload.get('data', [])
target=None
for item in items:
if item.get('uid') == uid:
target=item
break
assert target is not None
assert target.get('title') == 'Private Event'
assert (target.get('description') or '') == ''
assert (target.get('location') or '') == ''
assert (target.get('category') or '') == ''
PY
then
record_fail "private event leaked in public payload"
fi
fi
fi
step "caldav discovery compatibility"
CALDAV_UNAUTH="$(curl -s -o /tmp/remote_test_caldav_unauth.txt -w '%{http_code}' "${BASE_URL}/caldav/" || true)"
CALDAV_ROOT_URL=""
if [[ -n "${CAL_CALDAV_PATH}" ]]; then
if [[ "${CAL_CALDAV_PATH}" == http://* || "${CAL_CALDAV_PATH}" == https://* ]]; then
CALDAV_ROOT_URL="${CAL_CALDAV_PATH%/}/"
else
CALDAV_ROOT_URL="${BASE_URL}/${CAL_CALDAV_PATH#/}"
CALDAV_ROOT_URL="${CALDAV_ROOT_URL%/}/"
fi
else
URL_SLUG="${CAL_URL_SLUG}"
if [[ -z "${URL_SLUG}" ]]; then
URL_SLUG="${DETECTED_URL_SLUG}"
fi
if [[ -n "${URL_SLUG}" ]]; then
CALDAV_ROOT_URL="${BASE_URL}/${URL_SLUG#/}/caldav/"
else
CALDAV_ROOT_URL="${BASE_URL}/caldav/"
fi
fi
CALDAV_UNAUTH="$(curl -s -o /tmp/remote_test_caldav_unauth.txt -w '%{http_code}' "${CALDAV_ROOT_URL}" || true)"
if [[ "${CALDAV_UNAUTH}" == "404" ]]; then
ALT_CANDIDATES=("${BASE_URL}/caldav/")
if [[ -n "${DETECTED_URL_SLUG}" ]]; then
ALT_CANDIDATES+=("${BASE_URL}/${DETECTED_URL_SLUG#/}/caldav/")
fi
if [[ -n "${CAL_URL_SLUG}" ]]; then
ALT_CANDIDATES+=("${BASE_URL}/${CAL_URL_SLUG#/}/caldav/")
fi
for candidate in "${ALT_CANDIDATES[@]}"; do
code="$(curl -s -o /tmp/remote_test_caldav_unauth.txt -w '%{http_code}' "${candidate}" || true)"
if [[ "${code}" != "404" ]]; then
CALDAV_ROOT_URL="${candidate}"
CALDAV_UNAUTH="${code}"
break
fi
done
fi
if [[ "${CALDAV_UNAUTH}" != "401" ]]; then
record_fail "caldav unauth challenge expected 401 got ${CALDAV_UNAUTH}"
record_fail "caldav unauth challenge expected 401 got ${CALDAV_UNAUTH} (${CALDAV_ROOT_URL})"
fi
CALDAV_PROP="$(curl -s -u "${AUTH_USER}:${AUTH_PASS}" -X PROPFIND -H 'Depth: 0' \
-o /tmp/remote_test_caldav_propfind.xml -w '%{http_code}' \
"${BASE_URL}/caldav/" || true)"
"${CALDAV_ROOT_URL}" || true)"
if [[ "${CALDAV_PROP}" != "207" ]]; then
record_fail "caldav root PROPFIND expected 207 got ${CALDAV_PROP}"
record_fail "caldav root PROPFIND expected 207 got ${CALDAV_PROP} (${CALDAV_ROOT_URL})"
elif ! grep -Eqi 'calendar-home-set|current-user-principal' /tmp/remote_test_caldav_propfind.xml; then
record_fail "caldav PROPFIND missing expected discovery properties"
fi
CALDAV_COLLECTION_URL="${CALDAV_ROOT_URL%/}/calendars/public/"
CALDAV_COLLECTION_GET="$(curl -s -u "${AUTH_USER}:${AUTH_PASS}" -X GET \
-o /tmp/remote_test_caldav_collection_get.txt -w '%{http_code}' \
"${CALDAV_COLLECTION_URL}" || true)"
if [[ "${CALDAV_COLLECTION_GET}" != "200" ]]; then
record_fail "caldav collection GET expected 200 got ${CALDAV_COLLECTION_GET} (${CALDAV_COLLECTION_URL})"
fi
step "caldav DESCRIPTION ALTREP parsing regression"
ALTREP_UID="remote-caldav-altrep-$(date +%s)@calendar-plugin"
ALTREP_RESOURCE="remote-altrep-$(date +%s).ics"
TMP_ALTREP_ICS="$(mktemp)"
cat > "${TMP_ALTREP_ICS}" <<ICS
BEGIN:VCALENDAR
PRODID:-//Remote Regression//EN
VERSION:2.0
BEGIN:VEVENT
UID:${ALTREP_UID}
SUMMARY:Remote CalDAV ALTREP Regression
DTSTART;TZID=Europe/London:20260428T120000
DTEND;TZID=Europe/London:20260428T130000
DESCRIPTION;ALTREP="data:text/html,test%C2%A0":test
END:VEVENT
END:VCALENDAR
ICS
ALTREP_PUT_HTTP="$(curl -sS -u "${AUTH_USER}:${AUTH_PASS}" -X PUT \
-H 'Content-Type: text/calendar; charset=utf-8' \
--data-binary @"${TMP_ALTREP_ICS}" \
-o /tmp/remote_test_altrep_put.out -w '%{http_code}' \
"${CALDAV_COLLECTION_URL}${ALTREP_RESOURCE}" || true)"
rm -f "${TMP_ALTREP_ICS}"
if [[ "${ALTREP_PUT_HTTP}" != "201" && "${ALTREP_PUT_HTTP}" != "204" ]]; then
record_fail "caldav ALTREP PUT failed (http ${ALTREP_PUT_HTTP})"
else
ALTREP_EVENT_ID=""
ALTREP_LIST_HTTP="$(curl -sS -u "${AUTH_USER}:${AUTH_PASS}" -o /tmp/remote_test_altrep_list.json -w '%{http_code}' \
"${BASE_URL}/wp-json/calendar/v1/events" || true)"
if [[ "${ALTREP_LIST_HTTP}" != "200" ]]; then
record_fail "caldav ALTREP event list fetch failed (http ${ALTREP_LIST_HTTP})"
else
ALTREP_EVENT_ID="$(python3 - "${ALTREP_UID}" <<'PY'
import json,sys
uid=sys.argv[1]
payload=json.load(open('/tmp/remote_test_altrep_list.json'))
for event in payload.get("data", []):
if event.get("uid") == uid:
print(event.get("id", ""))
break
else:
print("")
PY
)"
fi
if [[ -z "${ALTREP_EVENT_ID}" ]]; then
record_fail "caldav ALTREP event not found via API list"
else
if ! curl -fsS -u "${AUTH_USER}:${AUTH_PASS}" "${BASE_URL}/wp-json/calendar/v1/events/${ALTREP_EVENT_ID}" >/tmp/remote_test_altrep_event.json; then
record_fail "caldav ALTREP event fetch failed"
elif ! json_assert /tmp/remote_test_altrep_event.json "data.get('data', {}).get('description') == 'test'"; then
record_fail "caldav ALTREP description parse regression (expected 'test')"
fi
curl -sS -u "${AUTH_USER}:${AUTH_PASS}" -X DELETE \
"${BASE_URL}/wp-json/calendar/v1/events/${ALTREP_EVENT_ID}" >/dev/null || true
fi
fi
step "caldav sync-collection stability"
SYNC_UID="remote-caldav-sync-del-$(date +%s)@calendar-plugin"
SYNC_CREATE_JSON=$(cat <<JSON
{"uid":"${SYNC_UID}","title":"Remote CalDAV Sync Delete","description":"sync tombstone regression","location":"Remote","category":"QA","start_datetime":"2026-04-25T10:00:00+01:00","end_datetime":"2026-04-25T11:00:00+01:00","repeat_type":"none","repeat_interval":1,"repeat_range_mode":"none"}
JSON
)
SYNC_CREATE_HTTP="$(curl -sS -u "${AUTH_USER}:${AUTH_PASS}" -H 'Content-Type: application/json' \
-d "${SYNC_CREATE_JSON}" -o /tmp/remote_test_sync_create.json -w '%{http_code}' \
"${BASE_URL}/wp-json/calendar/v1/events" || true)"
if [[ "${SYNC_CREATE_HTTP}" != "200" ]]; then
record_fail "sync tombstone setup create failed (http ${SYNC_CREATE_HTTP})"
else
CREATED_SYNC_DELETE_EVENT_ID="$(python3 - <<'PY'
import json
p=json.load(open('/tmp/remote_test_sync_create.json'))
print(p.get('data', {}).get('id', ''))
PY
)"
SYNC_RESOURCE="$(python3 - <<'PY'
import json
p=json.load(open('/tmp/remote_test_sync_create.json'))
print(p.get('data', {}).get('caldav_resource', ''))
PY
)"
if [[ -z "${CREATED_SYNC_DELETE_EVENT_ID}" || -z "${SYNC_RESOURCE}" ]]; then
record_fail "sync tombstone setup missing id/resource"
else
SYNC_DELETE_HTTP="$(curl -sS -u "${AUTH_USER}:${AUTH_PASS}" -X DELETE \
-o /tmp/remote_test_sync_delete.json -w '%{http_code}' \
"${BASE_URL}/wp-json/calendar/v1/events/${CREATED_SYNC_DELETE_EVENT_ID}" || true)"
if [[ "${SYNC_DELETE_HTTP}" != "200" ]]; then
record_fail "sync stability delete setup failed (http ${SYNC_DELETE_HTTP})"
else
CREATED_SYNC_DELETE_EVENT_ID=""
SYNC_REPORT_BODY_INITIAL='<?xml version="1.0" encoding="utf-8"?><D:sync-collection xmlns:D="DAV:"><D:sync-token/><D:sync-level>1</D:sync-level><D:prop><D:getetag/></D:prop></D:sync-collection>'
SYNC_REPORT_INITIAL_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_INITIAL}" -o /tmp/remote_test_sync_report_initial.xml -w '%{http_code}' \
"${CALDAV_COLLECTION_URL}" || true)"
if [[ "${SYNC_REPORT_INITIAL_HTTP}" != "207" ]]; then
record_fail "initial sync-collection report failed (http ${SYNC_REPORT_INITIAL_HTTP})"
fi
PRE_DELETE_TOKEN="$(python3 - <<'PY'
import re
xml=open('/tmp/remote_test_sync_report_initial.xml', encoding='utf-8', errors='ignore').read()
m=re.search(r'<D:sync-token>(.*?)</D:sync-token>', xml, re.S)
print((m.group(1).strip() if m else ''))
PY
)"
if [[ -z "${PRE_DELETE_TOKEN}" ]]; then
record_fail "initial sync-collection missing sync-token"
fi
SYNC_REPORT_BODY="<?xml version=\"1.0\" encoding=\"utf-8\"?><D:sync-collection xmlns:D=\"DAV:\"><D:sync-token>${PRE_DELETE_TOKEN}</D:sync-token><D:sync-level>1</D:sync-level><D:prop><D:getetag/></D:prop></D:sync-collection>"
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'<D:sync-token>(.*?)</D:sync-token>', 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="<?xml version=\"1.0\" encoding=\"utf-8\"?><D:sync-collection xmlns:D=\"DAV:\"><D:sync-token>${SYNC_TOKEN_1}</D:sync-token><D:sync-level>1</D:sync-level><D:prop><D:getetag/></D:prop></D:sync-collection>"
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'<D:response>', 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

View File

@ -46,6 +46,7 @@ Use a minimal subset:
- `pending_approval` or unverified user: cannot authenticate.
- `active` user: can discover/read/create/update/delete one event successfully.
- Monthly ordinal recurrence must round-trip for CalDAV writes (`BYDAY=2SA` and `BYSETPOS=-1` cases).
- Thunderbird-style `DESCRIPTION;ALTREP="data:text/html,..."` updates must persist clean plain-text `DESCRIPTION` values without leaking `ALTREP` parameter content into stored descriptions.
### SMK-007 API Smoke (If API Exposed)
- Event create/list/delete basic path.
@ -68,6 +69,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 (`<C:calendar/>`) 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)