Compare commits
No commits in common. "91fd6c2f439fbbec702d6364fa32380f08f897ba" and "79a207a6f2fbc7ce335d5e0de28a30deabc57c94" have entirely different histories.
91fd6c2f43
...
79a207a6f2
14
README.md
14
README.md
|
|
@ -1,13 +1,11 @@
|
||||||
# Calendar plugin for wordpress site
|
# Calendar plugin for wordpress site
|
||||||
|
|
||||||
Status as of 2026-03-31 when codex credit ran out:
|
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.
|
||||||
|
|
||||||
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
|
||||||
2. Deletion of event in ui doesn't delete event in thunderbird - done
|
3. Cannot subscribe to an empty calendar in thunderbird
|
||||||
3. Cannot subscribe to an empty calendar in thunderbird - ok
|
4. Click inside month cell doesn't add event.
|
||||||
4. Click inside month cell doesn't add event. - cannot reproduce
|
5. Login pane hidden under website hero/banner image
|
||||||
5. Login pane hidden under website hero/banner image - done
|
|
||||||
6. Updating an events description via caldav creates weird sequences, e.g.
|
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.
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
* Plugin Name: Calendar Plugin
|
* Plugin Name: Calendar Plugin
|
||||||
* Plugin URI: https://chezstephens.org.uk
|
* 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.
|
* 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: 1.0.1
|
* Version: 0.1.15
|
||||||
* Requires at least: 6.0
|
* Requires at least: 6.0
|
||||||
* Requires PHP: 8.1
|
* Requires PHP: 8.1
|
||||||
* Author: Adrian Stephens (with AI assistance)
|
* Author: Adrian Stephens (with AI assistance)
|
||||||
|
|
|
||||||
|
|
@ -140,14 +140,6 @@ final class CalDavService
|
||||||
return $out;
|
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
|
public function resourceForEvent(array $event): string
|
||||||
{
|
{
|
||||||
$resource = trim((string) ($event['caldav_resource'] ?? ''));
|
$resource = trim((string) ($event['caldav_resource'] ?? ''));
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ final class EventService
|
||||||
{
|
{
|
||||||
private readonly string $eventsTable;
|
private readonly string $eventsTable;
|
||||||
private readonly string $exceptionsTable;
|
private readonly string $exceptionsTable;
|
||||||
private readonly string $tombstonesTable;
|
|
||||||
|
|
||||||
public function __construct(private readonly DatabaseAdapterInterface $db, string $tableStem = 'cs_calendar')
|
public function __construct(private readonly DatabaseAdapterInterface $db, string $tableStem = 'cs_calendar')
|
||||||
{
|
{
|
||||||
|
|
@ -20,7 +19,6 @@ final class EventService
|
||||||
$stem = trim($tableStem, '_');
|
$stem = trim($tableStem, '_');
|
||||||
$this->eventsTable = $prefix . $stem . '_events';
|
$this->eventsTable = $prefix . $stem . '_events';
|
||||||
$this->exceptionsTable = $prefix . $stem . '_recurrence_exceptions';
|
$this->exceptionsTable = $prefix . $stem . '_recurrence_exceptions';
|
||||||
$this->tombstonesTable = $prefix . $stem . '_caldav_tombstones';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function listEvents(): array
|
public function listEvents(): array
|
||||||
|
|
@ -77,7 +75,6 @@ final class EventService
|
||||||
|
|
||||||
$data = [
|
$data = [
|
||||||
'uid' => $uid,
|
'uid' => $uid,
|
||||||
'visibility' => $this->canonicalVisibility((string) ($payload['visibility'] ?? 'public')),
|
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
'description' => (string) ($payload['description'] ?? ''),
|
'description' => (string) ($payload['description'] ?? ''),
|
||||||
'location' => (string) ($payload['location'] ?? ''),
|
'location' => (string) ($payload['location'] ?? ''),
|
||||||
|
|
@ -107,12 +104,7 @@ final class EventService
|
||||||
if ($inserted === false) {
|
if ($inserted === false) {
|
||||||
throw new \RuntimeException('failed to create event');
|
throw new \RuntimeException('failed to create event');
|
||||||
}
|
}
|
||||||
$created = (array) $this->getEvent($this->db->insertId());
|
return (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
|
public function updateEvent(int $id, array $payload): ?array
|
||||||
|
|
@ -156,9 +148,6 @@ final class EventService
|
||||||
$repeatNthWeekday
|
$repeatNthWeekday
|
||||||
);
|
);
|
||||||
$data = [
|
$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'])),
|
'title' => trim((string) ($payload['title'] ?? $existing['title'])),
|
||||||
'description' => (string) ($payload['description'] ?? $existing['description']),
|
'description' => (string) ($payload['description'] ?? $existing['description']),
|
||||||
'location' => (string) ($payload['location'] ?? $existing['location']),
|
'location' => (string) ($payload['location'] ?? $existing['location']),
|
||||||
|
|
@ -190,27 +179,13 @@ final class EventService
|
||||||
];
|
];
|
||||||
|
|
||||||
$this->db->update($this->eventsTable, $data, ['id' => $id]);
|
$this->db->update($this->eventsTable, $data, ['id' => $id]);
|
||||||
$updated = $this->getEvent($id);
|
return $this->getEvent($id);
|
||||||
if ($updated) {
|
|
||||||
$resource = trim((string) ($updated['caldav_resource'] ?? ''));
|
|
||||||
if ($resource !== '') {
|
|
||||||
$this->clearCalDavTombstone($resource);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $updated;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function deleteEvent(int $id): bool
|
public function deleteEvent(int $id): bool
|
||||||
{
|
{
|
||||||
$event = $this->getEvent($id);
|
|
||||||
$this->db->delete($this->exceptionsTable, ['event_id' => $id]);
|
$this->db->delete($this->exceptionsTable, ['event_id' => $id]);
|
||||||
$deleted = $this->db->delete($this->eventsTable, ['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;
|
return $deleted !== false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -303,7 +278,6 @@ final class EventService
|
||||||
$event = [
|
$event = [
|
||||||
'id' => 0,
|
'id' => 0,
|
||||||
'uid' => 'preview@calendar-plugin',
|
'uid' => 'preview@calendar-plugin',
|
||||||
'visibility' => $this->canonicalVisibility((string) ($payload['visibility'] ?? 'public')),
|
|
||||||
'title' => (string) ($payload['title'] ?? ''),
|
'title' => (string) ($payload['title'] ?? ''),
|
||||||
'description' => (string) ($payload['description'] ?? ''),
|
'description' => (string) ($payload['description'] ?? ''),
|
||||||
'location' => (string) ($payload['location'] ?? ''),
|
'location' => (string) ($payload['location'] ?? ''),
|
||||||
|
|
@ -378,7 +352,7 @@ final class EventService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function listPublicOccurrences(string $view, string $dateAnchor, bool $futureOnly = false, bool $redactPrivate = true): array
|
public function listPublicOccurrences(string $view, string $dateAnchor, bool $futureOnly = false): array
|
||||||
{
|
{
|
||||||
$tz = new DateTimeZone('Europe/London');
|
$tz = new DateTimeZone('Europe/London');
|
||||||
$anchor = $this->safeDate($dateAnchor, $tz);
|
$anchor = $this->safeDate($dateAnchor, $tz);
|
||||||
|
|
@ -408,14 +382,10 @@ final class EventService
|
||||||
static fn(array $a, array $b): int => strcmp((string) $a['occurrence_start'], (string) $b['occurrence_start'])
|
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;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function listSidebarUpcoming(int $days = 14, bool $redactPrivate = true): array
|
public function listSidebarUpcoming(int $days = 14): array
|
||||||
{
|
{
|
||||||
$tz = new DateTimeZone('Europe/London');
|
$tz = new DateTimeZone('Europe/London');
|
||||||
$start = new DateTimeImmutable('today', $tz);
|
$start = new DateTimeImmutable('today', $tz);
|
||||||
|
|
@ -434,10 +404,6 @@ final class EventService
|
||||||
static fn(array $a, array $b): int => strcmp((string) $a['occurrence_start'], (string) $b['occurrence_start'])
|
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;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -445,31 +411,11 @@ final class EventService
|
||||||
{
|
{
|
||||||
$events = $this->listEvents();
|
$events = $this->listEvents();
|
||||||
$count = count($events);
|
$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->exceptionsTable}");
|
||||||
$this->db->query("DELETE FROM {$this->eventsTable}");
|
$this->db->query("DELETE FROM {$this->eventsTable}");
|
||||||
return $count;
|
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
|
public function seedDefaultEvents(): int
|
||||||
{
|
{
|
||||||
$seed = [
|
$seed = [
|
||||||
|
|
@ -548,9 +494,6 @@ final class EventService
|
||||||
return [
|
return [
|
||||||
'id' => (int) $row->id,
|
'id' => (int) $row->id,
|
||||||
'uid' => (string) $row->uid,
|
'uid' => (string) $row->uid,
|
||||||
'visibility' => property_exists($row, 'visibility')
|
|
||||||
? $this->canonicalVisibility((string) ($row->visibility ?? 'public'))
|
|
||||||
: 'public',
|
|
||||||
'title' => (string) $row->title,
|
'title' => (string) $row->title,
|
||||||
'description' => (string) $row->description,
|
'description' => (string) $row->description,
|
||||||
'location' => (string) $row->location,
|
'location' => (string) $row->location,
|
||||||
|
|
@ -671,26 +614,6 @@ final class EventService
|
||||||
return in_array($v, ['none', 'count', 'until'], true) ? $v : 'none';
|
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(
|
private function normalizeMonthlyAnchor(
|
||||||
string $startIso,
|
string $startIso,
|
||||||
string $endIso,
|
string $endIso,
|
||||||
|
|
@ -783,30 +706,4 @@ final class EventService
|
||||||
}
|
}
|
||||||
return [$newYear, $newMonth];
|
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,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,7 @@ final class IcsService
|
||||||
{
|
{
|
||||||
private const PRODID = '-//Calendar Plugin//EN';
|
private const PRODID = '-//Calendar Plugin//EN';
|
||||||
|
|
||||||
public function buildCalendar(
|
public function buildCalendar(array $events, callable $deletedKeysProvider, string $calendarName = 'Calendar'): string
|
||||||
array $events,
|
|
||||||
callable $deletedKeysProvider,
|
|
||||||
string $calendarName = 'Calendar',
|
|
||||||
bool $redactPrivate = false
|
|
||||||
): string
|
|
||||||
{
|
{
|
||||||
$lines = [
|
$lines = [
|
||||||
'BEGIN:VCALENDAR',
|
'BEGIN:VCALENDAR',
|
||||||
|
|
@ -25,35 +20,10 @@ final class IcsService
|
||||||
'CALSCALE:GREGORIAN',
|
'CALSCALE:GREGORIAN',
|
||||||
'X-WR-CALNAME:' . $this->escapeText($calendarName),
|
'X-WR-CALNAME:' . $this->escapeText($calendarName),
|
||||||
'X-WR-TIMEZONE:Europe/London',
|
'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) {
|
foreach ($events as $event) {
|
||||||
$lines = array_merge(
|
$lines = array_merge($lines, $this->eventToLines($event, (array) $deletedKeysProvider((int) ($event['id'] ?? 0))));
|
||||||
$lines,
|
|
||||||
$this->eventToLines(
|
|
||||||
$event,
|
|
||||||
(array) $deletedKeysProvider((int) ($event['id'] ?? 0)),
|
|
||||||
$redactPrivate
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$lines[] = 'END:VCALENDAR';
|
$lines[] = 'END:VCALENDAR';
|
||||||
|
|
@ -106,11 +76,6 @@ final class IcsService
|
||||||
'repeat_until' => null,
|
'repeat_until' => null,
|
||||||
'timezone' => 'Europe/London',
|
'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] ?? '');
|
$rrule = (string) ($props['RRULE'][0] ?? '');
|
||||||
if ($rrule !== '') {
|
if ($rrule !== '') {
|
||||||
|
|
@ -132,7 +97,7 @@ final class IcsService
|
||||||
return $payload;
|
return $payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function eventToLines(array $event, array $deletedKeys, bool $redactPrivate): array
|
private function eventToLines(array $event, array $deletedKeys): array
|
||||||
{
|
{
|
||||||
$uid = (string) ($event['uid'] ?? '');
|
$uid = (string) ($event['uid'] ?? '');
|
||||||
$uid = $uid !== '' ? $uid : ('event-' . (string) ($event['id'] ?? 0) . '@calendar-plugin');
|
$uid = $uid !== '' ? $uid : ('event-' . (string) ($event['id'] ?? 0) . '@calendar-plugin');
|
||||||
|
|
@ -144,26 +109,18 @@ final class IcsService
|
||||||
}
|
}
|
||||||
|
|
||||||
$allDay = (bool) ($event['all_day_event'] ?? false);
|
$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'));
|
$updated = $this->toDateTime((string) ($event['updated_at'] ?? '')) ?? new DateTimeImmutable('now', new DateTimeZone('UTC'));
|
||||||
|
|
||||||
$lines = [
|
$lines = [
|
||||||
'BEGIN:VEVENT',
|
'BEGIN:VEVENT',
|
||||||
'UID:' . $this->escapeText($uid),
|
'UID:' . $this->escapeText($uid),
|
||||||
'SUMMARY:' . $this->escapeText($isRedactedPrivate ? 'Private Event' : (string) ($event['title'] ?? 'Untitled')),
|
'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'] ?? '')),
|
||||||
'DTSTAMP:' . $this->toUtcIcs($updated),
|
'DTSTAMP:' . $this->toUtcIcs($updated),
|
||||||
'LAST-MODIFIED:' . $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) {
|
if ($allDay) {
|
||||||
$lines[] = 'DTSTART;VALUE=DATE:' . $start->setTimezone(new DateTimeZone('Europe/London'))->format('Ymd');
|
$lines[] = 'DTSTART;VALUE=DATE:' . $start->setTimezone(new DateTimeZone('Europe/London'))->format('Ymd');
|
||||||
|
|
@ -346,7 +303,7 @@ final class IcsService
|
||||||
if (!$in) {
|
if (!$in) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
[$left, $value] = $this->splitContentLine($line);
|
[$left, $value] = array_pad(explode(':', $line, 2), 2, '');
|
||||||
if ($left === '') {
|
if ($left === '') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -364,23 +321,6 @@ final class IcsService
|
||||||
return $in ? $props : null;
|
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
|
private function parseIcsDateTime(string $value, bool $dateOnly): ?string
|
||||||
{
|
{
|
||||||
$value = trim($value);
|
$value = trim($value);
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,6 @@ final class RecurrenceExpander
|
||||||
return [
|
return [
|
||||||
'event_id' => (int) ($event['id'] ?? 0),
|
'event_id' => (int) ($event['id'] ?? 0),
|
||||||
'uid' => (string) ($event['uid'] ?? ''),
|
'uid' => (string) ($event['uid'] ?? ''),
|
||||||
'visibility' => (string) ($event['visibility'] ?? 'public'),
|
|
||||||
'title' => (string) ($event['title'] ?? ''),
|
'title' => (string) ($event['title'] ?? ''),
|
||||||
'description' => (string) ($event['description'] ?? ''),
|
'description' => (string) ($event['description'] ?? ''),
|
||||||
'location' => (string) ($event['location'] ?? ''),
|
'location' => (string) ($event['location'] ?? ''),
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ use DateTimeZone;
|
||||||
|
|
||||||
final class MigrationManager
|
final class MigrationManager
|
||||||
{
|
{
|
||||||
private const SCHEMA_VERSION = '5';
|
private const SCHEMA_VERSION = '3';
|
||||||
private const STEM_OPTION = 'calendar_plugin_table_stem';
|
private const STEM_OPTION = 'calendar_plugin_table_stem';
|
||||||
|
|
||||||
public function __construct(private readonly DatabaseAdapterInterface $db, private readonly string $tableStem = 'cs_calendar')
|
public function __construct(private readonly DatabaseAdapterInterface $db, private readonly string $tableStem = 'cs_calendar')
|
||||||
|
|
@ -31,12 +31,10 @@ final class MigrationManager
|
||||||
$users = $prefix . $stem . '_users';
|
$users = $prefix . $stem . '_users';
|
||||||
$tokens = $prefix . $stem . '_user_tokens';
|
$tokens = $prefix . $stem . '_user_tokens';
|
||||||
$audit = $prefix . $stem . '_audit_log';
|
$audit = $prefix . $stem . '_audit_log';
|
||||||
$tombstones = $prefix . $stem . '_caldav_tombstones';
|
|
||||||
|
|
||||||
$sqlEvents = "CREATE TABLE {$events} (
|
$sqlEvents = "CREATE TABLE {$events} (
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
uid VARCHAR(191) NOT NULL,
|
uid VARCHAR(191) NOT NULL,
|
||||||
visibility VARCHAR(16) NOT NULL DEFAULT 'public',
|
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
description LONGTEXT NOT NULL,
|
description LONGTEXT NOT NULL,
|
||||||
location TEXT NOT NULL,
|
location TEXT NOT NULL,
|
||||||
|
|
@ -117,18 +115,11 @@ final class MigrationManager
|
||||||
KEY created_at (created_at)
|
KEY created_at (created_at)
|
||||||
) {$charsetCollate};";
|
) {$charsetCollate};";
|
||||||
|
|
||||||
$sqlTombstones = "CREATE TABLE {$tombstones} (
|
|
||||||
resource VARCHAR(191) NOT NULL,
|
|
||||||
deleted_at VARCHAR(32) NOT NULL,
|
|
||||||
PRIMARY KEY (resource)
|
|
||||||
) {$charsetCollate};";
|
|
||||||
|
|
||||||
dbDelta($sqlEvents);
|
dbDelta($sqlEvents);
|
||||||
dbDelta($sqlExceptions);
|
dbDelta($sqlExceptions);
|
||||||
dbDelta($sqlUsers);
|
dbDelta($sqlUsers);
|
||||||
dbDelta($sqlTokens);
|
dbDelta($sqlTokens);
|
||||||
dbDelta($sqlAudit);
|
dbDelta($sqlAudit);
|
||||||
dbDelta($sqlTombstones);
|
|
||||||
|
|
||||||
// Ensure every event has a stable CalDAV object resource name.
|
// Ensure every event has a stable CalDAV object resource name.
|
||||||
$this->db->query(
|
$this->db->query(
|
||||||
|
|
@ -138,13 +129,6 @@ final class MigrationManager
|
||||||
AND uid IS NOT NULL
|
AND uid IS NOT NULL
|
||||||
AND uid <> ''"
|
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);
|
$this->normalizeEventDateTimesToLondon($events);
|
||||||
|
|
||||||
update_option(self::STEM_OPTION, $stem);
|
update_option(self::STEM_OPTION, $stem);
|
||||||
|
|
@ -186,7 +170,6 @@ final class MigrationManager
|
||||||
$prefix . $stem . '_users',
|
$prefix . $stem . '_users',
|
||||||
$prefix . $stem . '_user_tokens',
|
$prefix . $stem . '_user_tokens',
|
||||||
$prefix . $stem . '_audit_log',
|
$prefix . $stem . '_audit_log',
|
||||||
$prefix . $stem . '_caldav_tombstones',
|
|
||||||
];
|
];
|
||||||
foreach ($tables as $table) {
|
foreach ($tables as $table) {
|
||||||
$sql = $this->db->prepare('SHOW TABLES LIKE %s', $table);
|
$sql = $this->db->prepare('SHOW TABLES LIKE %s', $table);
|
||||||
|
|
|
||||||
|
|
@ -180,10 +180,10 @@ final class Plugin
|
||||||
<p id="cp-status" style="margin:10px 0 8px 0;"></p>
|
<p id="cp-status" style="margin:10px 0 8px 0;"></p>
|
||||||
<h3 id="cp-events-title" style="margin:0 0 8px 0;">Events</h3>
|
<h3 id="cp-events-title" style="margin:0 0 8px 0;">Events</h3>
|
||||||
<div id="cp-view-panel"></div>
|
<div id="cp-view-panel"></div>
|
||||||
<ul id="cp-public-list" style="margin-top:8px;list-style:none;padding-left:0;"></ul>
|
<ul id="cp-public-list" style="margin-top:8px;"></ul>
|
||||||
|
|
||||||
<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 id="cp-auth-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.35);z-index:1000;">
|
||||||
<div style="width:min(560px,100%);max-height:92vh;overflow:auto;background:#fff;border-radius:8px;padding:12px;">
|
<div style="max-width:560px;margin:8vh auto;background:#fff;border-radius:8px;padding:12px;">
|
||||||
<h3 style="margin:0 0 8px 0;">Account Login</h3>
|
<h3 style="margin:0 0 8px 0;">Account Login</h3>
|
||||||
<p id="cp-auth-status" style="margin:0 0 8px 0;"></p>
|
<p id="cp-auth-status" style="margin:0 0 8px 0;"></p>
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;">
|
||||||
|
|
@ -203,13 +203,12 @@ final class Plugin
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="cp-editor-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.35);z-index:2147483000;">
|
<div id="cp-editor-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.35);z-index:1000;">
|
||||||
<div style="max-width:900px;margin:3vh auto;background:#fff;border-radius:8px;padding:12px;max-height:94vh;overflow:auto;">
|
<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>
|
<h3 id="cp-editor-title" style="margin:0 0 8px 0;">Create Event</h3>
|
||||||
<input id="cp-event-id" type="hidden" />
|
<input id="cp-event-id" type="hidden" />
|
||||||
<div style="display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;">
|
<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;">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: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: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>
|
<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>
|
||||||
|
|
@ -252,12 +251,11 @@ final class Plugin
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="cp-details-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.35);z-index:2147483000;">
|
<div id="cp-details-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.35);z-index:1000;">
|
||||||
<div style="max-width:900px;margin:4vh auto;background:#fff;border-radius:8px;padding:12px;max-height:90vh;overflow:auto;">
|
<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>
|
<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="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;">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: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: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>
|
<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>
|
||||||
|
|
@ -287,35 +285,6 @@ final class Plugin
|
||||||
const esc=(v)=>{const d=document.createElement("div"); d.textContent=v==null?"":String(v); return d.innerHTML;};
|
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 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 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 itemId=(it)=>String(it.event_id||it.id||"");
|
||||||
const futureWrap=()=>s("cp-future-wrap");
|
const futureWrap=()=>s("cp-future-wrap");
|
||||||
const monthNames=["January","February","March","April","May","June","July","August","September","October","November","December"];
|
const monthNames=["January","February","March","April","May","June","July","August","September","October","November","December"];
|
||||||
|
|
@ -440,7 +409,6 @@ final class Plugin
|
||||||
const clearEditor=()=>{
|
const clearEditor=()=>{
|
||||||
s("cp-event-id").value="";
|
s("cp-event-id").value="";
|
||||||
["cp-title","cp-description","cp-location","cp-category","cp-occurrence-key"].forEach(k=>s(k).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-occurrence-key-iso").value="";
|
||||||
s("cp-all-day").checked=false;
|
s("cp-all-day").checked=false;
|
||||||
s("cp-repeat-type").value="none";
|
s("cp-repeat-type").value="none";
|
||||||
|
|
@ -484,7 +452,6 @@ final class Plugin
|
||||||
|
|
||||||
const openDetailsForItem=(it)=>{
|
const openDetailsForItem=(it)=>{
|
||||||
s("cp-details-title").value=it.title||"";
|
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-category").value=it.category||"";
|
||||||
s("cp-details-location").value=it.location||"";
|
s("cp-details-location").value=it.location||"";
|
||||||
s("cp-details-start").value=detailsDateTime(it.occurrence_start||it.start_datetime,!!it.all_day_event);
|
s("cp-details-start").value=detailsDateTime(it.occurrence_start||it.start_datetime,!!it.all_day_event);
|
||||||
|
|
@ -511,7 +478,6 @@ final class Plugin
|
||||||
s("cp-editor-title").textContent="Edit Event";
|
s("cp-editor-title").textContent="Edit Event";
|
||||||
s("cp-event-id").value=itemId(source);
|
s("cp-event-id").value=itemId(source);
|
||||||
s("cp-title").value=source.title||"";
|
s("cp-title").value=source.title||"";
|
||||||
s("cp-visibility").value=source.visibility==="private"?"private":"public";
|
|
||||||
s("cp-description").value=source.description||"";
|
s("cp-description").value=source.description||"";
|
||||||
s("cp-location").value=source.location||"";
|
s("cp-location").value=source.location||"";
|
||||||
s("cp-category").value=source.category||"";
|
s("cp-category").value=source.category||"";
|
||||||
|
|
@ -566,7 +532,6 @@ final class Plugin
|
||||||
ok:true,
|
ok:true,
|
||||||
payload:{
|
payload:{
|
||||||
title:title,
|
title:title,
|
||||||
visibility:s("cp-visibility").value==="private"?"private":"public",
|
|
||||||
description:s("cp-description").value,
|
description:s("cp-description").value,
|
||||||
location:s("cp-location").value,
|
location:s("cp-location").value,
|
||||||
category:s("cp-category").value,
|
category:s("cp-category").value,
|
||||||
|
|
@ -661,23 +626,23 @@ final class Plugin
|
||||||
|
|
||||||
const rowsForDay=(items)=>{
|
const rowsForDay=(items)=>{
|
||||||
const t=theme();
|
const t=theme();
|
||||||
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("");
|
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("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const bindClicks=()=>{
|
const bindClicks=()=>{
|
||||||
Array.from(document.querySelectorAll("#cp-view-panel [data-cp-id], #cp-public-list [data-cp-id]")).forEach(el=>{
|
Array.from(document.querySelectorAll("#cp-view-panel [data-id], #cp-public-list [data-id]")).forEach(el=>{
|
||||||
el.addEventListener("click",async()=>{
|
el.addEventListener("click",async()=>{
|
||||||
const id=el.getAttribute("data-cp-id")||"";
|
const id=el.getAttribute("data-id")||"";
|
||||||
const it=lastItems.find(x=>String(itemId(x))===String(id))||null;
|
const it=lastItems.find(x=>String(itemId(x))===String(id))||null;
|
||||||
if(it){await openEditorForItem(it);}
|
if(it){await openEditorForItem(it);}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
const bindCreateClicks=()=>{
|
const bindCreateClicks=()=>{
|
||||||
Array.from(document.querySelectorAll("#cp-view-panel [data-cp-create-date]")).forEach(el=>{
|
Array.from(document.querySelectorAll("#cp-view-panel [data-create-date]")).forEach(el=>{
|
||||||
el.addEventListener("click",(ev)=>{
|
el.addEventListener("click",(ev)=>{
|
||||||
if(ev.target && ev.target.closest("[data-cp-id]")){return;}
|
if(ev.target && ev.target.closest("[data-id]")){return;}
|
||||||
const dateYmd=el.getAttribute("data-cp-create-date")||"";
|
const dateYmd=el.getAttribute("data-create-date")||"";
|
||||||
if(dateYmd){openEditorCreateAt(dateYmd);}
|
if(dateYmd){openEditorCreateAt(dateYmd);}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -697,19 +662,14 @@ final class Plugin
|
||||||
panel.innerHTML="";
|
panel.innerHTML="";
|
||||||
(items||[]).slice(0,300).forEach(it=>{
|
(items||[]).slice(0,300).forEach(it=>{
|
||||||
const li=document.createElement("li");
|
const li=document.createElement("li");
|
||||||
li.setAttribute("data-cp-id", itemId(it));
|
li.setAttribute("data-id", itemId(it));
|
||||||
li.style.cursor="pointer";
|
li.style.cursor="pointer";
|
||||||
li.style.background=t.cellBg;
|
li.style.background=t.cellBg;
|
||||||
li.style.border=`1px solid ${t.border}`;
|
li.style.border=`1px solid ${t.border}`;
|
||||||
li.style.margin="4px 0";
|
li.style.margin="4px 0";
|
||||||
li.style.padding="6px 8px";
|
li.style.padding="6px 8px";
|
||||||
li.style.borderRadius="4px";
|
li.style.borderRadius="4px";
|
||||||
const dateLabel=longDate(it.occurrence_start||it.start_datetime);
|
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 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);
|
ul.appendChild(li);
|
||||||
});
|
});
|
||||||
bindClicks();
|
bindClicks();
|
||||||
|
|
@ -742,7 +702,7 @@ final class Plugin
|
||||||
const d=new Date(start); d.setDate(start.getDate()+i);
|
const d=new Date(start); d.setDate(start.getDate()+i);
|
||||||
const k=localYmd(d);
|
const k=localYmd(d);
|
||||||
const ev=(map[k]||[]).sort((a,b)=>String(a.occurrence_start).localeCompare(String(b.occurrence_start)));
|
const ev=(map[k]||[]).sort((a,b)=>String(a.occurrence_start).localeCompare(String(b.occurrence_start)));
|
||||||
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>`;
|
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>`;
|
||||||
}
|
}
|
||||||
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>`;
|
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();
|
bindClicks();
|
||||||
|
|
@ -766,7 +726,7 @@ final class Plugin
|
||||||
const ev=(map[k]||[]).sort((a,b)=>String(a.occurrence_start).localeCompare(String(b.occurrence_start)));
|
const ev=(map[k]||[]).sort((a,b)=>String(a.occurrence_start).localeCompare(String(b.occurrence_start)));
|
||||||
const inMonth=d.getMonth()===anchor.getMonth();
|
const inMonth=d.getMonth()===anchor.getMonth();
|
||||||
if(inMonth){rowHasInMonth=true;}
|
if(inMonth){rowHasInMonth=true;}
|
||||||
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>`;
|
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>`;
|
||||||
}
|
}
|
||||||
rowParts.push({html:`<tr>${tds}</tr>`,has:rowHasInMonth});
|
rowParts.push({html:`<tr>${tds}</tr>`,has:rowHasInMonth});
|
||||||
}
|
}
|
||||||
|
|
@ -791,15 +751,15 @@ final class Plugin
|
||||||
const d=new Date(start); d.setDate(start.getDate()+i);
|
const d=new Date(start); d.setDate(start.getDate()+i);
|
||||||
const k=localYmd(d);
|
const k=localYmd(d);
|
||||||
const same=d.getMonth()===m;
|
const same=d.getMonth()===m;
|
||||||
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>`;
|
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>`;
|
||||||
}
|
}
|
||||||
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 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>';
|
out+='</div>';
|
||||||
panel.innerHTML=out;
|
panel.innerHTML=out;
|
||||||
Array.from(panel.querySelectorAll("[data-cp-day]")).forEach(el=>{
|
Array.from(panel.querySelectorAll("[data-day]")).forEach(el=>{
|
||||||
el.addEventListener("click",()=>{
|
el.addEventListener("click",()=>{
|
||||||
s("cp-date").value=el.getAttribute("data-cp-day")||s("cp-date").value;
|
s("cp-date").value=el.getAttribute("data-day")||s("cp-date").value;
|
||||||
s("cp-view").value="week";
|
s("cp-view").value="week";
|
||||||
loadPublic();
|
loadPublic();
|
||||||
});
|
});
|
||||||
|
|
@ -819,7 +779,7 @@ final class Plugin
|
||||||
futureWrap().style.display=s("cp-view").value==="list"?"flex":"none";
|
futureWrap().style.display=s("cp-view").value==="list"?"flex":"none";
|
||||||
};
|
};
|
||||||
|
|
||||||
s("cp-open-login-btn").onclick=()=>{setAuthStatus("",false); s("cp-auth-modal").style.display="flex";};
|
s("cp-open-login-btn").onclick=()=>{setAuthStatus("",false); s("cp-auth-modal").style.display="block";};
|
||||||
s("cp-close-login-btn").onclick=()=>{s("cp-auth-modal").style.display="none";};
|
s("cp-close-login-btn").onclick=()=>{s("cp-auth-modal").style.display="none";};
|
||||||
s("cp-logout-btn").onclick=async()=>{
|
s("cp-logout-btn").onclick=async()=>{
|
||||||
await api("/users/logout",{method:"POST"});
|
await api("/users/logout",{method:"POST"});
|
||||||
|
|
@ -966,7 +926,7 @@ final class Plugin
|
||||||
const tokenFromUrl=(new URLSearchParams(window.location.search)).get("calendar_verify_token");
|
const tokenFromUrl=(new URLSearchParams(window.location.search)).get("calendar_verify_token");
|
||||||
if(tokenFromUrl){
|
if(tokenFromUrl){
|
||||||
s("cp-verify-token").value=tokenFromUrl;
|
s("cp-verify-token").value=tokenFromUrl;
|
||||||
s("cp-auth-modal").style.display="flex";
|
s("cp-auth-modal").style.display="block";
|
||||||
setAuthStatus("Verification token loaded from link. Press Verify Email.",false);
|
setAuthStatus("Verification token loaded from link. Press Verify Email.",false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1013,19 +973,18 @@ HTML
|
||||||
}
|
}
|
||||||
|
|
||||||
$rows = [];
|
$rows = [];
|
||||||
$sidebarTz = new \DateTimeZone('Europe/London');
|
|
||||||
foreach ($items as $item) {
|
foreach ($items as $item) {
|
||||||
$start = (string) ($item['occurrence_start'] ?? '');
|
$start = (string) ($item['occurrence_start'] ?? '');
|
||||||
$end = (string) ($item['occurrence_end'] ?? '');
|
$end = (string) ($item['occurrence_end'] ?? '');
|
||||||
$startDt = $this->toSidebarDateTime($start, $sidebarTz);
|
$startTs = strtotime($start);
|
||||||
$endDt = $this->toSidebarDateTime($end, $sidebarTz);
|
$endTs = strtotime($end);
|
||||||
$dateLabel = $startDt !== null ? $startDt->format('j F Y') : substr($start, 0, 10);
|
$dateLabel = $startTs !== false ? date('j F Y', $startTs) : substr($start, 0, 10);
|
||||||
$timeLabel = '';
|
$timeLabel = '';
|
||||||
if ($startDt !== null && $endDt !== null) {
|
if ($startTs !== false && $endTs !== false) {
|
||||||
$startTime = $startDt->format('H:i');
|
$startTime = date('H:i', $startTs);
|
||||||
$endTime = $endDt->format('H:i');
|
$endTime = date('H:i', $endTs);
|
||||||
if ($startTime !== '00:00' || $endTime !== '00:00') {
|
if ($startTime !== '00:00' || $endTime !== '00:00') {
|
||||||
$timeLabel = $this->formatSidebarTimeRange($startDt, $endDt);
|
$timeLabel = $this->formatSidebarTimeRange($startTs, $endTs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$title = trim((string) ($item['title'] ?? ''));
|
$title = trim((string) ($item['title'] ?? ''));
|
||||||
|
|
@ -1045,24 +1004,12 @@ HTML
|
||||||
return '<div class="calendar-plugin-shell" data-mode="sidebar">' . implode('', $rows) . '</div>';
|
return '<div class="calendar-plugin-shell" data-mode="sidebar">' . implode('', $rows) . '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
private function toSidebarDateTime(string $value, \DateTimeZone $timezone): ?\DateTimeImmutable
|
private function formatSidebarTimeRange(int $startTs, int $endTs): string
|
||||||
{
|
{
|
||||||
if ($value === '') {
|
$startMeridiem = strtolower(date('a', $startTs));
|
||||||
return null;
|
$endMeridiem = strtolower(date('a', $endTs));
|
||||||
}
|
$startLabel = $this->formatSidebarTimeValue($startTs);
|
||||||
try {
|
$endLabel = $this->formatSidebarTimeValue($endTs);
|
||||||
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) {
|
if ($startMeridiem === $endMeridiem) {
|
||||||
$startLabel = preg_replace('/(am|pm)$/', '', $startLabel) ?: $startLabel;
|
$startLabel = preg_replace('/(am|pm)$/', '', $startLabel) ?: $startLabel;
|
||||||
return $startLabel . '–' . $endLabel;
|
return $startLabel . '–' . $endLabel;
|
||||||
|
|
@ -1070,11 +1017,11 @@ HTML
|
||||||
return $startLabel . '–' . $endLabel;
|
return $startLabel . '–' . $endLabel;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function formatSidebarTimeValue(\DateTimeImmutable $dt): string
|
private function formatSidebarTimeValue(int $ts): string
|
||||||
{
|
{
|
||||||
$hour = (int) $dt->format('G');
|
$hour = (int) date('G', $ts);
|
||||||
$minute = (int) $dt->format('i');
|
$minute = (int) date('i', $ts);
|
||||||
$meridiem = strtolower($dt->format('a'));
|
$meridiem = strtolower(date('a', $ts));
|
||||||
$hour12 = $hour % 12;
|
$hour12 = $hour % 12;
|
||||||
if ($hour12 === 0) {
|
if ($hour12 === 0) {
|
||||||
$hour12 = 12;
|
$hour12 = 12;
|
||||||
|
|
@ -1422,7 +1369,7 @@ HTML
|
||||||
return [
|
return [
|
||||||
'status' => 'ok',
|
'status' => 'ok',
|
||||||
'plugin' => 'calendar-plugin',
|
'plugin' => 'calendar-plugin',
|
||||||
'version' => '1.0.1',
|
'version' => '0.1.15',
|
||||||
'db_prefix' => $this->db->getPrefix(),
|
'db_prefix' => $this->db->getPrefix(),
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
@ -1645,16 +1592,10 @@ HTML
|
||||||
$date = (string) ($request->get_param('date') ?: gmdate('Y-m-d'));
|
$date = (string) ($request->get_param('date') ?: gmdate('Y-m-d'));
|
||||||
$futureOnlyRaw = (string) ($request->get_param('future_only') ?? '');
|
$futureOnlyRaw = (string) ($request->get_param('future_only') ?? '');
|
||||||
$futureOnly = in_array(strtolower($futureOnlyRaw), ['1', 'true', 'yes', 'on'], true);
|
$futureOnly = in_array(strtolower($futureOnlyRaw), ['1', 'true', 'yes', 'on'], true);
|
||||||
$includePrivateDetails = $this->canWriteCalendar($request);
|
$items = $this->eventService->listPublicOccurrences($view, $date, $futureOnly);
|
||||||
$items = $this->eventService->listPublicOccurrences($view, $date, $futureOnly, !$includePrivateDetails);
|
|
||||||
return [
|
return [
|
||||||
'data' => $items,
|
'data' => $items,
|
||||||
'meta' => [
|
'meta' => ['count' => count($items), 'view' => $view, 'future_only' => $futureOnly],
|
||||||
'count' => count($items),
|
|
||||||
'view' => $view,
|
|
||||||
'future_only' => $futureOnly,
|
|
||||||
'redacted_private' => !$includePrivateDetails,
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
@ -1667,7 +1608,7 @@ HTML
|
||||||
'methods' => 'GET',
|
'methods' => 'GET',
|
||||||
'permission_callback' => '__return_true',
|
'permission_callback' => '__return_true',
|
||||||
'callback' => function (): array {
|
'callback' => function (): array {
|
||||||
$items = $this->eventService->listSidebarUpcoming(14, true);
|
$items = $this->eventService->listSidebarUpcoming(14);
|
||||||
return [
|
return [
|
||||||
'data' => $items,
|
'data' => $items,
|
||||||
'meta' => ['count' => count($items), 'window_days' => 14],
|
'meta' => ['count' => count($items), 'window_days' => 14],
|
||||||
|
|
@ -1899,19 +1840,13 @@ HTML
|
||||||
[
|
[
|
||||||
'methods' => 'GET',
|
'methods' => 'GET',
|
||||||
'permission_callback' => '__return_true',
|
'permission_callback' => '__return_true',
|
||||||
'callback' => function ($request): array|\WP_Error {
|
'callback' => function (): array {
|
||||||
$settings = $this->settingsService->getAll();
|
$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');
|
$calendarName = (string) ($settings['caldav_calendar_name'] ?? 'Public Calendar');
|
||||||
$ics = $this->icsService->buildCalendar(
|
$ics = $this->icsService->buildCalendar(
|
||||||
$this->eventService->listEvents(),
|
$this->eventService->listEvents(),
|
||||||
fn(int $eventId): array => $this->eventService->getDeletedOccurrenceKeys($eventId),
|
fn(int $eventId): array => $this->eventService->getDeletedOccurrenceKeys($eventId),
|
||||||
$calendarName,
|
$calendarName
|
||||||
!$includePrivateDetails
|
|
||||||
);
|
);
|
||||||
return ['data' => $ics];
|
return ['data' => $ics];
|
||||||
},
|
},
|
||||||
|
|
@ -2029,11 +1964,9 @@ HTML
|
||||||
private function serveIcsResponse(): void
|
private function serveIcsResponse(): void
|
||||||
{
|
{
|
||||||
$settings = $this->settingsService->getAll();
|
$settings = $this->settingsService->getAll();
|
||||||
$icsMode = (string) ($settings['ics_access_mode'] ?? 'public_read');
|
|
||||||
$includePrivateDetails = $this->canWriteCalendar(null);
|
|
||||||
if (
|
if (
|
||||||
$icsMode === 'authenticated_read'
|
(string) ($settings['ics_access_mode'] ?? 'public_read') === 'authenticated_read'
|
||||||
&& !$includePrivateDetails
|
&& $this->resolveCalDavUserForRequest(null) === null
|
||||||
) {
|
) {
|
||||||
http_response_code(401);
|
http_response_code(401);
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
@ -2045,8 +1978,7 @@ HTML
|
||||||
$ics = $this->icsService->buildCalendar(
|
$ics = $this->icsService->buildCalendar(
|
||||||
$this->eventService->listEvents(),
|
$this->eventService->listEvents(),
|
||||||
fn(int $eventId): array => $this->eventService->getDeletedOccurrenceKeys($eventId),
|
fn(int $eventId): array => $this->eventService->getDeletedOccurrenceKeys($eventId),
|
||||||
$calendarName,
|
$calendarName
|
||||||
!$includePrivateDetails
|
|
||||||
);
|
);
|
||||||
$etag = '"' . substr(sha1($ics), 0, 16) . '"';
|
$etag = '"' . substr(sha1($ics), 0, 16) . '"';
|
||||||
$lastModified = gmdate('D, d M Y H:i:s') . ' GMT';
|
$lastModified = gmdate('D, d M Y H:i:s') . ' GMT';
|
||||||
|
|
@ -2061,20 +1993,12 @@ HTML
|
||||||
|
|
||||||
private function serveCalDavPath(string $path, string $method): void
|
private function serveCalDavPath(string $path, string $method): void
|
||||||
{
|
{
|
||||||
$userAgent = (string) ($_SERVER['HTTP_USER_AGENT'] ?? '');
|
|
||||||
$caldavUser = $this->resolveCalDavUserForRequest(null);
|
$caldavUser = $this->resolveCalDavUserForRequest(null);
|
||||||
$this->caldavTrace('request', [
|
|
||||||
'method' => $method,
|
|
||||||
'path' => $path,
|
|
||||||
'user_agent' => $userAgent,
|
|
||||||
'authorized' => $caldavUser !== null,
|
|
||||||
]);
|
|
||||||
if ($caldavUser === null) {
|
if ($caldavUser === null) {
|
||||||
http_response_code(401);
|
http_response_code(401);
|
||||||
header('WWW-Authenticate: Basic realm="Calendar CalDAV"');
|
header('WWW-Authenticate: Basic realm="Calendar CalDAV"');
|
||||||
header('Content-Type: application/xml; charset=utf-8');
|
header('Content-Type: application/xml; charset=utf-8');
|
||||||
echo '<error><message>auth required</message></error>';
|
echo '<error><message>auth required</message></error>';
|
||||||
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 401, 'reason' => 'auth_required']);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2086,22 +2010,10 @@ HTML
|
||||||
$resourcePrefix = $collection;
|
$resourcePrefix = $collection;
|
||||||
|
|
||||||
if ($method === 'HEAD') {
|
if ($method === 'HEAD') {
|
||||||
if (
|
if ($path === $root || $path === $root . '/' || $path === $calendarsRoot || $path === rtrim($calendarsRoot, '/') || $path === $collection || $path === rtrim($collection, '/')) {
|
||||||
$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('Allow: OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, HEAD');
|
||||||
header('DAV: 1, calendar-access');
|
header('DAV: 1, calendar-access');
|
||||||
http_response_code(200);
|
http_response_code(200);
|
||||||
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 200, 'kind' => 'collection_head']);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (str_starts_with($path, $resourcePrefix) && str_ends_with($path, '.ics')) {
|
if (str_starts_with($path, $resourcePrefix) && str_ends_with($path, '.ics')) {
|
||||||
|
|
@ -2109,34 +2021,11 @@ HTML
|
||||||
$obj = $this->calDavService->getObject($resource);
|
$obj = $this->calDavService->getObject($resource);
|
||||||
if ($obj === null) {
|
if ($obj === null) {
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 404, 'resource' => $resource]);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
header('Content-Type: text/calendar; charset=utf-8');
|
header('Content-Type: text/calendar; charset=utf-8');
|
||||||
header('ETag: ' . (string) ($obj['etag'] ?? ''));
|
header('ETag: ' . (string) ($obj['etag'] ?? ''));
|
||||||
http_response_code(200);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2145,7 +2034,6 @@ HTML
|
||||||
header('Allow: OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, HEAD');
|
header('Allow: OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, HEAD');
|
||||||
header('DAV: 1, calendar-access');
|
header('DAV: 1, calendar-access');
|
||||||
http_response_code(200);
|
http_response_code(200);
|
||||||
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 200, 'kind' => 'options']);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2154,22 +2042,18 @@ HTML
|
||||||
http_response_code(207);
|
http_response_code(207);
|
||||||
if ($path === $root || $path === $root . '/') {
|
if ($path === $root || $path === $root . '/') {
|
||||||
echo $this->caldavPropfindRootXml($root, $principal, $calendarsRoot, $collection);
|
echo $this->caldavPropfindRootXml($root, $principal, $calendarsRoot, $collection);
|
||||||
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 207, 'kind' => 'propfind_root']);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ($path === $principalCollection || $path === rtrim($principalCollection, '/')) {
|
if ($path === $principalCollection || $path === rtrim($principalCollection, '/')) {
|
||||||
echo $this->caldavPropfindPrincipalCollectionXml($principalCollection, $principal);
|
echo $this->caldavPropfindPrincipalCollectionXml($principalCollection, $principal);
|
||||||
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 207, 'kind' => 'propfind_principal_collection']);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ($path === $principal || $path === rtrim($principal, '/')) {
|
if ($path === $principal || $path === rtrim($principal, '/')) {
|
||||||
echo $this->caldavPropfindPrincipalXml($principal, $calendarsRoot);
|
echo $this->caldavPropfindPrincipalXml($principal, $calendarsRoot);
|
||||||
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 207, 'kind' => 'propfind_principal']);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ($path === $calendarsRoot || $path === rtrim($calendarsRoot, '/')) {
|
if ($path === $calendarsRoot || $path === rtrim($calendarsRoot, '/')) {
|
||||||
echo $this->caldavPropfindCalendarsRootXml($calendarsRoot, $collection);
|
echo $this->caldavPropfindCalendarsRootXml($calendarsRoot, $collection);
|
||||||
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 207, 'kind' => 'propfind_calendars_root']);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ($path === rtrim($collection, '/')) {
|
if ($path === rtrim($collection, '/')) {
|
||||||
|
|
@ -2177,7 +2061,6 @@ HTML
|
||||||
}
|
}
|
||||||
if ($path === $collection) {
|
if ($path === $collection) {
|
||||||
echo $this->caldavPropfindCollectionXml($collection, $this->caldavSyncToken(), true);
|
echo $this->caldavPropfindCollectionXml($collection, $this->caldavSyncToken(), true);
|
||||||
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 207, 'kind' => 'propfind_collection']);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (str_starts_with($path, $resourcePrefix) && str_ends_with($path, '.ics')) {
|
if (str_starts_with($path, $resourcePrefix) && str_ends_with($path, '.ics')) {
|
||||||
|
|
@ -2186,33 +2069,21 @@ HTML
|
||||||
if ($obj === null) {
|
if ($obj === null) {
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
echo '<error><message>not found</message></error>';
|
echo '<error><message>not found</message></error>';
|
||||||
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 404, 'resource' => $resource, 'kind' => 'propfind_object']);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
echo $this->caldavPropfindObjectXml($collection . $resource, (string) ($obj['etag'] ?? ''));
|
echo $this->caldavPropfindObjectXml($collection . $resource, (string) ($obj['etag'] ?? ''));
|
||||||
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 207, 'resource' => $resource, 'kind' => 'propfind_object']);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
echo '<error><message>not found</message></error>';
|
echo '<error><message>not found</message></error>';
|
||||||
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 404, 'kind' => 'propfind_not_found']);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($method === 'REPORT' && ($path === $collection || $path === rtrim($collection, '/'))) {
|
if ($method === 'REPORT' && $path === $collection) {
|
||||||
$body = (string) file_get_contents('php://input');
|
$body = (string) file_get_contents('php://input');
|
||||||
$reportType = $this->caldavReportType($body);
|
|
||||||
header('Content-Type: application/xml; charset=utf-8');
|
header('Content-Type: application/xml; charset=utf-8');
|
||||||
http_response_code(207);
|
http_response_code(207);
|
||||||
echo $this->caldavReportXml($collection, $body, $this->caldavSyncToken());
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2222,14 +2093,12 @@ HTML
|
||||||
$obj = $this->calDavService->getObject($resource);
|
$obj = $this->calDavService->getObject($resource);
|
||||||
if ($obj === null) {
|
if ($obj === null) {
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 404, 'resource' => $resource, 'kind' => 'object_get']);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
header('Content-Type: text/calendar; charset=utf-8');
|
header('Content-Type: text/calendar; charset=utf-8');
|
||||||
header('ETag: ' . (string) ($obj['etag'] ?? ''));
|
header('ETag: ' . (string) ($obj['etag'] ?? ''));
|
||||||
http_response_code(200);
|
http_response_code(200);
|
||||||
echo (string) ($obj['ics'] ?? '');
|
echo (string) ($obj['ics'] ?? '');
|
||||||
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 200, 'resource' => $resource, 'kind' => 'object_get']);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ($method === 'PUT') {
|
if ($method === 'PUT') {
|
||||||
|
|
@ -2248,14 +2117,6 @@ HTML
|
||||||
http_response_code((int) ($error['status'] ?? 500));
|
http_response_code((int) ($error['status'] ?? 500));
|
||||||
header('Content-Type: application/xml; charset=utf-8');
|
header('Content-Type: application/xml; charset=utf-8');
|
||||||
echo '<error><message>' . esc_html((string) ($error['message'] ?? 'error')) . '</message></error>';
|
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;
|
return;
|
||||||
}
|
}
|
||||||
$status = (int) ($result['status'] ?? 204);
|
$status = (int) ($result['status'] ?? 204);
|
||||||
|
|
@ -2264,14 +2125,6 @@ HTML
|
||||||
header('ETag: ' . (string) $event['etag']);
|
header('ETag: ' . (string) $event['etag']);
|
||||||
}
|
}
|
||||||
http_response_code($status);
|
http_response_code($status);
|
||||||
$this->caldavTrace('response', [
|
|
||||||
'method' => $method,
|
|
||||||
'path' => $path,
|
|
||||||
'status' => $status,
|
|
||||||
'resource' => $resource,
|
|
||||||
'kind' => 'object_put',
|
|
||||||
'body_bytes' => strlen($raw),
|
|
||||||
]);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ($method === 'DELETE') {
|
if ($method === 'DELETE') {
|
||||||
|
|
@ -2279,24 +2132,15 @@ HTML
|
||||||
if (isset($result['error'])) {
|
if (isset($result['error'])) {
|
||||||
$error = (array) $result['error'];
|
$error = (array) $result['error'];
|
||||||
http_response_code((int) ($error['status'] ?? 500));
|
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;
|
return;
|
||||||
}
|
}
|
||||||
http_response_code(204);
|
http_response_code(204);
|
||||||
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 204, 'resource' => $resource, 'kind' => 'object_delete']);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
http_response_code(405);
|
http_response_code(405);
|
||||||
header('Allow: OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, HEAD');
|
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
|
private function caldavPropfindRootXml(string $root, string $principal, string $calendarsRoot, string $collection): string
|
||||||
|
|
@ -2423,30 +2267,9 @@ HTML
|
||||||
$bodyLower = strtolower($xmlBody);
|
$bodyLower = strtolower($xmlBody);
|
||||||
$resources = array_map(static fn(array $r): string => (string) ($r['resource'] ?? ''), $this->calDavService->listResources());
|
$resources = array_map(static fn(array $r): string => (string) ($r['resource'] ?? ''), $this->calDavService->listResources());
|
||||||
$items = [];
|
$items = [];
|
||||||
$reportType = $this->caldavReportType($xmlBody);
|
|
||||||
$includeDeleted = false;
|
|
||||||
$deletedCount = 0;
|
|
||||||
$clientSyncToken = '';
|
|
||||||
|
|
||||||
if (str_contains($bodyLower, 'sync-collection')) {
|
if (str_contains($bodyLower, 'sync-collection')) {
|
||||||
$clientSyncToken = $this->extractSyncCollectionToken($xmlBody);
|
$items = $this->calDavService->multiget($resources);
|
||||||
// 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')) {
|
} elseif (str_contains($bodyLower, 'calendar-query')) {
|
||||||
$items = $this->calDavService->multiget($resources);
|
$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)) {
|
if (preg_match('/start\\s*=\\s*"([0-9TzZ]+)"/i', $xmlBody, $s) && preg_match('/end\\s*=\\s*"([0-9TzZ]+)"/i', $xmlBody, $e)) {
|
||||||
|
|
@ -2476,38 +2299,17 @@ HTML
|
||||||
}
|
}
|
||||||
|
|
||||||
$responses = '';
|
$responses = '';
|
||||||
$okCount = 0;
|
|
||||||
$notFoundCount = 0;
|
|
||||||
foreach ($items as $item) {
|
foreach ($items as $item) {
|
||||||
$status = (int) ($item['status'] ?? 404);
|
$status = (int) ($item['status'] ?? 404);
|
||||||
$resource = (string) ($item['resource'] ?? '');
|
$resource = (string) ($item['resource'] ?? '');
|
||||||
$responses .= '<D:response><D:href>' . htmlspecialchars($collection . $resource, ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</D:href>';
|
$responses .= '<D:response><D:href>' . htmlspecialchars($collection . $resource, ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</D:href><D:propstat><D:prop>';
|
||||||
if ($status === 200) {
|
if ($status === 200) {
|
||||||
$okCount++;
|
|
||||||
$responses .= '<D:propstat><D:prop>';
|
|
||||||
$responses .= '<D:getetag>' . htmlspecialchars((string) ($item['etag'] ?? ''), ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</D:getetag>'
|
$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>';
|
. '<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:response>';
|
$responses .= '</D:prop><D:status>HTTP/1.1 ' . $status . ($status === 200 ? ' OK' : ' Not Found')
|
||||||
|
. '</D:status></D:propstat></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>'
|
return '<?xml version="1.0" encoding="utf-8"?><D:multistatus xmlns:D="DAV:"><D:sync-token>'
|
||||||
. htmlspecialchars($syncToken, ENT_XML1 | ENT_QUOTES, 'UTF-8')
|
. htmlspecialchars($syncToken, ENT_XML1 | ENT_QUOTES, 'UTF-8')
|
||||||
. '</D:sync-token>'
|
. '</D:sync-token>'
|
||||||
|
|
@ -2522,9 +2324,6 @@ HTML
|
||||||
foreach ($rows as $row) {
|
foreach ($rows as $row) {
|
||||||
$seed .= (string) ($row['resource'] ?? '') . '|' . (string) ($row['etag'] ?? '') . ';';
|
$seed .= (string) ($row['resource'] ?? '') . '|' . (string) ($row['etag'] ?? '') . ';';
|
||||||
}
|
}
|
||||||
foreach ($this->calDavService->listDeletedResources(1000) as $deletedResource) {
|
|
||||||
$seed .= 'deleted:' . $deletedResource . ';';
|
|
||||||
}
|
|
||||||
return 'urn:calendar-plugin:sync:' . sha1($seed);
|
return 'urn:calendar-plugin:sync:' . sha1($seed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2554,43 +2353,6 @@ HTML
|
||||||
return $this->icalToIso((string) $m[1]);
|
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
|
private function unwrapServiceResult(array $result): array|\WP_Error
|
||||||
{
|
{
|
||||||
if (isset($result['error']) && is_array($result['error'])) {
|
if (isset($result['error']) && is_array($result['error'])) {
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,20 @@
|
||||||
ca4806cd3e7827b9666e6929cbcae8f3e2eba87e499f7dd7008d8edf2fcc774e ./calendar-plugin.php
|
ca4806cd3e7827b9666e6929cbcae8f3e2eba87e499f7dd7008d8edf2fcc774e staging/calendar-plugin/calendar-plugin.php
|
||||||
1daa5861c0d10258c0d16c5c88c1a18fb3c8df7b590f7facfe7c28f678643bdb ./src/Contracts/AuthAdapterInterface.php
|
1daa5861c0d10258c0d16c5c88c1a18fb3c8df7b590f7facfe7c28f678643bdb staging/calendar-plugin/src/Contracts/AuthAdapterInterface.php
|
||||||
25cff4b0fc2ee292b53c152edd083c6af66200a25eec28f8dfce37d126a8892d ./src/Contracts/DatabaseAdapterInterface.php
|
25cff4b0fc2ee292b53c152edd083c6af66200a25eec28f8dfce37d126a8892d staging/calendar-plugin/src/Contracts/DatabaseAdapterInterface.php
|
||||||
4f0f4caa5ac98499854336f5b74af55ce889653f3956e5df10910f869a23fdba ./src/Contracts/HttpAdapterInterface.php
|
4f0f4caa5ac98499854336f5b74af55ce889653f3956e5df10910f869a23fdba staging/calendar-plugin/src/Contracts/HttpAdapterInterface.php
|
||||||
15e8f58c7360d6cd0c76c945abfeb026f8278d40b330e99b67955eb2f85f5563 ./src/Contracts/OptionsAdapterInterface.php
|
15e8f58c7360d6cd0c76c945abfeb026f8278d40b330e99b67955eb2f85f5563 staging/calendar-plugin/src/Contracts/OptionsAdapterInterface.php
|
||||||
da068811cd923bd6cf04a209b9b4eec55fa57aeedfccaa3250ebd37b906c6d04 ./src/Domain/CalDavService.php
|
47d55e845b8a696c55fa86f597bb2760b6e35d974b70f365593d38697ef398c8 staging/calendar-plugin/src/Domain/CalDavService.php
|
||||||
c8ab00f23e7cd198228734515c3ce9a2589b1ab6cb815307f3b8f7a3051e04d2 ./src/Domain/EventService.php
|
4dc3337761c97aa550896fcc377aaab8338c598f39e489742dacbfd20e1a71b1 staging/calendar-plugin/src/Domain/EventService.php
|
||||||
f48006beb0c5c8d7a98e9f33f80a6a08fb92a999c28c937f9afd814e98de0a05 ./src/Domain/IcsService.php
|
412a22ecd910535c7ace2549a86eacf08cc9cd824f1767e60116d8593355f57f staging/calendar-plugin/src/Domain/IcsService.php
|
||||||
fd5377f7852b0f35550a453451d98882b8488b332d9a051dac603851c7b586d1 ./src/Domain/RecurrenceExpander.php
|
5f7fb1f8c00136ad2c4a2dc8b909dabf6494a194b09aa73557a4bf68d73f4ed2 staging/calendar-plugin/src/Domain/RecurrenceExpander.php
|
||||||
20ee26671fd934f36e97606cfd2ec1d5101ac8064594989bc955bf805fae2502 ./src/Domain/SettingsService.php
|
20ee26671fd934f36e97606cfd2ec1d5101ac8064594989bc955bf805fae2502 staging/calendar-plugin/src/Domain/SettingsService.php
|
||||||
9956790a5d62f5798f3ec15fc507fb04f33e01fd27969db09e91a6d1b41cd33f ./src/Domain/UserService.php
|
9956790a5d62f5798f3ec15fc507fb04f33e01fd27969db09e91a6d1b41cd33f staging/calendar-plugin/src/Domain/UserService.php
|
||||||
5aaac066919b60461bde2e96cbfb4de66a5a28e89d2a61b78f0e2d346f23395b ./src/Infrastructure/ServiceContainer.php
|
5aaac066919b60461bde2e96cbfb4de66a5a28e89d2a61b78f0e2d346f23395b staging/calendar-plugin/src/Infrastructure/ServiceContainer.php
|
||||||
e65577c707c5a66e2097faa7720170180c79a9d9df219f0e1061fdcec55be744 ./src/Infrastructure/WordPress/MigrationManager.php
|
70334df8ca06c8fe61d81f24cb0d8f19285180f9950054bd86af0083adf8b4c8 staging/calendar-plugin/src/Infrastructure/WordPress/MigrationManager.php
|
||||||
8e6c95e9e1c051606e66d95cf0bcf92b2ca087bc491f32ab4921e0898cf77b81 ./src/Infrastructure/WordPress/WordPressAuthAdapter.php
|
8e6c95e9e1c051606e66d95cf0bcf92b2ca087bc491f32ab4921e0898cf77b81 staging/calendar-plugin/src/Infrastructure/WordPress/WordPressAuthAdapter.php
|
||||||
68c0ca15ad2c8b6363a2578b85f8daf0d3a094e612a120a2cdd2a2bfd8fe5e3c ./src/Infrastructure/WordPress/WordPressDatabaseAdapter.php
|
68c0ca15ad2c8b6363a2578b85f8daf0d3a094e612a120a2cdd2a2bfd8fe5e3c staging/calendar-plugin/src/Infrastructure/WordPress/WordPressDatabaseAdapter.php
|
||||||
8da85db3c1e69c2c5f01aaa2f558aa8f0323446d8af0aec5b34d4607cd4afe1b ./src/Infrastructure/WordPress/WordPressHttpAdapter.php
|
8da85db3c1e69c2c5f01aaa2f558aa8f0323446d8af0aec5b34d4607cd4afe1b staging/calendar-plugin/src/Infrastructure/WordPress/WordPressHttpAdapter.php
|
||||||
cf9fddcecb07af2c03ad2c0e448be12a6b45dd936efc8bc1fd46a52b4af864ea ./src/Infrastructure/WordPress/WordPressOptionsAdapter.php
|
cf9fddcecb07af2c03ad2c0e448be12a6b45dd936efc8bc1fd46a52b4af864ea staging/calendar-plugin/src/Infrastructure/WordPress/WordPressOptionsAdapter.php
|
||||||
7c740beff8c22271e1d3e578368c57b9d4b9ebdc42852f0fc967441d39c87d7b ./src/Plugin.php
|
ffc7c3eef8f3ed0873b5f614765219925d07c24ffd6ffa7422a3d862a7342450 staging/calendar-plugin/src/Plugin.php
|
||||||
4e6930c79d9ce1045be6863ddd7546e19f7ef7e839d41a41014a3efb3183eaf0 ./src/bootstrap.php
|
4e6930c79d9ce1045be6863ddd7546e19f7ef7e839d41a41014a3efb3183eaf0 staging/calendar-plugin/src/bootstrap.php
|
||||||
893c6df62beed87a981d372c473e5012d1b5d1c254d23b39cda44ae8a08cd16c ./uninstall.php
|
893c6df62beed87a981d372c473e5012d1b5d1c254d23b39cda44ae8a08cd16c staging/calendar-plugin/uninstall.php
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -1,20 +0,0 @@
|
||||||
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.
|
|
@ -1,20 +0,0 @@
|
||||||
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.
|
|
@ -1,20 +0,0 @@
|
||||||
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.
|
|
@ -1,20 +0,0 @@
|
||||||
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.
|
|
@ -1,20 +0,0 @@
|
||||||
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.
|
|
@ -1,20 +0,0 @@
|
||||||
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.
|
|
@ -1,20 +0,0 @@
|
||||||
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.
|
|
@ -3,7 +3,7 @@
|
||||||
* Plugin Name: Calendar Plugin
|
* Plugin Name: Calendar Plugin
|
||||||
* Plugin URI: https://chezstephens.org.uk
|
* 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.
|
* 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: 1.0.1
|
* Version: 0.1.15
|
||||||
* Requires at least: 6.0
|
* Requires at least: 6.0
|
||||||
* Requires PHP: 8.1
|
* Requires PHP: 8.1
|
||||||
* Author: Adrian Stephens (with AI assistance)
|
* Author: Adrian Stephens (with AI assistance)
|
||||||
|
|
|
||||||
|
|
@ -140,14 +140,6 @@ final class CalDavService
|
||||||
return $out;
|
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
|
public function resourceForEvent(array $event): string
|
||||||
{
|
{
|
||||||
$resource = trim((string) ($event['caldav_resource'] ?? ''));
|
$resource = trim((string) ($event['caldav_resource'] ?? ''));
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ final class EventService
|
||||||
{
|
{
|
||||||
private readonly string $eventsTable;
|
private readonly string $eventsTable;
|
||||||
private readonly string $exceptionsTable;
|
private readonly string $exceptionsTable;
|
||||||
private readonly string $tombstonesTable;
|
|
||||||
|
|
||||||
public function __construct(private readonly DatabaseAdapterInterface $db, string $tableStem = 'cs_calendar')
|
public function __construct(private readonly DatabaseAdapterInterface $db, string $tableStem = 'cs_calendar')
|
||||||
{
|
{
|
||||||
|
|
@ -20,7 +19,6 @@ final class EventService
|
||||||
$stem = trim($tableStem, '_');
|
$stem = trim($tableStem, '_');
|
||||||
$this->eventsTable = $prefix . $stem . '_events';
|
$this->eventsTable = $prefix . $stem . '_events';
|
||||||
$this->exceptionsTable = $prefix . $stem . '_recurrence_exceptions';
|
$this->exceptionsTable = $prefix . $stem . '_recurrence_exceptions';
|
||||||
$this->tombstonesTable = $prefix . $stem . '_caldav_tombstones';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function listEvents(): array
|
public function listEvents(): array
|
||||||
|
|
@ -77,7 +75,6 @@ final class EventService
|
||||||
|
|
||||||
$data = [
|
$data = [
|
||||||
'uid' => $uid,
|
'uid' => $uid,
|
||||||
'visibility' => $this->canonicalVisibility((string) ($payload['visibility'] ?? 'public')),
|
|
||||||
'title' => $title,
|
'title' => $title,
|
||||||
'description' => (string) ($payload['description'] ?? ''),
|
'description' => (string) ($payload['description'] ?? ''),
|
||||||
'location' => (string) ($payload['location'] ?? ''),
|
'location' => (string) ($payload['location'] ?? ''),
|
||||||
|
|
@ -107,12 +104,7 @@ final class EventService
|
||||||
if ($inserted === false) {
|
if ($inserted === false) {
|
||||||
throw new \RuntimeException('failed to create event');
|
throw new \RuntimeException('failed to create event');
|
||||||
}
|
}
|
||||||
$created = (array) $this->getEvent($this->db->insertId());
|
return (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
|
public function updateEvent(int $id, array $payload): ?array
|
||||||
|
|
@ -156,9 +148,6 @@ final class EventService
|
||||||
$repeatNthWeekday
|
$repeatNthWeekday
|
||||||
);
|
);
|
||||||
$data = [
|
$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'])),
|
'title' => trim((string) ($payload['title'] ?? $existing['title'])),
|
||||||
'description' => (string) ($payload['description'] ?? $existing['description']),
|
'description' => (string) ($payload['description'] ?? $existing['description']),
|
||||||
'location' => (string) ($payload['location'] ?? $existing['location']),
|
'location' => (string) ($payload['location'] ?? $existing['location']),
|
||||||
|
|
@ -190,27 +179,13 @@ final class EventService
|
||||||
];
|
];
|
||||||
|
|
||||||
$this->db->update($this->eventsTable, $data, ['id' => $id]);
|
$this->db->update($this->eventsTable, $data, ['id' => $id]);
|
||||||
$updated = $this->getEvent($id);
|
return $this->getEvent($id);
|
||||||
if ($updated) {
|
|
||||||
$resource = trim((string) ($updated['caldav_resource'] ?? ''));
|
|
||||||
if ($resource !== '') {
|
|
||||||
$this->clearCalDavTombstone($resource);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return $updated;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function deleteEvent(int $id): bool
|
public function deleteEvent(int $id): bool
|
||||||
{
|
{
|
||||||
$event = $this->getEvent($id);
|
|
||||||
$this->db->delete($this->exceptionsTable, ['event_id' => $id]);
|
$this->db->delete($this->exceptionsTable, ['event_id' => $id]);
|
||||||
$deleted = $this->db->delete($this->eventsTable, ['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;
|
return $deleted !== false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -303,7 +278,6 @@ final class EventService
|
||||||
$event = [
|
$event = [
|
||||||
'id' => 0,
|
'id' => 0,
|
||||||
'uid' => 'preview@calendar-plugin',
|
'uid' => 'preview@calendar-plugin',
|
||||||
'visibility' => $this->canonicalVisibility((string) ($payload['visibility'] ?? 'public')),
|
|
||||||
'title' => (string) ($payload['title'] ?? ''),
|
'title' => (string) ($payload['title'] ?? ''),
|
||||||
'description' => (string) ($payload['description'] ?? ''),
|
'description' => (string) ($payload['description'] ?? ''),
|
||||||
'location' => (string) ($payload['location'] ?? ''),
|
'location' => (string) ($payload['location'] ?? ''),
|
||||||
|
|
@ -378,7 +352,7 @@ final class EventService
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function listPublicOccurrences(string $view, string $dateAnchor, bool $futureOnly = false, bool $redactPrivate = true): array
|
public function listPublicOccurrences(string $view, string $dateAnchor, bool $futureOnly = false): array
|
||||||
{
|
{
|
||||||
$tz = new DateTimeZone('Europe/London');
|
$tz = new DateTimeZone('Europe/London');
|
||||||
$anchor = $this->safeDate($dateAnchor, $tz);
|
$anchor = $this->safeDate($dateAnchor, $tz);
|
||||||
|
|
@ -408,14 +382,10 @@ final class EventService
|
||||||
static fn(array $a, array $b): int => strcmp((string) $a['occurrence_start'], (string) $b['occurrence_start'])
|
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;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function listSidebarUpcoming(int $days = 14, bool $redactPrivate = true): array
|
public function listSidebarUpcoming(int $days = 14): array
|
||||||
{
|
{
|
||||||
$tz = new DateTimeZone('Europe/London');
|
$tz = new DateTimeZone('Europe/London');
|
||||||
$start = new DateTimeImmutable('today', $tz);
|
$start = new DateTimeImmutable('today', $tz);
|
||||||
|
|
@ -434,10 +404,6 @@ final class EventService
|
||||||
static fn(array $a, array $b): int => strcmp((string) $a['occurrence_start'], (string) $b['occurrence_start'])
|
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;
|
return $out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -445,31 +411,11 @@ final class EventService
|
||||||
{
|
{
|
||||||
$events = $this->listEvents();
|
$events = $this->listEvents();
|
||||||
$count = count($events);
|
$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->exceptionsTable}");
|
||||||
$this->db->query("DELETE FROM {$this->eventsTable}");
|
$this->db->query("DELETE FROM {$this->eventsTable}");
|
||||||
return $count;
|
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
|
public function seedDefaultEvents(): int
|
||||||
{
|
{
|
||||||
$seed = [
|
$seed = [
|
||||||
|
|
@ -548,9 +494,6 @@ final class EventService
|
||||||
return [
|
return [
|
||||||
'id' => (int) $row->id,
|
'id' => (int) $row->id,
|
||||||
'uid' => (string) $row->uid,
|
'uid' => (string) $row->uid,
|
||||||
'visibility' => property_exists($row, 'visibility')
|
|
||||||
? $this->canonicalVisibility((string) ($row->visibility ?? 'public'))
|
|
||||||
: 'public',
|
|
||||||
'title' => (string) $row->title,
|
'title' => (string) $row->title,
|
||||||
'description' => (string) $row->description,
|
'description' => (string) $row->description,
|
||||||
'location' => (string) $row->location,
|
'location' => (string) $row->location,
|
||||||
|
|
@ -671,26 +614,6 @@ final class EventService
|
||||||
return in_array($v, ['none', 'count', 'until'], true) ? $v : 'none';
|
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(
|
private function normalizeMonthlyAnchor(
|
||||||
string $startIso,
|
string $startIso,
|
||||||
string $endIso,
|
string $endIso,
|
||||||
|
|
@ -783,30 +706,4 @@ final class EventService
|
||||||
}
|
}
|
||||||
return [$newYear, $newMonth];
|
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,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,7 @@ final class IcsService
|
||||||
{
|
{
|
||||||
private const PRODID = '-//Calendar Plugin//EN';
|
private const PRODID = '-//Calendar Plugin//EN';
|
||||||
|
|
||||||
public function buildCalendar(
|
public function buildCalendar(array $events, callable $deletedKeysProvider, string $calendarName = 'Calendar'): string
|
||||||
array $events,
|
|
||||||
callable $deletedKeysProvider,
|
|
||||||
string $calendarName = 'Calendar',
|
|
||||||
bool $redactPrivate = false
|
|
||||||
): string
|
|
||||||
{
|
{
|
||||||
$lines = [
|
$lines = [
|
||||||
'BEGIN:VCALENDAR',
|
'BEGIN:VCALENDAR',
|
||||||
|
|
@ -25,35 +20,10 @@ final class IcsService
|
||||||
'CALSCALE:GREGORIAN',
|
'CALSCALE:GREGORIAN',
|
||||||
'X-WR-CALNAME:' . $this->escapeText($calendarName),
|
'X-WR-CALNAME:' . $this->escapeText($calendarName),
|
||||||
'X-WR-TIMEZONE:Europe/London',
|
'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) {
|
foreach ($events as $event) {
|
||||||
$lines = array_merge(
|
$lines = array_merge($lines, $this->eventToLines($event, (array) $deletedKeysProvider((int) ($event['id'] ?? 0))));
|
||||||
$lines,
|
|
||||||
$this->eventToLines(
|
|
||||||
$event,
|
|
||||||
(array) $deletedKeysProvider((int) ($event['id'] ?? 0)),
|
|
||||||
$redactPrivate
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$lines[] = 'END:VCALENDAR';
|
$lines[] = 'END:VCALENDAR';
|
||||||
|
|
@ -106,11 +76,6 @@ final class IcsService
|
||||||
'repeat_until' => null,
|
'repeat_until' => null,
|
||||||
'timezone' => 'Europe/London',
|
'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] ?? '');
|
$rrule = (string) ($props['RRULE'][0] ?? '');
|
||||||
if ($rrule !== '') {
|
if ($rrule !== '') {
|
||||||
|
|
@ -132,7 +97,7 @@ final class IcsService
|
||||||
return $payload;
|
return $payload;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function eventToLines(array $event, array $deletedKeys, bool $redactPrivate): array
|
private function eventToLines(array $event, array $deletedKeys): array
|
||||||
{
|
{
|
||||||
$uid = (string) ($event['uid'] ?? '');
|
$uid = (string) ($event['uid'] ?? '');
|
||||||
$uid = $uid !== '' ? $uid : ('event-' . (string) ($event['id'] ?? 0) . '@calendar-plugin');
|
$uid = $uid !== '' ? $uid : ('event-' . (string) ($event['id'] ?? 0) . '@calendar-plugin');
|
||||||
|
|
@ -144,26 +109,18 @@ final class IcsService
|
||||||
}
|
}
|
||||||
|
|
||||||
$allDay = (bool) ($event['all_day_event'] ?? false);
|
$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'));
|
$updated = $this->toDateTime((string) ($event['updated_at'] ?? '')) ?? new DateTimeImmutable('now', new DateTimeZone('UTC'));
|
||||||
|
|
||||||
$lines = [
|
$lines = [
|
||||||
'BEGIN:VEVENT',
|
'BEGIN:VEVENT',
|
||||||
'UID:' . $this->escapeText($uid),
|
'UID:' . $this->escapeText($uid),
|
||||||
'SUMMARY:' . $this->escapeText($isRedactedPrivate ? 'Private Event' : (string) ($event['title'] ?? 'Untitled')),
|
'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'] ?? '')),
|
||||||
'DTSTAMP:' . $this->toUtcIcs($updated),
|
'DTSTAMP:' . $this->toUtcIcs($updated),
|
||||||
'LAST-MODIFIED:' . $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) {
|
if ($allDay) {
|
||||||
$lines[] = 'DTSTART;VALUE=DATE:' . $start->setTimezone(new DateTimeZone('Europe/London'))->format('Ymd');
|
$lines[] = 'DTSTART;VALUE=DATE:' . $start->setTimezone(new DateTimeZone('Europe/London'))->format('Ymd');
|
||||||
|
|
@ -346,7 +303,7 @@ final class IcsService
|
||||||
if (!$in) {
|
if (!$in) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
[$left, $value] = $this->splitContentLine($line);
|
[$left, $value] = array_pad(explode(':', $line, 2), 2, '');
|
||||||
if ($left === '') {
|
if ($left === '') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -364,23 +321,6 @@ final class IcsService
|
||||||
return $in ? $props : null;
|
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
|
private function parseIcsDateTime(string $value, bool $dateOnly): ?string
|
||||||
{
|
{
|
||||||
$value = trim($value);
|
$value = trim($value);
|
||||||
|
|
|
||||||
|
|
@ -148,7 +148,6 @@ final class RecurrenceExpander
|
||||||
return [
|
return [
|
||||||
'event_id' => (int) ($event['id'] ?? 0),
|
'event_id' => (int) ($event['id'] ?? 0),
|
||||||
'uid' => (string) ($event['uid'] ?? ''),
|
'uid' => (string) ($event['uid'] ?? ''),
|
||||||
'visibility' => (string) ($event['visibility'] ?? 'public'),
|
|
||||||
'title' => (string) ($event['title'] ?? ''),
|
'title' => (string) ($event['title'] ?? ''),
|
||||||
'description' => (string) ($event['description'] ?? ''),
|
'description' => (string) ($event['description'] ?? ''),
|
||||||
'location' => (string) ($event['location'] ?? ''),
|
'location' => (string) ($event['location'] ?? ''),
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ use DateTimeZone;
|
||||||
|
|
||||||
final class MigrationManager
|
final class MigrationManager
|
||||||
{
|
{
|
||||||
private const SCHEMA_VERSION = '5';
|
private const SCHEMA_VERSION = '3';
|
||||||
private const STEM_OPTION = 'calendar_plugin_table_stem';
|
private const STEM_OPTION = 'calendar_plugin_table_stem';
|
||||||
|
|
||||||
public function __construct(private readonly DatabaseAdapterInterface $db, private readonly string $tableStem = 'cs_calendar')
|
public function __construct(private readonly DatabaseAdapterInterface $db, private readonly string $tableStem = 'cs_calendar')
|
||||||
|
|
@ -31,12 +31,10 @@ final class MigrationManager
|
||||||
$users = $prefix . $stem . '_users';
|
$users = $prefix . $stem . '_users';
|
||||||
$tokens = $prefix . $stem . '_user_tokens';
|
$tokens = $prefix . $stem . '_user_tokens';
|
||||||
$audit = $prefix . $stem . '_audit_log';
|
$audit = $prefix . $stem . '_audit_log';
|
||||||
$tombstones = $prefix . $stem . '_caldav_tombstones';
|
|
||||||
|
|
||||||
$sqlEvents = "CREATE TABLE {$events} (
|
$sqlEvents = "CREATE TABLE {$events} (
|
||||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
uid VARCHAR(191) NOT NULL,
|
uid VARCHAR(191) NOT NULL,
|
||||||
visibility VARCHAR(16) NOT NULL DEFAULT 'public',
|
|
||||||
title TEXT NOT NULL,
|
title TEXT NOT NULL,
|
||||||
description LONGTEXT NOT NULL,
|
description LONGTEXT NOT NULL,
|
||||||
location TEXT NOT NULL,
|
location TEXT NOT NULL,
|
||||||
|
|
@ -117,18 +115,11 @@ final class MigrationManager
|
||||||
KEY created_at (created_at)
|
KEY created_at (created_at)
|
||||||
) {$charsetCollate};";
|
) {$charsetCollate};";
|
||||||
|
|
||||||
$sqlTombstones = "CREATE TABLE {$tombstones} (
|
|
||||||
resource VARCHAR(191) NOT NULL,
|
|
||||||
deleted_at VARCHAR(32) NOT NULL,
|
|
||||||
PRIMARY KEY (resource)
|
|
||||||
) {$charsetCollate};";
|
|
||||||
|
|
||||||
dbDelta($sqlEvents);
|
dbDelta($sqlEvents);
|
||||||
dbDelta($sqlExceptions);
|
dbDelta($sqlExceptions);
|
||||||
dbDelta($sqlUsers);
|
dbDelta($sqlUsers);
|
||||||
dbDelta($sqlTokens);
|
dbDelta($sqlTokens);
|
||||||
dbDelta($sqlAudit);
|
dbDelta($sqlAudit);
|
||||||
dbDelta($sqlTombstones);
|
|
||||||
|
|
||||||
// Ensure every event has a stable CalDAV object resource name.
|
// Ensure every event has a stable CalDAV object resource name.
|
||||||
$this->db->query(
|
$this->db->query(
|
||||||
|
|
@ -138,13 +129,6 @@ final class MigrationManager
|
||||||
AND uid IS NOT NULL
|
AND uid IS NOT NULL
|
||||||
AND uid <> ''"
|
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);
|
$this->normalizeEventDateTimesToLondon($events);
|
||||||
|
|
||||||
update_option(self::STEM_OPTION, $stem);
|
update_option(self::STEM_OPTION, $stem);
|
||||||
|
|
@ -186,7 +170,6 @@ final class MigrationManager
|
||||||
$prefix . $stem . '_users',
|
$prefix . $stem . '_users',
|
||||||
$prefix . $stem . '_user_tokens',
|
$prefix . $stem . '_user_tokens',
|
||||||
$prefix . $stem . '_audit_log',
|
$prefix . $stem . '_audit_log',
|
||||||
$prefix . $stem . '_caldav_tombstones',
|
|
||||||
];
|
];
|
||||||
foreach ($tables as $table) {
|
foreach ($tables as $table) {
|
||||||
$sql = $this->db->prepare('SHOW TABLES LIKE %s', $table);
|
$sql = $this->db->prepare('SHOW TABLES LIKE %s', $table);
|
||||||
|
|
|
||||||
|
|
@ -180,10 +180,10 @@ final class Plugin
|
||||||
<p id="cp-status" style="margin:10px 0 8px 0;"></p>
|
<p id="cp-status" style="margin:10px 0 8px 0;"></p>
|
||||||
<h3 id="cp-events-title" style="margin:0 0 8px 0;">Events</h3>
|
<h3 id="cp-events-title" style="margin:0 0 8px 0;">Events</h3>
|
||||||
<div id="cp-view-panel"></div>
|
<div id="cp-view-panel"></div>
|
||||||
<ul id="cp-public-list" style="margin-top:8px;list-style:none;padding-left:0;"></ul>
|
<ul id="cp-public-list" style="margin-top:8px;"></ul>
|
||||||
|
|
||||||
<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 id="cp-auth-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.35);z-index:1000;">
|
||||||
<div style="width:min(560px,100%);max-height:92vh;overflow:auto;background:#fff;border-radius:8px;padding:12px;">
|
<div style="max-width:560px;margin:8vh auto;background:#fff;border-radius:8px;padding:12px;">
|
||||||
<h3 style="margin:0 0 8px 0;">Account Login</h3>
|
<h3 style="margin:0 0 8px 0;">Account Login</h3>
|
||||||
<p id="cp-auth-status" style="margin:0 0 8px 0;"></p>
|
<p id="cp-auth-status" style="margin:0 0 8px 0;"></p>
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;">
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;">
|
||||||
|
|
@ -203,13 +203,12 @@ final class Plugin
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="cp-editor-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.35);z-index:2147483000;">
|
<div id="cp-editor-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.35);z-index:1000;">
|
||||||
<div style="max-width:900px;margin:3vh auto;background:#fff;border-radius:8px;padding:12px;max-height:94vh;overflow:auto;">
|
<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>
|
<h3 id="cp-editor-title" style="margin:0 0 8px 0;">Create Event</h3>
|
||||||
<input id="cp-event-id" type="hidden" />
|
<input id="cp-event-id" type="hidden" />
|
||||||
<div style="display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;">
|
<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;">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: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: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>
|
<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>
|
||||||
|
|
@ -252,12 +251,11 @@ final class Plugin
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="cp-details-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.35);z-index:2147483000;">
|
<div id="cp-details-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,0.35);z-index:1000;">
|
||||||
<div style="max-width:900px;margin:4vh auto;background:#fff;border-radius:8px;padding:12px;max-height:90vh;overflow:auto;">
|
<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>
|
<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="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;">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: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: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>
|
<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>
|
||||||
|
|
@ -287,35 +285,6 @@ final class Plugin
|
||||||
const esc=(v)=>{const d=document.createElement("div"); d.textContent=v==null?"":String(v); return d.innerHTML;};
|
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 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 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 itemId=(it)=>String(it.event_id||it.id||"");
|
||||||
const futureWrap=()=>s("cp-future-wrap");
|
const futureWrap=()=>s("cp-future-wrap");
|
||||||
const monthNames=["January","February","March","April","May","June","July","August","September","October","November","December"];
|
const monthNames=["January","February","March","April","May","June","July","August","September","October","November","December"];
|
||||||
|
|
@ -440,7 +409,6 @@ final class Plugin
|
||||||
const clearEditor=()=>{
|
const clearEditor=()=>{
|
||||||
s("cp-event-id").value="";
|
s("cp-event-id").value="";
|
||||||
["cp-title","cp-description","cp-location","cp-category","cp-occurrence-key"].forEach(k=>s(k).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-occurrence-key-iso").value="";
|
||||||
s("cp-all-day").checked=false;
|
s("cp-all-day").checked=false;
|
||||||
s("cp-repeat-type").value="none";
|
s("cp-repeat-type").value="none";
|
||||||
|
|
@ -484,7 +452,6 @@ final class Plugin
|
||||||
|
|
||||||
const openDetailsForItem=(it)=>{
|
const openDetailsForItem=(it)=>{
|
||||||
s("cp-details-title").value=it.title||"";
|
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-category").value=it.category||"";
|
||||||
s("cp-details-location").value=it.location||"";
|
s("cp-details-location").value=it.location||"";
|
||||||
s("cp-details-start").value=detailsDateTime(it.occurrence_start||it.start_datetime,!!it.all_day_event);
|
s("cp-details-start").value=detailsDateTime(it.occurrence_start||it.start_datetime,!!it.all_day_event);
|
||||||
|
|
@ -511,7 +478,6 @@ final class Plugin
|
||||||
s("cp-editor-title").textContent="Edit Event";
|
s("cp-editor-title").textContent="Edit Event";
|
||||||
s("cp-event-id").value=itemId(source);
|
s("cp-event-id").value=itemId(source);
|
||||||
s("cp-title").value=source.title||"";
|
s("cp-title").value=source.title||"";
|
||||||
s("cp-visibility").value=source.visibility==="private"?"private":"public";
|
|
||||||
s("cp-description").value=source.description||"";
|
s("cp-description").value=source.description||"";
|
||||||
s("cp-location").value=source.location||"";
|
s("cp-location").value=source.location||"";
|
||||||
s("cp-category").value=source.category||"";
|
s("cp-category").value=source.category||"";
|
||||||
|
|
@ -566,7 +532,6 @@ final class Plugin
|
||||||
ok:true,
|
ok:true,
|
||||||
payload:{
|
payload:{
|
||||||
title:title,
|
title:title,
|
||||||
visibility:s("cp-visibility").value==="private"?"private":"public",
|
|
||||||
description:s("cp-description").value,
|
description:s("cp-description").value,
|
||||||
location:s("cp-location").value,
|
location:s("cp-location").value,
|
||||||
category:s("cp-category").value,
|
category:s("cp-category").value,
|
||||||
|
|
@ -661,23 +626,23 @@ final class Plugin
|
||||||
|
|
||||||
const rowsForDay=(items)=>{
|
const rowsForDay=(items)=>{
|
||||||
const t=theme();
|
const t=theme();
|
||||||
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("");
|
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("");
|
||||||
};
|
};
|
||||||
|
|
||||||
const bindClicks=()=>{
|
const bindClicks=()=>{
|
||||||
Array.from(document.querySelectorAll("#cp-view-panel [data-cp-id], #cp-public-list [data-cp-id]")).forEach(el=>{
|
Array.from(document.querySelectorAll("#cp-view-panel [data-id], #cp-public-list [data-id]")).forEach(el=>{
|
||||||
el.addEventListener("click",async()=>{
|
el.addEventListener("click",async()=>{
|
||||||
const id=el.getAttribute("data-cp-id")||"";
|
const id=el.getAttribute("data-id")||"";
|
||||||
const it=lastItems.find(x=>String(itemId(x))===String(id))||null;
|
const it=lastItems.find(x=>String(itemId(x))===String(id))||null;
|
||||||
if(it){await openEditorForItem(it);}
|
if(it){await openEditorForItem(it);}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
const bindCreateClicks=()=>{
|
const bindCreateClicks=()=>{
|
||||||
Array.from(document.querySelectorAll("#cp-view-panel [data-cp-create-date]")).forEach(el=>{
|
Array.from(document.querySelectorAll("#cp-view-panel [data-create-date]")).forEach(el=>{
|
||||||
el.addEventListener("click",(ev)=>{
|
el.addEventListener("click",(ev)=>{
|
||||||
if(ev.target && ev.target.closest("[data-cp-id]")){return;}
|
if(ev.target && ev.target.closest("[data-id]")){return;}
|
||||||
const dateYmd=el.getAttribute("data-cp-create-date")||"";
|
const dateYmd=el.getAttribute("data-create-date")||"";
|
||||||
if(dateYmd){openEditorCreateAt(dateYmd);}
|
if(dateYmd){openEditorCreateAt(dateYmd);}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -697,19 +662,14 @@ final class Plugin
|
||||||
panel.innerHTML="";
|
panel.innerHTML="";
|
||||||
(items||[]).slice(0,300).forEach(it=>{
|
(items||[]).slice(0,300).forEach(it=>{
|
||||||
const li=document.createElement("li");
|
const li=document.createElement("li");
|
||||||
li.setAttribute("data-cp-id", itemId(it));
|
li.setAttribute("data-id", itemId(it));
|
||||||
li.style.cursor="pointer";
|
li.style.cursor="pointer";
|
||||||
li.style.background=t.cellBg;
|
li.style.background=t.cellBg;
|
||||||
li.style.border=`1px solid ${t.border}`;
|
li.style.border=`1px solid ${t.border}`;
|
||||||
li.style.margin="4px 0";
|
li.style.margin="4px 0";
|
||||||
li.style.padding="6px 8px";
|
li.style.padding="6px 8px";
|
||||||
li.style.borderRadius="4px";
|
li.style.borderRadius="4px";
|
||||||
const dateLabel=longDate(it.occurrence_start||it.start_datetime);
|
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 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);
|
ul.appendChild(li);
|
||||||
});
|
});
|
||||||
bindClicks();
|
bindClicks();
|
||||||
|
|
@ -742,7 +702,7 @@ final class Plugin
|
||||||
const d=new Date(start); d.setDate(start.getDate()+i);
|
const d=new Date(start); d.setDate(start.getDate()+i);
|
||||||
const k=localYmd(d);
|
const k=localYmd(d);
|
||||||
const ev=(map[k]||[]).sort((a,b)=>String(a.occurrence_start).localeCompare(String(b.occurrence_start)));
|
const ev=(map[k]||[]).sort((a,b)=>String(a.occurrence_start).localeCompare(String(b.occurrence_start)));
|
||||||
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>`;
|
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>`;
|
||||||
}
|
}
|
||||||
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>`;
|
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();
|
bindClicks();
|
||||||
|
|
@ -766,7 +726,7 @@ final class Plugin
|
||||||
const ev=(map[k]||[]).sort((a,b)=>String(a.occurrence_start).localeCompare(String(b.occurrence_start)));
|
const ev=(map[k]||[]).sort((a,b)=>String(a.occurrence_start).localeCompare(String(b.occurrence_start)));
|
||||||
const inMonth=d.getMonth()===anchor.getMonth();
|
const inMonth=d.getMonth()===anchor.getMonth();
|
||||||
if(inMonth){rowHasInMonth=true;}
|
if(inMonth){rowHasInMonth=true;}
|
||||||
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>`;
|
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>`;
|
||||||
}
|
}
|
||||||
rowParts.push({html:`<tr>${tds}</tr>`,has:rowHasInMonth});
|
rowParts.push({html:`<tr>${tds}</tr>`,has:rowHasInMonth});
|
||||||
}
|
}
|
||||||
|
|
@ -791,15 +751,15 @@ final class Plugin
|
||||||
const d=new Date(start); d.setDate(start.getDate()+i);
|
const d=new Date(start); d.setDate(start.getDate()+i);
|
||||||
const k=localYmd(d);
|
const k=localYmd(d);
|
||||||
const same=d.getMonth()===m;
|
const same=d.getMonth()===m;
|
||||||
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>`;
|
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>`;
|
||||||
}
|
}
|
||||||
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 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>';
|
out+='</div>';
|
||||||
panel.innerHTML=out;
|
panel.innerHTML=out;
|
||||||
Array.from(panel.querySelectorAll("[data-cp-day]")).forEach(el=>{
|
Array.from(panel.querySelectorAll("[data-day]")).forEach(el=>{
|
||||||
el.addEventListener("click",()=>{
|
el.addEventListener("click",()=>{
|
||||||
s("cp-date").value=el.getAttribute("data-cp-day")||s("cp-date").value;
|
s("cp-date").value=el.getAttribute("data-day")||s("cp-date").value;
|
||||||
s("cp-view").value="week";
|
s("cp-view").value="week";
|
||||||
loadPublic();
|
loadPublic();
|
||||||
});
|
});
|
||||||
|
|
@ -819,7 +779,7 @@ final class Plugin
|
||||||
futureWrap().style.display=s("cp-view").value==="list"?"flex":"none";
|
futureWrap().style.display=s("cp-view").value==="list"?"flex":"none";
|
||||||
};
|
};
|
||||||
|
|
||||||
s("cp-open-login-btn").onclick=()=>{setAuthStatus("",false); s("cp-auth-modal").style.display="flex";};
|
s("cp-open-login-btn").onclick=()=>{setAuthStatus("",false); s("cp-auth-modal").style.display="block";};
|
||||||
s("cp-close-login-btn").onclick=()=>{s("cp-auth-modal").style.display="none";};
|
s("cp-close-login-btn").onclick=()=>{s("cp-auth-modal").style.display="none";};
|
||||||
s("cp-logout-btn").onclick=async()=>{
|
s("cp-logout-btn").onclick=async()=>{
|
||||||
await api("/users/logout",{method:"POST"});
|
await api("/users/logout",{method:"POST"});
|
||||||
|
|
@ -966,7 +926,7 @@ final class Plugin
|
||||||
const tokenFromUrl=(new URLSearchParams(window.location.search)).get("calendar_verify_token");
|
const tokenFromUrl=(new URLSearchParams(window.location.search)).get("calendar_verify_token");
|
||||||
if(tokenFromUrl){
|
if(tokenFromUrl){
|
||||||
s("cp-verify-token").value=tokenFromUrl;
|
s("cp-verify-token").value=tokenFromUrl;
|
||||||
s("cp-auth-modal").style.display="flex";
|
s("cp-auth-modal").style.display="block";
|
||||||
setAuthStatus("Verification token loaded from link. Press Verify Email.",false);
|
setAuthStatus("Verification token loaded from link. Press Verify Email.",false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1013,19 +973,18 @@ HTML
|
||||||
}
|
}
|
||||||
|
|
||||||
$rows = [];
|
$rows = [];
|
||||||
$sidebarTz = new \DateTimeZone('Europe/London');
|
|
||||||
foreach ($items as $item) {
|
foreach ($items as $item) {
|
||||||
$start = (string) ($item['occurrence_start'] ?? '');
|
$start = (string) ($item['occurrence_start'] ?? '');
|
||||||
$end = (string) ($item['occurrence_end'] ?? '');
|
$end = (string) ($item['occurrence_end'] ?? '');
|
||||||
$startDt = $this->toSidebarDateTime($start, $sidebarTz);
|
$startTs = strtotime($start);
|
||||||
$endDt = $this->toSidebarDateTime($end, $sidebarTz);
|
$endTs = strtotime($end);
|
||||||
$dateLabel = $startDt !== null ? $startDt->format('j F Y') : substr($start, 0, 10);
|
$dateLabel = $startTs !== false ? date('j F Y', $startTs) : substr($start, 0, 10);
|
||||||
$timeLabel = '';
|
$timeLabel = '';
|
||||||
if ($startDt !== null && $endDt !== null) {
|
if ($startTs !== false && $endTs !== false) {
|
||||||
$startTime = $startDt->format('H:i');
|
$startTime = date('H:i', $startTs);
|
||||||
$endTime = $endDt->format('H:i');
|
$endTime = date('H:i', $endTs);
|
||||||
if ($startTime !== '00:00' || $endTime !== '00:00') {
|
if ($startTime !== '00:00' || $endTime !== '00:00') {
|
||||||
$timeLabel = $this->formatSidebarTimeRange($startDt, $endDt);
|
$timeLabel = $this->formatSidebarTimeRange($startTs, $endTs);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
$title = trim((string) ($item['title'] ?? ''));
|
$title = trim((string) ($item['title'] ?? ''));
|
||||||
|
|
@ -1045,24 +1004,12 @@ HTML
|
||||||
return '<div class="calendar-plugin-shell" data-mode="sidebar">' . implode('', $rows) . '</div>';
|
return '<div class="calendar-plugin-shell" data-mode="sidebar">' . implode('', $rows) . '</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
private function toSidebarDateTime(string $value, \DateTimeZone $timezone): ?\DateTimeImmutable
|
private function formatSidebarTimeRange(int $startTs, int $endTs): string
|
||||||
{
|
{
|
||||||
if ($value === '') {
|
$startMeridiem = strtolower(date('a', $startTs));
|
||||||
return null;
|
$endMeridiem = strtolower(date('a', $endTs));
|
||||||
}
|
$startLabel = $this->formatSidebarTimeValue($startTs);
|
||||||
try {
|
$endLabel = $this->formatSidebarTimeValue($endTs);
|
||||||
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) {
|
if ($startMeridiem === $endMeridiem) {
|
||||||
$startLabel = preg_replace('/(am|pm)$/', '', $startLabel) ?: $startLabel;
|
$startLabel = preg_replace('/(am|pm)$/', '', $startLabel) ?: $startLabel;
|
||||||
return $startLabel . '–' . $endLabel;
|
return $startLabel . '–' . $endLabel;
|
||||||
|
|
@ -1070,11 +1017,11 @@ HTML
|
||||||
return $startLabel . '–' . $endLabel;
|
return $startLabel . '–' . $endLabel;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function formatSidebarTimeValue(\DateTimeImmutable $dt): string
|
private function formatSidebarTimeValue(int $ts): string
|
||||||
{
|
{
|
||||||
$hour = (int) $dt->format('G');
|
$hour = (int) date('G', $ts);
|
||||||
$minute = (int) $dt->format('i');
|
$minute = (int) date('i', $ts);
|
||||||
$meridiem = strtolower($dt->format('a'));
|
$meridiem = strtolower(date('a', $ts));
|
||||||
$hour12 = $hour % 12;
|
$hour12 = $hour % 12;
|
||||||
if ($hour12 === 0) {
|
if ($hour12 === 0) {
|
||||||
$hour12 = 12;
|
$hour12 = 12;
|
||||||
|
|
@ -1422,7 +1369,7 @@ HTML
|
||||||
return [
|
return [
|
||||||
'status' => 'ok',
|
'status' => 'ok',
|
||||||
'plugin' => 'calendar-plugin',
|
'plugin' => 'calendar-plugin',
|
||||||
'version' => '1.0.1',
|
'version' => '0.1.15',
|
||||||
'db_prefix' => $this->db->getPrefix(),
|
'db_prefix' => $this->db->getPrefix(),
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
|
|
@ -1645,16 +1592,10 @@ HTML
|
||||||
$date = (string) ($request->get_param('date') ?: gmdate('Y-m-d'));
|
$date = (string) ($request->get_param('date') ?: gmdate('Y-m-d'));
|
||||||
$futureOnlyRaw = (string) ($request->get_param('future_only') ?? '');
|
$futureOnlyRaw = (string) ($request->get_param('future_only') ?? '');
|
||||||
$futureOnly = in_array(strtolower($futureOnlyRaw), ['1', 'true', 'yes', 'on'], true);
|
$futureOnly = in_array(strtolower($futureOnlyRaw), ['1', 'true', 'yes', 'on'], true);
|
||||||
$includePrivateDetails = $this->canWriteCalendar($request);
|
$items = $this->eventService->listPublicOccurrences($view, $date, $futureOnly);
|
||||||
$items = $this->eventService->listPublicOccurrences($view, $date, $futureOnly, !$includePrivateDetails);
|
|
||||||
return [
|
return [
|
||||||
'data' => $items,
|
'data' => $items,
|
||||||
'meta' => [
|
'meta' => ['count' => count($items), 'view' => $view, 'future_only' => $futureOnly],
|
||||||
'count' => count($items),
|
|
||||||
'view' => $view,
|
|
||||||
'future_only' => $futureOnly,
|
|
||||||
'redacted_private' => !$includePrivateDetails,
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
@ -1667,7 +1608,7 @@ HTML
|
||||||
'methods' => 'GET',
|
'methods' => 'GET',
|
||||||
'permission_callback' => '__return_true',
|
'permission_callback' => '__return_true',
|
||||||
'callback' => function (): array {
|
'callback' => function (): array {
|
||||||
$items = $this->eventService->listSidebarUpcoming(14, true);
|
$items = $this->eventService->listSidebarUpcoming(14);
|
||||||
return [
|
return [
|
||||||
'data' => $items,
|
'data' => $items,
|
||||||
'meta' => ['count' => count($items), 'window_days' => 14],
|
'meta' => ['count' => count($items), 'window_days' => 14],
|
||||||
|
|
@ -1899,19 +1840,13 @@ HTML
|
||||||
[
|
[
|
||||||
'methods' => 'GET',
|
'methods' => 'GET',
|
||||||
'permission_callback' => '__return_true',
|
'permission_callback' => '__return_true',
|
||||||
'callback' => function ($request): array|\WP_Error {
|
'callback' => function (): array {
|
||||||
$settings = $this->settingsService->getAll();
|
$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');
|
$calendarName = (string) ($settings['caldav_calendar_name'] ?? 'Public Calendar');
|
||||||
$ics = $this->icsService->buildCalendar(
|
$ics = $this->icsService->buildCalendar(
|
||||||
$this->eventService->listEvents(),
|
$this->eventService->listEvents(),
|
||||||
fn(int $eventId): array => $this->eventService->getDeletedOccurrenceKeys($eventId),
|
fn(int $eventId): array => $this->eventService->getDeletedOccurrenceKeys($eventId),
|
||||||
$calendarName,
|
$calendarName
|
||||||
!$includePrivateDetails
|
|
||||||
);
|
);
|
||||||
return ['data' => $ics];
|
return ['data' => $ics];
|
||||||
},
|
},
|
||||||
|
|
@ -2029,11 +1964,9 @@ HTML
|
||||||
private function serveIcsResponse(): void
|
private function serveIcsResponse(): void
|
||||||
{
|
{
|
||||||
$settings = $this->settingsService->getAll();
|
$settings = $this->settingsService->getAll();
|
||||||
$icsMode = (string) ($settings['ics_access_mode'] ?? 'public_read');
|
|
||||||
$includePrivateDetails = $this->canWriteCalendar(null);
|
|
||||||
if (
|
if (
|
||||||
$icsMode === 'authenticated_read'
|
(string) ($settings['ics_access_mode'] ?? 'public_read') === 'authenticated_read'
|
||||||
&& !$includePrivateDetails
|
&& $this->resolveCalDavUserForRequest(null) === null
|
||||||
) {
|
) {
|
||||||
http_response_code(401);
|
http_response_code(401);
|
||||||
header('Content-Type: application/json; charset=utf-8');
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
@ -2045,8 +1978,7 @@ HTML
|
||||||
$ics = $this->icsService->buildCalendar(
|
$ics = $this->icsService->buildCalendar(
|
||||||
$this->eventService->listEvents(),
|
$this->eventService->listEvents(),
|
||||||
fn(int $eventId): array => $this->eventService->getDeletedOccurrenceKeys($eventId),
|
fn(int $eventId): array => $this->eventService->getDeletedOccurrenceKeys($eventId),
|
||||||
$calendarName,
|
$calendarName
|
||||||
!$includePrivateDetails
|
|
||||||
);
|
);
|
||||||
$etag = '"' . substr(sha1($ics), 0, 16) . '"';
|
$etag = '"' . substr(sha1($ics), 0, 16) . '"';
|
||||||
$lastModified = gmdate('D, d M Y H:i:s') . ' GMT';
|
$lastModified = gmdate('D, d M Y H:i:s') . ' GMT';
|
||||||
|
|
@ -2061,20 +1993,12 @@ HTML
|
||||||
|
|
||||||
private function serveCalDavPath(string $path, string $method): void
|
private function serveCalDavPath(string $path, string $method): void
|
||||||
{
|
{
|
||||||
$userAgent = (string) ($_SERVER['HTTP_USER_AGENT'] ?? '');
|
|
||||||
$caldavUser = $this->resolveCalDavUserForRequest(null);
|
$caldavUser = $this->resolveCalDavUserForRequest(null);
|
||||||
$this->caldavTrace('request', [
|
|
||||||
'method' => $method,
|
|
||||||
'path' => $path,
|
|
||||||
'user_agent' => $userAgent,
|
|
||||||
'authorized' => $caldavUser !== null,
|
|
||||||
]);
|
|
||||||
if ($caldavUser === null) {
|
if ($caldavUser === null) {
|
||||||
http_response_code(401);
|
http_response_code(401);
|
||||||
header('WWW-Authenticate: Basic realm="Calendar CalDAV"');
|
header('WWW-Authenticate: Basic realm="Calendar CalDAV"');
|
||||||
header('Content-Type: application/xml; charset=utf-8');
|
header('Content-Type: application/xml; charset=utf-8');
|
||||||
echo '<error><message>auth required</message></error>';
|
echo '<error><message>auth required</message></error>';
|
||||||
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 401, 'reason' => 'auth_required']);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2086,22 +2010,10 @@ HTML
|
||||||
$resourcePrefix = $collection;
|
$resourcePrefix = $collection;
|
||||||
|
|
||||||
if ($method === 'HEAD') {
|
if ($method === 'HEAD') {
|
||||||
if (
|
if ($path === $root || $path === $root . '/' || $path === $calendarsRoot || $path === rtrim($calendarsRoot, '/') || $path === $collection || $path === rtrim($collection, '/')) {
|
||||||
$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('Allow: OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, HEAD');
|
||||||
header('DAV: 1, calendar-access');
|
header('DAV: 1, calendar-access');
|
||||||
http_response_code(200);
|
http_response_code(200);
|
||||||
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 200, 'kind' => 'collection_head']);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (str_starts_with($path, $resourcePrefix) && str_ends_with($path, '.ics')) {
|
if (str_starts_with($path, $resourcePrefix) && str_ends_with($path, '.ics')) {
|
||||||
|
|
@ -2109,34 +2021,11 @@ HTML
|
||||||
$obj = $this->calDavService->getObject($resource);
|
$obj = $this->calDavService->getObject($resource);
|
||||||
if ($obj === null) {
|
if ($obj === null) {
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 404, 'resource' => $resource]);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
header('Content-Type: text/calendar; charset=utf-8');
|
header('Content-Type: text/calendar; charset=utf-8');
|
||||||
header('ETag: ' . (string) ($obj['etag'] ?? ''));
|
header('ETag: ' . (string) ($obj['etag'] ?? ''));
|
||||||
http_response_code(200);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -2145,7 +2034,6 @@ HTML
|
||||||
header('Allow: OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, HEAD');
|
header('Allow: OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, HEAD');
|
||||||
header('DAV: 1, calendar-access');
|
header('DAV: 1, calendar-access');
|
||||||
http_response_code(200);
|
http_response_code(200);
|
||||||
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 200, 'kind' => 'options']);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2154,22 +2042,18 @@ HTML
|
||||||
http_response_code(207);
|
http_response_code(207);
|
||||||
if ($path === $root || $path === $root . '/') {
|
if ($path === $root || $path === $root . '/') {
|
||||||
echo $this->caldavPropfindRootXml($root, $principal, $calendarsRoot, $collection);
|
echo $this->caldavPropfindRootXml($root, $principal, $calendarsRoot, $collection);
|
||||||
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 207, 'kind' => 'propfind_root']);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ($path === $principalCollection || $path === rtrim($principalCollection, '/')) {
|
if ($path === $principalCollection || $path === rtrim($principalCollection, '/')) {
|
||||||
echo $this->caldavPropfindPrincipalCollectionXml($principalCollection, $principal);
|
echo $this->caldavPropfindPrincipalCollectionXml($principalCollection, $principal);
|
||||||
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 207, 'kind' => 'propfind_principal_collection']);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ($path === $principal || $path === rtrim($principal, '/')) {
|
if ($path === $principal || $path === rtrim($principal, '/')) {
|
||||||
echo $this->caldavPropfindPrincipalXml($principal, $calendarsRoot);
|
echo $this->caldavPropfindPrincipalXml($principal, $calendarsRoot);
|
||||||
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 207, 'kind' => 'propfind_principal']);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ($path === $calendarsRoot || $path === rtrim($calendarsRoot, '/')) {
|
if ($path === $calendarsRoot || $path === rtrim($calendarsRoot, '/')) {
|
||||||
echo $this->caldavPropfindCalendarsRootXml($calendarsRoot, $collection);
|
echo $this->caldavPropfindCalendarsRootXml($calendarsRoot, $collection);
|
||||||
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 207, 'kind' => 'propfind_calendars_root']);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ($path === rtrim($collection, '/')) {
|
if ($path === rtrim($collection, '/')) {
|
||||||
|
|
@ -2177,7 +2061,6 @@ HTML
|
||||||
}
|
}
|
||||||
if ($path === $collection) {
|
if ($path === $collection) {
|
||||||
echo $this->caldavPropfindCollectionXml($collection, $this->caldavSyncToken(), true);
|
echo $this->caldavPropfindCollectionXml($collection, $this->caldavSyncToken(), true);
|
||||||
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 207, 'kind' => 'propfind_collection']);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (str_starts_with($path, $resourcePrefix) && str_ends_with($path, '.ics')) {
|
if (str_starts_with($path, $resourcePrefix) && str_ends_with($path, '.ics')) {
|
||||||
|
|
@ -2186,33 +2069,21 @@ HTML
|
||||||
if ($obj === null) {
|
if ($obj === null) {
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
echo '<error><message>not found</message></error>';
|
echo '<error><message>not found</message></error>';
|
||||||
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 404, 'resource' => $resource, 'kind' => 'propfind_object']);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
echo $this->caldavPropfindObjectXml($collection . $resource, (string) ($obj['etag'] ?? ''));
|
echo $this->caldavPropfindObjectXml($collection . $resource, (string) ($obj['etag'] ?? ''));
|
||||||
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 207, 'resource' => $resource, 'kind' => 'propfind_object']);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
echo '<error><message>not found</message></error>';
|
echo '<error><message>not found</message></error>';
|
||||||
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 404, 'kind' => 'propfind_not_found']);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($method === 'REPORT' && ($path === $collection || $path === rtrim($collection, '/'))) {
|
if ($method === 'REPORT' && $path === $collection) {
|
||||||
$body = (string) file_get_contents('php://input');
|
$body = (string) file_get_contents('php://input');
|
||||||
$reportType = $this->caldavReportType($body);
|
|
||||||
header('Content-Type: application/xml; charset=utf-8');
|
header('Content-Type: application/xml; charset=utf-8');
|
||||||
http_response_code(207);
|
http_response_code(207);
|
||||||
echo $this->caldavReportXml($collection, $body, $this->caldavSyncToken());
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2222,14 +2093,12 @@ HTML
|
||||||
$obj = $this->calDavService->getObject($resource);
|
$obj = $this->calDavService->getObject($resource);
|
||||||
if ($obj === null) {
|
if ($obj === null) {
|
||||||
http_response_code(404);
|
http_response_code(404);
|
||||||
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 404, 'resource' => $resource, 'kind' => 'object_get']);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
header('Content-Type: text/calendar; charset=utf-8');
|
header('Content-Type: text/calendar; charset=utf-8');
|
||||||
header('ETag: ' . (string) ($obj['etag'] ?? ''));
|
header('ETag: ' . (string) ($obj['etag'] ?? ''));
|
||||||
http_response_code(200);
|
http_response_code(200);
|
||||||
echo (string) ($obj['ics'] ?? '');
|
echo (string) ($obj['ics'] ?? '');
|
||||||
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 200, 'resource' => $resource, 'kind' => 'object_get']);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ($method === 'PUT') {
|
if ($method === 'PUT') {
|
||||||
|
|
@ -2248,14 +2117,6 @@ HTML
|
||||||
http_response_code((int) ($error['status'] ?? 500));
|
http_response_code((int) ($error['status'] ?? 500));
|
||||||
header('Content-Type: application/xml; charset=utf-8');
|
header('Content-Type: application/xml; charset=utf-8');
|
||||||
echo '<error><message>' . esc_html((string) ($error['message'] ?? 'error')) . '</message></error>';
|
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;
|
return;
|
||||||
}
|
}
|
||||||
$status = (int) ($result['status'] ?? 204);
|
$status = (int) ($result['status'] ?? 204);
|
||||||
|
|
@ -2264,14 +2125,6 @@ HTML
|
||||||
header('ETag: ' . (string) $event['etag']);
|
header('ETag: ' . (string) $event['etag']);
|
||||||
}
|
}
|
||||||
http_response_code($status);
|
http_response_code($status);
|
||||||
$this->caldavTrace('response', [
|
|
||||||
'method' => $method,
|
|
||||||
'path' => $path,
|
|
||||||
'status' => $status,
|
|
||||||
'resource' => $resource,
|
|
||||||
'kind' => 'object_put',
|
|
||||||
'body_bytes' => strlen($raw),
|
|
||||||
]);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ($method === 'DELETE') {
|
if ($method === 'DELETE') {
|
||||||
|
|
@ -2279,24 +2132,15 @@ HTML
|
||||||
if (isset($result['error'])) {
|
if (isset($result['error'])) {
|
||||||
$error = (array) $result['error'];
|
$error = (array) $result['error'];
|
||||||
http_response_code((int) ($error['status'] ?? 500));
|
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;
|
return;
|
||||||
}
|
}
|
||||||
http_response_code(204);
|
http_response_code(204);
|
||||||
$this->caldavTrace('response', ['method' => $method, 'path' => $path, 'status' => 204, 'resource' => $resource, 'kind' => 'object_delete']);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
http_response_code(405);
|
http_response_code(405);
|
||||||
header('Allow: OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, HEAD');
|
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
|
private function caldavPropfindRootXml(string $root, string $principal, string $calendarsRoot, string $collection): string
|
||||||
|
|
@ -2423,30 +2267,9 @@ HTML
|
||||||
$bodyLower = strtolower($xmlBody);
|
$bodyLower = strtolower($xmlBody);
|
||||||
$resources = array_map(static fn(array $r): string => (string) ($r['resource'] ?? ''), $this->calDavService->listResources());
|
$resources = array_map(static fn(array $r): string => (string) ($r['resource'] ?? ''), $this->calDavService->listResources());
|
||||||
$items = [];
|
$items = [];
|
||||||
$reportType = $this->caldavReportType($xmlBody);
|
|
||||||
$includeDeleted = false;
|
|
||||||
$deletedCount = 0;
|
|
||||||
$clientSyncToken = '';
|
|
||||||
|
|
||||||
if (str_contains($bodyLower, 'sync-collection')) {
|
if (str_contains($bodyLower, 'sync-collection')) {
|
||||||
$clientSyncToken = $this->extractSyncCollectionToken($xmlBody);
|
$items = $this->calDavService->multiget($resources);
|
||||||
// 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')) {
|
} elseif (str_contains($bodyLower, 'calendar-query')) {
|
||||||
$items = $this->calDavService->multiget($resources);
|
$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)) {
|
if (preg_match('/start\\s*=\\s*"([0-9TzZ]+)"/i', $xmlBody, $s) && preg_match('/end\\s*=\\s*"([0-9TzZ]+)"/i', $xmlBody, $e)) {
|
||||||
|
|
@ -2476,38 +2299,17 @@ HTML
|
||||||
}
|
}
|
||||||
|
|
||||||
$responses = '';
|
$responses = '';
|
||||||
$okCount = 0;
|
|
||||||
$notFoundCount = 0;
|
|
||||||
foreach ($items as $item) {
|
foreach ($items as $item) {
|
||||||
$status = (int) ($item['status'] ?? 404);
|
$status = (int) ($item['status'] ?? 404);
|
||||||
$resource = (string) ($item['resource'] ?? '');
|
$resource = (string) ($item['resource'] ?? '');
|
||||||
$responses .= '<D:response><D:href>' . htmlspecialchars($collection . $resource, ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</D:href>';
|
$responses .= '<D:response><D:href>' . htmlspecialchars($collection . $resource, ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</D:href><D:propstat><D:prop>';
|
||||||
if ($status === 200) {
|
if ($status === 200) {
|
||||||
$okCount++;
|
|
||||||
$responses .= '<D:propstat><D:prop>';
|
|
||||||
$responses .= '<D:getetag>' . htmlspecialchars((string) ($item['etag'] ?? ''), ENT_XML1 | ENT_QUOTES, 'UTF-8') . '</D:getetag>'
|
$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>';
|
. '<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:response>';
|
$responses .= '</D:prop><D:status>HTTP/1.1 ' . $status . ($status === 200 ? ' OK' : ' Not Found')
|
||||||
|
. '</D:status></D:propstat></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>'
|
return '<?xml version="1.0" encoding="utf-8"?><D:multistatus xmlns:D="DAV:"><D:sync-token>'
|
||||||
. htmlspecialchars($syncToken, ENT_XML1 | ENT_QUOTES, 'UTF-8')
|
. htmlspecialchars($syncToken, ENT_XML1 | ENT_QUOTES, 'UTF-8')
|
||||||
. '</D:sync-token>'
|
. '</D:sync-token>'
|
||||||
|
|
@ -2522,9 +2324,6 @@ HTML
|
||||||
foreach ($rows as $row) {
|
foreach ($rows as $row) {
|
||||||
$seed .= (string) ($row['resource'] ?? '') . '|' . (string) ($row['etag'] ?? '') . ';';
|
$seed .= (string) ($row['resource'] ?? '') . '|' . (string) ($row['etag'] ?? '') . ';';
|
||||||
}
|
}
|
||||||
foreach ($this->calDavService->listDeletedResources(1000) as $deletedResource) {
|
|
||||||
$seed .= 'deleted:' . $deletedResource . ';';
|
|
||||||
}
|
|
||||||
return 'urn:calendar-plugin:sync:' . sha1($seed);
|
return 'urn:calendar-plugin:sync:' . sha1($seed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2554,43 +2353,6 @@ HTML
|
||||||
return $this->icalToIso((string) $m[1]);
|
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
|
private function unwrapServiceResult(array $result): array|\WP_Error
|
||||||
{
|
{
|
||||||
if (isset($result['error']) && is_array($result['error'])) {
|
if (isset($result['error']) && is_array($result['error'])) {
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,6 @@ The endpoint must support these operations at minimum.
|
||||||
- `REPORT` (`calendar-query`): return events in collection, including time-range filtering.
|
- `REPORT` (`calendar-query`): return events in collection, including time-range filtering.
|
||||||
- `REPORT` (`calendar-multiget`): fetch specific event resources by href.
|
- `REPORT` (`calendar-multiget`): fetch specific event resources by href.
|
||||||
- `GET`: retrieve individual event resource (`text/calendar`).
|
- `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
|
### Create and Update
|
||||||
- `PUT`: create new event resource or replace an existing event resource.
|
- `PUT`: create new event resource or replace an existing event resource.
|
||||||
|
|
@ -87,8 +86,6 @@ The endpoint must support these operations at minimum.
|
||||||
- `If-Match`/`If-None-Match` preconditions must be honored for safe updates/creates.
|
- `If-Match`/`If-None-Match` preconditions must be honored for safe updates/creates.
|
||||||
- `REPORT` (`sync-collection`) should be supported for incremental sync tokens.
|
- `REPORT` (`sync-collection`) should be supported for incremental sync tokens.
|
||||||
- Sync token invalidation/rotation behavior must be deterministic and documented.
|
- 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
|
## iCalendar Representation Requirements
|
||||||
CalDAV event payloads must be standards-compatible `VCALENDAR` with `VEVENT` components.
|
CalDAV event payloads must be standards-compatible `VCALENDAR` with `VEVENT` components.
|
||||||
|
|
@ -104,7 +101,6 @@ Minimum mapping expectations:
|
||||||
- Last modification timestamp -> `DTSTAMP` (and `LAST-MODIFIED` when available)
|
- Last modification timestamp -> `DTSTAMP` (and `LAST-MODIFIED` when available)
|
||||||
- Recurrence rules -> `RRULE`
|
- Recurrence rules -> `RRULE`
|
||||||
- Recurrence exceptions -> `EXDATE` and/or additional `VEVENT` with matching `UID` plus `RECURRENCE-ID`
|
- 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:
|
Privacy visibility mapping:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,6 @@ URI rules:
|
||||||
### Calendar collection resources
|
### Calendar collection resources
|
||||||
- `OPTIONS`
|
- `OPTIONS`
|
||||||
- `PROPFIND`
|
- `PROPFIND`
|
||||||
- `GET` (availability probe support; returns `200` with empty body on collection URL)
|
|
||||||
- `REPORT` (`calendar-query`, `calendar-multiget`, `sync-collection`)
|
- `REPORT` (`calendar-query`, `calendar-multiget`, `sync-collection`)
|
||||||
|
|
||||||
### Event object resources
|
### Event object resources
|
||||||
|
|
@ -79,9 +78,6 @@ Calendar collection and principal responses must support, at minimum, these prop
|
||||||
- `calendar-query` with time-range filtering
|
- `calendar-query` with time-range filtering
|
||||||
- `calendar-multiget` by href set
|
- `calendar-multiget` by href set
|
||||||
- `sync-collection` for incremental changes since sync token
|
- `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.
|
If a report is unsupported for a resource, server returns standards-appropriate error status with DAV error body.
|
||||||
|
|
||||||
|
|
@ -119,7 +115,4 @@ Acceptance should verify:
|
||||||
- URI layout and discovery flows are stable.
|
- URI layout and discovery flows are stable.
|
||||||
- Required methods return expected statuses.
|
- Required methods return expected statuses.
|
||||||
- REPORT responses include correct event sets.
|
- 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.
|
- Conditional write and etag behavior prevents stale overwrite.
|
||||||
|
|
|
||||||
|
|
@ -44,16 +44,11 @@ Before deployment:
|
||||||
## Deployment Procedure Requirements
|
## Deployment Procedure Requirements
|
||||||
Required high-level procedure:
|
Required high-level procedure:
|
||||||
|
|
||||||
1. Build a fresh deploy artifact as part of deploy execution (package step) using the current repository state.
|
1. Transfer approved artifact to remote host staging area.
|
||||||
2. Transfer approved artifact to remote host staging area.
|
2. Extract artifact to a clean temporary directory on remote host.
|
||||||
3. Extract artifact to a clean temporary directory on remote host.
|
3. Validate extracted plugin directory structure.
|
||||||
4. Validate extracted plugin directory structure.
|
4. Synchronize extracted plugin directory to deploy directory.
|
||||||
5. Synchronize extracted plugin directory to deploy directory.
|
5. Run post-deploy verification checks.
|
||||||
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)
|
## 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).
|
After deployment, deployed plugin files must exactly match the approved artifact contents (excluding allowed mutable runtime files if any are explicitly listed).
|
||||||
|
|
|
||||||
|
|
@ -61,10 +61,8 @@ Required steps:
|
||||||
1. Create/clean a staging folder under `package/`.
|
1. Create/clean a staging folder under `package/`.
|
||||||
2. Copy approved runtime files from `code/` into staging.
|
2. Copy approved runtime files from `code/` into staging.
|
||||||
3. Apply exclusion rules to remove non-runtime artifacts.
|
3. Apply exclusion rules to remove non-runtime artifacts.
|
||||||
4. Increment plugin patch version (`X.Y.Z -> X.Y.(Z+1)`) for each package build unless an explicit version override is provided.
|
4. Create a versioned zip archive in `package/`.
|
||||||
5. Keep runtime-visible version fields synchronized for the package (plugin header version and health/API version metadata).
|
5. Record artifact name and version in release notes/changelog.
|
||||||
6. Create a versioned zip archive in `package/`.
|
|
||||||
7. Record artifact name and version in release notes/changelog.
|
|
||||||
|
|
||||||
Artifact naming requirement:
|
Artifact naming requirement:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -68,10 +68,6 @@ Must run:
|
||||||
- Recurrence exception behavior (single occurrence delete without split) is mandatory coverage.
|
- Recurrence exception behavior (single occurrence delete without split) is mandatory coverage.
|
||||||
- Authn/Authz paths for admin, API, and CalDAV roles are mandatory coverage.
|
- Authn/Authz paths for admin, API, and CalDAV roles are mandatory coverage.
|
||||||
- Error-model contract coverage is mandatory for API endpoints.
|
- 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
|
## Pass/Fail Gates
|
||||||
- Any required suite failure blocks merge/release as applicable.
|
- Any required suite failure blocks merge/release as applicable.
|
||||||
|
|
|
||||||
|
|
@ -83,15 +83,6 @@ Minimum display requirements:
|
||||||
- Category (if provided)
|
- Category (if provided)
|
||||||
- Description excerpt/summary (if configured for display)
|
- 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 `9–10am`).
|
|
||||||
- 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:
|
Privacy display rules:
|
||||||
|
|
||||||
- Public events render full details per normal display rules.
|
- Public events render full details per normal display rules.
|
||||||
|
|
@ -128,9 +119,6 @@ Requirements:
|
||||||
- Single-occurrence selection must be user-friendly via a date list or compact calendar selection.
|
- 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 mode should support a 3-month grid with previous/next navigation.
|
||||||
- The compact selection grid must remain readable in modal layout.
|
- 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
|
## Login and Access Modes
|
||||||
- `/calendar` must support two user modes: public (not logged in) and logged-in.
|
- `/calendar` must support two user modes: public (not logged in) and logged-in.
|
||||||
|
|
@ -143,8 +131,6 @@ Requirements:
|
||||||
- Login dialog must support password-reset request initiation.
|
- Login dialog must support password-reset request initiation.
|
||||||
- Logged-in but non-approved users remain read-only.
|
- Logged-in but non-approved users remain read-only.
|
||||||
- Approved users can perform event CRUD.
|
- 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
|
## ICS Link in Web UI
|
||||||
The UI must include a user-visible link to an ICS representation of calendar data.
|
The UI must include a user-visible link to an ICS representation of calendar data.
|
||||||
|
|
@ -176,5 +162,3 @@ Acceptance should verify:
|
||||||
- Empty-state behavior is clear and user-friendly.
|
- Empty-state behavior is clear and user-friendly.
|
||||||
- ICS link is present and returns valid calendar payload.
|
- ICS link is present and returns valid calendar payload.
|
||||||
- Privacy redaction behavior is correct in public views, sidebar, and logged-in views.
|
- 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.
|
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ fi
|
||||||
|
|
||||||
ARTIFACT=""
|
ARTIFACT=""
|
||||||
WP_ROOT="${REMOTE_WP_PATH:-/var/www/wordpress}"
|
WP_ROOT="${REMOTE_WP_PATH:-/var/www/wordpress}"
|
||||||
VERSION_OVERRIDE=""
|
|
||||||
|
|
||||||
while [[ $# -gt 0 ]]; do
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
|
|
@ -23,24 +22,17 @@ while [[ $# -gt 0 ]]; do
|
||||||
WP_ROOT="${2:-}"
|
WP_ROOT="${2:-}"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
--version)
|
|
||||||
VERSION_OVERRIDE="${2:-}"
|
|
||||||
shift 2
|
|
||||||
;;
|
|
||||||
--help|-h)
|
--help|-h)
|
||||||
cat <<'USAGE'
|
cat <<'USAGE'
|
||||||
Deploy a plugin artifact to remote WordPress.
|
Deploy a plugin artifact to remote WordPress.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
scripts/deploy_remote.sh [--wp-root /var/www/wordpress] [--version X.Y.Z]
|
scripts/deploy_remote.sh [--artifact /abs/or/relative/path.zip] [--wp-root /var/www/wordpress]
|
||||||
|
|
||||||
Defaults:
|
Defaults:
|
||||||
- Always builds a fresh package first (which auto-bumps patch version)
|
- Artifact: latest ./package/calendar-plugin-*.zip
|
||||||
- Remote host settings from credentials/.env
|
- Remote host settings from credentials/.env
|
||||||
|
|
||||||
Notes:
|
|
||||||
- Use `--version` for explicit release version packaging (for example `1.0.0`).
|
|
||||||
|
|
||||||
This script enforces ownership:
|
This script enforces ownership:
|
||||||
- chown -R www-data:www-data <remote plugin dir>
|
- chown -R www-data:www-data <remote plugin dir>
|
||||||
USAGE
|
USAGE
|
||||||
|
|
@ -53,20 +45,10 @@ USAGE
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
if [[ -n "${ARTIFACT}" ]]; then
|
if [[ -z "${ARTIFACT}" ]]; then
|
||||||
echo "[deploy] --artifact is not supported; deploy always builds a fresh package with bumped patch version" >&2
|
ARTIFACT="$(ls -1 "${ROOT_DIR}/package/calendar-plugin-"*.zip 2>/dev/null | sort -V | tail -n1 || true)"
|
||||||
exit 2
|
|
||||||
fi
|
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
|
if [[ -z "${ARTIFACT}" ]]; then
|
||||||
echo "[deploy] no artifact found; run scripts/package_plugin.sh first" >&2
|
echo "[deploy] no artifact found; run scripts/package_plugin.sh first" >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
|
@ -123,12 +105,10 @@ 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; 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; 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; chown -R www-data:www-data '${REMOTE_APP_DIR}'"
|
||||||
# Always cycle plugin activation so activation-hook migrations run on every deploy.
|
"${SSH[@]}" "set -euo pipefail; '${REMOTE_WP_CLI}' --path='${WP_ROOT}' plugin activate calendar-plugin --allow-root >/dev/null 2>&1 || true"
|
||||||
"${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 for content drift.
|
# Exact-match style checksum dry-run check
|
||||||
# Ignore directory metadata-only differences, which are expected after chown/remote extraction.
|
"${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"
|
||||||
"${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
|
# 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")"
|
non_owned="$("${SSH[@]}" "set -euo pipefail; find '${REMOTE_APP_DIR}' \( ! -user www-data -o ! -group www-data \) | wc -l")"
|
||||||
|
|
|
||||||
|
|
@ -36,25 +36,15 @@ USAGE
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
CURRENT_VERSION="$(sed -n 's/^ \* Version: \(.*\)$/\1/p' "${ROOT_DIR}/code/calendar-plugin.php" | head -n1 | tr -d '[:space:]')"
|
if [[ -z "${VERSION}" ]]; then
|
||||||
|
VERSION="$(sed -n 's/^ \* Version: \(.*\)$/\1/p' "${ROOT_DIR}/code/calendar-plugin.php" | head -n1 | tr -d '[:space:]')"
|
||||||
if [[ -z "${CURRENT_VERSION}" ]]; then
|
|
||||||
echo "[package] unable to detect plugin version from code/calendar-plugin.php" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -z "${VERSION}" ]]; then
|
if [[ -z "${VERSION}" ]]; then
|
||||||
if [[ "${CURRENT_VERSION}" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then
|
echo "[package] unable to detect plugin version from code/calendar-plugin.php" >&2
|
||||||
VERSION="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}.$((BASH_REMATCH[3] + 1))"
|
exit 1
|
||||||
else
|
|
||||||
echo "[package] current version is not semantic (X.Y.Z): ${CURRENT_VERSION}" >&2
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
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"
|
SLUG="calendar-plugin"
|
||||||
PACKAGE_DIR="${ROOT_DIR}/package"
|
PACKAGE_DIR="${ROOT_DIR}/package"
|
||||||
STAGING_DIR="${PACKAGE_DIR}/staging/${SLUG}"
|
STAGING_DIR="${PACKAGE_DIR}/staging/${SLUG}"
|
||||||
|
|
@ -77,6 +67,5 @@ rsync -a --delete "${ROOT_DIR}/code/" "${STAGING_DIR}/"
|
||||||
find . -type f -print0 | sort -z | xargs -0 sha256sum
|
find . -type f -print0 | sort -z | xargs -0 sha256sum
|
||||||
) > "${MANIFEST}"
|
) > "${MANIFEST}"
|
||||||
|
|
||||||
echo "[package] version: ${CURRENT_VERSION} -> ${VERSION}"
|
|
||||||
echo "[package] created artifact: ${ARTIFACT}"
|
echo "[package] created artifact: ${ARTIFACT}"
|
||||||
echo "[package] created manifest: ${MANIFEST}"
|
echo "[package] created manifest: ${MANIFEST}"
|
||||||
|
|
|
||||||
|
|
@ -142,31 +142,6 @@ Adjust paths to actual implementation while preserving case coverage.
|
||||||
- CalDAV link uses `/<url_slug>/caldav/`.
|
- CalDAV link uses `/<url_slug>/caldav/`.
|
||||||
- Link path changes match configured slug value.
|
- 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
|
## Negative and Security Tests
|
||||||
|
|
||||||
### API-SEC-001 Unauthorized Access
|
### API-SEC-001 Unauthorized Access
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,8 @@ fi
|
||||||
BASE_URL="${BASE_URL:-${WP_URL:-}}"
|
BASE_URL="${BASE_URL:-${WP_URL:-}}"
|
||||||
AUTH_USER="${CAL_TEST_USER:-adrians@chezstephens.org.uk}"
|
AUTH_USER="${CAL_TEST_USER:-adrians@chezstephens.org.uk}"
|
||||||
AUTH_PASS="${CAL_TEST_PASSWORD:-brillig1}"
|
AUTH_PASS="${CAL_TEST_PASSWORD:-brillig1}"
|
||||||
|
EXPECTED_TABLE_PREFIX="${CAL_TABLE_PREFIX_EXPECTED:-wp_cs_calendar}"
|
||||||
ENABLE_PREFIX_CHECK="${CAL_ENABLE_PREFIX_CHECK:-1}"
|
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:-}"
|
REMOTE_WP_PATH="${REMOTE_WP_PATH:-}"
|
||||||
if [[ -z "${REMOTE_WP_PATH}" ]] && [[ -n "${REMOTE_APP_DIR:-}" ]]; then
|
if [[ -z "${REMOTE_WP_PATH}" ]] && [[ -n "${REMOTE_APP_DIR:-}" ]]; then
|
||||||
REMOTE_WP_PATH="$(dirname "$(dirname "${REMOTE_APP_DIR}")")"
|
REMOTE_WP_PATH="$(dirname "$(dirname "${REMOTE_APP_DIR}")")"
|
||||||
|
|
@ -25,10 +24,6 @@ fi
|
||||||
FAILURES=0
|
FAILURES=0
|
||||||
CREATED_EVENT_ID=""
|
CREATED_EVENT_ID=""
|
||||||
CREATED_REC_EVENT_ID=""
|
CREATED_REC_EVENT_ID=""
|
||||||
CREATED_PRIVATE_EVENT_ID=""
|
|
||||||
CREATED_SYNC_DELETE_EVENT_ID=""
|
|
||||||
DETECTED_URL_SLUG=""
|
|
||||||
SSH_OK=0
|
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<'TXT'
|
cat <<'TXT'
|
||||||
|
|
@ -41,9 +36,8 @@ Env overrides:
|
||||||
BASE_URL
|
BASE_URL
|
||||||
CAL_TEST_USER
|
CAL_TEST_USER
|
||||||
CAL_TEST_PASSWORD
|
CAL_TEST_PASSWORD
|
||||||
|
CAL_TABLE_PREFIX_EXPECTED
|
||||||
CAL_ENABLE_PREFIX_CHECK
|
CAL_ENABLE_PREFIX_CHECK
|
||||||
CAL_URL_SLUG
|
|
||||||
CAL_CALDAV_PATH
|
|
||||||
|
|
||||||
Defaults:
|
Defaults:
|
||||||
BASE_URL -> credentials/.env:WP_URL
|
BASE_URL -> credentials/.env:WP_URL
|
||||||
|
|
@ -116,14 +110,6 @@ cleanup() {
|
||||||
curl -sS -u "${AUTH_USER}:${AUTH_PASS}" -X DELETE \
|
curl -sS -u "${AUTH_USER}:${AUTH_PASS}" -X DELETE \
|
||||||
"${BASE_URL}/wp-json/calendar/v1/events/${CREATED_REC_EVENT_ID}" >/dev/null || true
|
"${BASE_URL}/wp-json/calendar/v1/events/${CREATED_REC_EVENT_ID}" >/dev/null || true
|
||||||
fi
|
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
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
|
@ -136,11 +122,7 @@ else
|
||||||
record_fail "health endpoint unreachable"
|
record_fail "health endpoint unreachable"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -n "${REMOTE_HOST:-}" ]] && [[ -n "${REMOTE_USER:-}" ]] && [[ -n "${REMOTE_SSH_KEY_PATH:-}" ]] && [[ -n "${REMOTE_PORT:-}" ]] && [[ -n "${REMOTE_WP_CLI:-}" ]]; then
|
if [[ "${ENABLE_PREFIX_CHECK}" == "1" ]] && [[ -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"
|
step "table prefix configuration"
|
||||||
SSH_KEY_PATH="${REMOTE_SSH_KEY_PATH}"
|
SSH_KEY_PATH="${REMOTE_SSH_KEY_PATH}"
|
||||||
if [[ "${SSH_KEY_PATH}" != /* ]]; then
|
if [[ "${SSH_KEY_PATH}" != /* ]]; then
|
||||||
|
|
@ -149,21 +131,16 @@ if [[ "${ENABLE_PREFIX_CHECK}" == "1" ]] && [[ "${SSH_OK}" == "1" ]]; then
|
||||||
SSH_PREFIX=(ssh -F /dev/null -i "${SSH_KEY_PATH}" -p "${REMOTE_PORT}" -o StrictHostKeyChecking=accept-new "${REMOTE_USER}@${REMOTE_HOST}")
|
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
|
if ! "${SSH_PREFIX[@]}" "echo ok" >/dev/null 2>&1; then
|
||||||
echo "[remote-tests] WARN: SSH unavailable, skipping table prefix check"
|
echo "[remote-tests] WARN: SSH unavailable, skipping table prefix check"
|
||||||
SSH_OK=0
|
|
||||||
else
|
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)"
|
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)"
|
||||||
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)"
|
if [[ -n "${STEM}" ]] && [[ "${STEM}" != "cs_calendar" ]] && [[ "${STEM}" != "calendar" ]]; then
|
||||||
|
record_fail "table stem option expected cs_calendar/calendar got '${STEM}'"
|
||||||
|
fi
|
||||||
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_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')"
|
||||||
if [[ -n "${STEM}" ]] && [[ -n "${WP_DB_PREFIX}" ]]; then
|
TABLE_EXISTS="$(printf '%s\n' "${TABLE_LIST}" | grep -Fx "${EXPECTED_TABLE_PREFIX}_events" | head -n1 || true)"
|
||||||
EXPECTED_EVENTS_TABLE="${WP_DB_PREFIX}${STEM}_events"
|
LEGACY_EXISTS="$(printf '%s\n' "${TABLE_LIST}" | grep -Fx "wp_calendar_events" | head -n1 || true)"
|
||||||
if ! printf '%s\n' "${TABLE_LIST}" | grep -Fxq "${EXPECTED_EVENTS_TABLE}"; then
|
if [[ "${TABLE_EXISTS}" != "${EXPECTED_TABLE_PREFIX}_events" ]] && [[ "${LEGACY_EXISTS}" != "wp_calendar_events" ]]; then
|
||||||
record_fail "table stem '${STEM}' does not match an existing events table (${EXPECTED_EVENTS_TABLE})"
|
record_fail "expected table prefix '${EXPECTED_TABLE_PREFIX}' (or legacy wp_calendar) not found"
|
||||||
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
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
@ -290,275 +267,19 @@ PY
|
||||||
fi
|
fi
|
||||||
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"
|
step "caldav discovery compatibility"
|
||||||
CALDAV_ROOT_URL=""
|
CALDAV_UNAUTH="$(curl -s -o /tmp/remote_test_caldav_unauth.txt -w '%{http_code}' "${BASE_URL}/caldav/" || true)"
|
||||||
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
|
if [[ "${CALDAV_UNAUTH}" != "401" ]]; then
|
||||||
record_fail "caldav unauth challenge expected 401 got ${CALDAV_UNAUTH} (${CALDAV_ROOT_URL})"
|
record_fail "caldav unauth challenge expected 401 got ${CALDAV_UNAUTH}"
|
||||||
fi
|
fi
|
||||||
CALDAV_PROP="$(curl -s -u "${AUTH_USER}:${AUTH_PASS}" -X PROPFIND -H 'Depth: 0' \
|
CALDAV_PROP="$(curl -s -u "${AUTH_USER}:${AUTH_PASS}" -X PROPFIND -H 'Depth: 0' \
|
||||||
-o /tmp/remote_test_caldav_propfind.xml -w '%{http_code}' \
|
-o /tmp/remote_test_caldav_propfind.xml -w '%{http_code}' \
|
||||||
"${CALDAV_ROOT_URL}" || true)"
|
"${BASE_URL}/caldav/" || true)"
|
||||||
if [[ "${CALDAV_PROP}" != "207" ]]; then
|
if [[ "${CALDAV_PROP}" != "207" ]]; then
|
||||||
record_fail "caldav root PROPFIND expected 207 got ${CALDAV_PROP} (${CALDAV_ROOT_URL})"
|
record_fail "caldav root PROPFIND expected 207 got ${CALDAV_PROP}"
|
||||||
elif ! grep -Eqi 'calendar-home-set|current-user-principal' /tmp/remote_test_caldav_propfind.xml; then
|
elif ! grep -Eqi 'calendar-home-set|current-user-principal' /tmp/remote_test_caldav_propfind.xml; then
|
||||||
record_fail "caldav PROPFIND missing expected discovery properties"
|
record_fail "caldav PROPFIND missing expected discovery properties"
|
||||||
fi
|
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}"
|
printf '\n[remote-tests] completed with %d failure(s)\n' "${FAILURES}"
|
||||||
if [[ "${FAILURES}" -gt 0 ]]; then
|
if [[ "${FAILURES}" -gt 0 ]]; then
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,6 @@ Use a minimal subset:
|
||||||
- `pending_approval` or unverified user: cannot authenticate.
|
- `pending_approval` or unverified user: cannot authenticate.
|
||||||
- `active` user: can discover/read/create/update/delete one event successfully.
|
- `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).
|
- 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)
|
### SMK-007 API Smoke (If API Exposed)
|
||||||
- Event create/list/delete basic path.
|
- Event create/list/delete basic path.
|
||||||
|
|
@ -69,9 +68,6 @@ Use a minimal subset:
|
||||||
- discoverable calendar collection href
|
- discoverable calendar collection href
|
||||||
- Authenticated `PROPFIND` on principal must return `calendar-home-set`.
|
- 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 `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`.
|
- Legacy local harness check is archived at `fixture-tests/fixture_caldav_client_compat_smoke.sh`.
|
||||||
|
|
||||||
### SMK-010 Lifecycle Controls (Staging Only)
|
### SMK-010 Lifecycle Controls (Staging Only)
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue