moving from svn

This commit is contained in:
Adrian Stephens 2026-04-02 09:44:38 +01:00
commit 8d805ca8dc
124 changed files with 21629 additions and 0 deletions

0
.codex Normal file
View File

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
.svn

22
code/calendar-plugin.php Normal file
View File

@ -0,0 +1,22 @@
<?php
/**
* Plugin Name: Calendar Plugin
* Plugin URI: https://chezstephens.org.uk
* Description: Provides a single shared calendar for WordPress with public display, authenticated event editing, user approval workflow, ICS publishing, and CalDAV read/write sync. Supports recurring events, single-occurrence exceptions, admin setup and diagnostics pages, and shortcode rendering for full calendar and upcoming-events sidebar views.
* Version: 0.1.15
* Requires at least: 6.0
* Requires PHP: 8.1
* Author: Adrian Stephens (with AI assistance)
* License: GPL-2.0-or-later
* Text Domain: calendar-plugin
*/
declare(strict_types=1);
if (!defined('ABSPATH')) {
exit;
}
require_once __DIR__ . '/src/bootstrap.php';
\CalendarPlugin\Plugin::boot(__FILE__);

View File

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace CalendarPlugin\Contracts;
interface AuthAdapterInterface
{
public function currentUserId(): int;
public function currentUserCan(string $capability): bool;
public function verifyNonce(string $nonce, string $action): bool;
public function currentUserEmail(): string;
}

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace CalendarPlugin\Contracts;
interface DatabaseAdapterInterface
{
public function getPrefix(): string;
public function prepare(string $query, mixed ...$args): string;
public function query(string $query): int|false;
public function getResults(string $query): array;
public function getRow(string $query): ?object;
public function insert(string $table, array $data, array $formats = []): int|false;
public function update(string $table, array $data, array $where, array $formats = [], array $whereFormats = []): int|false;
public function delete(string $table, array $where, array $whereFormats = []): int|false;
public function insertId(): int;
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace CalendarPlugin\Contracts;
interface HttpAdapterInterface
{
public function addAction(string $hook, callable $callback, int $priority = 10, int $acceptedArgs = 1): void;
public function addShortcode(string $tag, callable $callback): void;
public function registerRestRoute(string $namespace, string $route, array $args): void;
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace CalendarPlugin\Contracts;
interface OptionsAdapterInterface
{
public function get(string $key, mixed $default = false): mixed;
public function set(string $key, mixed $value, bool $autoload = true): bool;
public function delete(string $key): bool;
}

View File

@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
namespace CalendarPlugin\Domain;
final class CalDavService
{
public function __construct(
private readonly EventService $events,
private readonly IcsService $ics
) {
}
public function listResources(): array
{
$items = [];
foreach ($this->events->listEvents() as $event) {
$resource = $this->resourceForEvent($event);
$items[] = [
'resource' => $resource,
'href' => '/caldav/calendars/public/' . $resource,
'uid' => (string) ($event['uid'] ?? ''),
'etag' => (string) ($event['etag'] ?? ''),
'updated_at' => (string) ($event['updated_at'] ?? ''),
'sync_version' => (int) ($event['sync_version'] ?? 1),
];
}
return $items;
}
public function getObject(string $resource): ?array
{
$event = $this->findEventByResource($resource);
if (!$event) {
return null;
}
$ics = $this->ics->buildCalendar(
[$event],
fn(int $eventId): array => $this->events->getDeletedOccurrenceKeys($eventId)
);
return [
'resource' => $resource,
'etag' => (string) ($event['etag'] ?? ''),
'event' => $event,
'ics' => $ics,
];
}
public function putObject(string $resource, string $icsPayload, ?string $ifMatch = null, ?string $ifNoneMatch = null, ?int $userId = null): array
{
$payload = $this->ics->parseEventFromIcs($icsPayload);
if ($payload === null) {
return ['error' => ['code' => 'invalid_ics', 'message' => 'invalid iCalendar payload', 'status' => 422]];
}
$existing = $this->events->getEventByResource($resource);
if (!$existing) {
$existing = $this->findEventByResource($resource);
}
if ($ifNoneMatch === '*' && $existing) {
return ['error' => ['code' => 'precondition_failed', 'message' => 'resource already exists', 'status' => 412]];
}
if ($ifMatch !== null) {
if (!$existing) {
return ['error' => ['code' => 'precondition_failed', 'message' => 'resource does not exist', 'status' => 412]];
}
if ((string) ($existing['etag'] ?? '') !== trim($ifMatch)) {
return ['error' => ['code' => 'precondition_failed', 'message' => 'etag mismatch', 'status' => 412]];
}
}
$payload['caldav_resource'] = $resource;
if ($userId !== null) {
$payload['last_modified_by_user_id'] = $userId;
}
$deleted = (array) ($payload['deleted_occurrence_keys'] ?? []);
unset($payload['deleted_occurrence_keys']);
if ($existing) {
$nextSyncVersion = ((int) ($existing['sync_version'] ?? 1)) + 1;
$payload['sync_version'] = $nextSyncVersion;
$payload['etag'] = $this->etagFor((string) ($payload['uid'] ?? $existing['uid'] ?? ''), $nextSyncVersion);
$event = $this->events->updateEvent((int) $existing['id'], $payload);
if (!$event) {
return ['error' => ['code' => 'update_failed', 'message' => 'failed to update object', 'status' => 500]];
}
$this->events->syncDeletedOccurrenceKeys((int) $event['id'], $deleted, true);
$event = $this->events->getEvent((int) $event['id']) ?? $event;
return ['status' => 204, 'created' => false, 'event' => $event];
}
$payload['sync_version'] = 1;
$payload['etag'] = $this->etagFor((string) ($payload['uid'] ?? ''), 1);
$event = $this->events->createEvent($payload);
$this->events->syncDeletedOccurrenceKeys((int) $event['id'], $deleted, true);
$event = $this->events->getEvent((int) $event['id']) ?? $event;
return ['status' => 201, 'created' => true, 'event' => $event];
}
public function deleteObject(string $resource): array
{
$existing = $this->findEventByResource($resource);
if (!$existing) {
return ['error' => ['code' => 'not_found', 'message' => 'resource not found', 'status' => 404]];
}
$ok = $this->events->deleteEvent((int) $existing['id']);
if (!$ok) {
return ['error' => ['code' => 'delete_failed', 'message' => 'failed to delete resource', 'status' => 500]];
}
return ['status' => 204, 'deleted' => true];
}
public function multiget(array $resources): array
{
$out = [];
foreach ($resources as $resource) {
$resource = basename((string) $resource);
if ($resource === '') {
continue;
}
$object = $this->getObject($resource);
if ($object === null) {
$out[] = ['resource' => $resource, 'status' => 404];
continue;
}
$out[] = [
'resource' => $resource,
'status' => 200,
'etag' => $object['etag'],
'ics' => $object['ics'],
];
}
return $out;
}
public function resourceForEvent(array $event): string
{
$resource = trim((string) ($event['caldav_resource'] ?? ''));
if ($resource !== '') {
return $resource;
}
return (string) ($event['uid'] ?? 'event-' . (string) ($event['id'] ?? 0)) . '.ics';
}
private function etagFor(string $uid, int $version): string
{
return '"' . substr(sha1($uid . ':' . $version . ':' . gmdate('c')), 0, 16) . '"';
}
private function findEventByResource(string $resource): ?array
{
foreach ($this->events->listEvents() as $event) {
if ($this->resourceForEvent($event) === $resource) {
return $event;
}
}
return null;
}
}

View File

@ -0,0 +1,709 @@
<?php
declare(strict_types=1);
namespace CalendarPlugin\Domain;
use CalendarPlugin\Contracts\DatabaseAdapterInterface;
use DateTimeImmutable;
use DateTimeZone;
final class EventService
{
private readonly string $eventsTable;
private readonly string $exceptionsTable;
public function __construct(private readonly DatabaseAdapterInterface $db, string $tableStem = 'cs_calendar')
{
$prefix = $db->getPrefix();
$stem = trim($tableStem, '_');
$this->eventsTable = $prefix . $stem . '_events';
$this->exceptionsTable = $prefix . $stem . '_recurrence_exceptions';
}
public function listEvents(): array
{
$rows = $this->db->getResults("SELECT * FROM {$this->eventsTable} ORDER BY id ASC");
return array_map([$this, 'normalizeRow'], $rows);
}
public function getEvent(int $id): ?array
{
$sql = $this->db->prepare("SELECT * FROM {$this->eventsTable} WHERE id = %d", $id);
$row = $this->db->getRow($sql);
return $row ? $this->normalizeRow($row) : null;
}
public function createEvent(array $payload): array
{
$now = gmdate('c');
$uid = (string) ($payload['uid'] ?? bin2hex(random_bytes(10)) . '@calendar-plugin');
$resource = $this->resourceFromUid($uid);
$title = trim((string) ($payload['title'] ?? 'Untitled'));
$startRaw = (string) ($payload['start_datetime'] ?? '');
$endRaw = (string) ($payload['end_datetime'] ?? '');
$start = $this->toLondonDateTimeString($startRaw);
$end = $this->toLondonDateTimeString($endRaw);
if ($start === '' || $end === '') {
throw new \InvalidArgumentException('start_datetime and end_datetime are required');
}
if (new DateTimeImmutable($end) < new DateTimeImmutable($start)) {
throw new \InvalidArgumentException('end_datetime must be at or after start_datetime');
}
$repeatType = (string) ($payload['repeat_type'] ?? 'none');
$repeatInterval = max(1, (int) ($payload['repeat_interval'] ?? 1));
$repeatNthMode = (string) ($payload['repeat_nth_mode'] ?? '');
$repeatNthDay = array_key_exists('repeat_nth_day', $payload) && $payload['repeat_nth_day'] !== null && $payload['repeat_nth_day'] !== ''
? (int) $payload['repeat_nth_day']
: null;
$repeatNthPos = array_key_exists('repeat_nth_pos', $payload) && $payload['repeat_nth_pos'] !== null && $payload['repeat_nth_pos'] !== ''
? (int) $payload['repeat_nth_pos']
: null;
$repeatNthWeekday = array_key_exists('repeat_nth_weekday', $payload) && $payload['repeat_nth_weekday'] !== null && $payload['repeat_nth_weekday'] !== ''
? (int) $payload['repeat_nth_weekday']
: null;
[$start, $end] = $this->normalizeMonthlyAnchor(
$start,
$end,
$repeatType,
$repeatInterval,
$repeatNthMode,
$repeatNthDay,
$repeatNthPos,
$repeatNthWeekday
);
$data = [
'uid' => $uid,
'title' => $title,
'description' => (string) ($payload['description'] ?? ''),
'location' => (string) ($payload['location'] ?? ''),
'category' => (string) ($payload['category'] ?? ''),
'all_day_event' => !empty($payload['all_day_event']) ? 1 : 0,
'start_datetime' => $start,
'end_datetime' => $end,
'repeat_type' => $repeatType,
'repeat_interval' => $repeatInterval,
'repeat_nth_mode' => $repeatNthMode,
'repeat_nth_day' => $repeatNthDay,
'repeat_nth_pos' => $repeatNthPos,
'repeat_nth_weekday' => $repeatNthWeekday,
'repeat_range_mode' => $this->canonicalRangeMode((string) ($payload['repeat_range_mode'] ?? 'none')),
'repeat_count' => isset($payload['repeat_count']) ? (int) $payload['repeat_count'] : null,
'repeat_until' => !empty($payload['repeat_until']) ? (string) $payload['repeat_until'] : null,
'timezone' => (string) ($payload['timezone'] ?? 'Europe/London'),
'caldav_resource' => !empty($payload['caldav_resource']) ? (string) $payload['caldav_resource'] : $resource,
'etag' => (string) ($payload['etag'] ?? $this->makeEtag($uid, 1, $now)),
'sync_version' => (int) ($payload['sync_version'] ?? 1),
'last_modified_by_user_id' => isset($payload['last_modified_by_user_id']) ? (int) $payload['last_modified_by_user_id'] : null,
'created_at' => $now,
'updated_at' => $now,
];
$inserted = $this->db->insert($this->eventsTable, $data);
if ($inserted === false) {
throw new \RuntimeException('failed to create event');
}
return (array) $this->getEvent($this->db->insertId());
}
public function updateEvent(int $id, array $payload): ?array
{
$existing = $this->getEvent($id);
if (!$existing) {
return null;
}
$now = gmdate('c');
$currentResource = trim((string) ($existing['caldav_resource'] ?? ''));
$fallbackResource = $this->resourceFromUid((string) ($existing['uid'] ?? ''));
$start = array_key_exists('start_datetime', $payload)
? $this->toLondonDateTimeString((string) $payload['start_datetime'])
: (string) $existing['start_datetime'];
$end = array_key_exists('end_datetime', $payload)
? $this->toLondonDateTimeString((string) $payload['end_datetime'])
: (string) $existing['end_datetime'];
if ($start !== '' && $end !== '' && new DateTimeImmutable($end) < new DateTimeImmutable($start)) {
throw new \InvalidArgumentException('end_datetime must be at or after start_datetime');
}
$repeatType = (string) ($payload['repeat_type'] ?? $existing['repeat_type']);
$repeatInterval = max(1, (int) ($payload['repeat_interval'] ?? $existing['repeat_interval']));
$repeatNthMode = (string) ($payload['repeat_nth_mode'] ?? ($existing['repeat_nth_mode'] ?? ''));
$repeatNthDay = array_key_exists('repeat_nth_day', $payload)
? ($payload['repeat_nth_day'] === null || $payload['repeat_nth_day'] === '' ? null : (int) $payload['repeat_nth_day'])
: ($existing['repeat_nth_day'] ?? null);
$repeatNthPos = array_key_exists('repeat_nth_pos', $payload)
? ($payload['repeat_nth_pos'] === null || $payload['repeat_nth_pos'] === '' ? null : (int) $payload['repeat_nth_pos'])
: ($existing['repeat_nth_pos'] ?? null);
$repeatNthWeekday = array_key_exists('repeat_nth_weekday', $payload)
? ($payload['repeat_nth_weekday'] === null || $payload['repeat_nth_weekday'] === '' ? null : (int) $payload['repeat_nth_weekday'])
: ($existing['repeat_nth_weekday'] ?? null);
[$start, $end] = $this->normalizeMonthlyAnchor(
$start,
$end,
$repeatType,
$repeatInterval,
$repeatNthMode,
$repeatNthDay,
$repeatNthPos,
$repeatNthWeekday
);
$data = [
'title' => trim((string) ($payload['title'] ?? $existing['title'])),
'description' => (string) ($payload['description'] ?? $existing['description']),
'location' => (string) ($payload['location'] ?? $existing['location']),
'category' => (string) ($payload['category'] ?? $existing['category']),
'all_day_event' => array_key_exists('all_day_event', $payload)
? (!empty($payload['all_day_event']) ? 1 : 0)
: ((bool) $existing['all_day_event'] ? 1 : 0),
'start_datetime' => $start,
'end_datetime' => $end,
'repeat_type' => $repeatType,
'repeat_interval' => $repeatInterval,
'repeat_nth_mode' => $repeatNthMode,
'repeat_nth_day' => $repeatNthDay,
'repeat_nth_pos' => $repeatNthPos,
'repeat_nth_weekday' => $repeatNthWeekday,
'repeat_range_mode' => $this->canonicalRangeMode((string) ($payload['repeat_range_mode'] ?? $existing['repeat_range_mode'])),
'repeat_count' => array_key_exists('repeat_count', $payload) ? (is_null($payload['repeat_count']) ? null : (int) $payload['repeat_count']) : $existing['repeat_count'],
'repeat_until' => array_key_exists('repeat_until', $payload) ? (empty($payload['repeat_until']) ? null : (string) $payload['repeat_until']) : $existing['repeat_until'],
'timezone' => (string) ($payload['timezone'] ?? $existing['timezone']),
'caldav_resource' => !empty($payload['caldav_resource'])
? (string) $payload['caldav_resource']
: ($currentResource !== '' ? $currentResource : $fallbackResource),
'etag' => (string) ($payload['etag'] ?? $existing['etag'] ?? $this->makeEtag((string) $existing['uid'], (int) ($existing['sync_version'] ?? 1), $now)),
'sync_version' => (int) ($payload['sync_version'] ?? (($existing['sync_version'] ?? 1) + 1)),
'last_modified_by_user_id' => array_key_exists('last_modified_by_user_id', $payload)
? (is_null($payload['last_modified_by_user_id']) ? null : (int) $payload['last_modified_by_user_id'])
: ($existing['last_modified_by_user_id'] ?? null),
'updated_at' => $now,
];
$this->db->update($this->eventsTable, $data, ['id' => $id]);
return $this->getEvent($id);
}
public function deleteEvent(int $id): bool
{
$this->db->delete($this->exceptionsTable, ['event_id' => $id]);
$deleted = $this->db->delete($this->eventsTable, ['id' => $id]);
return $deleted !== false;
}
public function deleteOccurrence(int $eventId, string $occurrenceKey): bool
{
$event = $this->getEvent($eventId);
if (!$event) {
return false;
}
$canonical = $this->canonicalOccurrenceKey($occurrenceKey);
if ($canonical === null) {
return false;
}
if (in_array($canonical, $this->deletedKeysForEvent($eventId), true)) {
return true;
}
$now = gmdate('c');
$inserted = $this->db->insert(
$this->exceptionsTable,
[
'event_id' => $eventId,
'occurrence_key' => $canonical,
'exception_type' => 'deleted_occurrence',
'created_at' => $now,
'updated_at' => $now,
]
);
return $inserted !== false;
}
public function listEventOccurrences(int $eventId, string $fromDate, int $months = 3): ?array
{
$event = $this->getEvent($eventId);
if (!$event) {
return null;
}
$tz = new DateTimeZone('Europe/London');
$start = $this->safeDate($fromDate, $tz)->setTime(0, 0, 0);
$months = max(1, min($months, 24));
$end = $start->modify('+' . $months . ' month');
$deleted = $this->deletedKeysForEvent($eventId);
$items = RecurrenceExpander::expand($event, $start, $end, $deleted);
usort(
$items,
static fn(array $a, array $b): int => strcmp((string) $a['occurrence_start'], (string) $b['occurrence_start'])
);
return $items;
}
public function previewOccurrences(array $payload, string $fromDate, int $months = 3): array
{
$startRaw = (string) ($payload['start_datetime'] ?? '');
$endRaw = (string) ($payload['end_datetime'] ?? '');
$start = $this->toLondonDateTimeString($startRaw);
$end = $this->toLondonDateTimeString($endRaw);
if ($start === '' || $end === '') {
throw new \InvalidArgumentException('start_datetime and end_datetime are required');
}
if (new DateTimeImmutable($end) < new DateTimeImmutable($start)) {
throw new \InvalidArgumentException('end_datetime must be at or after start_datetime');
}
$repeatType = (string) ($payload['repeat_type'] ?? 'none');
if ($repeatType === 'none') {
return [];
}
$repeatInterval = max(1, (int) ($payload['repeat_interval'] ?? 1));
$repeatNthMode = (string) ($payload['repeat_nth_mode'] ?? '');
$repeatNthDay = array_key_exists('repeat_nth_day', $payload) && $payload['repeat_nth_day'] !== null && $payload['repeat_nth_day'] !== ''
? (int) $payload['repeat_nth_day']
: null;
$repeatNthPos = array_key_exists('repeat_nth_pos', $payload) && $payload['repeat_nth_pos'] !== null && $payload['repeat_nth_pos'] !== ''
? (int) $payload['repeat_nth_pos']
: null;
$repeatNthWeekday = array_key_exists('repeat_nth_weekday', $payload) && $payload['repeat_nth_weekday'] !== null && $payload['repeat_nth_weekday'] !== ''
? (int) $payload['repeat_nth_weekday']
: null;
[$start, $end] = $this->normalizeMonthlyAnchor(
$start,
$end,
$repeatType,
$repeatInterval,
$repeatNthMode,
$repeatNthDay,
$repeatNthPos,
$repeatNthWeekday
);
$event = [
'id' => 0,
'uid' => 'preview@calendar-plugin',
'title' => (string) ($payload['title'] ?? ''),
'description' => (string) ($payload['description'] ?? ''),
'location' => (string) ($payload['location'] ?? ''),
'category' => (string) ($payload['category'] ?? ''),
'all_day_event' => !empty($payload['all_day_event']),
'start_datetime' => $start,
'end_datetime' => $end,
'repeat_type' => $repeatType,
'repeat_interval' => $repeatInterval,
'repeat_nth_mode' => $repeatNthMode,
'repeat_nth_day' => $repeatNthDay,
'repeat_nth_pos' => $repeatNthPos,
'repeat_nth_weekday' => $repeatNthWeekday,
'repeat_range_mode' => $this->canonicalRangeMode((string) ($payload['repeat_range_mode'] ?? 'none')),
'repeat_count' => isset($payload['repeat_count']) ? (int) $payload['repeat_count'] : null,
'repeat_until' => !empty($payload['repeat_until']) ? (string) $payload['repeat_until'] : null,
'timezone' => 'Europe/London',
];
$tz = new DateTimeZone('Europe/London');
$startWindow = $this->safeDate($fromDate, $tz)->setTime(0, 0, 0);
$months = max(1, min($months, 24));
$endWindow = $startWindow->modify('+' . $months . ' month');
$deleted = [];
foreach ((array) ($payload['deleted_occurrence_keys'] ?? []) as $key) {
$canonical = $this->canonicalOccurrenceKey((string) $key);
if ($canonical !== null) {
$deleted[] = $canonical;
}
}
$items = RecurrenceExpander::expand($event, $startWindow, $endWindow, $deleted);
usort(
$items,
static fn(array $a, array $b): int => strcmp((string) $a['occurrence_start'], (string) $b['occurrence_start'])
);
return $items;
}
public function getEventByResource(string $resource): ?array
{
$sql = $this->db->prepare("SELECT * FROM {$this->eventsTable} WHERE caldav_resource = %s", $resource);
$row = $this->db->getRow($sql);
return $row ? $this->normalizeRow($row) : null;
}
public function getDeletedOccurrenceKeys(int $eventId): array
{
return $this->deletedKeysForEvent($eventId);
}
public function syncDeletedOccurrenceKeys(int $eventId, array $keys, bool $replace = true): void
{
if ($replace) {
$this->db->delete($this->exceptionsTable, ['event_id' => $eventId, 'exception_type' => 'deleted_occurrence']);
}
$now = gmdate('c');
foreach ($keys as $key) {
$canonical = $this->canonicalOccurrenceKey((string) $key);
if ($canonical === null) {
continue;
}
$this->db->insert(
$this->exceptionsTable,
[
'event_id' => $eventId,
'occurrence_key' => $canonical,
'exception_type' => 'deleted_occurrence',
'created_at' => $now,
'updated_at' => $now,
]
);
}
}
public function listPublicOccurrences(string $view, string $dateAnchor, bool $futureOnly = false): array
{
$tz = new DateTimeZone('Europe/London');
$anchor = $this->safeDate($dateAnchor, $tz);
if (strtolower($view) === 'list') {
$windowStart = $anchor->setTime(0, 0, 0);
if ($futureOnly) {
$today = new DateTimeImmutable('today', $tz);
if ($today > $windowStart) {
$windowStart = $today;
}
}
$windowEnd = $windowStart->modify('+18 months');
} else {
[$windowStart, $windowEnd] = $this->windowForView($view, $anchor);
}
$events = $this->listEvents();
$out = [];
foreach ($events as $event) {
$deleted = $this->deletedKeysForEvent((int) $event['id']);
$items = RecurrenceExpander::expand($event, $windowStart, $windowEnd, $deleted);
array_push($out, ...$items);
}
usort(
$out,
static fn(array $a, array $b): int => strcmp((string) $a['occurrence_start'], (string) $b['occurrence_start'])
);
return $out;
}
public function listSidebarUpcoming(int $days = 14): array
{
$tz = new DateTimeZone('Europe/London');
$start = new DateTimeImmutable('today', $tz);
$end = $start->modify('+' . max(1, $days) . ' days');
$events = $this->listEvents();
$out = [];
foreach ($events as $event) {
$deleted = $this->deletedKeysForEvent((int) $event['id']);
$items = RecurrenceExpander::expand($event, $start, $end, $deleted);
array_push($out, ...$items);
}
usort(
$out,
static fn(array $a, array $b): int => strcmp((string) $a['occurrence_start'], (string) $b['occurrence_start'])
);
return $out;
}
public function deleteAllEventsData(): int
{
$events = $this->listEvents();
$count = count($events);
$this->db->query("DELETE FROM {$this->exceptionsTable}");
$this->db->query("DELETE FROM {$this->eventsTable}");
return $count;
}
public function seedDefaultEvents(): int
{
$seed = [
[
'uid' => 'seed-ce-001@calendar-plugin',
'title' => 'Board Meeting',
'description' => 'Quarterly board review.',
'location' => 'Room A',
'category' => 'Governance',
'start_datetime' => '2026-04-01T10:00:00+01:00',
'end_datetime' => '2026-04-01T11:30:00+01:00',
'repeat_type' => 'none',
],
[
'uid' => 'seed-ce-002@calendar-plugin',
'title' => 'Office Closed',
'description' => 'Public holiday closure.',
'location' => 'HQ',
'category' => 'Operations',
'all_day_event' => true,
'start_datetime' => '2026-05-04T00:00:00+01:00',
'end_datetime' => '2026-05-05T00:00:00+01:00',
'repeat_type' => 'none',
],
[
'uid' => 'seed-ce-003@calendar-plugin',
'title' => 'Daily Standup',
'description' => '15 minute sync.',
'location' => 'Online',
'category' => 'Team',
'start_datetime' => '2026-04-06T09:00:00+01:00',
'end_datetime' => '2026-04-06T09:15:00+01:00',
'repeat_type' => 'daily',
'repeat_interval' => 1,
'repeat_range_mode' => 'until',
'repeat_until' => '2026-04-15',
],
[
'uid' => 'seed-ce-004@calendar-plugin',
'title' => 'Community Lunch',
'description' => 'Weekly community lunch.',
'location' => 'Cafeteria',
'category' => 'Community',
'start_datetime' => '2026-04-08T12:30:00+01:00',
'end_datetime' => '2026-04-08T13:30:00+01:00',
'repeat_type' => 'weekly',
'repeat_interval' => 1,
'repeat_range_mode' => 'until',
'repeat_until' => '2026-05-06',
],
[
'uid' => 'seed-ce-005@calendar-plugin',
'title' => 'Finance Close',
'description' => 'Month-end close process.',
'location' => 'Finance Office',
'category' => 'Finance',
'start_datetime' => '2026-03-31T17:00:00+01:00',
'end_datetime' => '2026-03-31T18:00:00+01:00',
'repeat_type' => 'monthly',
'repeat_interval' => 1,
'repeat_nth_mode' => 'day_of_month',
'repeat_nth_day' => 30,
'repeat_range_mode' => 'until',
'repeat_until' => '2026-06-30',
],
];
foreach ($seed as $event) {
$this->createEvent($event);
}
return count($seed);
}
private function normalizeRow(object $row): array
{
return [
'id' => (int) $row->id,
'uid' => (string) $row->uid,
'title' => (string) $row->title,
'description' => (string) $row->description,
'location' => (string) $row->location,
'category' => (string) $row->category,
'all_day_event' => (bool) $row->all_day_event,
'start_datetime' => (string) $row->start_datetime,
'end_datetime' => (string) $row->end_datetime,
'repeat_type' => (string) $row->repeat_type,
'repeat_interval' => (int) $row->repeat_interval,
'repeat_nth_mode' => property_exists($row, 'repeat_nth_mode') ? (string) ($row->repeat_nth_mode ?? '') : '',
'repeat_nth_day' => property_exists($row, 'repeat_nth_day') && $row->repeat_nth_day !== null ? (int) $row->repeat_nth_day : null,
'repeat_nth_pos' => property_exists($row, 'repeat_nth_pos') && $row->repeat_nth_pos !== null ? (int) $row->repeat_nth_pos : null,
'repeat_nth_weekday' => property_exists($row, 'repeat_nth_weekday') && $row->repeat_nth_weekday !== null ? (int) $row->repeat_nth_weekday : null,
'repeat_range_mode' => (string) $row->repeat_range_mode,
'repeat_count' => is_null($row->repeat_count) ? null : (int) $row->repeat_count,
'repeat_until' => $row->repeat_until === null ? null : (string) $row->repeat_until,
'timezone' => (string) $row->timezone,
'caldav_resource' => property_exists($row, 'caldav_resource') ? (string) ($row->caldav_resource ?? '') : '',
'etag' => property_exists($row, 'etag') ? (string) ($row->etag ?? '') : '',
'sync_version' => property_exists($row, 'sync_version') ? (int) ($row->sync_version ?? 1) : 1,
'last_modified_by_user_id' => property_exists($row, 'last_modified_by_user_id') && $row->last_modified_by_user_id !== null
? (int) $row->last_modified_by_user_id
: null,
'created_at' => (string) $row->created_at,
'updated_at' => (string) $row->updated_at,
];
}
private function deletedKeysForEvent(int $eventId): array
{
$sql = $this->db->prepare(
"SELECT occurrence_key FROM {$this->exceptionsTable} WHERE event_id = %d AND exception_type = 'deleted_occurrence'",
$eventId
);
$rows = $this->db->getResults($sql);
return array_map(static fn(object $r): string => (string) $r->occurrence_key, $rows);
}
private function safeDate(string $dateAnchor, DateTimeZone $tz): DateTimeImmutable
{
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateAnchor)) {
return new DateTimeImmutable($dateAnchor . 'T00:00:00', $tz);
}
return new DateTimeImmutable('today', $tz);
}
private function windowForView(string $view, DateTimeImmutable $anchor): array
{
$view = strtolower($view);
if ($view === 'day') {
$start = $anchor->setTime(0, 0, 0);
return [$start, $start->modify('+1 day')];
}
if ($view === 'week') {
$weekday = (int) $anchor->format('w');
$start = $anchor->modify('-' . $weekday . ' day')->setTime(0, 0, 0);
return [$start, $start->modify('+7 days')];
}
if ($view === 'year') {
$start = $anchor->setDate((int) $anchor->format('Y'), 1, 1)->setTime(0, 0, 0);
return [$start, $start->modify('+1 year')];
}
$monthStart = $anchor->setDate((int) $anchor->format('Y'), (int) $anchor->format('m'), 1)->setTime(0, 0, 0);
$startWeekday = (int) $monthStart->format('w');
$gridStart = $monthStart->modify('-' . $startWeekday . ' day');
$gridEnd = $gridStart->modify('+42 days');
return [$gridStart, $gridEnd];
}
private function canonicalOccurrenceKey(string $value): ?string
{
try {
if (!str_contains($value, 'T') && preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
$dt = new DateTimeImmutable($value . 'T00:00:00', new DateTimeZone('Europe/London'));
return $dt->format('c');
}
$dt = new DateTimeImmutable($value, new DateTimeZone('Europe/London'));
return $dt->format('c');
} catch (\Throwable) {
return null;
}
}
private function makeEtag(string $uid, int $syncVersion, string $stamp): string
{
return '"' . substr(sha1($uid . ':' . $syncVersion . ':' . $stamp), 0, 16) . '"';
}
private function resourceFromUid(string $uid): string
{
$uid = trim($uid);
if ($uid === '') {
$uid = bin2hex(random_bytes(10)) . '@calendar-plugin';
}
return $uid . '.ics';
}
private function toLondonDateTimeString(string $value): string
{
$value = trim($value);
if ($value === '') {
return '';
}
try {
$dt = new DateTimeImmutable($value, new DateTimeZone('Europe/London'));
return $dt->setTimezone(new DateTimeZone('Europe/London'))->format('c');
} catch (\Throwable) {
throw new \InvalidArgumentException('invalid datetime value');
}
}
private function canonicalRangeMode(string $value): string
{
$v = strtolower(trim($value));
if ($v === 'no_end' || $v === '') {
return 'none';
}
return in_array($v, ['none', 'count', 'until'], true) ? $v : 'none';
}
private function normalizeMonthlyAnchor(
string $startIso,
string $endIso,
string $repeatType,
int $repeatInterval,
string $repeatNthMode,
?int $repeatNthDay,
?int $repeatNthPos,
?int $repeatNthWeekday
): array {
if ($repeatType !== 'monthly') {
return [$startIso, $endIso];
}
try {
$tz = new DateTimeZone('Europe/London');
$start = new DateTimeImmutable($startIso, $tz);
$end = new DateTimeImmutable($endIso, $tz);
$duration = $start->diff($end);
$targetDay = (int) $start->format('j');
if ($repeatNthMode === 'day_of_month' && $repeatNthDay !== null) {
$daysInMonth = (int) $start->format('t');
$targetDay = max(1, min($repeatNthDay, $daysInMonth));
} elseif ($repeatNthMode === 'weekday_of_month' && $repeatNthPos !== null && $repeatNthWeekday !== null) {
$nthDay = $this->nthWeekdayOfMonth((int) $start->format('Y'), (int) $start->format('n'), $repeatNthWeekday, $repeatNthPos);
if ($nthDay === null) {
$year = (int) $start->format('Y');
$month = (int) $start->format('n');
$step = max(1, $repeatInterval);
for ($i = 0; $i < 120; $i++) {
[$year, $month] = $this->addMonths($year, $month, $step);
$nthDay = $this->nthWeekdayOfMonth($year, $month, $repeatNthWeekday, $repeatNthPos);
if ($nthDay !== null) {
$start = $start->setDate($year, $month, $nthDay);
$targetDay = $nthDay;
break;
}
}
} else {
$targetDay = $nthDay;
}
}
$anchoredStart = $start->setDate((int) $start->format('Y'), (int) $start->format('n'), $targetDay);
$anchoredEnd = $anchoredStart->add($duration);
return [$anchoredStart->format('c'), $anchoredEnd->format('c')];
} catch (\Throwable) {
return [$startIso, $endIso];
}
}
private function nthWeekdayOfMonth(int $year, int $month, int $weekday, int $pos): ?int
{
$weekday = max(0, min(6, $weekday));
$tz = new DateTimeZone('Europe/London');
if ($pos === -1) {
$last = new DateTimeImmutable(sprintf('%04d-%02d-01T00:00:00', $year, $month), $tz);
$last = $last->modify('last day of this month');
for ($day = (int) $last->format('j'); $day >= 1; $day--) {
$d = $last->setDate($year, $month, $day);
if ((int) $d->format('w') === $weekday) {
return $day;
}
}
return null;
}
$first = new DateTimeImmutable(sprintf('%04d-%02d-01T00:00:00', $year, $month), $tz);
$daysInMonth = (int) $first->format('t');
$seen = 0;
for ($day = 1; $day <= $daysInMonth; $day++) {
$d = $first->setDate($year, $month, $day);
if ((int) $d->format('w') !== $weekday) {
continue;
}
$seen++;
if ($seen === $pos) {
return $day;
}
}
return null;
}
private function addMonths(int $year, int $month, int $delta): array
{
$index = ($year * 12) + ($month - 1) + $delta;
$newYear = (int) floor($index / 12);
$newMonth = ($index % 12) + 1;
if ($newMonth <= 0) {
$newMonth += 12;
$newYear -= 1;
}
return [$newYear, $newMonth];
}
}

View File

@ -0,0 +1,439 @@
<?php
declare(strict_types=1);
namespace CalendarPlugin\Domain;
use DateTimeImmutable;
use DateTimeZone;
final class IcsService
{
private const PRODID = '-//Calendar Plugin//EN';
public function buildCalendar(array $events, callable $deletedKeysProvider, string $calendarName = 'Calendar'): string
{
$lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:' . self::PRODID,
'CALSCALE:GREGORIAN',
'X-WR-CALNAME:' . $this->escapeText($calendarName),
'X-WR-TIMEZONE:Europe/London',
];
foreach ($events as $event) {
$lines = array_merge($lines, $this->eventToLines($event, (array) $deletedKeysProvider((int) ($event['id'] ?? 0))));
}
$lines[] = 'END:VCALENDAR';
return implode("\r\n", $this->foldLines($lines)) . "\r\n";
}
public function parseEventFromIcs(string $ics): ?array
{
$props = $this->extractVeventProperties($ics);
if ($props === null) {
return null;
}
$uid = (string) ($props['UID'][0] ?? '');
$summary = (string) ($props['SUMMARY'][0] ?? 'Untitled');
$description = (string) ($props['DESCRIPTION'][0] ?? '');
$location = (string) ($props['LOCATION'][0] ?? '');
$category = (string) ($props['CATEGORIES'][0] ?? '');
$dtstartRaw = (string) ($props['DTSTART'][0] ?? '');
$dtendRaw = (string) ($props['DTEND'][0] ?? '');
if ($dtstartRaw === '' || $dtendRaw === '') {
return null;
}
$allDay = str_contains((string) ($props['_DTSTART_PARAMS'][0] ?? ''), 'VALUE=DATE');
$start = $this->parseIcsDateTime($dtstartRaw, $allDay);
$end = $this->parseIcsDateTime($dtendRaw, $allDay);
if ($start === null || $end === null) {
return null;
}
$payload = [
'uid' => $uid !== '' ? $uid : bin2hex(random_bytes(10)) . '@calendar-plugin',
'title' => $summary,
'description' => $description,
'location' => $location,
'category' => $category,
'all_day_event' => $allDay,
'start_datetime' => $start,
'end_datetime' => $end,
'repeat_type' => 'none',
'repeat_interval' => 1,
'repeat_nth_mode' => '',
'repeat_nth_day' => null,
'repeat_nth_pos' => null,
'repeat_nth_weekday' => null,
'repeat_range_mode' => 'none',
'repeat_count' => null,
'repeat_until' => null,
'timezone' => 'Europe/London',
];
$rrule = (string) ($props['RRULE'][0] ?? '');
if ($rrule !== '') {
$payload = array_merge($payload, $this->parseRrule($rrule));
}
$exdates = [];
foreach (($props['EXDATE'] ?? []) as $exdateRaw) {
$chunks = array_filter(array_map('trim', explode(',', (string) $exdateRaw)));
foreach ($chunks as $chunk) {
$asDate = $this->parseIcsDateTime($chunk, false);
if ($asDate !== null) {
$exdates[] = $asDate;
}
}
}
$payload['deleted_occurrence_keys'] = $exdates;
return $payload;
}
private function eventToLines(array $event, array $deletedKeys): array
{
$uid = (string) ($event['uid'] ?? '');
$uid = $uid !== '' ? $uid : ('event-' . (string) ($event['id'] ?? 0) . '@calendar-plugin');
$start = $this->toDateTime((string) ($event['start_datetime'] ?? ''));
$end = $this->toDateTime((string) ($event['end_datetime'] ?? ''));
if ($start === null || $end === null) {
return [];
}
$allDay = (bool) ($event['all_day_event'] ?? false);
$updated = $this->toDateTime((string) ($event['updated_at'] ?? '')) ?? new DateTimeImmutable('now', new DateTimeZone('UTC'));
$lines = [
'BEGIN:VEVENT',
'UID:' . $this->escapeText($uid),
'SUMMARY:' . $this->escapeText((string) ($event['title'] ?? 'Untitled')),
'DESCRIPTION:' . $this->escapeText((string) ($event['description'] ?? '')),
'LOCATION:' . $this->escapeText((string) ($event['location'] ?? '')),
'CATEGORIES:' . $this->escapeText((string) ($event['category'] ?? '')),
'DTSTAMP:' . $this->toUtcIcs($updated),
'LAST-MODIFIED:' . $this->toUtcIcs($updated),
];
if ($allDay) {
$lines[] = 'DTSTART;VALUE=DATE:' . $start->setTimezone(new DateTimeZone('Europe/London'))->format('Ymd');
$lines[] = 'DTEND;VALUE=DATE:' . $end->setTimezone(new DateTimeZone('Europe/London'))->format('Ymd');
} else {
$lines[] = 'DTSTART;TZID=Europe/London:' . $start->setTimezone(new DateTimeZone('Europe/London'))->format('Ymd\\THis');
$lines[] = 'DTEND;TZID=Europe/London:' . $end->setTimezone(new DateTimeZone('Europe/London'))->format('Ymd\\THis');
}
$rrule = $this->eventToRrule($event);
if ($rrule !== null) {
$lines[] = 'RRULE:' . $rrule;
}
if ($deletedKeys) {
$parts = [];
foreach ($deletedKeys as $key) {
$dt = $this->toDateTime((string) $key);
if ($dt === null) {
continue;
}
$parts[] = $dt->setTimezone(new DateTimeZone('Europe/London'))->format('Ymd\\THis');
}
if ($parts) {
$lines[] = 'EXDATE;TZID=Europe/London:' . implode(',', $parts);
}
}
$lines[] = 'END:VEVENT';
return $lines;
}
private function eventToRrule(array $event): ?string
{
$type = strtolower((string) ($event['repeat_type'] ?? 'none'));
if ($type === 'none') {
return null;
}
$freq = match ($type) {
'daily' => 'DAILY',
'weekly', 'custom' => 'WEEKLY',
'monthly' => 'MONTHLY',
'yearly' => 'YEARLY',
default => null,
};
if ($freq === null) {
return null;
}
$interval = max(1, (int) ($event['repeat_interval'] ?? 1));
$parts = ['FREQ=' . $freq, 'INTERVAL=' . $interval];
if ($type === 'monthly') {
$nthMode = (string) ($event['repeat_nth_mode'] ?? '');
$nthDay = isset($event['repeat_nth_day']) && $event['repeat_nth_day'] !== null ? (int) $event['repeat_nth_day'] : null;
$nthPos = isset($event['repeat_nth_pos']) && $event['repeat_nth_pos'] !== null ? (int) $event['repeat_nth_pos'] : null;
$nthWeekday = isset($event['repeat_nth_weekday']) && $event['repeat_nth_weekday'] !== null ? (int) $event['repeat_nth_weekday'] : null;
if ($nthMode === 'day_of_month' && $nthDay !== null) {
$parts[] = 'BYMONTHDAY=' . max(1, min(31, $nthDay));
} elseif ($nthMode === 'weekday_of_month' && $nthPos !== null && $nthWeekday !== null) {
$byDay = $this->weekdayNumToToken($nthWeekday);
if ($byDay !== null) {
$parts[] = 'BYDAY=' . $byDay;
$parts[] = 'BYSETPOS=' . ($nthPos < 0 ? -1 : max(1, min(5, $nthPos)));
}
}
}
$rangeMode = strtolower((string) ($event['repeat_range_mode'] ?? 'none'));
if ($rangeMode === 'count' && !empty($event['repeat_count'])) {
$parts[] = 'COUNT=' . max(1, (int) $event['repeat_count']);
}
if ($rangeMode === 'until' && !empty($event['repeat_until'])) {
$until = $this->toDateTime((string) $event['repeat_until'] . 'T23:59:59');
if ($until !== null) {
$parts[] = 'UNTIL=' . $this->toUtcIcs($until);
}
}
return implode(';', $parts);
}
private function parseRrule(string $rrule): array
{
$parts = [];
foreach (explode(';', strtoupper(trim($rrule))) as $chunk) {
[$k, $v] = array_pad(explode('=', $chunk, 2), 2, '');
if ($k !== '') {
$parts[$k] = $v;
}
}
$repeatType = match ($parts['FREQ'] ?? '') {
'DAILY' => 'daily',
'WEEKLY' => 'weekly',
'MONTHLY' => 'monthly',
'YEARLY' => 'yearly',
default => 'none',
};
$payload = [
'repeat_type' => $repeatType,
'repeat_interval' => max(1, (int) ($parts['INTERVAL'] ?? 1)),
'repeat_nth_mode' => '',
'repeat_nth_day' => null,
'repeat_nth_pos' => null,
'repeat_nth_weekday' => null,
'repeat_range_mode' => 'none',
'repeat_count' => null,
'repeat_until' => null,
];
if ($repeatType === 'monthly') {
if (!empty($parts['BYMONTHDAY'])) {
$raw = trim(explode(',', (string) $parts['BYMONTHDAY'])[0]);
if (preg_match('/^-?\d+$/', $raw)) {
$payload['repeat_nth_mode'] = 'day_of_month';
$payload['repeat_nth_day'] = max(1, min(31, (int) $raw));
}
} elseif (!empty($parts['BYDAY'])) {
$byDayRaw = trim(explode(',', (string) $parts['BYDAY'])[0]);
$pos = null;
$token = $byDayRaw;
if (preg_match('/^(-?\d+)([A-Z]{2})$/', $byDayRaw, $m)) {
$pos = (int) $m[1];
$token = $m[2];
}
$weekday = $this->weekdayTokenToNum($token);
if ($weekday !== null) {
$payload['repeat_nth_mode'] = 'weekday_of_month';
$payload['repeat_nth_weekday'] = $weekday;
if (isset($parts['BYSETPOS']) && preg_match('/^-?\d+$/', (string) $parts['BYSETPOS'])) {
$pos = (int) $parts['BYSETPOS'];
}
$payload['repeat_nth_pos'] = $pos === null ? 1 : ($pos < 0 ? -1 : max(1, min(5, $pos)));
}
}
}
if (isset($parts['COUNT'])) {
$payload['repeat_range_mode'] = 'count';
$payload['repeat_count'] = max(1, (int) $parts['COUNT']);
} elseif (isset($parts['UNTIL'])) {
$until = $this->parseIcsDateTime($parts['UNTIL'], false);
if ($until !== null) {
$payload['repeat_range_mode'] = 'until';
$payload['repeat_until'] = substr($until, 0, 10);
}
}
return $payload;
}
private function extractVeventProperties(string $ics): ?array
{
$lines = preg_split('/\r\n|\n|\r/', $ics) ?: [];
$unfolded = [];
foreach ($lines as $line) {
if ($line === '') {
continue;
}
if (($line[0] ?? '') === ' ' && $unfolded) {
$unfolded[count($unfolded) - 1] .= substr($line, 1);
continue;
}
$unfolded[] = $line;
}
$in = false;
$props = [];
foreach ($unfolded as $line) {
$upper = strtoupper($line);
if ($upper === 'BEGIN:VEVENT') {
$in = true;
continue;
}
if ($upper === 'END:VEVENT') {
break;
}
if (!$in) {
continue;
}
[$left, $value] = array_pad(explode(':', $line, 2), 2, '');
if ($left === '') {
continue;
}
[$name, $params] = array_pad(explode(';', $left, 2), 2, '');
$name = strtoupper(trim($name));
if ($name === '') {
continue;
}
$props[$name][] = $this->unescapeText(trim($value));
if ($name === 'DTSTART') {
$props['_DTSTART_PARAMS'][] = strtoupper(trim($params));
}
}
return $in ? $props : null;
}
private function parseIcsDateTime(string $value, bool $dateOnly): ?string
{
$value = trim($value);
if ($value === '') {
return null;
}
try {
if ($dateOnly && preg_match('/^\d{8}$/', $value)) {
$dt = DateTimeImmutable::createFromFormat('Ymd H:i:s', $value . ' 00:00:00', new DateTimeZone('Europe/London'));
if ($dt instanceof DateTimeImmutable) {
return $dt->format('Y-m-d\\T00:00:00P');
}
}
if (preg_match('/^\d{8}T\d{6}Z$/', $value)) {
$dt = DateTimeImmutable::createFromFormat('Ymd\\THis\\Z', $value, new DateTimeZone('UTC'));
if ($dt instanceof DateTimeImmutable) {
return $dt->setTimezone(new DateTimeZone('Europe/London'))->format('c');
}
}
if (preg_match('/^\d{8}T\d{6}$/', $value)) {
$dt = DateTimeImmutable::createFromFormat('Ymd\\THis', $value, new DateTimeZone('Europe/London'));
if ($dt instanceof DateTimeImmutable) {
return $dt->format('c');
}
}
$dt = new DateTimeImmutable($value, new DateTimeZone('Europe/London'));
return $dt->format('c');
} catch (\Throwable) {
return null;
}
}
private function toDateTime(string $value): ?DateTimeImmutable
{
if ($value === '') {
return null;
}
try {
return new DateTimeImmutable($value, new DateTimeZone('Europe/London'));
} catch (\Throwable) {
return null;
}
}
private function toUtcIcs(DateTimeImmutable $dt): string
{
return $dt->setTimezone(new DateTimeZone('UTC'))->format('Ymd\\THis\\Z');
}
private function escapeText(string $value): string
{
return str_replace(
["\\", ";", ",", "\r\n", "\n", "\r"],
["\\\\", "\\;", "\\,", "\\n", "\\n", "\\n"],
$value
);
}
private function unescapeText(string $value): string
{
return str_replace(
["\\n", "\\N", "\\,", "\\;", "\\\\"],
["\n", "\n", ",", ";", "\\"],
$value
);
}
private function weekdayNumToToken(int $weekday): ?string
{
return match ($weekday) {
0 => 'SU',
1 => 'MO',
2 => 'TU',
3 => 'WE',
4 => 'TH',
5 => 'FR',
6 => 'SA',
default => null,
};
}
private function weekdayTokenToNum(string $token): ?int
{
return match (strtoupper(trim($token))) {
'SU' => 0,
'MO' => 1,
'TU' => 2,
'WE' => 3,
'TH' => 4,
'FR' => 5,
'SA' => 6,
default => null,
};
}
private function foldLines(array $lines): array
{
$out = [];
foreach ($lines as $line) {
if ($line === '') {
$out[] = $line;
continue;
}
while (strlen($line) > 73) {
$out[] = substr($line, 0, 73);
$line = ' ' . substr($line, 73);
}
$out[] = $line;
}
return $out;
}
}

View File

@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
namespace CalendarPlugin\Domain;
use DateInterval;
use DateTimeImmutable;
use DateTimeZone;
final class RecurrenceExpander
{
private const MAX_ITERATIONS = 512;
public static function expand(array $event, DateTimeImmutable $windowStart, DateTimeImmutable $windowEnd, array $deletedKeys = []): array
{
$tz = new DateTimeZone('Europe/London');
$start = self::parseDateTime((string) ($event['start_datetime'] ?? ''), $tz);
$end = self::parseDateTime((string) ($event['end_datetime'] ?? ''), $tz);
if (!$start || !$end || $end < $start) {
return [];
}
$deletedMap = [];
foreach ($deletedKeys as $key) {
$deletedMap[(string) $key] = true;
}
$duration = $start->diff($end);
$repeatType = (string) ($event['repeat_type'] ?? 'none');
$interval = max(1, (int) ($event['repeat_interval'] ?? 1));
$rangeMode = (string) ($event['repeat_range_mode'] ?? 'none');
$repeatCount = isset($event['repeat_count']) ? (int) $event['repeat_count'] : null;
$repeatUntil = null;
if ($rangeMode === 'until' && !empty($event['repeat_until'])) {
$repeatUntil = self::parseDateTime((string) $event['repeat_until'] . 'T23:59:59', $tz);
}
if ($repeatType === 'none') {
if (self::overlaps($start, $end, $windowStart, $windowEnd)) {
return [self::occurrence($event, $start, $end)];
}
return [];
}
$occurrences = [];
$current = $start;
$produced = 0;
for ($i = 0; $i < self::MAX_ITERATIONS; $i++) {
if ($rangeMode === 'count' && $repeatCount !== null && $produced >= $repeatCount) {
break;
}
if ($repeatUntil && $current > $repeatUntil) {
break;
}
$currentEnd = $current->add($duration);
if (self::overlaps($current, $currentEnd, $windowStart, $windowEnd)) {
$key = $current->format('c');
if (!isset($deletedMap[$key])) {
$occurrences[] = self::occurrence($event, $current, $currentEnd);
}
}
if ($current > $windowEnd->modify('+400 days')) {
break;
}
$produced++;
$current = self::nextStart($current, $repeatType, $interval, $event);
if (!$current) {
break;
}
}
return $occurrences;
}
private static function nextStart(DateTimeImmutable $current, string $repeatType, int $interval, array $event): ?DateTimeImmutable
{
return match ($repeatType) {
'daily' => $current->add(new DateInterval('P' . $interval . 'D')),
'weekly', 'custom' => $current->add(new DateInterval('P' . $interval . 'W')),
'monthly' => self::nextMonthlyStart($current, $interval, $event),
'yearly' => $current->modify('+' . $interval . ' year') ?: null,
default => null,
};
}
private static function nextMonthlyStart(DateTimeImmutable $current, int $interval, array $event): ?DateTimeImmutable
{
$mode = (string) ($event['repeat_nth_mode'] ?? '');
$next = $current->modify('+' . $interval . ' month');
if (!$next) {
return null;
}
if ($mode === 'day_of_month' && isset($event['repeat_nth_day']) && $event['repeat_nth_day'] !== null) {
$day = max(1, (int) $event['repeat_nth_day']);
$daysInMonth = (int) $next->format('t');
return $next->setDate((int) $next->format('Y'), (int) $next->format('n'), min($day, $daysInMonth));
}
if ($mode === 'weekday_of_month' && isset($event['repeat_nth_pos'], $event['repeat_nth_weekday']) && $event['repeat_nth_pos'] !== null && $event['repeat_nth_weekday'] !== null) {
$year = (int) $current->format('Y');
$month = (int) $current->format('n');
for ($i = 0; $i < 120; $i++) {
[$year, $month] = self::addMonths($year, $month, max(1, $interval));
$day = self::nthWeekdayOfMonth($year, $month, (int) $event['repeat_nth_weekday'], (int) $event['repeat_nth_pos']);
if ($day !== null) {
return $current->setDate($year, $month, $day);
}
}
return null;
}
return $next;
}
private static function nthWeekdayOfMonth(int $year, int $month, int $weekday, int $pos): ?int
{
$weekday = max(0, min(6, $weekday));
$tz = new DateTimeZone('Europe/London');
if ($pos === -1) {
$last = new DateTimeImmutable(sprintf('%04d-%02d-01T00:00:00', $year, $month), $tz);
$last = $last->modify('last day of this month');
for ($day = (int) $last->format('j'); $day >= 1; $day--) {
$dt = $last->setDate($year, $month, $day);
if ((int) $dt->format('w') === $weekday) {
return $day;
}
}
return null;
}
$first = new DateTimeImmutable(sprintf('%04d-%02d-01T00:00:00', $year, $month), $tz);
$daysInMonth = (int) $first->format('t');
$seen = 0;
for ($day = 1; $day <= $daysInMonth; $day++) {
$dt = $first->setDate($year, $month, $day);
if ((int) $dt->format('w') !== $weekday) {
continue;
}
$seen++;
if ($seen === $pos) {
return $day;
}
}
return null;
}
private static function occurrence(array $event, DateTimeImmutable $start, DateTimeImmutable $end): array
{
return [
'event_id' => (int) ($event['id'] ?? 0),
'uid' => (string) ($event['uid'] ?? ''),
'title' => (string) ($event['title'] ?? ''),
'description' => (string) ($event['description'] ?? ''),
'location' => (string) ($event['location'] ?? ''),
'category' => (string) ($event['category'] ?? ''),
'all_day_event' => (bool) ($event['all_day_event'] ?? false),
'occurrence_start' => $start->format('c'),
'occurrence_end' => $end->format('c'),
'repeat_type' => (string) ($event['repeat_type'] ?? 'none'),
];
}
private static function overlaps(DateTimeImmutable $start, DateTimeImmutable $end, DateTimeImmutable $windowStart, DateTimeImmutable $windowEnd): bool
{
return $start < $windowEnd && $end > $windowStart;
}
private static function parseDateTime(string $value, DateTimeZone $tz): ?DateTimeImmutable
{
if ($value === '') {
return null;
}
if (!str_contains($value, 'T') && preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
return new DateTimeImmutable($value . 'T00:00:00', $tz);
}
try {
return new DateTimeImmutable($value, $tz);
} catch (\Throwable) {
return null;
}
}
private static function addMonths(int $year, int $month, int $delta): array
{
$index = ($year * 12) + ($month - 1) + $delta;
$newYear = (int) floor($index / 12);
$newMonth = ($index % 12) + 1;
if ($newMonth <= 0) {
$newMonth += 12;
$newYear -= 1;
}
return [$newYear, $newMonth];
}
}

View File

@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace CalendarPlugin\Domain;
use CalendarPlugin\Contracts\OptionsAdapterInterface;
final class SettingsService
{
private const TRUE_VALUES = ['1', 'true', 'yes', 'on'];
public const DEFAULTS = [
'caldav_calendar_name' => 'Public Calendar',
'url_slug' => '',
'verification_page_path' => '/calendar',
'ics_access_mode' => 'public_read',
'diagnostics_enabled' => '1',
'uninstall_cleanup_mode' => 'keep',
];
private const OPTION_PREFIX = 'calendar_plugin_';
public function __construct(private readonly OptionsAdapterInterface $options)
{
}
public function getAll(): array
{
$out = [];
foreach (self::DEFAULTS as $key => $default) {
$out[$key] = $this->get($key, $default);
}
return $out;
}
public function get(string $key, mixed $default = null): mixed
{
$fallback = $default ?? (self::DEFAULTS[$key] ?? null);
return $this->options->get(self::OPTION_PREFIX . $key, $fallback);
}
public function update(array $payload): array
{
$allowed = array_keys(self::DEFAULTS);
$updated = $this->getAll();
foreach ($allowed as $key) {
if (!array_key_exists($key, $payload)) {
continue;
}
$value = $this->sanitize($key, $payload[$key]);
$this->options->set(self::OPTION_PREFIX . $key, $value);
$updated[$key] = $value;
}
return $updated;
}
private function sanitize(string $key, mixed $value): mixed
{
return match ($key) {
'caldav_calendar_name' => trim((string) $value) ?: self::DEFAULTS[$key],
'url_slug' => trim((string) $value, " \t\n\r\0\x0B/"),
'verification_page_path' => $this->normalizePath((string) $value),
'ics_access_mode' => in_array((string) $value, ['public_read', 'authenticated_read'], true)
? (string) $value
: self::DEFAULTS['ics_access_mode'],
'diagnostics_enabled' => $this->isTruthy($value) ? '1' : '0',
'uninstall_cleanup_mode' => in_array((string) $value, ['keep', 'remove'], true) ? (string) $value : 'keep',
default => $value,
};
}
private function isTruthy(mixed $value): bool
{
return in_array(strtolower(trim((string) $value)), self::TRUE_VALUES, true);
}
private function normalizePath(string $value): string
{
$path = trim($value);
if ($path === '') {
return self::DEFAULTS['verification_page_path'];
}
if (!str_starts_with($path, '/')) {
$path = '/' . $path;
}
return '/' . trim($path, '/');
}
}

View File

@ -0,0 +1,473 @@
<?php
declare(strict_types=1);
namespace CalendarPlugin\Domain;
use CalendarPlugin\Contracts\DatabaseAdapterInterface;
use DateTimeImmutable;
use DateTimeZone;
final class UserService
{
private readonly string $usersTable;
private readonly string $tokensTable;
private readonly string $auditTable;
public function __construct(private readonly DatabaseAdapterInterface $db, string $tableStem = 'cs_calendar')
{
$prefix = $db->getPrefix();
$stem = trim($tableStem, '_');
$this->usersTable = $prefix . $stem . '_users';
$this->tokensTable = $prefix . $stem . '_user_tokens';
$this->auditTable = $prefix . $stem . '_audit_log';
}
public function register(string $email, string $password): array
{
$email = $this->normalizeEmail($email);
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
return $this->error('validation_error', 'email is required', 422);
}
if (strlen($password) < 8) {
return $this->error('validation_error', 'password must be at least 8 characters', 422);
}
if ($this->isRateLimited('register:' . $email, 10, 3600)) {
return $this->error('rate_limited', 'too many requests', 429);
}
if ($this->findUserByEmail($email)) {
return $this->error('conflict_error', 'account already exists', 409);
}
$now = gmdate('c');
$inserted = $this->db->insert(
$this->usersTable,
[
'email' => $email,
'password_hash' => password_hash($password, PASSWORD_DEFAULT),
'email_verified_at' => null,
'account_status' => 'pending_approval',
'created_at' => $now,
'updated_at' => $now,
]
);
if ($inserted === false) {
return $this->error('internal_error', 'unable to create account', 500);
}
$userId = $this->db->insertId();
$token = $this->issueToken($userId, 'email_verify', 24 * 3600);
$this->audit('user.register', (string) $userId, 'success', ['email' => $email]);
return [
'ok' => true,
'user' => $this->publicUser((array) $this->getUserById($userId)),
'verify_token' => $token,
'message' => 'registration submitted',
];
}
public function verifyEmail(string $token): array
{
if ($token === '') {
return $this->error('validation_error', 'token is required', 422);
}
$tok = $this->consumeToken($token, 'email_verify');
if (!$tok) {
return $this->error('validation_error', 'invalid or expired token', 422);
}
$user = $this->getUserById((int) $tok['user_id']);
if (!$user) {
return $this->error('not_found', 'user not found', 404);
}
$now = gmdate('c');
$this->db->update(
$this->usersTable,
['email_verified_at' => $now, 'updated_at' => $now],
['id' => (int) $user['id']]
);
$updated = $this->getUserById((int) $user['id']);
$this->audit('user.verify_email', (string) $user['id'], 'success', []);
return [
'ok' => true,
'user' => $updated ? $this->publicUser($updated) : $this->publicUser($user),
'message' => 'An admin will review your request and notify you if approved.',
];
}
public function login(string $email, string $password): array
{
$email = $this->normalizeEmail($email);
if ($this->isRateLimited('login:' . $email, 20, 3600)) {
return $this->error('rate_limited', 'too many requests', 429);
}
$user = $this->findUserByEmail($email);
if (!$user || !password_verify($password, (string) ($user['password_hash'] ?? ''))) {
return $this->error('auth_required', 'Login failure', 401);
}
if (empty($user['email_verified_at'])) {
return $this->error('auth_required', 'Login failure', 401);
}
if ((string) ($user['account_status'] ?? '') !== 'active') {
return $this->error('auth_required', 'Login failure', 401);
}
$this->audit('user.login', (string) $user['id'], 'success', []);
return [
'ok' => true,
'user' => $this->publicUser($user),
];
}
public function authenticateActiveUserCredentials(string $email, string $password): ?array
{
$email = $this->normalizeEmail($email);
if ($email === '' || $password === '') {
return null;
}
$user = $this->findUserByEmail($email);
if (!$user) {
return null;
}
if (!password_verify($password, (string) ($user['password_hash'] ?? ''))) {
return null;
}
if (empty($user['email_verified_at'])) {
return null;
}
if ((string) ($user['account_status'] ?? '') !== 'active') {
return null;
}
return $this->publicUser($user);
}
public function issueSessionToken(int $userId, int $ttlSeconds = 2592000): string
{
if ($userId <= 0) {
return '';
}
return $this->issueToken($userId, 'session', max(300, $ttlSeconds));
}
public function authenticateSessionToken(string $token): ?array
{
$row = $this->findValidToken($token, 'session');
if ($row === null) {
return null;
}
$user = $this->getUserById((int) ($row['user_id'] ?? 0));
if (!$user) {
return null;
}
if (empty($user['email_verified_at'])) {
return null;
}
if ((string) ($user['account_status'] ?? '') !== 'active') {
return null;
}
return $this->publicUser($user);
}
public function revokeSessionToken(string $token): void
{
$row = $this->findValidToken($token, 'session');
if ($row === null) {
return;
}
$this->db->update(
$this->tokensTable,
['used_at' => gmdate('c')],
['id' => (int) $row['id']]
);
}
public function requestPasswordReset(string $email): array
{
$email = $this->normalizeEmail($email);
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
return $this->error('validation_error', 'email is required', 422);
}
if ($this->isRateLimited('reset:' . $email, 10, 3600)) {
return $this->error('rate_limited', 'too many requests', 429);
}
$user = $this->findUserByEmail($email);
if (!$user) {
return ['ok' => true, 'message' => 'if account exists, reset email will be sent'];
}
$token = $this->issueToken((int) $user['id'], 'password_reset', 30 * 60);
$this->audit('user.password_reset.request', (string) $user['id'], 'success', []);
return [
'ok' => true,
'reset_token' => $token,
'message' => 'password reset requested',
];
}
public function resetPassword(string $token, string $newPassword): array
{
if (strlen($newPassword) < 8) {
return $this->error('validation_error', 'password must be at least 8 characters', 422);
}
$tok = $this->consumeToken($token, 'password_reset');
if (!$tok) {
return $this->error('validation_error', 'invalid or expired token', 422);
}
$user = $this->getUserById((int) $tok['user_id']);
if (!$user) {
return $this->error('not_found', 'user not found', 404);
}
$this->db->update(
$this->usersTable,
['password_hash' => password_hash($newPassword, PASSWORD_DEFAULT), 'updated_at' => gmdate('c')],
['id' => (int) $user['id']]
);
// Invalidate persistent web sessions after password reset.
$this->db->delete($this->tokensTable, ['user_id' => (int) $user['id'], 'token_type' => 'session']);
$this->audit('user.password_reset.complete', (string) $user['id'], 'success', []);
return ['ok' => true, 'message' => 'password updated'];
}
public function listUsers(): array
{
$rows = $this->db->getResults("SELECT * FROM {$this->usersTable} ORDER BY id ASC");
return array_map(fn(object $r): array => $this->publicUser((array) $r), $rows);
}
public function approveUser(int $id): ?array
{
$user = $this->getUserById($id);
if (!$user) {
return null;
}
if (empty($user['email_verified_at'])) {
return null;
}
$this->db->update(
$this->usersTable,
['account_status' => 'active', 'updated_at' => gmdate('c')],
['id' => $id]
);
$updated = $this->getUserById($id);
$this->audit('user.approve', (string) $id, 'success', []);
return $updated ? $this->publicUser($updated) : null;
}
public function removeUser(int $id): bool
{
$deleted = $this->db->delete($this->usersTable, ['id' => $id]);
$this->db->delete($this->tokensTable, ['user_id' => $id]);
if ($deleted !== false) {
$this->audit('user.remove', (string) $id, 'success', []);
return true;
}
return false;
}
private function findUserByEmail(string $email): ?array
{
foreach ($this->listRawUsers() as $user) {
if (strtolower((string) ($user['email'] ?? '')) === strtolower($email)) {
return $user;
}
}
return null;
}
private function getUserById(int $id): ?array
{
$sql = $this->db->prepare("SELECT * FROM {$this->usersTable} WHERE id = %d", $id);
$row = $this->db->getRow($sql);
return $row ? (array) $row : null;
}
private function listRawUsers(): array
{
$rows = $this->db->getResults("SELECT * FROM {$this->usersTable} ORDER BY id ASC");
return array_map(static fn(object $r): array => (array) $r, $rows);
}
private function issueToken(int $userId, string $type, int $ttlSeconds): string
{
$token = bin2hex(random_bytes(16));
$hash = hash('sha256', $token);
$expiresAt = (new DateTimeImmutable('now', new DateTimeZone('UTC')))->modify('+' . $ttlSeconds . ' seconds')->format('c');
$this->db->insert(
$this->tokensTable,
[
'user_id' => $userId,
'token_type' => $type,
'token_hash' => $hash,
'expires_at' => $expiresAt,
'used_at' => null,
'created_at' => gmdate('c'),
]
);
return $token;
}
private function consumeToken(string $token, string $type): ?array
{
$row = $this->findValidToken($token, $type);
if ($row === null) {
return null;
}
$this->db->update(
$this->tokensTable,
['used_at' => gmdate('c')],
['id' => (int) $row['id']]
);
return $row;
}
private function findValidToken(string $token, string $type): ?array
{
$raw = trim($token);
if ($raw === '') {
return null;
}
$hash = hash('sha256', $raw);
$now = new DateTimeImmutable('now', new DateTimeZone('UTC'));
$rows = $this->db->getResults("SELECT * FROM {$this->tokensTable} ORDER BY id ASC");
foreach ($rows as $rowObj) {
$row = (array) $rowObj;
if ((string) ($row['token_type'] ?? '') !== $type) {
continue;
}
if ((string) ($row['token_hash'] ?? '') !== $hash) {
continue;
}
if (!empty($row['used_at'])) {
return null;
}
try {
$expires = new DateTimeImmutable((string) $row['expires_at'], new DateTimeZone('UTC'));
} catch (\Throwable) {
return null;
}
if ($expires < $now) {
return null;
}
return $row;
}
return null;
}
private function publicUser(array $user): array
{
return [
'id' => (int) ($user['id'] ?? 0),
'email' => (string) ($user['email'] ?? ''),
'email_verified_at' => $user['email_verified_at'] ?? null,
'account_status' => (string) ($user['account_status'] ?? 'pending_approval'),
'created_at' => (string) ($user['created_at'] ?? ''),
'updated_at' => (string) ($user['updated_at'] ?? ''),
];
}
private function error(string $code, string $message, int $status): array
{
return ['error' => ['code' => $code, 'message' => $message, 'status' => $status]];
}
private function normalizeEmail(string $email): string
{
return strtolower(trim($email));
}
private function audit(string $action, string $target, string $result, array $context): void
{
if (!$this->isDiagnosticsEnabled()) {
return;
}
$this->db->insert(
$this->auditTable,
[
'actor' => 'plugin',
'action' => $action,
'target' => $target,
'result' => $result,
'created_at' => gmdate('c'),
'context_json' => json_encode($context),
]
);
}
private function isDiagnosticsEnabled(): bool
{
$optionsTable = $this->db->getPrefix() . 'options';
$sql = $this->db->prepare(
"SELECT option_value FROM {$optionsTable} WHERE option_name = %s LIMIT 1",
'calendar_plugin_diagnostics_enabled'
);
$row = $this->db->getRow($sql);
if (!$row || !property_exists($row, 'option_value')) {
return true;
}
return in_array(strtolower(trim((string) $row->option_value)), ['1', 'true', 'yes', 'on'], true);
}
private function isRateLimited(string $bucket, int $limit, int $windowSeconds): bool
{
$now = new DateTimeImmutable('now', new DateTimeZone('UTC'));
$type = 'rate:' . substr(hash('sha256', $bucket), 0, 32);
$sql = $this->db->prepare("SELECT * FROM {$this->tokensTable} WHERE token_type = %s ORDER BY id ASC", $type);
$rows = $this->db->getResults($sql);
$activeCount = 0;
foreach ($rows as $rowObj) {
$row = (array) $rowObj;
$id = (int) ($row['id'] ?? 0);
$usedAt = (string) ($row['used_at'] ?? '');
$expiresAtRaw = (string) ($row['expires_at'] ?? '');
$expired = true;
try {
$expiresAt = new DateTimeImmutable($expiresAtRaw, new DateTimeZone('UTC'));
$expired = $expiresAt < $now;
} catch (\Throwable) {
$expired = true;
}
if ($id > 0 && ($usedAt !== '' || $expired)) {
$this->db->delete($this->tokensTable, ['id' => $id]);
continue;
}
if (!$expired && $usedAt === '') {
$activeCount++;
}
}
if ($activeCount >= $limit) {
return true;
}
$this->db->insert(
$this->tokensTable,
[
'user_id' => 0,
'token_type' => $type,
'token_hash' => hash('sha256', bin2hex(random_bytes(16))),
'expires_at' => $now->modify('+' . max(1, $windowSeconds) . ' seconds')->format('c'),
'used_at' => null,
'created_at' => gmdate('c'),
]
);
return false;
}
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace CalendarPlugin\Infrastructure;
final class ServiceContainer
{
/** @var array<string, object> */
private array $services = [];
public function set(string $id, object $service): void
{
$this->services[$id] = $service;
}
public function get(string $id): object
{
if (!isset($this->services[$id])) {
throw new \RuntimeException(sprintf('Service not found: %s', $id));
}
return $this->services[$id];
}
}

View File

@ -0,0 +1,222 @@
<?php
declare(strict_types=1);
namespace CalendarPlugin\Infrastructure\WordPress;
use CalendarPlugin\Contracts\DatabaseAdapterInterface;
use DateTimeImmutable;
use DateTimeZone;
final class MigrationManager
{
private const SCHEMA_VERSION = '3';
private const STEM_OPTION = 'calendar_plugin_table_stem';
public function __construct(private readonly DatabaseAdapterInterface $db, private readonly string $tableStem = 'cs_calendar')
{
}
public function migrate(): void
{
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
global $wpdb;
$charsetCollate = $wpdb->get_charset_collate();
$prefix = $this->db->getPrefix();
$stem = trim($this->tableStem, '_');
$events = $prefix . $stem . '_events';
$exceptions = $prefix . $stem . '_recurrence_exceptions';
$users = $prefix . $stem . '_users';
$tokens = $prefix . $stem . '_user_tokens';
$audit = $prefix . $stem . '_audit_log';
$sqlEvents = "CREATE TABLE {$events} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
uid VARCHAR(191) NOT NULL,
title TEXT NOT NULL,
description LONGTEXT NOT NULL,
location TEXT NOT NULL,
category TEXT NOT NULL,
all_day_event TINYINT(1) NOT NULL DEFAULT 0,
start_datetime VARCHAR(64) NOT NULL,
end_datetime VARCHAR(64) NOT NULL,
repeat_type VARCHAR(24) NOT NULL DEFAULT 'none',
repeat_interval INT NOT NULL DEFAULT 1,
repeat_nth_mode VARCHAR(32) NOT NULL DEFAULT '',
repeat_nth_day INT NULL,
repeat_nth_pos INT NULL,
repeat_nth_weekday INT NULL,
repeat_range_mode VARCHAR(24) NOT NULL DEFAULT 'none',
repeat_count INT NULL,
repeat_until VARCHAR(16) NULL,
timezone VARCHAR(64) NOT NULL DEFAULT 'Europe/London',
caldav_resource VARCHAR(191) NULL,
etag VARCHAR(64) NULL,
sync_version INT NOT NULL DEFAULT 1,
last_modified_by_user_id BIGINT UNSIGNED NULL,
created_at VARCHAR(32) NOT NULL,
updated_at VARCHAR(32) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uid (uid(191)),
UNIQUE KEY caldav_resource (caldav_resource),
KEY start_datetime (start_datetime(32)),
KEY end_datetime (end_datetime(32))
) {$charsetCollate};";
$sqlExceptions = "CREATE TABLE {$exceptions} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
event_id BIGINT UNSIGNED NOT NULL,
occurrence_key VARCHAR(64) NOT NULL,
exception_type VARCHAR(32) NOT NULL,
created_at VARCHAR(32) NOT NULL,
updated_at VARCHAR(32) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY event_occurrence (event_id, occurrence_key),
KEY event_id (event_id)
) {$charsetCollate};";
$sqlUsers = "CREATE TABLE {$users} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
email VARCHAR(191) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
email_verified_at VARCHAR(32) NULL,
account_status VARCHAR(32) NOT NULL DEFAULT 'pending_approval',
created_at VARCHAR(32) NOT NULL,
updated_at VARCHAR(32) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY email (email)
) {$charsetCollate};";
$sqlTokens = "CREATE TABLE {$tokens} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
token_type VARCHAR(32) NOT NULL,
token_hash VARCHAR(255) NOT NULL,
expires_at VARCHAR(32) NOT NULL,
used_at VARCHAR(32) NULL,
created_at VARCHAR(32) NOT NULL,
PRIMARY KEY (id),
KEY user_id (user_id),
KEY token_type (token_type)
) {$charsetCollate};";
$sqlAudit = "CREATE TABLE {$audit} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
actor VARCHAR(191) NOT NULL,
action VARCHAR(191) NOT NULL,
target VARCHAR(191) NOT NULL,
result VARCHAR(32) NOT NULL,
created_at VARCHAR(32) NOT NULL,
context_json LONGTEXT NULL,
PRIMARY KEY (id),
KEY action (action),
KEY created_at (created_at)
) {$charsetCollate};";
dbDelta($sqlEvents);
dbDelta($sqlExceptions);
dbDelta($sqlUsers);
dbDelta($sqlTokens);
dbDelta($sqlAudit);
// Ensure every event has a stable CalDAV object resource name.
$this->db->query(
"UPDATE {$events}
SET caldav_resource = CONCAT(uid, '.ics')
WHERE (caldav_resource IS NULL OR caldav_resource = '')
AND uid IS NOT NULL
AND uid <> ''"
);
$this->normalizeEventDateTimesToLondon($events);
update_option(self::STEM_OPTION, $stem);
update_option('calendar_plugin_schema_version', self::SCHEMA_VERSION);
}
public function assertActivationSafe(): void
{
$stem = trim($this->tableStem, '_');
$ownedStem = trim((string) get_option(self::STEM_OPTION, ''), '_');
if ($ownedStem !== '' && $ownedStem !== $stem) {
throw new \RuntimeException(
sprintf(
'Calendar Plugin is already initialized with table stem "%s". Requested stem "%s" is different.',
$ownedStem,
$stem
)
);
}
if ($ownedStem !== $stem && $this->anyTargetTablesExist($stem)) {
$legacySchemaVersion = trim((string) get_option('calendar_plugin_schema_version', ''));
if ($legacySchemaVersion === '') {
throw new \RuntimeException(
sprintf(
'Calendar Plugin activation blocked: target tables for stem "%s" already exist. Choose another table stem via CALENDAR_PLUGIN_TABLE_STEM.',
$stem
)
);
}
}
}
private function anyTargetTablesExist(string $stem): bool
{
$prefix = $this->db->getPrefix();
$tables = [
$prefix . $stem . '_events',
$prefix . $stem . '_recurrence_exceptions',
$prefix . $stem . '_users',
$prefix . $stem . '_user_tokens',
$prefix . $stem . '_audit_log',
];
foreach ($tables as $table) {
$sql = $this->db->prepare('SHOW TABLES LIKE %s', $table);
if (count($this->db->getResults($sql)) > 0) {
return true;
}
}
return false;
}
private function normalizeEventDateTimesToLondon(string $eventsTable): void
{
$rows = $this->db->getResults(
"SELECT id, start_datetime, end_datetime FROM {$eventsTable}"
);
$tz = new DateTimeZone('Europe/London');
foreach ($rows as $row) {
$id = (int) ($row->id ?? 0);
if ($id <= 0) {
continue;
}
$start = $this->normalizeDateTimeString((string) ($row->start_datetime ?? ''), $tz);
$end = $this->normalizeDateTimeString((string) ($row->end_datetime ?? ''), $tz);
if ($start === null || $end === null) {
continue;
}
$sql = $this->db->prepare(
"UPDATE {$eventsTable} SET start_datetime = %s, end_datetime = %s WHERE id = %d",
$start,
$end,
$id
);
$this->db->query($sql);
}
}
private function normalizeDateTimeString(string $value, DateTimeZone $tz): ?string
{
$value = trim($value);
if ($value === '') {
return null;
}
try {
$dt = new DateTimeImmutable($value, $tz);
return $dt->setTimezone($tz)->format('c');
} catch (\Throwable) {
return null;
}
}
}

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace CalendarPlugin\Infrastructure\WordPress;
use CalendarPlugin\Contracts\AuthAdapterInterface;
final class WordPressAuthAdapter implements AuthAdapterInterface
{
public function currentUserId(): int
{
return (int) get_current_user_id();
}
public function currentUserCan(string $capability): bool
{
return current_user_can($capability);
}
public function verifyNonce(string $nonce, string $action): bool
{
return wp_verify_nonce($nonce, $action) !== false;
}
public function currentUserEmail(): string
{
$user = wp_get_current_user();
return is_object($user) ? (string) ($user->user_email ?? '') : '';
}
}

View File

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace CalendarPlugin\Infrastructure\WordPress;
use CalendarPlugin\Contracts\DatabaseAdapterInterface;
final class WordPressDatabaseAdapter implements DatabaseAdapterInterface
{
public function __construct(private readonly object $wpdb)
{
}
public function getPrefix(): string
{
return (string) $this->wpdb->prefix;
}
public function prepare(string $query, mixed ...$args): string
{
return (string) $this->wpdb->prepare($query, ...$args);
}
public function query(string $query): int|false
{
return $this->wpdb->query($query);
}
public function getResults(string $query): array
{
return $this->wpdb->get_results($query) ?: [];
}
public function getRow(string $query): ?object
{
$row = $this->wpdb->get_row($query);
return is_object($row) ? $row : null;
}
public function insert(string $table, array $data, array $formats = []): int|false
{
return $this->wpdb->insert($table, $data, $formats);
}
public function update(string $table, array $data, array $where, array $formats = [], array $whereFormats = []): int|false
{
return $this->wpdb->update($table, $data, $where, $formats, $whereFormats);
}
public function delete(string $table, array $where, array $whereFormats = []): int|false
{
return $this->wpdb->delete($table, $where, $whereFormats);
}
public function insertId(): int
{
return (int) $this->wpdb->insert_id;
}
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace CalendarPlugin\Infrastructure\WordPress;
use CalendarPlugin\Contracts\HttpAdapterInterface;
final class WordPressHttpAdapter implements HttpAdapterInterface
{
public function addAction(string $hook, callable $callback, int $priority = 10, int $acceptedArgs = 1): void
{
add_action($hook, $callback, $priority, $acceptedArgs);
}
public function addShortcode(string $tag, callable $callback): void
{
add_shortcode($tag, $callback);
}
public function registerRestRoute(string $namespace, string $route, array $args): void
{
register_rest_route($namespace, $route, $args);
}
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace CalendarPlugin\Infrastructure\WordPress;
use CalendarPlugin\Contracts\OptionsAdapterInterface;
final class WordPressOptionsAdapter implements OptionsAdapterInterface
{
public function get(string $key, mixed $default = false): mixed
{
return get_option($key, $default);
}
public function set(string $key, mixed $value, bool $autoload = true): bool
{
return update_option($key, $value, $autoload);
}
public function delete(string $key): bool
{
return delete_option($key);
}
}

2744
code/src/Plugin.php Normal file

File diff suppressed because it is too large Load Diff

18
code/src/bootstrap.php Normal file
View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
spl_autoload_register(
static function (string $class): void {
$prefix = 'CalendarPlugin\\';
if (strncmp($class, $prefix, strlen($prefix)) !== 0) {
return;
}
$relative = substr($class, strlen($prefix));
$path = __DIR__ . '/' . str_replace('\\', '/', $relative) . '.php';
if (is_file($path)) {
require_once $path;
}
}
);

51
code/uninstall.php Normal file
View File

@ -0,0 +1,51 @@
<?php
/**
* Calendar Plugin uninstall handler.
*/
declare(strict_types=1);
if (!defined('WP_UNINSTALL_PLUGIN')) {
exit;
}
global $wpdb;
if (!isset($wpdb)) {
return;
}
$cleanupMode = (string) get_option('calendar_plugin_uninstall_cleanup_mode', 'keep');
if ($cleanupMode !== 'remove') {
return;
}
$stem = trim((string) get_option('calendar_plugin_table_stem', 'cs_calendar'), '_');
if ($stem === '') {
$stem = 'cs_calendar';
}
$prefix = (string) $wpdb->prefix;
$tables = [
$prefix . $stem . '_events',
$prefix . $stem . '_recurrence_exceptions',
$prefix . $stem . '_users',
$prefix . $stem . '_user_tokens',
$prefix . $stem . '_audit_log',
];
foreach ($tables as $table) {
$wpdb->query("DROP TABLE IF EXISTS `{$table}`");
}
$options = [
'calendar_plugin_caldav_calendar_name',
'calendar_plugin_url_slug',
'calendar_plugin_ics_access_mode',
'calendar_plugin_diagnostics_enabled',
'calendar_plugin_uninstall_cleanup_mode',
'calendar_plugin_table_stem',
'calendar_plugin_schema_version',
];
foreach ($options as $optionName) {
delete_option($optionName);
}

View File

@ -0,0 +1,26 @@
# Compatibility Layer
This directory is the canonical local WordPress emulation entrypoint.
Purpose:
- run the stand-alone harness used for local deterministic testing
- keep emulation tooling out of deployable plugin code (`code/`)
Current implementation delegates to the existing harness implementation under `fixture/`.
Primary commands from repository root:
```bash
./compatibility-layer/reset.sh
./compatibility-layer/seed.sh
./compatibility-layer/run.sh
```
Optional archived local checks (fixture-era):
```bash
./compatibility-layer/smoke.sh
./compatibility-layer/security_smoke.sh
```
These delegate to scripts under `fixture-tests/`. Active development validation now targets the remote server first.

Binary file not shown.

View File

@ -0,0 +1,3 @@
#!/usr/bin/env bash
set -euo pipefail
"$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/../fixture-tests/fixture_caldav_client_compat_smoke.sh" "$@"

View File

@ -0,0 +1,346 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
function assert_true(bool $cond, string $msg): void
{
if (!$cond) {
fwrite(STDERR, "[FAIL] {$msg}\n");
exit(1);
}
}
function request(string $method, string $route, array $json = [], array $headers = []): array|WP_Error
{
$req = new WP_REST_Request($method, $route, $json, $headers);
return rest_do_request($req);
}
function unwrap(array|WP_Error $res, string $context): array
{
if ($res instanceof WP_Error) {
$status = $res->data['status'] ?? 500;
fwrite(STDERR, "[FAIL] {$context}: {$res->code} {$res->message} ({$status})\n");
exit(1);
}
return $res;
}
require __DIR__ . '/wp_emulation.php';
$health = unwrap(request('GET', '/calendar/v1/health'), 'health');
assert_true(($health['status'] ?? '') === 'ok', 'health status should be ok');
$GLOBALS['wp_user'] = [
'id' => 0,
'email' => '',
'caps' => [],
];
$unauthCreate = request('POST', '/calendar/v1/events', [
'title' => 'No Auth Event',
'start_datetime' => '2026-05-01T09:00:00+01:00',
'end_datetime' => '2026-05-01T10:00:00+01:00',
]);
assert_true($unauthCreate instanceof WP_Error, 'unauthenticated event create should be denied');
assert_true(($unauthCreate->data['status'] ?? 0) === 403, 'unauthenticated event create should return 403');
$GLOBALS['wp_user'] = [
'id' => 1,
'email' => 'admin@example.test',
'caps' => ['manage_options', 'edit_posts'],
];
$settingsPatch = unwrap(
request('PATCH', '/calendar/v1/settings', ['caldav_calendar_name' => 'Calendar E2E']),
'patch settings'
);
assert_true(($settingsPatch['data']['caldav_calendar_name'] ?? '') === 'Calendar E2E', 'settings patch must persist');
$settingsGet = unwrap(request('GET', '/calendar/v1/settings'), 'get settings');
assert_true(($settingsGet['data']['caldav_calendar_name'] ?? '') === 'Calendar E2E', 'settings get should reflect patch');
$register = unwrap(
request('POST', '/calendar/v1/users/register', ['email' => 'demo@example.test', 'password' => 'pass12345', 'debug_tokens' => true]),
'register user'
);
$verifyToken = (string) ($register['data']['verify_token'] ?? '');
assert_true($verifyToken !== '', 'register should issue verify token');
$userId = (int) (($register['data']['user']['id'] ?? 0));
assert_true($userId > 0, 'register should create user id');
$loginPending = request('POST', '/calendar/v1/users/login', ['email' => 'demo@example.test', 'password' => 'pass12345']);
assert_true($loginPending instanceof WP_Error, 'pending user login should fail');
assert_true(($loginPending->data['status'] ?? 0) === 401, 'pending user login should return 401');
$verified = unwrap(request('POST', '/calendar/v1/users/verify', ['token' => $verifyToken]), 'verify email');
assert_true(str_contains((string) ($verified['data']['message'] ?? ''), 'admin will review'), 'verify should return approval message');
$approved = unwrap(request('PATCH', '/calendar/v1/admin/users/' . $userId . '/approve', []), 'approve user');
assert_true((string) ($approved['data']['account_status'] ?? '') === 'active', 'approved user status should be active');
$loginActive = unwrap(
request('POST', '/calendar/v1/users/login', ['email' => 'demo@example.test', 'password' => 'pass12345']),
'active user login'
);
assert_true((bool) (($loginActive['data']['ok'] ?? false) === true), 'active user login should succeed');
$resetReq = unwrap(
request('POST', '/calendar/v1/users/password/request', ['email' => 'demo@example.test', 'debug_tokens' => true]),
'request reset'
);
$resetToken = (string) ($resetReq['data']['reset_token'] ?? '');
assert_true($resetToken !== '', 'password reset request should return reset token');
$resetDone = unwrap(
request('POST', '/calendar/v1/users/password/reset', ['token' => $resetToken, 'new_password' => 'newpass123']),
'reset password'
);
assert_true((bool) (($resetDone['data']['ok'] ?? false) === true), 'password reset should succeed');
$loginNewPass = unwrap(
request('POST', '/calendar/v1/users/login', ['email' => 'demo@example.test', 'password' => 'newpass123']),
'login with new password'
);
assert_true((bool) (($loginNewPass['data']['ok'] ?? false) === true), 'login should work after password reset');
$resetReuse = request('POST', '/calendar/v1/users/password/reset', ['token' => $resetToken, 'new_password' => 'anotherpass123']);
assert_true($resetReuse instanceof WP_Error, 'password reset token should be single-use');
assert_true(($resetReuse->data['status'] ?? 0) === 422, 'reused reset token should return 422');
$usersList = unwrap(request('GET', '/calendar/v1/admin/users'), 'list users');
assert_true(count((array) ($usersList['data'] ?? [])) >= 1, 'admin users list should return at least one row');
$removeUser = unwrap(request('DELETE', '/calendar/v1/admin/users/' . $userId), 'remove user');
assert_true((bool) (($removeUser['data']['deleted'] ?? false) === true), 'admin remove user should succeed');
$loginRemoved = request('POST', '/calendar/v1/users/login', ['email' => 'demo@example.test', 'password' => 'newpass123']);
assert_true($loginRemoved instanceof WP_Error, 'removed user login should fail');
assert_true(($loginRemoved->data['status'] ?? 0) === 401, 'removed user login should return 401');
$created = unwrap(
request('POST', '/calendar/v1/events', [
'title' => 'Emulated Recurring Event',
'description' => 'e2e',
'location' => 'Room 1',
'category' => 'Test',
'all_day_event' => false,
'start_datetime' => '2026-04-01T10:00:00+01:00',
'end_datetime' => '2026-04-01T11:00:00+01:00',
'repeat_type' => 'daily',
'repeat_interval' => 1,
'repeat_range_mode' => 'count',
'repeat_count' => 3,
]),
'create event'
);
$eventId = (int) ($created['data']['id'] ?? 0);
assert_true($eventId > 0, 'event id should be generated');
$invalidEvent = request('POST', '/calendar/v1/events', [
'title' => 'Bad Range',
'start_datetime' => '2026-04-12T12:00:00+01:00',
'end_datetime' => '2026-04-12T11:00:00+01:00',
]);
assert_true($invalidEvent instanceof WP_Error, 'invalid event with end before start should fail');
assert_true(($invalidEvent->data['status'] ?? 0) === 422, 'invalid event should return 422');
$public = unwrap(request('GET', '/calendar/v1/public/events', ['view' => 'month', 'date' => '2026-04-01']), 'public events');
$countBefore = (int) ($public['meta']['count'] ?? 0);
assert_true($countBefore >= 3, 'public month view should include recurrence occurrences');
$preview = unwrap(
request('POST', '/calendar/v1/events/preview-occurrences', [
'event' => [
'title' => 'Preview Event',
'start_datetime' => '2026-04-05T08:00:00+01:00',
'end_datetime' => '2026-04-05T09:00:00+01:00',
'repeat_type' => 'weekly',
'repeat_interval' => 1,
'repeat_range_mode' => 'count',
'repeat_count' => 4,
],
'from' => '2026-04-01',
'months' => 1,
]),
'preview occurrences'
);
assert_true((int) ($preview['meta']['count'] ?? 0) >= 1, 'preview occurrences should return generated items');
$previewBad = request('POST', '/calendar/v1/events/preview-occurrences', [
'event' => [
'title' => 'Bad Preview',
'start_datetime' => '2026-04-05T10:00:00+01:00',
'end_datetime' => '2026-04-05T09:00:00+01:00',
'repeat_type' => 'weekly',
],
'from' => '2026-04-01',
'months' => 1,
]);
assert_true($previewBad instanceof WP_Error, 'preview occurrences should validate invalid event range');
assert_true(($previewBad->data['status'] ?? 0) === 422, 'invalid preview payload should return 422');
$deleteOne = unwrap(
request('DELETE', '/calendar/v1/events/' . $eventId . '/occurrences/' . rawurlencode('2026-04-02T10:00:00+01:00')),
'delete occurrence'
);
assert_true(($deleteOne['data']['deleted'] ?? false) === true, 'delete occurrence should succeed');
$deleteSameAgain = unwrap(
request('DELETE', '/calendar/v1/events/' . $eventId . '/occurrences/' . rawurlencode('2026-04-02T10:00:00+01:00')),
'delete same occurrence again'
);
assert_true(($deleteSameAgain['data']['deleted'] ?? false) === true, 'repeat delete occurrence should be idempotent success');
$occurrences = unwrap(
request('GET', '/calendar/v1/events/' . $eventId . '/occurrences', ['from' => '2026-04-01', 'months' => 1]),
'event occurrences'
);
assert_true((int) ($occurrences['meta']['count'] ?? 0) >= 2, 'event occurrences endpoint should return remaining recurrence instances');
$publicAfter = unwrap(request('GET', '/calendar/v1/public/events', ['view' => 'month', 'date' => '2026-04-01']), 'public events after delete');
$days = [];
foreach (($publicAfter['data'] ?? []) as $item) {
$days[] = substr((string) ($item['occurrence_start'] ?? ''), 0, 10);
}
assert_true(!in_array('2026-04-02', $days, true), 'deleted single occurrence should be excluded');
$ics = unwrap(request('GET', '/calendar/v1/public/ics'), 'public ics');
$icsBody = (string) ($ics['data'] ?? '');
assert_true(str_contains($icsBody, 'BEGIN:VCALENDAR'), 'ics response should include vcalendar envelope');
assert_true(str_contains($icsBody, 'RRULE:'), 'ics response should include recurrence rule');
assert_true(str_contains($icsBody, 'EXDATE'), 'ics response should include deleted occurrence as exdate');
$monthlyOrdinal = unwrap(
request('POST', '/calendar/v1/events', [
'title' => 'Monthly Ordinal',
'description' => 'monthly weekday parity',
'location' => 'Room 2',
'category' => 'Monthly',
'all_day_event' => false,
'start_datetime' => '2026-04-05T09:30:00+01:00',
'end_datetime' => '2026-04-05T10:30:00+01:00',
'repeat_type' => 'monthly',
'repeat_interval' => 1,
'repeat_nth_mode' => 'weekday_of_month',
'repeat_nth_pos' => -1,
'repeat_nth_weekday' => 0,
'repeat_range_mode' => 'count',
'repeat_count' => 3,
]),
'create monthly ordinal event'
);
$monthlyEventId = (int) ($monthlyOrdinal['data']['id'] ?? 0);
assert_true($monthlyEventId > 0, 'monthly ordinal event id should be generated');
$monthlyOcc = unwrap(
request('GET', '/calendar/v1/events/' . $monthlyEventId . '/occurrences', ['from' => '2026-04-01', 'months' => 3]),
'list monthly ordinal occurrences'
);
$monthlyDays = array_map(
static fn(array $row): string => substr((string) ($row['occurrence_start'] ?? ''), 0, 10),
(array) ($monthlyOcc['data'] ?? [])
);
assert_true(in_array('2026-04-26', $monthlyDays, true), 'monthly ordinal should include last Sunday of April 2026');
assert_true(in_array('2026-05-31', $monthlyDays, true), 'monthly ordinal should include last Sunday of May 2026');
$resources = unwrap(request('GET', '/calendar/v1/caldav/resources'), 'list caldav resources');
$resourceList = (array) ($resources['data'] ?? []);
assert_true(count($resourceList) >= 1, 'caldav resources should list at least one object');
$firstResource = (string) ($resourceList[0]['resource'] ?? '');
assert_true($firstResource !== '', 'caldav resource should have object name');
$getObject = unwrap(request('GET', '/calendar/v1/caldav/object/' . rawurlencode($firstResource)), 'get caldav object');
$firstEtag = (string) ($getObject['data']['etag'] ?? '');
assert_true($firstEtag !== '', 'caldav object should include etag');
$newResource = 'new-fixture-event.ics';
$newIcs = implode("\r\n", [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Calendar Plugin E2E//EN',
'BEGIN:VEVENT',
'UID:new-fixture-event@calendar-plugin',
'SUMMARY:Fixture PUT Event',
'DESCRIPTION:Created via CalDAV PUT',
'DTSTART;TZID=Europe/London:20260410T093000',
'DTEND;TZID=Europe/London:20260410T103000',
'END:VEVENT',
'END:VCALENDAR',
'',
]);
$createdViaPut = unwrap(
request('PUT', '/calendar/v1/caldav/object/' . $newResource, ['ics' => $newIcs], ['If-None-Match' => '*']),
'caldav put create'
);
assert_true((bool) (($createdViaPut['data']['created'] ?? false) === true), 'caldav put should create object with if-none-match');
$duplicateBlocked = request(
'PUT',
'/calendar/v1/caldav/object/' . $newResource,
['ics' => $newIcs],
['If-None-Match' => '*']
);
assert_true($duplicateBlocked instanceof WP_Error, 'duplicate create should fail on if-none-match precondition');
assert_true(($duplicateBlocked->data['status'] ?? 0) === 412, 'duplicate create should return 412');
$updatedIcs = implode("\r\n", [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Calendar Plugin E2E//EN',
'BEGIN:VEVENT',
'UID:new-fixture-event@calendar-plugin',
'SUMMARY:Fixture PUT Event Updated',
'DESCRIPTION:Updated via CalDAV PUT',
'DTSTART;TZID=Europe/London:20260410T100000',
'DTEND;TZID=Europe/London:20260410T110000',
'END:VEVENT',
'END:VCALENDAR',
'',
]);
$staleUpdate = request(
'PUT',
'/calendar/v1/caldav/object/' . $newResource,
['ics' => $updatedIcs],
['If-Match' => '"stale-etag"']
);
assert_true($staleUpdate instanceof WP_Error, 'stale etag update should fail with precondition error');
assert_true(($staleUpdate->data['status'] ?? 0) === 412, 'stale etag update should return 412');
$currentObj = unwrap(request('GET', '/calendar/v1/caldav/object/' . $newResource), 'get new resource object');
$currentEtag = (string) ($currentObj['data']['etag'] ?? '');
assert_true($currentEtag !== '', 'newly created object should have etag');
$updatedOk = unwrap(
request('PUT', '/calendar/v1/caldav/object/' . $newResource, ['ics' => $updatedIcs], ['If-Match' => $currentEtag]),
'caldav put update with etag'
);
assert_true((bool) (($updatedOk['data']['created'] ?? true) === false), 'caldav put with if-match should update existing object');
$multi = unwrap(
request('POST', '/calendar/v1/caldav/multiget', ['resources' => [$newResource, 'missing-event.ics']]),
'caldav multiget'
);
$multiData = (array) ($multi['data'] ?? []);
assert_true(count($multiData) === 2, 'multiget should return one row per requested resource');
assert_true((int) ($multiData[0]['status'] ?? 0) === 200, 'multiget should return 200 for existing resource');
assert_true((int) ($multiData[1]['status'] ?? 0) === 404, 'multiget should return 404 for missing resource');
$deletedObj = unwrap(request('DELETE', '/calendar/v1/caldav/object/' . $newResource), 'caldav delete object');
assert_true((bool) (($deletedObj['data']['deleted'] ?? false) === true), 'caldav delete should remove object');
$deletedNotFound = request('GET', '/calendar/v1/caldav/object/' . $newResource);
assert_true($deletedNotFound instanceof WP_Error, 'deleted caldav object should no longer resolve');
assert_true(($deletedNotFound->data['status'] ?? 0) === 404, 'deleted caldav object should return 404');
$sluggedSettings = unwrap(
request('PATCH', '/calendar/v1/settings', ['url_slug' => 'events']),
'patch slug setting'
);
assert_true(($sluggedSettings['data']['url_slug'] ?? '') === 'events', 'url_slug setting should persist');
$sluggedCalendarHtml = do_shortcode('[calendar]');
assert_true(str_contains($sluggedCalendarHtml, 'href="/events/calendar.ics"'), 'calendar shortcode should use slugged ICS path');
assert_true(str_contains($sluggedCalendarHtml, 'href="/events/caldav/"'), 'calendar shortcode should use slugged CalDAV path');
$sidebarHtml = do_shortcode('[calendar_sidebar_upcoming]');
assert_true(str_contains($sidebarHtml, '<div class="calendar-plugin-shell"'), 'sidebar shortcode should render');
echo "[PASS] compatibility-layer e2e wp emulation\n";

3
compatibility-layer/init.sh Executable file
View File

@ -0,0 +1,3 @@
#!/usr/bin/env bash
set -euo pipefail
"$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/../fixture/init.sh" "$@"

3
compatibility-layer/reset.sh Executable file
View File

@ -0,0 +1,3 @@
#!/usr/bin/env bash
set -euo pipefail
"$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/../fixture/reset.sh" "$@"

3
compatibility-layer/run.sh Executable file
View File

@ -0,0 +1,3 @@
#!/usr/bin/env bash
set -euo pipefail
"$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/../fixture/run.sh" "$@"

View File

@ -0,0 +1,3 @@
#!/usr/bin/env bash
set -euo pipefail
"$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/../fixture-tests/fixture_security_smoke.sh" "$@"

3
compatibility-layer/seed.sh Executable file
View File

@ -0,0 +1,3 @@
#!/usr/bin/env bash
set -euo pipefail
"$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/../fixture/seed.sh" "$@"

9
compatibility-layer/server.py Executable file
View File

@ -0,0 +1,9 @@
#!/usr/bin/env python3
"""Compatibility-layer entrypoint that delegates to fixture/server.py."""
from pathlib import Path
import runpy
if __name__ == "__main__":
root = Path(__file__).resolve().parents[1]
runpy.run_path(str(root / "fixture" / "server.py"), run_name="__main__")

3
compatibility-layer/smoke.sh Executable file
View File

@ -0,0 +1,3 @@
#!/usr/bin/env bash
set -euo pipefail
"$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/../fixture-tests/fixture_smoke.sh" "$@"

3
compatibility-layer/ui_e2e.sh Executable file
View File

@ -0,0 +1,3 @@
#!/usr/bin/env bash
set -euo pipefail
"$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/ui_e2e_wp_emulation.php" "$@"

View File

@ -0,0 +1,108 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
function assert_true(bool $cond, string $msg): void
{
if (!$cond) {
fwrite(STDERR, "[FAIL] {$msg}\n");
exit(1);
}
}
function request(string $method, string $route, array $json = [], array $headers = []): array|WP_Error
{
$req = new WP_REST_Request($method, $route, $json, $headers);
return rest_do_request($req);
}
function unwrap(array|WP_Error $res, string $context): array
{
if ($res instanceof WP_Error) {
$status = $res->data['status'] ?? 500;
fwrite(STDERR, "[FAIL] {$context}: {$res->code} {$res->message} ({$status})\n");
exit(1);
}
return $res;
}
require __DIR__ . '/wp_emulation.php';
$html = do_shortcode('[calendar]');
assert_true(str_contains($html, 'cp-login-btn'), 'calendar shortcode should render login button');
assert_true(str_contains($html, 'cp-create-btn'), 'calendar shortcode should render create button');
assert_true(str_contains($html, 'cp-public-list'), 'calendar shortcode should render public list');
assert_true(str_contains($html, 'cp-verify-btn'), 'calendar shortcode should render verify action');
assert_true(str_contains($html, 'cp-reset-request-btn'), 'calendar shortcode should render reset action');
assert_true(str_contains($html, 'cp-future-only'), 'calendar shortcode should render future-only control');
assert_true(str_contains($html, 'cp-theme'), 'calendar shortcode should render theme selector');
assert_true(str_contains($html, 'cp-delete-occ-confirm-btn'), 'calendar shortcode should render delete occurrence control');
$reg = unwrap(request('POST', '/calendar/v1/users/register', [
'email' => 'ui-emu@example.test',
'password' => 'pass12345',
'debug_tokens' => true,
]), 'register');
$verify = (string) ($reg['data']['verify_token'] ?? '');
$userId = (int) ($reg['data']['user']['id'] ?? 0);
assert_true($verify !== '' && $userId > 0, 'registration should provide token/id in debug mode');
unwrap(request('POST', '/calendar/v1/users/verify', ['token' => $verify]), 'verify');
unwrap(request('PATCH', '/calendar/v1/admin/users/' . $userId . '/approve', []), 'approve');
$GLOBALS['wp_user'] = [
'id' => 0,
'email' => '',
'caps' => [],
];
$authHeader = 'Basic ' . base64_encode('ui-emu@example.test:pass12345');
$me = unwrap(request('GET', '/calendar/v1/users/me', [], ['Authorization' => $authHeader]), 'users me');
assert_true((string) ($me['data']['email'] ?? '') === 'ui-emu@example.test', 'me endpoint should resolve basic user');
$created = unwrap(request('POST', '/calendar/v1/events', [
'title' => 'UI Emu Event',
'description' => 'ui flow',
'location' => 'Desk',
'category' => 'Test',
'all_day_event' => false,
'start_datetime' => '2026-06-01T10:00:00+01:00',
'end_datetime' => '2026-06-01T11:00:00+01:00',
'repeat_type' => 'daily',
'repeat_interval' => 1,
'repeat_range_mode' => 'count',
'repeat_count' => 3,
], ['Authorization' => $authHeader]), 'create event');
$eventId = (int) ($created['data']['id'] ?? 0);
assert_true($eventId > 0, 'basic-auth user should be able to create event');
unwrap(request('DELETE', '/calendar/v1/events/' . $eventId . '/occurrences/' . rawurlencode('2026-06-02T10:00:00+01:00'), [], ['Authorization' => $authHeader]), 'delete occurrence');
$occ = unwrap(request('GET', '/calendar/v1/events/' . $eventId . '/occurrences', ['from' => '2026-06-01', 'months' => 1], ['Authorization' => $authHeader]), 'list occurrences');
$days = [];
foreach ((array) ($occ['data'] ?? []) as $row) {
$days[] = substr((string) ($row['occurrence_start'] ?? ''), 0, 10);
}
assert_true(!in_array('2026-06-02', $days, true), 'deleted occurrence should not remain');
unwrap(request('DELETE', '/calendar/v1/events/' . $eventId, [], ['Authorization' => $authHeader]), 'delete event');
$deletedEvent = request('GET', '/calendar/v1/events/' . $eventId, [], ['Authorization' => $authHeader]);
assert_true($deletedEvent instanceof WP_Error, 'deleted event should not resolve');
assert_true(($deletedEvent->data['status'] ?? 0) === 404, 'deleted event fetch should return 404');
$GLOBALS['wp_user'] = [
'id' => 1,
'email' => 'admin@example.test',
'caps' => ['manage_options', 'edit_posts'],
];
unwrap(request('DELETE', '/calendar/v1/admin/users/' . $userId, []), 'remove user');
$GLOBALS['wp_user'] = [
'id' => 0,
'email' => '',
'caps' => [],
];
$meAfterRemove = request('GET', '/calendar/v1/users/me', [], ['Authorization' => $authHeader]);
assert_true($meAfterRemove instanceof WP_Error, 'removed user should not authenticate');
assert_true(($meAfterRemove->data['status'] ?? 0) === 401, 'removed user me endpoint should return 401');
echo "[PASS] ui-e2e wp emulation\n";

View File

@ -0,0 +1,2 @@
<?php
if (!function_exists('dbDelta')) { function dbDelta(string $sql): array { return []; } }

View File

@ -0,0 +1,465 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
/**
* Lightweight local WordPress emulation for executing plugin code in code/.
*/
final class WP_Error
{
public function __construct(
public string $code,
public string $message,
public array $data = []
) {
}
}
final class WP_REST_Request
{
private array $params = [];
private array $headers = [];
public function __construct(
private readonly string $method,
private readonly string $route,
private readonly array $jsonParams = [],
array $headers = []
) {
$this->params = $jsonParams;
foreach ($headers as $k => $v) {
$this->headers[strtolower((string) $k)] = (string) $v;
}
}
public function get_method(): string
{
return $this->method;
}
public function get_route(): string
{
return $this->route;
}
public function get_json_params(): array
{
return $this->jsonParams;
}
public function set_param(string $key, mixed $value): void
{
$this->params[$key] = $value;
}
public function get_param(string $key): mixed
{
return $this->params[$key] ?? null;
}
public function get_header(string $name): string
{
return $this->headers[strtolower($name)] ?? '';
}
}
final class WPDB_Compat
{
public string $prefix = 'wp_';
public int $insert_id = 0;
/** @var array<string, array<int, array<string, mixed>>> */
private array $tables = [];
/** @var array<string, int> */
private array $autoIds = [];
public function __construct(string $dbPath)
{
// In-memory emulation; path retained for compatibility.
}
public function get_charset_collate(): string
{
return '';
}
public function prepare(string $query, mixed ...$args): string
{
$out = $query;
foreach ($args as $arg) {
$replacement = is_int($arg) ? (string) $arg : ("'" . str_replace("'", "''", (string) $arg) . "'");
$out = preg_replace('/%[dsf]/', $replacement, $out, 1) ?? $out;
}
return $out;
}
public function query(string $query): int|false
{
$trimmed = trim($query);
if (preg_match('/^CREATE TABLE IF NOT EXISTS\s+([a-zA-Z0-9_]+)/i', $trimmed, $m)) {
$table = $m[1];
$this->tables[$table] = $this->tables[$table] ?? [];
$this->autoIds[$table] = $this->autoIds[$table] ?? 0;
return 0;
}
return 0;
}
public function get_results(string $query): array
{
$q = trim($query);
if (preg_match('/^SELECT \* FROM\s+([a-zA-Z0-9_]+)\s+ORDER BY\s+id\s+ASC$/i', $q, $m)) {
$rows = $this->tables[$m[1]] ?? [];
usort($rows, static fn(array $a, array $b): int => ((int) ($a['id'] ?? 0)) <=> ((int) ($b['id'] ?? 0)));
return array_map(static fn(array $r): object => (object) $r, $rows);
}
if (preg_match('/^SELECT\s+\*\s+FROM\s+([a-zA-Z0-9_]+)\s+WHERE\s+id\s*=\s*(\d+)$/i', $q, $m)) {
$row = $this->findById($m[1], (int) $m[2]);
return $row ? [(object) $row] : [];
}
if (preg_match('/^SELECT\s+\*\s+FROM\s+([a-zA-Z0-9_]+)\s+WHERE\s+caldav_resource\s*=\s*\'([^\']*)\'$/i', $q, $m)) {
$table = $m[1];
$needle = str_replace("''", "'", $m[2]);
$rows = $this->tables[$table] ?? [];
foreach ($rows as $row) {
if ((string) ($row['caldav_resource'] ?? '') === $needle) {
return [(object) $row];
}
}
return [];
}
if (preg_match('/^SELECT\s+occurrence_key\s+FROM\s+([a-zA-Z0-9_]+)\s+WHERE\s+event_id\s*=\s*(\d+)\s+AND\s+exception_type\s*=\s*\'([^\']+)\'$/i', $q, $m)) {
$table = $m[1];
$eventId = (int) $m[2];
$type = $m[3];
$rows = $this->tables[$table] ?? [];
$filtered = array_values(array_filter(
$rows,
static fn(array $r): bool => ((int) ($r['event_id'] ?? 0) === $eventId) && ((string) ($r['exception_type'] ?? '') === $type)
));
return array_map(
static fn(array $r): object => (object) ['occurrence_key' => (string) ($r['occurrence_key'] ?? '')],
$filtered
);
}
return [];
}
public function get_row(string $query): ?object
{
$rows = $this->get_results($query);
return $rows[0] ?? null;
}
public function get_var(string $query): mixed
{
$q = trim($query);
if (preg_match('/^SHOW TABLES LIKE\s*\'([^\']+)\'$/i', $q, $m)) {
$table = str_replace("''", "'", $m[1]);
return array_key_exists($table, $this->tables) ? $table : null;
}
$row = $this->get_row($query);
if (!$row) {
return null;
}
$vars = get_object_vars($row);
foreach ($vars as $value) {
return $value;
}
return null;
}
public function insert(string $table, array $data, array $formats = []): int|false
{
$this->tables[$table] = $this->tables[$table] ?? [];
$this->autoIds[$table] = $this->autoIds[$table] ?? 0;
$id = ++$this->autoIds[$table];
$row = ['id' => $id] + $data;
$this->tables[$table][] = $row;
$this->insert_id = $id;
return 1;
}
public function update(string $table, array $data, array $where, array $formats = [], array $whereFormats = []): int|false
{
$rows = $this->tables[$table] ?? [];
$updated = 0;
foreach ($rows as $i => $row) {
if (!$this->matchesWhere($row, $where)) {
continue;
}
$rows[$i] = array_merge($row, $data);
$updated++;
}
$this->tables[$table] = $rows;
return $updated;
}
public function delete(string $table, array $where, array $whereFormats = []): int|false
{
$rows = $this->tables[$table] ?? [];
$kept = [];
$deleted = 0;
foreach ($rows as $row) {
if ($this->matchesWhere($row, $where)) {
$deleted++;
continue;
}
$kept[] = $row;
}
$this->tables[$table] = $kept;
return $deleted;
}
private function findById(string $table, int $id): ?array
{
$rows = $this->tables[$table] ?? [];
foreach ($rows as $row) {
if ((int) ($row['id'] ?? 0) === $id) {
return $row;
}
}
return null;
}
private function matchesWhere(array $row, array $where): bool
{
foreach ($where as $k => $v) {
if (!array_key_exists($k, $row)) {
return false;
}
if ((string) $row[$k] !== (string) $v) {
return false;
}
}
return true;
}
}
$GLOBALS['wp_actions'] = [];
$GLOBALS['wp_shortcodes'] = [];
$GLOBALS['wp_routes'] = [];
$GLOBALS['wp_options'] = [];
$GLOBALS['wp_mail_outbox'] = [];
$GLOBALS['wp_user'] = [
'id' => 1,
'email' => 'admin@example.test',
'caps' => ['manage_options', 'edit_posts'],
];
$GLOBALS['wp_activation_hooks'] = [];
$GLOBALS['wp_admin_menu'] = [];
$GLOBALS['wp_options']['admin_email'] = 'admin@example.test';
function add_action(string $hook, callable $callback, int $priority = 10, int $acceptedArgs = 1): void
{
$GLOBALS['wp_actions'][$hook][] = $callback;
}
function do_action(string $hook, mixed ...$args): void
{
foreach (($GLOBALS['wp_actions'][$hook] ?? []) as $cb) {
$cb(...$args);
}
}
function add_shortcode(string $tag, callable $callback): void
{
$GLOBALS['wp_shortcodes'][$tag] = $callback;
}
function do_shortcode(string $content): string
{
if (preg_match('/\[([a-z0-9_\-]+)\]/i', $content, $m)) {
$tag = $m[1];
if (isset($GLOBALS['wp_shortcodes'][$tag])) {
return (string) call_user_func($GLOBALS['wp_shortcodes'][$tag]);
}
}
return $content;
}
function register_rest_route(string $namespace, string $route, array $args): void
{
$GLOBALS['wp_routes'][] = [
'namespace' => '/' . trim($namespace, '/'),
'route' => $route,
'args' => $args,
];
}
function rest_do_request(WP_REST_Request $request): array|WP_Error
{
$method = strtoupper($request->get_method());
$path = '/' . trim($request->get_route(), '/');
foreach ($GLOBALS['wp_routes'] as $entry) {
$namespace = $entry['namespace'];
$routePattern = $entry['route'];
$args = $entry['args'];
$registeredMethods = strtoupper((string) ($args['methods'] ?? 'GET'));
if ($registeredMethods !== $method) {
continue;
}
$regex = '#^' . preg_quote($namespace, '#') . preg_replace('#/#', '\\/', $routePattern) . '$#';
if (!preg_match($regex, $path, $matches)) {
continue;
}
foreach ($matches as $k => $v) {
if (is_string($k)) {
$request->set_param($k, $v);
}
}
$perm = $args['permission_callback'] ?? '__return_true';
$allowed = is_callable($perm) ? (bool) call_user_func($perm, $request) : false;
if (!$allowed) {
return new WP_Error('forbidden', 'Forbidden', ['status' => 403]);
}
$cb = $args['callback'] ?? null;
if (!is_callable($cb)) {
return new WP_Error('server_error', 'Route callback missing', ['status' => 500]);
}
return call_user_func($cb, $request);
}
return new WP_Error('not_found', 'Route not found', ['status' => 404]);
}
function register_activation_hook(string $pluginFile, callable $callback): void
{
$GLOBALS['wp_activation_hooks'][$pluginFile] = $callback;
}
function register_deactivation_hook(string $pluginFile, callable $callback): void
{
$GLOBALS['wp_activation_hooks'][$pluginFile . ':deactivate'] = $callback;
}
function get_option(string $key, mixed $default = false): mixed
{
return array_key_exists($key, $GLOBALS['wp_options']) ? $GLOBALS['wp_options'][$key] : $default;
}
function update_option(string $key, mixed $value, bool $autoload = true): bool
{
$GLOBALS['wp_options'][$key] = $value;
return true;
}
function delete_option(string $key): bool
{
unset($GLOBALS['wp_options'][$key]);
return true;
}
function get_current_user_id(): int
{
return (int) ($GLOBALS['wp_user']['id'] ?? 0);
}
function current_user_can(string $capability): bool
{
return in_array($capability, (array) ($GLOBALS['wp_user']['caps'] ?? []), true);
}
function wp_verify_nonce(string $nonce, string $action): bool
{
return $nonce === 'ok:' . $action;
}
function wp_get_current_user(): object
{
return (object) ['user_email' => (string) ($GLOBALS['wp_user']['email'] ?? '')];
}
function esc_html(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function __return_true(): bool
{
return true;
}
if (!function_exists('dbDelta')) {
function dbDelta(string $sql): array
{
return [];
}
}
function add_menu_page(string $pageTitle, string $menuTitle, string $capability, string $menuSlug, callable $callback): void
{
$GLOBALS['wp_admin_menu'][$menuSlug] = ['title' => $menuTitle, 'callback' => $callback];
}
function add_submenu_page(string $parentSlug, string $pageTitle, string $menuTitle, string $capability, string $menuSlug, callable $callback): void
{
$GLOBALS['wp_admin_menu'][$menuSlug] = ['title' => $menuTitle, 'parent' => $parentSlug, 'callback' => $callback];
}
function admin_url(string $path = ''): string
{
return '/wp-admin/' . ltrim($path, '/');
}
function home_url(string $path = ''): string
{
return 'http://localhost' . (str_starts_with($path, '/') ? $path : ('/' . $path));
}
function wp_mail(string|array $to, string $subject, string $message): bool
{
$GLOBALS['wp_mail_outbox'][] = ['to' => $to, 'subject' => $subject, 'message' => $message];
return true;
}
function wp_generate_password(int $length = 12, bool $specialChars = true): string
{
$bytes = random_bytes(max(1, intdiv($length + 1, 2)));
return substr(bin2hex($bytes), 0, $length);
}
if (!defined('ABSPATH')) {
define('ABSPATH', __DIR__ . '/wp-emu-root/');
define('CALENDAR_PLUGIN_ALLOW_DEBUG_TOKENS', true);
}
$upgradePath = ABSPATH . 'wp-admin/includes/upgrade.php';
@mkdir(dirname($upgradePath), 0775, true);
file_put_contents(
$upgradePath,
"<?php\nif (!function_exists('dbDelta')) { function dbDelta(string \$sql): array { return []; } }\n"
);
$wpdb = new WPDB_Compat(__DIR__ . '/wp_emulation.db');
$GLOBALS['wpdb'] = $wpdb;
$wpdb->query('CREATE TABLE IF NOT EXISTS wp_calendar_events (id INTEGER PRIMARY KEY AUTOINCREMENT)');
$wpdb->query('CREATE TABLE IF NOT EXISTS wp_calendar_recurrence_exceptions (id INTEGER PRIMARY KEY AUTOINCREMENT)');
require_once dirname(__DIR__) . '/code/calendar-plugin.php';
do_action('init');
do_action('rest_api_init');
if (PHP_SAPI === 'cli' && basename((string) ($_SERVER['SCRIPT_FILENAME'] ?? '')) === 'wp_emulation.php') {
echo "wp-emulation booted\n";
echo 'routes=' . count($GLOBALS['wp_routes']) . "\n";
}

48
credentials/.env Normal file
View File

@ -0,0 +1,48 @@
# Dummy remote testing server environment settings
# Replace all placeholder values before real use.
# Remote host access
REMOTE_HOST=cs.chezstephens.org.uk
REMOTE_PORT=22
REMOTE_USER=root
REMOTE_SSH_KEY_PATH=credentials/id_rsa
REMOTE_APP_DIR=/var/www/wordpress/wp-content/plugins/calendar-plugin
# Remote runtime
REMOTE_ENV=testing
REMOTE_TIMEZONE=Europe/London
REMOTE_PHP_BIN=/usr/bin/php
REMOTE_WP_CLI=/usr/local/bin/wp
# WordPress context (testing only)
WP_PATH=/var/www/html
WP_URL=https://chezstephens.org.uk
WP_PLUGIN_SLUG=calendar-plugin
# Database placeholders (testing only)
DB_HOST=127.0.0.1
DB_PORT=3306
DB_NAME=wordpress_test
DB_USER=wp_test_user
DB_PASSWORD=change_me
# Deployment/package settings
PACKAGE_DIR=/home/wp_tester/releases
PACKAGE_NAME=calendar-plugin-0.0.0.zip
KEEP_DATA_ON_UNINSTALL=true
# Local testing database (Debian 13 / MariaDB)
LOCAL_DB_HOST=localhost
LOCAL_DB_PORT=3306
LOCAL_DB_NAME=calendar_plugin_test
LOCAL_DB_USER=calendar_plugin_test_user
LOCAL_DB_PASSWORD=38Kgw7WY5w9xJpwyFcnK
# SMTP settings for fixture email workflows (register/verify/reset/request-write)
SMTP_HOST=mail.chezstephens.org.uk
SMTP_PORT=587
SMTP_USE_TLS=true
SMTP_USERNAME=adrians@chezstephens.org.uk
SMTP_PASSWORD=buck..it
SMTP_FROM=adrians@chezstephens.org.uk
SMTP_ADMIN_TO=adrians@chezstephens.org.uk

38
credentials/id_rsa Normal file
View File

@ -0,0 +1,38 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn
NhAAAAAwEAAQAAAYEAxWDcob7BWPrw/bMOCfm0QGj6gdKNxuatgjbX4uJLk1f3f74erHGq
610ebDUqdrmcVjeG70eIJKJEp+xxMtmyr+vRIQXBTzuAkAunXQigFKe985VPVHsH8DU+mY
sOH+W+Rnsh0at8yExyqKImH5P980ZuxQJDoM5DJk96Mn3YstUySlTXOZ+X3lBbYf93pYZ/
cyrBzwkakjluL9oEwqSPqjjM8EbnJi4JHNsxtAZPxIluGbsdlIKDSzePXl67aA7PLzQunO
L2MN7yUrymSALVGqLHZE8M4FMJByyjmRKSnWswhtsXuGc6jkMMv1idg8VwypCGLvTOjibG
p4fGgXJLd8jYaq2QrXy5OXyZqoAtlZo7FCEBKhMG3VRlJ5JEeBamkCstyKuy4qqYF2uwe9
pnt9wbmFAIBVNRK/GR3J6ki50YWtfAMpYlfaLdfTuliceQf4Jsc+Bj3K+xtZyz9J1Xjcvt
dQk9rqX/NZ7VbOpprzky8sCp9mEjVqvhaopVN7mTAAAFgLYbU1+2G1NfAAAAB3NzaC1yc2
EAAAGBAMVg3KG+wVj68P2zDgn5tEBo+oHSjcbmrYI21+LiS5NX93++HqxxqutdHmw1Kna5
nFY3hu9HiCSiRKfscTLZsq/r0SEFwU87gJALp10IoBSnvfOVT1R7B/A1PpmLDh/lvkZ7Id
GrfMhMcqiiJh+T/fNGbsUCQ6DOQyZPejJ92LLVMkpU1zmfl95QW2H/d6WGf3Mqwc8JGpI5
bi/aBMKkj6o4zPBG5yYuCRzbMbQGT8SJbhm7HZSCg0s3j15eu2gOzy80Lpzi9jDe8lK8pk
gC1Rqix2RPDOBTCQcso5kSkp1rMIbbF7hnOo5DDL9YnYPFcMqQhi70zo4mxqeHxoFyS3fI
2GqtkK18uTl8maqALZWaOxQhASoTBt1UZSeSRHgWppArLcirsuKqmBdrsHvaZ7fcG5hQCA
VTUSvxkdyepIudGFrXwDKWJX2i3X07pYnHkH+CbHPgY9yvsbWcs/SdV43L7XUJPa6l/zWe
1Wzqaa85MvLAqfZhI1ar4WqKVTe5kwAAAAMBAAEAAAGAWs/jE0QZ31+ty3w7hFlwFoZ2Y4
7FjnMJ97RWBdyKWyOJCywlHsA5nIq+eZjIjdF+Xai0m5j0ya4jGoPN3VCORySflqr4MwU0
dJH4EfTq+jXnTpAu7Laig2FsCOcSu5hPwEvc1oQpKFsMEgxwr+y+VdTdGCWfiff8qz68AU
knj7hJqCt6ztdf33hnYyJQIUdNkmZkv2X35Lkpujh8IjXmp7H0kMR+i3F43d738lVJFCsL
DimqRW77C3tnqkq5vPm6iI6UW+rKuUyXRnGX4VFM9PYvDnGpznHRJe43aQepgDmvBJQaVU
u67lDfpmGnaj/8UBIjC2sWlaqST/TltmcfmabjW9RE8pu1r+mI9b3Q/J2viDbhqqR8Jr3e
XFRqE7ndH+pGycwIWSmumAUo7Dyf86d398ZjpaJRzoMhD3HsBvZP3VEb7/gfaGv2hnFobc
VUtXP9SEB51hUwuvevKb6rmHrhlIXtVs5b4GEyv7tTNdzVIa+SaD+AlohjIlAo6AE5AAAA
wQC97JCbpX8BUb1lfps7CuKoMrnV7eV2o5hBzRmDLz/nSgrnQ22D4QR1Rmrk2/zWWZ3/VH
wB75WO5lWe6ww832Q3h5qbx9R14DtOLUXkZRw6veEa6cnJnGYFJVemOqKsMYmKIxMkd6wq
ynPVR+oseXQ7XFDwUGWi7x2q4EAaMQUqIAeylWeOVYDZUQdn/tCnYz1M56NADGEKo3o13R
E+GWN/xO2D90EXpVuJdSMvs5FWF5wpSioBf7NiaEqoW1JfI2YAAADBAOhTviR6/GgVmd/y
f48HOlhOv8yax9cLJ1uw8IoMu5VABTiSdVL2gdpBdsjNG1tzgccjmxzFWaDR3OLw4Ho1w9
ke/AvZ7ysqoNRzRTFMuYshXg1K2GeTTBXCj4QApFZdT2bz+KbpWIO9k2eU5Y2i5AhyZq7E
NcTBeDQzYE+3VHTWfAHoTaTXCBewc1V49NTM2DOMRwKXb9Dm84m12gI3gUtjb/z3W2pZA+
0o3M3CYtKjQTd2Fnr9MtqIA/QZACiplQAAAMEA2X167zBeebDQG00MQF0GfKY64lElelZX
8CxH4N7QtTGnSsJkBD7xR8S2aOlgP8ZNQJV3NhjbMzqTxsCsHX1+185IcUUrhitphSktW4
WQDenScMNhWZG/SS2o5RAZHRA+6CcypvPOZNoFCZkHNwOlrUuarzvMlKfBrlax9z3L/CpJ
AytTVc4haM4PkC4U8FOmIUAufagxmj8nqtUut1nc9O676OgWrZRnBhf1voJphdNg0b3RrE
szaVxAD1qt0hyHAAAACGNzYmFja3VwAQI=
-----END OPENSSH PRIVATE KEY-----

118
docs/fixture.md Normal file
View File

@ -0,0 +1,118 @@
# Local Compatibility Harness
## Purpose
Run a local, deterministic compatibility harness that exercises the current requirements for:
- Admin navigation pages (`Edit Calendar`, `Users`, `Setup`)
- API CRUD and user lifecycle endpoints
- Shared-calendar CalDAV read/write flows
- ICS export endpoint
Seed data includes canonical test fixtures `CE-001` through `CE-010` from `tests/calendar_entries.md`.
## Quick Start
From repository root:
```bash
./compatibility-layer/reset.sh
./compatibility-layer/seed.sh
./compatibility-layer/run.sh
```
Server default:
- `http://127.0.0.1:8080`
## WP Emulation Check
Run the stand-alone local WordPress emulation check (loads plugin code from `code/` and runs a minimal E2E flow):
```bash
./compatibility-layer/e2e_wp_emulation.php
```
## Harness Authentication Model
This harness uses simple test auth headers/credentials to emulate behavior.
### Admin/API (WordPress-style harness auth)
- Header: `X-WP-User: admin` (full admin)
- Header: `X-WP-User: editor` (editor-limited)
### CalDAV (plugin user basic auth over local HTTP harness)
Seeded users:
- `rw_user@example.test` / `rwpass123456` (`write`)
- `ro_user@example.test` / `ropass123456` (`read_only`)
## Key Endpoints
### Admin Pages
- `/wp-admin/admin.php?page=calendar-edit&as=admin`
- `/wp-admin/admin.php?page=calendar-users&as=admin`
- `/wp-admin/admin.php?page=calendar-setup&as=admin`
Interactive pages available:
- Public calendar UI: `/calendar`
- User self-service portal: `/users`
If setup `url_slug` is configured (for example `demo`), canonical URLs move under that prefix (for example `/demo/calendar`, `/demo/users`, `/demo/wp-json/calendar/v1/...`, `/demo/calendar.ics`, `/demo/caldav/...`).
### API
- `/wp-json/calendar/v1/events`
- `/wp-json/calendar/v1/events/{id}`
- `/wp-json/calendar/v1/events/{id}/occurrences/{occurrence_key}`
- `/wp-json/calendar/v1/users/register`
- `/wp-json/calendar/v1/users/verify`
- `/wp-json/calendar/v1/users/forgot-password`
- `/wp-json/calendar/v1/users/reset-password`
- `/wp-json/calendar/v1/users/{id}/request-write`
- `/wp-json/calendar/v1/admin/users`
- `/wp-json/calendar/v1/admin/users/{id}`
- `/wp-json/calendar/v1/admin/setup`
- `/wp-json/calendar/v1/public/events`
- `/wp-json/calendar/v1/users/me`
### ICS
- `/calendar.ics`
### CalDAV (single shared public calendar)
- `/caldav/`
- `/caldav/calendars/`
- `/caldav/calendars/public/`
- `/caldav/calendars/public/{object_id}.ics`
## Smoke Commands
List events:
```bash
curl -sS -H 'X-WP-User: admin' http://127.0.0.1:8080/wp-json/calendar/v1/events
```
Delete one recurring occurrence as exception:
```bash
curl -sS -X DELETE \
-H 'X-WP-User: admin' \
"http://127.0.0.1:8080/wp-json/calendar/v1/events/10/occurrences/2026-04-17T14:00:00+01:00" \
-i
```
Fetch ICS:
```bash
curl -sS http://127.0.0.1:8080/calendar.ics
```
CalDAV read as read-only user:
```bash
curl -sS -u ro_user@example.test:ropass123456 \
http://127.0.0.1:8080/caldav/calendars/public/1.ics -i
```
## Notes
- This compatibility harness is intentionally minimal and deterministic for local development.
- It is not a production security implementation.
- It enforces the shared-calendar model with per-user read/write permissions.
- Current implementation delegates to `fixture/` internals via wrapper scripts in `compatibility-layer/`.

9
fixture-tests/README.md Normal file
View File

@ -0,0 +1,9 @@
# Fixture Tests (Archived)
These tests are retained from the local fixture/harness phase.
- `fixture_smoke.sh`
- `fixture_security_smoke.sh`
- `fixture_caldav_client_compat_smoke.sh`
Current development validation is remote-server-first. Keep these scripts for legacy comparison/debug only.

View File

@ -0,0 +1,77 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="${BASE_URL:-http://127.0.0.1:8080}"
CALDAV_USER="${CALDAV_USER:-rw_user@example.test}"
CALDAV_PASSWORD="${CALDAV_PASSWORD:-rwpass123456}"
require_contains() {
local haystack="$1"
local needle="$2"
local msg="$3"
if ! printf '%s' "$haystack" | grep -q "$needle"; then
echo "[caldav-compat] FAIL: $msg"
exit 1
fi
}
require_contains_ci() {
local haystack="$1"
local needle="$2"
local msg="$3"
if ! printf '%s' "$haystack" | grep -qi "$needle"; then
echo "[caldav-compat] FAIL: $msg"
exit 1
fi
}
echo "[caldav-compat] checking unauthenticated challenge"
unauth_headers="$(curl -sS -i "$BASE_URL/caldav/" | tr -d '\r')"
require_contains "$unauth_headers" ' 401 ' "expected 401 from /caldav/"
require_contains_ci "$unauth_headers" 'www-authenticate: basic' "missing WWW-Authenticate Basic challenge"
echo "[caldav-compat] checking OPTIONS capability advertisement"
opt_headers="$(curl -sS -i -u "$CALDAV_USER:$CALDAV_PASSWORD" -X OPTIONS "$BASE_URL/caldav/" | tr -d '\r')"
require_contains_ci "$opt_headers" 'dav:' "missing DAV header"
require_contains_ci "$opt_headers" 'calendar-access' "missing DAV calendar-access advertisement"
require_contains_ci "$opt_headers" 'allow: ' "missing Allow header for CalDAV OPTIONS"
require_contains_ci "$opt_headers" 'options' "Allow header missing OPTIONS"
require_contains_ci "$opt_headers" 'propfind' "Allow header missing PROPFIND"
require_contains_ci "$opt_headers" 'report' "Allow header missing REPORT"
echo "[caldav-compat] checking root PROPFIND discovery"
root_xml="$(curl -fsS -u "$CALDAV_USER:$CALDAV_PASSWORD" -X PROPFIND \
-H 'Depth: 1' \
-H 'Content-Type: application/xml' \
--data '<D:propfind xmlns:D="DAV:"><D:prop><D:current-user-principal/><D:resourcetype/><D:displayname/></D:prop></D:propfind>' \
"$BASE_URL/caldav/")"
require_contains "$root_xml" '<D:current-user-principal>' "root discovery missing current-user-principal"
require_contains "$root_xml" '/caldav/calendars/public/' "root discovery missing public calendar href"
require_contains "$root_xml" '<C:calendar/>' "root discovery missing calendar collection marker"
principal_href="$(printf '%s' "$root_xml" | grep -oE '/caldav/principals/[^<"]+/' | head -n 1 || true)"
if [ -z "$principal_href" ]; then
echo "[caldav-compat] FAIL: unable to locate principal href in root PROPFIND"
exit 1
fi
echo "[caldav-compat] checking principal calendar-home-set"
principal_xml="$(curl -fsS -u "$CALDAV_USER:$CALDAV_PASSWORD" -X PROPFIND \
-H 'Depth: 0' \
-H 'Content-Type: application/xml' \
--data '<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav"><D:prop><C:calendar-home-set/></D:prop></D:propfind>' \
"$BASE_URL$principal_href")"
require_contains "$principal_xml" '<C:calendar-home-set>' "principal missing calendar-home-set"
require_contains "$principal_xml" '/caldav/calendars/' "calendar-home-set does not point to /caldav/calendars/"
echo "[caldav-compat] checking calendars home-set collection listing"
home_xml="$(curl -fsS -u "$CALDAV_USER:$CALDAV_PASSWORD" -X PROPFIND \
-H 'Depth: 1' \
-H 'Content-Type: application/xml' \
--data '<D:propfind xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav"><D:prop><D:displayname/><D:resourcetype/><C:supported-calendar-component-set/></D:prop></D:propfind>' \
"$BASE_URL/caldav/calendars/")"
require_contains "$home_xml" '/caldav/calendars/public/' "calendar home-set listing missing public calendar href"
require_contains "$home_xml" '<C:calendar/>' "calendar home-set listing missing calendar marker"
require_contains "$home_xml" '<C:comp name="VEVENT"/>' "calendar home-set listing missing supported component set"
echo "[caldav-compat] all checks passed"

View File

@ -0,0 +1,110 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="${BASE_URL:-http://127.0.0.1:8080}"
DB_PATH="${DB_PATH:-fixture/fixture.db}"
RUN_ID="${RUN_ID:-$(date +%s)}"
echo "[security-smoke] checking service readiness"
curl -fsS -H 'X-WP-User: admin' "$BASE_URL/wp-json/calendar/v1/events" >/dev/null
echo "[security-smoke] checking register token non-disclosure"
register_email="sec-nodisclose-${RUN_ID}@example.test"
reg_code="$(curl -sS -o /tmp/sec_register.json -w '%{http_code}' -H 'Content-Type: application/json' \
-d "{\"email\":\"${register_email}\",\"password\":\"strongpass123\"}" \
"$BASE_URL/wp-json/calendar/v1/users/register")"
if [ "$reg_code" != "200" ] && [ "$reg_code" != "201" ] && [ "$reg_code" != "409" ]; then
echo "[security-smoke] FAIL: unexpected register status $reg_code"
exit 1
fi
reg_payload="$(cat /tmp/sec_register.json)"
if echo "$reg_payload" | grep -q 'verification_token_fixture'; then
echo "[security-smoke] FAIL: register response leaked verification token"
exit 1
fi
echo "[security-smoke] checking forgot-password token non-disclosure"
forgot_payload="$(curl -fsS -H 'Content-Type: application/json' \
-d '{"email":"adrians@chezstephens.org.uk"}' \
"$BASE_URL/wp-json/calendar/v1/users/forgot-password")"
if echo "$forgot_payload" | grep -q 'reset_token_fixture'; then
echo "[security-smoke] FAIL: forgot-password response leaked reset token"
exit 1
fi
echo "[security-smoke] checking register rate limiting"
seen_429=0
rate_prefix="sec-rate-${RUN_ID}"
for i in $(seq 1 10); do
code="$(curl -sS -o /tmp/sec_reg_$i.json -w '%{http_code}' \
-H 'Content-Type: application/json' \
-d "{\"email\":\"${rate_prefix}-$i@example.test\",\"password\":\"strongpass123\"}" \
"$BASE_URL/wp-json/calendar/v1/users/register")"
if [ "$code" = "429" ]; then
seen_429=1
break
fi
done
if [ "$seen_429" -ne 1 ]; then
echo "[security-smoke] FAIL: expected 429 from register rate limiter"
exit 1
fi
echo "[security-smoke] checking password hash format in database"
python3 - "$DB_PATH" <<'PY'
import sqlite3
import sys
db_path = sys.argv[1]
conn = sqlite3.connect(db_path)
cur = conn.cursor()
rows = cur.execute("SELECT email, password_hash FROM caldav_users").fetchall()
conn.close()
if not rows:
print("[security-smoke] FAIL: no users found")
raise SystemExit(1)
for email, pw_hash in rows:
if not isinstance(pw_hash, str) or not pw_hash.startswith("pbkdf2_sha256$"):
print(f"[security-smoke] FAIL: non-pbkdf2 hash for {email}: {pw_hash!r}")
raise SystemExit(1)
print("[security-smoke] password hash format OK")
PY
echo "[security-smoke] checking strict CalDAV resource filename semantics"
resource="sec-$(date +%s).ics"
cat > /tmp/sec_caldav.ics <<'ICS'
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Security Smoke//EN
BEGIN:VEVENT
UID:sec-smoke-uid@example.test
SUMMARY:Security Smoke Event
DTSTART:20260415T100000
DTEND:20260415T110000
END:VEVENT
END:VCALENDAR
ICS
put_code="$(curl -sS -o /tmp/sec_caldav_put.json -w '%{http_code}' \
-u adrians@chezstephens.org.uk:brillig1 \
-X PUT --data-binary @/tmp/sec_caldav.ics \
"$BASE_URL/caldav/calendars/public/$resource")"
if [ "$put_code" != "201" ] && [ "$put_code" != "200" ]; then
echo "[security-smoke] FAIL: expected 200/201 for CalDAV PUT, got $put_code"
exit 1
fi
get_code="$(curl -sS -o /tmp/sec_caldav_get.ics -w '%{http_code}' \
-u adrians@chezstephens.org.uk:brillig1 \
"$BASE_URL/caldav/calendars/public/$resource")"
if [ "$get_code" != "200" ]; then
echo "[security-smoke] FAIL: expected 200 for CalDAV GET on same resource, got $get_code"
exit 1
fi
missing_code="$(curl -sS -o /tmp/sec_caldav_missing.out -w '%{http_code}' \
-u adrians@chezstephens.org.uk:brillig1 \
"$BASE_URL/caldav/calendars/public/999999.ics")"
if [ "$missing_code" != "404" ]; then
echo "[security-smoke] FAIL: expected 404 for unknown numeric resource, got $missing_code"
exit 1
fi
echo "[security-smoke] all checks passed"

330
fixture-tests/fixture_smoke.sh Executable file
View File

@ -0,0 +1,330 @@
#!/usr/bin/env bash
set -euo pipefail
BASE_URL="${BASE_URL:-http://127.0.0.1:8080}"
echo "[smoke] checking api list"
curl -fsS -H 'X-WP-User: admin' "$BASE_URL/wp-json/calendar/v1/events" >/dev/null
echo "[smoke] checking ics"
curl -fsS "$BASE_URL/calendar.ics" | grep -q 'BEGIN:VCALENDAR'
echo "[smoke] checking caldav read"
curl -fsS -u rw_user@example.test:rwpass123456 "$BASE_URL/caldav/calendars/public/1.ics" >/dev/null
echo "[smoke] checking caldav multiget href filtering"
TMP_XML="$(mktemp)"
cleanup_tmp_xml() { rm -f "$TMP_XML"; }
trap cleanup_tmp_xml EXIT
cat > "$TMP_XML" <<'XML'
<?xml version="1.0" encoding="UTF-8"?>
<C:calendar-multiget xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop><D:getetag/><C:calendar-data/></D:prop>
<D:href>/caldav/calendars/public/4.ics</D:href>
</C:calendar-multiget>
XML
REPORT_XML="$(curl -fsS -u rw_user@example.test:rwpass123456 -X REPORT \
"$BASE_URL/caldav/calendars/public/" \
-H 'Content-Type: text/xml; charset=utf-8' \
-H 'Depth: 1' \
--data-binary @"$TMP_XML")"
echo "$REPORT_XML" | grep -q '/caldav/calendars/public/4.ics'
if echo "$REPORT_XML" | grep -q '/caldav/calendars/public/1.ics'; then
echo "[smoke] multiget returned unrelated resources"
exit 1
fi
trap - EXIT
cleanup_tmp_xml
echo "[smoke] checking recurrence exception delete"
curl -fsS -X DELETE -H 'X-WP-User: admin' \
"$BASE_URL/wp-json/calendar/v1/events/10/occurrences/2026-04-17T14:00:00+01:00" \
-o /dev/null -w '%{http_code}' | grep -q '^204$'
echo "[smoke] checking event occurrences endpoint + idempotent delete"
CREATE_JSON='{"title":"Smoke Occurrences API","description":"occ-endpoint","location":"","category":"","all_day_event":false,"start_datetime":"2026-04-01T10:00:00+01:00","end_datetime":"2026-04-01T11:00:00+01:00","repeat_type":"daily","repeat_interval":1,"repeat_range_mode":"count","repeat_count":3}'
CREATE_RESP="$(curl -fsS -H 'Content-Type: application/json' -H 'X-WP-User: admin' \
-d "$CREATE_JSON" "$BASE_URL/wp-json/calendar/v1/events")"
EVENT_ID="$(python3 - <<'PY' "$CREATE_RESP"
import json,sys
print(json.loads(sys.argv[1])["data"]["id"])
PY
)"
OCC_RESP="$(curl -fsS -H 'X-WP-User: admin' \
"$BASE_URL/wp-json/calendar/v1/events/${EVENT_ID}/occurrences?from=2026-04-01&months=1")"
python3 - <<'PY' "$OCC_RESP"
import json,sys
data=json.loads(sys.argv[1])
days=[x["occurrence_start"][:10] for x in data["data"]]
assert len(data["data"]) >= 3
assert "2026-04-02" in days
PY
curl -fsS -X DELETE -H 'X-WP-User: admin' \
"$BASE_URL/wp-json/calendar/v1/events/${EVENT_ID}/occurrences/2026-04-02T10:00:00+01:00" \
-o /dev/null -w '%{http_code}' | grep -q '^204$'
curl -fsS -X DELETE -H 'X-WP-User: admin' \
"$BASE_URL/wp-json/calendar/v1/events/${EVENT_ID}/occurrences/2026-04-02T10:00:00+01:00" \
-o /dev/null -w '%{http_code}' | grep -q '^204$'
OCC_RESP="$(curl -fsS -H 'X-WP-User: admin' \
"$BASE_URL/wp-json/calendar/v1/events/${EVENT_ID}/occurrences?from=2026-04-01&months=1")"
python3 - <<'PY' "$OCC_RESP"
import json,sys
days=[x["occurrence_start"][:10] for x in json.loads(sys.argv[1])["data"]]
assert "2026-04-02" not in days
PY
echo "[smoke] checking caldav vtimezone parsing regression"
TMP_ICS="$(mktemp)"
cleanup_tmp_ics() { rm -f "$TMP_ICS"; }
trap cleanup_tmp_ics EXIT
cat > "$TMP_ICS" <<'ICS'
BEGIN:VCALENDAR
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
VERSION:2.0
BEGIN:VTIMEZONE
TZID:Europe/London
BEGIN:STANDARD
DTSTART:18471201T000000
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=9
TZOFFSETFROM:+0115
TZOFFSETTO:+0000
TZNAME:GMT
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
UID:smoke-vtimezone-parser-001
SUMMARY:Smoke VTIMEZONE Parse
DTSTART;TZID=Europe/London:20260423T150000
DTEND;TZID=Europe/London:20260423T160000
END:VEVENT
END:VCALENDAR
ICS
curl -fsS -u rw_user@example.test:rwpass123456 -X PUT \
-H 'Content-Type: text/calendar; charset=utf-8' \
--data-binary @"$TMP_ICS" \
"$BASE_URL/caldav/calendars/public/smoke-vtimezone.ics" >/dev/null
CALDAV_ICS="$(curl -fsS -u rw_user@example.test:rwpass123456 \
"$BASE_URL/caldav/calendars/public/smoke-vtimezone.ics")"
echo "$CALDAV_ICS" | grep -q 'DTSTART[^[:space:]]*:20260423T150000'
if echo "$CALDAV_ICS" | grep -q 'RRULE'; then
echo "[smoke] unexpected RRULE found in VEVENT-only upload"
exit 1
fi
trap - EXIT
cleanup_tmp_ics
echo "[smoke] checking caldav monthly nth-weekday roundtrip"
TMP_ICS="$(mktemp)"
cleanup_tmp_ics() { rm -f "$TMP_ICS"; }
trap cleanup_tmp_ics EXIT
cat > "$TMP_ICS" <<'ICS'
BEGIN:VCALENDAR
PRODID:-//Smoke//EN
VERSION:2.0
BEGIN:VEVENT
UID:smoke-monthly-nth-001
SUMMARY:Smoke Monthly Nth
DTSTART;TZID=Europe/London:20260402T150000
DTEND;TZID=Europe/London:20260402T160000
RRULE:FREQ=MONTHLY;BYDAY=SU;BYSETPOS=4
END:VEVENT
END:VCALENDAR
ICS
curl -fsS -u rw_user@example.test:rwpass123456 -X PUT \
-H 'Content-Type: text/calendar; charset=utf-8' \
--data-binary @"$TMP_ICS" \
"$BASE_URL/caldav/calendars/public/smoke-monthly-nth.ics" >/dev/null
CALDAV_ICS="$(curl -fsS -u rw_user@example.test:rwpass123456 \
"$BASE_URL/caldav/calendars/public/smoke-monthly-nth.ics")"
echo "$CALDAV_ICS" | grep -q 'RRULE:FREQ=MONTHLY;BYDAY=SU;BYSETPOS=4'
trap - EXIT
cleanup_tmp_ics
echo "[smoke] checking caldav monthly BYDAY ordinal import"
TMP_ICS="$(mktemp)"
cleanup_tmp_ics() { rm -f "$TMP_ICS"; }
trap cleanup_tmp_ics EXIT
cat > "$TMP_ICS" <<'ICS'
BEGIN:VCALENDAR
PRODID:-//Smoke//EN
VERSION:2.0
BEGIN:VEVENT
UID:smoke-monthly-ordinal-001
SUMMARY:Smoke Monthly Ordinal
DTSTART;TZID=Europe/London:20260411T150000
DTEND;TZID=Europe/London:20260411T160000
RRULE:FREQ=MONTHLY;BYDAY=2SA
END:VEVENT
END:VCALENDAR
ICS
curl -fsS -u rw_user@example.test:rwpass123456 -X PUT \
-H 'Content-Type: text/calendar; charset=utf-8' \
--data-binary @"$TMP_ICS" \
"$BASE_URL/caldav/calendars/public/smoke-monthly-ordinal.ics" >/dev/null
CALDAV_ICS="$(curl -fsS -u rw_user@example.test:rwpass123456 \
"$BASE_URL/caldav/calendars/public/smoke-monthly-ordinal.ics")"
echo "$CALDAV_ICS" | grep -q 'RRULE:FREQ=MONTHLY;BYDAY=SA;BYSETPOS=2'
trap - EXIT
cleanup_tmp_ics
echo "[smoke] checking caldav monthly last-weekday roundtrip"
TMP_ICS="$(mktemp)"
cleanup_tmp_ics() { rm -f "$TMP_ICS"; }
trap cleanup_tmp_ics EXIT
cat > "$TMP_ICS" <<'ICS'
BEGIN:VCALENDAR
PRODID:-//Smoke//EN
VERSION:2.0
BEGIN:VEVENT
UID:smoke-monthly-last-001
SUMMARY:Smoke Monthly Last
DTSTART;TZID=Europe/London:20260425T150000
DTEND;TZID=Europe/London:20260425T160000
RRULE:FREQ=MONTHLY;BYDAY=SA;BYSETPOS=-1
END:VEVENT
END:VCALENDAR
ICS
curl -fsS -u rw_user@example.test:rwpass123456 -X PUT \
-H 'Content-Type: text/calendar; charset=utf-8' \
--data-binary @"$TMP_ICS" \
"$BASE_URL/caldav/calendars/public/smoke-monthly-last.ics" >/dev/null
CALDAV_ICS="$(curl -fsS -u rw_user@example.test:rwpass123456 \
"$BASE_URL/caldav/calendars/public/smoke-monthly-last.ics")"
echo "$CALDAV_ICS" | grep -q 'RRULE:FREQ=MONTHLY;BYDAY=SA;BYSETPOS=-1'
trap - EXIT
cleanup_tmp_ics
echo "[smoke] checking monthly nth-weekday delete-exception preserves rule"
CREATE_JSON='{"title":"Smoke 4th Sunday Anchor","description":"anchor-normalization","location":"","category":"","all_day_event":false,"start_datetime":"2026-05-19T15:00:00+01:00","end_datetime":"2026-05-19T16:00:00+01:00","repeat_type":"monthly","repeat_interval":1,"repeat_nth_mode":"weekday_of_month","repeat_nth_pos":4,"repeat_nth_weekday":0,"repeat_range_mode":"no_end"}'
CREATE_RESP="$(curl -fsS -H 'Content-Type: application/json' -H 'X-WP-User: admin' \
-d "$CREATE_JSON" "$BASE_URL/wp-json/calendar/v1/events")"
python3 - <<'PY' "$CREATE_RESP"
import json,sys
data=json.loads(sys.argv[1])["data"]
assert data["start_datetime"] == "2026-05-24T15:00:00+01:00"
PY
EVENT_ID="$(python3 - <<'PY' "$CREATE_RESP"
import json,sys
print(json.loads(sys.argv[1])["data"]["id"])
PY
)"
curl -fsS -X DELETE -H 'X-WP-User: admin' \
"$BASE_URL/wp-json/calendar/v1/events/${EVENT_ID}/occurrences/2026-05-24T15:00:00+01:00" \
-o /dev/null -w '%{http_code}' | grep -q '^204$'
CALDAV_ICS="$(curl -fsS -u rw_user@example.test:rwpass123456 \
"$BASE_URL/caldav/calendars/public/${EVENT_ID}.ics")"
echo "$CALDAV_ICS" | grep -q 'RRULE:FREQ=MONTHLY;BYDAY=SU;BYSETPOS=4'
echo "$CALDAV_ICS" | grep -q 'EXDATE;TZID=Europe/London:20260524T150000'
echo "[smoke] checking delete-exception canonical key matching"
CREATE_JSON='{"title":"Smoke Daily Exception TZ","description":"tz-key","location":"","category":"","all_day_event":false,"start_datetime":"2026-03-02T10:00:00+00:00","end_datetime":"2026-03-02T11:00:00+00:00","repeat_type":"daily","repeat_interval":1,"repeat_range_mode":"until","repeat_until":"2026-03-19"}'
CREATE_RESP="$(curl -fsS -H 'Content-Type: application/json' -H 'X-WP-User: admin' \
-d "$CREATE_JSON" "$BASE_URL/wp-json/calendar/v1/events")"
EVENT_ID="$(python3 - <<'PY' "$CREATE_RESP"
import json,sys
print(json.loads(sys.argv[1])["data"]["id"])
PY
)"
curl -fsS -X DELETE -H 'X-WP-User: admin' \
"$BASE_URL/wp-json/calendar/v1/events/${EVENT_ID}/occurrences/2026-03-11T11:00:00+01:00" \
-o /dev/null -w '%{http_code}' | grep -q '^204$'
OCC_RESP="$(curl -fsS -H 'X-WP-User: admin' \
"$BASE_URL/wp-json/calendar/v1/events/${EVENT_ID}/occurrences?from=2026-03-01&months=1")"
python3 - <<'PY' "$OCC_RESP"
import json,sys
days=[x["occurrence_start"][:10] for x in json.loads(sys.argv[1])["data"]]
assert "2026-03-11" not in days
PY
curl -fsS -X DELETE -H 'X-WP-User: admin' \
"$BASE_URL/wp-json/calendar/v1/events/${EVENT_ID}/occurrences/2026-03-10" \
-o /dev/null -w '%{http_code}' | grep -q '^204$'
OCC_RESP="$(curl -fsS -H 'X-WP-User: admin' \
"$BASE_URL/wp-json/calendar/v1/events/${EVENT_ID}/occurrences?from=2026-03-01&months=1")"
python3 - <<'PY' "$OCC_RESP"
import json,sys
days=[x["occurrence_start"][:10] for x in json.loads(sys.argv[1])["data"]]
assert "2026-03-10" not in days
PY
echo "[smoke] checking caldav EXDATE import to recurrence exceptions"
TMP_ICS="$(mktemp)"
cleanup_tmp_ics() { rm -f "$TMP_ICS"; }
trap cleanup_tmp_ics EXIT
cat > "$TMP_ICS" <<'ICS'
BEGIN:VCALENDAR
PRODID:-//Smoke//EN
VERSION:2.0
BEGIN:VEVENT
UID:smoke-exdate-import-001
SUMMARY:Smoke EXDATE Import
DTSTART;TZID=Europe/London:20260302T100000
DTEND;TZID=Europe/London:20260302T110000
RRULE:FREQ=DAILY;UNTIL=20260319T235959
EXDATE;TZID=Europe/London:20260310T100000,20260311T100000
END:VEVENT
END:VCALENDAR
ICS
curl -fsS -u rw_user@example.test:rwpass123456 -X PUT \
-H 'Content-Type: text/calendar; charset=utf-8' \
--data-binary @"$TMP_ICS" \
"$BASE_URL/caldav/calendars/public/smoke-exdate-import.ics" >/dev/null
EVENT_ID="$(curl -fsS -H 'X-WP-User: admin' "$BASE_URL/wp-json/calendar/v1/events" | python3 -c "import json,sys; data=json.load(sys.stdin)['data']; print([e for e in data if e.get('uid')=='smoke-exdate-import-001'][0]['id'])")"
OCC_RESP="$(curl -fsS -H 'X-WP-User: admin' \
"$BASE_URL/wp-json/calendar/v1/events/${EVENT_ID}/occurrences?from=2026-03-01&months=1")"
python3 - <<'PY' "$OCC_RESP"
import json,sys
days=[x["occurrence_start"][:10] for x in json.loads(sys.argv[1])["data"]]
assert "2026-03-10" not in days
assert "2026-03-11" not in days
PY
trap - EXIT
cleanup_tmp_ics
echo "[smoke] checking caldav cancelled-occurrence component handling"
TMP_ICS="$(mktemp)"
cleanup_tmp_ics() { rm -f "$TMP_ICS"; }
trap cleanup_tmp_ics EXIT
cat > "$TMP_ICS" <<'ICS'
BEGIN:VCALENDAR
PRODID:-//Smoke//EN
VERSION:2.0
BEGIN:VEVENT
UID:smoke-cancelled-occurrence-001
SUMMARY:Smoke Cancelled Occurrence
DTSTART;TZID=Europe/London:20260408T123000
DTEND;TZID=Europe/London:20260408T133000
RRULE:FREQ=WEEKLY
END:VEVENT
BEGIN:VEVENT
UID:smoke-cancelled-occurrence-001
RECURRENCE-ID;TZID=Europe/London:20260506T123000
DTSTART;TZID=Europe/London:20260506T123000
DTEND;TZID=Europe/London:20260506T133000
STATUS:CANCELLED
END:VEVENT
END:VCALENDAR
ICS
curl -fsS -u rw_user@example.test:rwpass123456 -X PUT \
-H 'Content-Type: text/calendar; charset=utf-8' \
--data-binary @"$TMP_ICS" \
"$BASE_URL/caldav/calendars/public/smoke-cancelled-occurrence.ics" >/dev/null
CALDAV_ICS="$(curl -fsS -u rw_user@example.test:rwpass123456 \
"$BASE_URL/caldav/calendars/public/smoke-cancelled-occurrence.ics")"
echo "$CALDAV_ICS" | grep -q 'RRULE:FREQ=WEEKLY'
echo "$CALDAV_ICS" | grep -q 'EXDATE;TZID=Europe/London:20260506T123000'
EVENT_ID="$(curl -fsS -H 'X-WP-User: admin' "$BASE_URL/wp-json/calendar/v1/events" | python3 -c "import json,sys; data=json.load(sys.stdin)['data']; print([e for e in data if e.get('uid')=='smoke-cancelled-occurrence-001'][0]['id'])")"
OCC_RESP="$(curl -fsS -H 'X-WP-User: admin' \
"$BASE_URL/wp-json/calendar/v1/events/${EVENT_ID}/occurrences?from=2026-05-01&months=1")"
python3 - <<'PY' "$OCC_RESP"
import json,sys
days=[x["occurrence_start"][:10] for x in json.loads(sys.argv[1])["data"]]
assert "2026-05-06" not in days
assert "2026-05-13" in days
PY
trap - EXIT
cleanup_tmp_ics
echo "[smoke] checking future-only default"
curl -fsS "$BASE_URL/calendar" | grep -q 'id="futureOnly" type="checkbox" checked'
echo "[smoke] all checks passed"

Binary file not shown.

BIN
fixture/fixture.db Normal file

Binary file not shown.

1164
fixture/http_trace.log Normal file

File diff suppressed because one or more lines are too long

4
fixture/init.sh Executable file
View File

@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
python3 fixture/server.py init

4
fixture/reset.sh Executable file
View File

@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
python3 fixture/server.py reset

4
fixture/run.sh Executable file
View File

@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
python3 fixture/server.py run --host "${FIXTURE_HOST:-127.0.0.1}" --port "${FIXTURE_PORT:-8080}"

4
fixture/seed.sh Executable file
View File

@ -0,0 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")/.."
python3 fixture/server.py seed

4230
fixture/server.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,19 @@
e44decf1154a146e77f0c759c26455ae22ad54bff7ea175dea1c76dd95257c7b calendar-plugin.php
1daa5861c0d10258c0d16c5c88c1a18fb3c8df7b590f7facfe7c28f678643bdb src/Contracts/AuthAdapterInterface.php
25cff4b0fc2ee292b53c152edd083c6af66200a25eec28f8dfce37d126a8892d src/Contracts/DatabaseAdapterInterface.php
4f0f4caa5ac98499854336f5b74af55ce889653f3956e5df10910f869a23fdba src/Contracts/HttpAdapterInterface.php
15e8f58c7360d6cd0c76c945abfeb026f8278d40b330e99b67955eb2f85f5563 src/Contracts/OptionsAdapterInterface.php
47d55e845b8a696c55fa86f597bb2760b6e35d974b70f365593d38697ef398c8 src/Domain/CalDavService.php
58038a39389008eb1b3c4bc57a9daf6e672bf3d7ec216d16a8e2f1429ea5054e src/Domain/EventService.php
412a22ecd910535c7ace2549a86eacf08cc9cd824f1767e60116d8593355f57f src/Domain/IcsService.php
a6bc2e7b2d1862603d5709ee2860ff3a28cd128b6eb15ee2e56b6bbd2ae806eb src/Domain/RecurrenceExpander.php
a107073f4d114daf22b6bdd79b1347634f102116b046e9d60316e69b30fde805 src/Domain/SettingsService.php
232961b0e6a0087f0b0069f52ec3db156e19b04004c0b13d4397bc1635c5ac14 src/Domain/UserService.php
5aaac066919b60461bde2e96cbfb4de66a5a28e89d2a61b78f0e2d346f23395b src/Infrastructure/ServiceContainer.php
7686d0778fc27a24ef6e6683e79dfed9d61bf40c64fab8a2793f291bdcf2935b 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
faa235d11b0fcb3a197091bf79ce3184132427d6ec7e732e5f4f5a3abb379c50 src/Plugin.php
4e6930c79d9ce1045be6863ddd7546e19f7ef7e839d41a41014a3efb3183eaf0 src/bootstrap.php

Binary file not shown.

View File

@ -0,0 +1,19 @@
af462e458d03144996d4fc18da700e49b9285a2f03941a30df4f5b6775edd1e6 calendar-plugin.php
1daa5861c0d10258c0d16c5c88c1a18fb3c8df7b590f7facfe7c28f678643bdb src/Contracts/AuthAdapterInterface.php
25cff4b0fc2ee292b53c152edd083c6af66200a25eec28f8dfce37d126a8892d src/Contracts/DatabaseAdapterInterface.php
4f0f4caa5ac98499854336f5b74af55ce889653f3956e5df10910f869a23fdba src/Contracts/HttpAdapterInterface.php
15e8f58c7360d6cd0c76c945abfeb026f8278d40b330e99b67955eb2f85f5563 src/Contracts/OptionsAdapterInterface.php
47d55e845b8a696c55fa86f597bb2760b6e35d974b70f365593d38697ef398c8 src/Domain/CalDavService.php
58038a39389008eb1b3c4bc57a9daf6e672bf3d7ec216d16a8e2f1429ea5054e src/Domain/EventService.php
412a22ecd910535c7ace2549a86eacf08cc9cd824f1767e60116d8593355f57f src/Domain/IcsService.php
a6bc2e7b2d1862603d5709ee2860ff3a28cd128b6eb15ee2e56b6bbd2ae806eb src/Domain/RecurrenceExpander.php
a107073f4d114daf22b6bdd79b1347634f102116b046e9d60316e69b30fde805 src/Domain/SettingsService.php
232961b0e6a0087f0b0069f52ec3db156e19b04004c0b13d4397bc1635c5ac14 src/Domain/UserService.php
5aaac066919b60461bde2e96cbfb4de66a5a28e89d2a61b78f0e2d346f23395b src/Infrastructure/ServiceContainer.php
7686d0778fc27a24ef6e6683e79dfed9d61bf40c64fab8a2793f291bdcf2935b 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
3cf15101dab7b51195075ae0b45930576fd76d4fe0111b6d8d7db8df444b5b7b src/Plugin.php
4e6930c79d9ce1045be6863ddd7546e19f7ef7e839d41a41014a3efb3183eaf0 src/bootstrap.php

Binary file not shown.

View File

@ -0,0 +1,19 @@
af462e458d03144996d4fc18da700e49b9285a2f03941a30df4f5b6775edd1e6 calendar-plugin.php
1daa5861c0d10258c0d16c5c88c1a18fb3c8df7b590f7facfe7c28f678643bdb src/Contracts/AuthAdapterInterface.php
25cff4b0fc2ee292b53c152edd083c6af66200a25eec28f8dfce37d126a8892d src/Contracts/DatabaseAdapterInterface.php
4f0f4caa5ac98499854336f5b74af55ce889653f3956e5df10910f869a23fdba src/Contracts/HttpAdapterInterface.php
15e8f58c7360d6cd0c76c945abfeb026f8278d40b330e99b67955eb2f85f5563 src/Contracts/OptionsAdapterInterface.php
47d55e845b8a696c55fa86f597bb2760b6e35d974b70f365593d38697ef398c8 src/Domain/CalDavService.php
58038a39389008eb1b3c4bc57a9daf6e672bf3d7ec216d16a8e2f1429ea5054e src/Domain/EventService.php
412a22ecd910535c7ace2549a86eacf08cc9cd824f1767e60116d8593355f57f src/Domain/IcsService.php
a6bc2e7b2d1862603d5709ee2860ff3a28cd128b6eb15ee2e56b6bbd2ae806eb src/Domain/RecurrenceExpander.php
633a781fff77c39c4e06b1a04f9972c2b8bcfb21095031c5b4f6f4de65cdee9a src/Domain/SettingsService.php
232961b0e6a0087f0b0069f52ec3db156e19b04004c0b13d4397bc1635c5ac14 src/Domain/UserService.php
5aaac066919b60461bde2e96cbfb4de66a5a28e89d2a61b78f0e2d346f23395b src/Infrastructure/ServiceContainer.php
7686d0778fc27a24ef6e6683e79dfed9d61bf40c64fab8a2793f291bdcf2935b 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
0a10c002ffabbe349bc706f9c122d6d83c6a6a4ea6edf5fe892657ba64135c63 src/Plugin.php
4e6930c79d9ce1045be6863ddd7546e19f7ef7e839d41a41014a3efb3183eaf0 src/bootstrap.php

Binary file not shown.

View File

@ -0,0 +1,20 @@
10c82cc04fa26fdea842d59090dccf5adb452705792b045108749a6d64d2d710 calendar-plugin.php
1daa5861c0d10258c0d16c5c88c1a18fb3c8df7b590f7facfe7c28f678643bdb src/Contracts/AuthAdapterInterface.php
25cff4b0fc2ee292b53c152edd083c6af66200a25eec28f8dfce37d126a8892d src/Contracts/DatabaseAdapterInterface.php
4f0f4caa5ac98499854336f5b74af55ce889653f3956e5df10910f869a23fdba src/Contracts/HttpAdapterInterface.php
15e8f58c7360d6cd0c76c945abfeb026f8278d40b330e99b67955eb2f85f5563 src/Contracts/OptionsAdapterInterface.php
47d55e845b8a696c55fa86f597bb2760b6e35d974b70f365593d38697ef398c8 src/Domain/CalDavService.php
6e18669ebbf7dad5fc6a402954428283ada4f2def369206e5065f4a37554437d src/Domain/EventService.php
412a22ecd910535c7ace2549a86eacf08cc9cd824f1767e60116d8593355f57f src/Domain/IcsService.php
a6bc2e7b2d1862603d5709ee2860ff3a28cd128b6eb15ee2e56b6bbd2ae806eb src/Domain/RecurrenceExpander.php
633a781fff77c39c4e06b1a04f9972c2b8bcfb21095031c5b4f6f4de65cdee9a src/Domain/SettingsService.php
c870fc51a6ab8d1b2306ce61e2c94c443a954204305b14743cb913ce243a90f6 src/Domain/UserService.php
5aaac066919b60461bde2e96cbfb4de66a5a28e89d2a61b78f0e2d346f23395b src/Infrastructure/ServiceContainer.php
70334df8ca06c8fe61d81f24cb0d8f19285180f9950054bd86af0083adf8b4c8 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
773eb382da988a3fa283c0041d91b008497e3157bbe5171be5a4622239c05074 src/Plugin.php
4e6930c79d9ce1045be6863ddd7546e19f7ef7e839d41a41014a3efb3183eaf0 src/bootstrap.php
4217aa37b3f979f9d16b5a821c9b7d9e7d30b4dc615389495ff5f380c3080c75 uninstall.php

Binary file not shown.

View File

@ -0,0 +1,20 @@
21c6e7f75de826055d42ff3a530770266870652c47537002c2a78a18f1536176 calendar-plugin.php
1daa5861c0d10258c0d16c5c88c1a18fb3c8df7b590f7facfe7c28f678643bdb src/Contracts/AuthAdapterInterface.php
25cff4b0fc2ee292b53c152edd083c6af66200a25eec28f8dfce37d126a8892d src/Contracts/DatabaseAdapterInterface.php
4f0f4caa5ac98499854336f5b74af55ce889653f3956e5df10910f869a23fdba src/Contracts/HttpAdapterInterface.php
15e8f58c7360d6cd0c76c945abfeb026f8278d40b330e99b67955eb2f85f5563 src/Contracts/OptionsAdapterInterface.php
47d55e845b8a696c55fa86f597bb2760b6e35d974b70f365593d38697ef398c8 src/Domain/CalDavService.php
6e18669ebbf7dad5fc6a402954428283ada4f2def369206e5065f4a37554437d src/Domain/EventService.php
412a22ecd910535c7ace2549a86eacf08cc9cd824f1767e60116d8593355f57f src/Domain/IcsService.php
a6bc2e7b2d1862603d5709ee2860ff3a28cd128b6eb15ee2e56b6bbd2ae806eb src/Domain/RecurrenceExpander.php
633a781fff77c39c4e06b1a04f9972c2b8bcfb21095031c5b4f6f4de65cdee9a src/Domain/SettingsService.php
c870fc51a6ab8d1b2306ce61e2c94c443a954204305b14743cb913ce243a90f6 src/Domain/UserService.php
5aaac066919b60461bde2e96cbfb4de66a5a28e89d2a61b78f0e2d346f23395b src/Infrastructure/ServiceContainer.php
70334df8ca06c8fe61d81f24cb0d8f19285180f9950054bd86af0083adf8b4c8 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
8d8eb0518838370e636a1e32b82e61d5c1ece6875f85c333a6df7f8c7d46b60a src/Plugin.php
4e6930c79d9ce1045be6863ddd7546e19f7ef7e839d41a41014a3efb3183eaf0 src/bootstrap.php
bfecf2aa282942bbf143725152d3c7f682927c40c9febd19f2925b42f32c2c6d uninstall.php

Binary file not shown.

View File

@ -0,0 +1,20 @@
21c6e7f75de826055d42ff3a530770266870652c47537002c2a78a18f1536176 calendar-plugin.php
1daa5861c0d10258c0d16c5c88c1a18fb3c8df7b590f7facfe7c28f678643bdb src/Contracts/AuthAdapterInterface.php
25cff4b0fc2ee292b53c152edd083c6af66200a25eec28f8dfce37d126a8892d src/Contracts/DatabaseAdapterInterface.php
4f0f4caa5ac98499854336f5b74af55ce889653f3956e5df10910f869a23fdba src/Contracts/HttpAdapterInterface.php
15e8f58c7360d6cd0c76c945abfeb026f8278d40b330e99b67955eb2f85f5563 src/Contracts/OptionsAdapterInterface.php
47d55e845b8a696c55fa86f597bb2760b6e35d974b70f365593d38697ef398c8 src/Domain/CalDavService.php
6e18669ebbf7dad5fc6a402954428283ada4f2def369206e5065f4a37554437d src/Domain/EventService.php
412a22ecd910535c7ace2549a86eacf08cc9cd824f1767e60116d8593355f57f src/Domain/IcsService.php
a6bc2e7b2d1862603d5709ee2860ff3a28cd128b6eb15ee2e56b6bbd2ae806eb src/Domain/RecurrenceExpander.php
a6ecfcb4894883ea62daf4547b54456b10b69f0c82d92c73d8c26472476a0aff src/Domain/SettingsService.php
c870fc51a6ab8d1b2306ce61e2c94c443a954204305b14743cb913ce243a90f6 src/Domain/UserService.php
5aaac066919b60461bde2e96cbfb4de66a5a28e89d2a61b78f0e2d346f23395b src/Infrastructure/ServiceContainer.php
70334df8ca06c8fe61d81f24cb0d8f19285180f9950054bd86af0083adf8b4c8 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
11a04e9b063188e4a4a39dae0c36d35206d0cee31e73a18baada591b58847650 src/Plugin.php
4e6930c79d9ce1045be6863ddd7546e19f7ef7e839d41a41014a3efb3183eaf0 src/bootstrap.php
bfecf2aa282942bbf143725152d3c7f682927c40c9febd19f2925b42f32c2c6d uninstall.php

Binary file not shown.

View File

@ -0,0 +1,20 @@
1bf98ffa4bf22472be330b49aeee42507aef988cb56f1dc15415e903b4e74b6a calendar-plugin.php
1daa5861c0d10258c0d16c5c88c1a18fb3c8df7b590f7facfe7c28f678643bdb src/Contracts/AuthAdapterInterface.php
25cff4b0fc2ee292b53c152edd083c6af66200a25eec28f8dfce37d126a8892d src/Contracts/DatabaseAdapterInterface.php
4f0f4caa5ac98499854336f5b74af55ce889653f3956e5df10910f869a23fdba src/Contracts/HttpAdapterInterface.php
15e8f58c7360d6cd0c76c945abfeb026f8278d40b330e99b67955eb2f85f5563 src/Contracts/OptionsAdapterInterface.php
47d55e845b8a696c55fa86f597bb2760b6e35d974b70f365593d38697ef398c8 src/Domain/CalDavService.php
37a811d322ef40dc0c3af0b62cb5ad8e19c004e0fbf76d5b46fc46f4a1c47398 src/Domain/EventService.php
412a22ecd910535c7ace2549a86eacf08cc9cd824f1767e60116d8593355f57f src/Domain/IcsService.php
a6bc2e7b2d1862603d5709ee2860ff3a28cd128b6eb15ee2e56b6bbd2ae806eb src/Domain/RecurrenceExpander.php
1f337ca1d51e39bbf16e2e5d17ebb232a9a1a5b5fd1924f3800c8b6fc12c1760 src/Domain/SettingsService.php
c870fc51a6ab8d1b2306ce61e2c94c443a954204305b14743cb913ce243a90f6 src/Domain/UserService.php
5aaac066919b60461bde2e96cbfb4de66a5a28e89d2a61b78f0e2d346f23395b src/Infrastructure/ServiceContainer.php
70334df8ca06c8fe61d81f24cb0d8f19285180f9950054bd86af0083adf8b4c8 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
a1c7f4a793d3647cb4c24f3f3cef61e6a11eb45cc559ab528ebfd15cddf005da src/Plugin.php
4e6930c79d9ce1045be6863ddd7546e19f7ef7e839d41a41014a3efb3183eaf0 src/bootstrap.php
893c6df62beed87a981d372c473e5012d1b5d1c254d23b39cda44ae8a08cd16c uninstall.php

Binary file not shown.

View File

@ -0,0 +1,20 @@
6735506aef88825a3d66d53d8a90ac75aaf57331e09aab685db2de993e92da96 calendar-plugin.php
1daa5861c0d10258c0d16c5c88c1a18fb3c8df7b590f7facfe7c28f678643bdb src/Contracts/AuthAdapterInterface.php
25cff4b0fc2ee292b53c152edd083c6af66200a25eec28f8dfce37d126a8892d src/Contracts/DatabaseAdapterInterface.php
4f0f4caa5ac98499854336f5b74af55ce889653f3956e5df10910f869a23fdba src/Contracts/HttpAdapterInterface.php
15e8f58c7360d6cd0c76c945abfeb026f8278d40b330e99b67955eb2f85f5563 src/Contracts/OptionsAdapterInterface.php
47d55e845b8a696c55fa86f597bb2760b6e35d974b70f365593d38697ef398c8 src/Domain/CalDavService.php
37a811d322ef40dc0c3af0b62cb5ad8e19c004e0fbf76d5b46fc46f4a1c47398 src/Domain/EventService.php
412a22ecd910535c7ace2549a86eacf08cc9cd824f1767e60116d8593355f57f src/Domain/IcsService.php
a6bc2e7b2d1862603d5709ee2860ff3a28cd128b6eb15ee2e56b6bbd2ae806eb src/Domain/RecurrenceExpander.php
1f337ca1d51e39bbf16e2e5d17ebb232a9a1a5b5fd1924f3800c8b6fc12c1760 src/Domain/SettingsService.php
c870fc51a6ab8d1b2306ce61e2c94c443a954204305b14743cb913ce243a90f6 src/Domain/UserService.php
5aaac066919b60461bde2e96cbfb4de66a5a28e89d2a61b78f0e2d346f23395b src/Infrastructure/ServiceContainer.php
70334df8ca06c8fe61d81f24cb0d8f19285180f9950054bd86af0083adf8b4c8 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
7dd9f30ce2fd45acab665e1c2a5da34ac05ad6dff9695c1da438c8a4afd7925d src/Plugin.php
4e6930c79d9ce1045be6863ddd7546e19f7ef7e839d41a41014a3efb3183eaf0 src/bootstrap.php
893c6df62beed87a981d372c473e5012d1b5d1c254d23b39cda44ae8a08cd16c uninstall.php

Binary file not shown.

View File

@ -0,0 +1,56 @@
# Deployment Record - 2026-03-30
- Artifact: `package/calendar-plugin-0.1.0.zip`
- Manifest: `package/calendar-plugin-0.1.0.manifest.sha256`
- Source revision: SVN r405 (`https://svn.chezstephens.org.uk/adrian/tools/calendar_wp_plugin`)
- Deploy target host: `cs.chezstephens.org.uk`
- Deploy target path: `/var/www/wordpress/wp-content/plugins/calendar-plugin`
- Operator: Codex (with user-approved escalations)
- Timestamp (UTC): 2026-03-30
## Pre-deploy Gates (Local)
- `compatibility-layer/e2e_wp_emulation.php`: PASS
- `compatibility-layer/ui_e2e.sh`: PASS
- `compatibility-layer/smoke.sh`: PASS
- `fixture/security_smoke.sh`: PASS
## Packaging Validation
- Archive root contains single plugin folder: PASS (`calendar-plugin/`)
- Main entry present: PASS (`calendar-plugin/calendar-plugin.php`)
- Runtime-only source (`code/`) packaged: PASS
## Deployment Procedure
1. Uploaded artifact+manifest to remote staging `/tmp/calendar-plugin-0.1.0`.
2. Extracted and synced to plugin path with `rsync -a --delete`.
3. Activated plugin via WP-CLI (`wp plugin activate calendar-plugin --allow-root`).
## Exact-Match Validation
- Compared artifact and deployed SHA256 manifests (relative paths normalized).
- `diff -u` returned no differences.
- Result: PASS (`REDEPLOY_HASH_MATCH_OK`).
## Post-deploy Checks
- `/calendar.ics` -> `200`
- `/caldav/` (unauthenticated) -> `401`
- `/wp-json/calendar/v1/health` -> `200`
- `/wp-json/calendar/v1/public/ics` -> `200`
- Authenticated CalDAV checks:
- `/caldav/` -> `405`
- `PROPFIND /caldav/calendars/public/` -> `207`
- `GET /caldav/calendars/public/deploy-smoke.ics` -> `200`
## Backup/Rollback
- Previous deployment backup path (if existed):
- `/var/www/wordpress/wp-content/plugins/calendar-plugin.backup.prev`
## Redeploy Update (Same Day)
- Redeployed latest local UI/auth shortcode fixes from current workspace artifact.
- Remote sync to `/var/www/wordpress/wp-content/plugins/calendar-plugin` completed.
- Exact-match manifest validation re-run: PASS (`DEPLOY_AND_HASH_MATCH_OK`).
- Stale duplicate plugin directory removed:
- `/var/www/wordpress/wp-content/plugins/calendar-plugin.backup.prev`
- Remote validation after cleanup:
- only `calendar-plugin` present under plugins directory
- `wp plugin list` shows `calendar-plugin,active,0.1.0`
- `GET /calendar.ics` -> `200`
- `GET /wp-json/calendar/v1/health` -> `200`

View File

@ -0,0 +1,22 @@
<?php
/**
* Plugin Name: Calendar Plugin
* Plugin URI: https://chezstephens.org.uk
* Description: Provides a single shared calendar for WordPress with public display, authenticated event editing, user approval workflow, ICS publishing, and CalDAV read/write sync. Supports recurring events, single-occurrence exceptions, admin setup and diagnostics pages, and shortcode rendering for full calendar and upcoming-events sidebar views.
* Version: 0.1.15
* Requires at least: 6.0
* Requires PHP: 8.1
* Author: Adrian Stephens (with AI assistance)
* License: GPL-2.0-or-later
* Text Domain: calendar-plugin
*/
declare(strict_types=1);
if (!defined('ABSPATH')) {
exit;
}
require_once __DIR__ . '/src/bootstrap.php';
\CalendarPlugin\Plugin::boot(__FILE__);

View File

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace CalendarPlugin\Contracts;
interface AuthAdapterInterface
{
public function currentUserId(): int;
public function currentUserCan(string $capability): bool;
public function verifyNonce(string $nonce, string $action): bool;
public function currentUserEmail(): string;
}

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace CalendarPlugin\Contracts;
interface DatabaseAdapterInterface
{
public function getPrefix(): string;
public function prepare(string $query, mixed ...$args): string;
public function query(string $query): int|false;
public function getResults(string $query): array;
public function getRow(string $query): ?object;
public function insert(string $table, array $data, array $formats = []): int|false;
public function update(string $table, array $data, array $where, array $formats = [], array $whereFormats = []): int|false;
public function delete(string $table, array $where, array $whereFormats = []): int|false;
public function insertId(): int;
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace CalendarPlugin\Contracts;
interface HttpAdapterInterface
{
public function addAction(string $hook, callable $callback, int $priority = 10, int $acceptedArgs = 1): void;
public function addShortcode(string $tag, callable $callback): void;
public function registerRestRoute(string $namespace, string $route, array $args): void;
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace CalendarPlugin\Contracts;
interface OptionsAdapterInterface
{
public function get(string $key, mixed $default = false): mixed;
public function set(string $key, mixed $value, bool $autoload = true): bool;
public function delete(string $key): bool;
}

View File

@ -0,0 +1,166 @@
<?php
declare(strict_types=1);
namespace CalendarPlugin\Domain;
final class CalDavService
{
public function __construct(
private readonly EventService $events,
private readonly IcsService $ics
) {
}
public function listResources(): array
{
$items = [];
foreach ($this->events->listEvents() as $event) {
$resource = $this->resourceForEvent($event);
$items[] = [
'resource' => $resource,
'href' => '/caldav/calendars/public/' . $resource,
'uid' => (string) ($event['uid'] ?? ''),
'etag' => (string) ($event['etag'] ?? ''),
'updated_at' => (string) ($event['updated_at'] ?? ''),
'sync_version' => (int) ($event['sync_version'] ?? 1),
];
}
return $items;
}
public function getObject(string $resource): ?array
{
$event = $this->findEventByResource($resource);
if (!$event) {
return null;
}
$ics = $this->ics->buildCalendar(
[$event],
fn(int $eventId): array => $this->events->getDeletedOccurrenceKeys($eventId)
);
return [
'resource' => $resource,
'etag' => (string) ($event['etag'] ?? ''),
'event' => $event,
'ics' => $ics,
];
}
public function putObject(string $resource, string $icsPayload, ?string $ifMatch = null, ?string $ifNoneMatch = null, ?int $userId = null): array
{
$payload = $this->ics->parseEventFromIcs($icsPayload);
if ($payload === null) {
return ['error' => ['code' => 'invalid_ics', 'message' => 'invalid iCalendar payload', 'status' => 422]];
}
$existing = $this->events->getEventByResource($resource);
if (!$existing) {
$existing = $this->findEventByResource($resource);
}
if ($ifNoneMatch === '*' && $existing) {
return ['error' => ['code' => 'precondition_failed', 'message' => 'resource already exists', 'status' => 412]];
}
if ($ifMatch !== null) {
if (!$existing) {
return ['error' => ['code' => 'precondition_failed', 'message' => 'resource does not exist', 'status' => 412]];
}
if ((string) ($existing['etag'] ?? '') !== trim($ifMatch)) {
return ['error' => ['code' => 'precondition_failed', 'message' => 'etag mismatch', 'status' => 412]];
}
}
$payload['caldav_resource'] = $resource;
if ($userId !== null) {
$payload['last_modified_by_user_id'] = $userId;
}
$deleted = (array) ($payload['deleted_occurrence_keys'] ?? []);
unset($payload['deleted_occurrence_keys']);
if ($existing) {
$nextSyncVersion = ((int) ($existing['sync_version'] ?? 1)) + 1;
$payload['sync_version'] = $nextSyncVersion;
$payload['etag'] = $this->etagFor((string) ($payload['uid'] ?? $existing['uid'] ?? ''), $nextSyncVersion);
$event = $this->events->updateEvent((int) $existing['id'], $payload);
if (!$event) {
return ['error' => ['code' => 'update_failed', 'message' => 'failed to update object', 'status' => 500]];
}
$this->events->syncDeletedOccurrenceKeys((int) $event['id'], $deleted, true);
$event = $this->events->getEvent((int) $event['id']) ?? $event;
return ['status' => 204, 'created' => false, 'event' => $event];
}
$payload['sync_version'] = 1;
$payload['etag'] = $this->etagFor((string) ($payload['uid'] ?? ''), 1);
$event = $this->events->createEvent($payload);
$this->events->syncDeletedOccurrenceKeys((int) $event['id'], $deleted, true);
$event = $this->events->getEvent((int) $event['id']) ?? $event;
return ['status' => 201, 'created' => true, 'event' => $event];
}
public function deleteObject(string $resource): array
{
$existing = $this->findEventByResource($resource);
if (!$existing) {
return ['error' => ['code' => 'not_found', 'message' => 'resource not found', 'status' => 404]];
}
$ok = $this->events->deleteEvent((int) $existing['id']);
if (!$ok) {
return ['error' => ['code' => 'delete_failed', 'message' => 'failed to delete resource', 'status' => 500]];
}
return ['status' => 204, 'deleted' => true];
}
public function multiget(array $resources): array
{
$out = [];
foreach ($resources as $resource) {
$resource = basename((string) $resource);
if ($resource === '') {
continue;
}
$object = $this->getObject($resource);
if ($object === null) {
$out[] = ['resource' => $resource, 'status' => 404];
continue;
}
$out[] = [
'resource' => $resource,
'status' => 200,
'etag' => $object['etag'],
'ics' => $object['ics'],
];
}
return $out;
}
public function resourceForEvent(array $event): string
{
$resource = trim((string) ($event['caldav_resource'] ?? ''));
if ($resource !== '') {
return $resource;
}
return (string) ($event['uid'] ?? 'event-' . (string) ($event['id'] ?? 0)) . '.ics';
}
private function etagFor(string $uid, int $version): string
{
return '"' . substr(sha1($uid . ':' . $version . ':' . gmdate('c')), 0, 16) . '"';
}
private function findEventByResource(string $resource): ?array
{
foreach ($this->events->listEvents() as $event) {
if ($this->resourceForEvent($event) === $resource) {
return $event;
}
}
return null;
}
}

View File

@ -0,0 +1,709 @@
<?php
declare(strict_types=1);
namespace CalendarPlugin\Domain;
use CalendarPlugin\Contracts\DatabaseAdapterInterface;
use DateTimeImmutable;
use DateTimeZone;
final class EventService
{
private readonly string $eventsTable;
private readonly string $exceptionsTable;
public function __construct(private readonly DatabaseAdapterInterface $db, string $tableStem = 'cs_calendar')
{
$prefix = $db->getPrefix();
$stem = trim($tableStem, '_');
$this->eventsTable = $prefix . $stem . '_events';
$this->exceptionsTable = $prefix . $stem . '_recurrence_exceptions';
}
public function listEvents(): array
{
$rows = $this->db->getResults("SELECT * FROM {$this->eventsTable} ORDER BY id ASC");
return array_map([$this, 'normalizeRow'], $rows);
}
public function getEvent(int $id): ?array
{
$sql = $this->db->prepare("SELECT * FROM {$this->eventsTable} WHERE id = %d", $id);
$row = $this->db->getRow($sql);
return $row ? $this->normalizeRow($row) : null;
}
public function createEvent(array $payload): array
{
$now = gmdate('c');
$uid = (string) ($payload['uid'] ?? bin2hex(random_bytes(10)) . '@calendar-plugin');
$resource = $this->resourceFromUid($uid);
$title = trim((string) ($payload['title'] ?? 'Untitled'));
$startRaw = (string) ($payload['start_datetime'] ?? '');
$endRaw = (string) ($payload['end_datetime'] ?? '');
$start = $this->toLondonDateTimeString($startRaw);
$end = $this->toLondonDateTimeString($endRaw);
if ($start === '' || $end === '') {
throw new \InvalidArgumentException('start_datetime and end_datetime are required');
}
if (new DateTimeImmutable($end) < new DateTimeImmutable($start)) {
throw new \InvalidArgumentException('end_datetime must be at or after start_datetime');
}
$repeatType = (string) ($payload['repeat_type'] ?? 'none');
$repeatInterval = max(1, (int) ($payload['repeat_interval'] ?? 1));
$repeatNthMode = (string) ($payload['repeat_nth_mode'] ?? '');
$repeatNthDay = array_key_exists('repeat_nth_day', $payload) && $payload['repeat_nth_day'] !== null && $payload['repeat_nth_day'] !== ''
? (int) $payload['repeat_nth_day']
: null;
$repeatNthPos = array_key_exists('repeat_nth_pos', $payload) && $payload['repeat_nth_pos'] !== null && $payload['repeat_nth_pos'] !== ''
? (int) $payload['repeat_nth_pos']
: null;
$repeatNthWeekday = array_key_exists('repeat_nth_weekday', $payload) && $payload['repeat_nth_weekday'] !== null && $payload['repeat_nth_weekday'] !== ''
? (int) $payload['repeat_nth_weekday']
: null;
[$start, $end] = $this->normalizeMonthlyAnchor(
$start,
$end,
$repeatType,
$repeatInterval,
$repeatNthMode,
$repeatNthDay,
$repeatNthPos,
$repeatNthWeekday
);
$data = [
'uid' => $uid,
'title' => $title,
'description' => (string) ($payload['description'] ?? ''),
'location' => (string) ($payload['location'] ?? ''),
'category' => (string) ($payload['category'] ?? ''),
'all_day_event' => !empty($payload['all_day_event']) ? 1 : 0,
'start_datetime' => $start,
'end_datetime' => $end,
'repeat_type' => $repeatType,
'repeat_interval' => $repeatInterval,
'repeat_nth_mode' => $repeatNthMode,
'repeat_nth_day' => $repeatNthDay,
'repeat_nth_pos' => $repeatNthPos,
'repeat_nth_weekday' => $repeatNthWeekday,
'repeat_range_mode' => $this->canonicalRangeMode((string) ($payload['repeat_range_mode'] ?? 'none')),
'repeat_count' => isset($payload['repeat_count']) ? (int) $payload['repeat_count'] : null,
'repeat_until' => !empty($payload['repeat_until']) ? (string) $payload['repeat_until'] : null,
'timezone' => (string) ($payload['timezone'] ?? 'Europe/London'),
'caldav_resource' => !empty($payload['caldav_resource']) ? (string) $payload['caldav_resource'] : $resource,
'etag' => (string) ($payload['etag'] ?? $this->makeEtag($uid, 1, $now)),
'sync_version' => (int) ($payload['sync_version'] ?? 1),
'last_modified_by_user_id' => isset($payload['last_modified_by_user_id']) ? (int) $payload['last_modified_by_user_id'] : null,
'created_at' => $now,
'updated_at' => $now,
];
$inserted = $this->db->insert($this->eventsTable, $data);
if ($inserted === false) {
throw new \RuntimeException('failed to create event');
}
return (array) $this->getEvent($this->db->insertId());
}
public function updateEvent(int $id, array $payload): ?array
{
$existing = $this->getEvent($id);
if (!$existing) {
return null;
}
$now = gmdate('c');
$currentResource = trim((string) ($existing['caldav_resource'] ?? ''));
$fallbackResource = $this->resourceFromUid((string) ($existing['uid'] ?? ''));
$start = array_key_exists('start_datetime', $payload)
? $this->toLondonDateTimeString((string) $payload['start_datetime'])
: (string) $existing['start_datetime'];
$end = array_key_exists('end_datetime', $payload)
? $this->toLondonDateTimeString((string) $payload['end_datetime'])
: (string) $existing['end_datetime'];
if ($start !== '' && $end !== '' && new DateTimeImmutable($end) < new DateTimeImmutable($start)) {
throw new \InvalidArgumentException('end_datetime must be at or after start_datetime');
}
$repeatType = (string) ($payload['repeat_type'] ?? $existing['repeat_type']);
$repeatInterval = max(1, (int) ($payload['repeat_interval'] ?? $existing['repeat_interval']));
$repeatNthMode = (string) ($payload['repeat_nth_mode'] ?? ($existing['repeat_nth_mode'] ?? ''));
$repeatNthDay = array_key_exists('repeat_nth_day', $payload)
? ($payload['repeat_nth_day'] === null || $payload['repeat_nth_day'] === '' ? null : (int) $payload['repeat_nth_day'])
: ($existing['repeat_nth_day'] ?? null);
$repeatNthPos = array_key_exists('repeat_nth_pos', $payload)
? ($payload['repeat_nth_pos'] === null || $payload['repeat_nth_pos'] === '' ? null : (int) $payload['repeat_nth_pos'])
: ($existing['repeat_nth_pos'] ?? null);
$repeatNthWeekday = array_key_exists('repeat_nth_weekday', $payload)
? ($payload['repeat_nth_weekday'] === null || $payload['repeat_nth_weekday'] === '' ? null : (int) $payload['repeat_nth_weekday'])
: ($existing['repeat_nth_weekday'] ?? null);
[$start, $end] = $this->normalizeMonthlyAnchor(
$start,
$end,
$repeatType,
$repeatInterval,
$repeatNthMode,
$repeatNthDay,
$repeatNthPos,
$repeatNthWeekday
);
$data = [
'title' => trim((string) ($payload['title'] ?? $existing['title'])),
'description' => (string) ($payload['description'] ?? $existing['description']),
'location' => (string) ($payload['location'] ?? $existing['location']),
'category' => (string) ($payload['category'] ?? $existing['category']),
'all_day_event' => array_key_exists('all_day_event', $payload)
? (!empty($payload['all_day_event']) ? 1 : 0)
: ((bool) $existing['all_day_event'] ? 1 : 0),
'start_datetime' => $start,
'end_datetime' => $end,
'repeat_type' => $repeatType,
'repeat_interval' => $repeatInterval,
'repeat_nth_mode' => $repeatNthMode,
'repeat_nth_day' => $repeatNthDay,
'repeat_nth_pos' => $repeatNthPos,
'repeat_nth_weekday' => $repeatNthWeekday,
'repeat_range_mode' => $this->canonicalRangeMode((string) ($payload['repeat_range_mode'] ?? $existing['repeat_range_mode'])),
'repeat_count' => array_key_exists('repeat_count', $payload) ? (is_null($payload['repeat_count']) ? null : (int) $payload['repeat_count']) : $existing['repeat_count'],
'repeat_until' => array_key_exists('repeat_until', $payload) ? (empty($payload['repeat_until']) ? null : (string) $payload['repeat_until']) : $existing['repeat_until'],
'timezone' => (string) ($payload['timezone'] ?? $existing['timezone']),
'caldav_resource' => !empty($payload['caldav_resource'])
? (string) $payload['caldav_resource']
: ($currentResource !== '' ? $currentResource : $fallbackResource),
'etag' => (string) ($payload['etag'] ?? $existing['etag'] ?? $this->makeEtag((string) $existing['uid'], (int) ($existing['sync_version'] ?? 1), $now)),
'sync_version' => (int) ($payload['sync_version'] ?? (($existing['sync_version'] ?? 1) + 1)),
'last_modified_by_user_id' => array_key_exists('last_modified_by_user_id', $payload)
? (is_null($payload['last_modified_by_user_id']) ? null : (int) $payload['last_modified_by_user_id'])
: ($existing['last_modified_by_user_id'] ?? null),
'updated_at' => $now,
];
$this->db->update($this->eventsTable, $data, ['id' => $id]);
return $this->getEvent($id);
}
public function deleteEvent(int $id): bool
{
$this->db->delete($this->exceptionsTable, ['event_id' => $id]);
$deleted = $this->db->delete($this->eventsTable, ['id' => $id]);
return $deleted !== false;
}
public function deleteOccurrence(int $eventId, string $occurrenceKey): bool
{
$event = $this->getEvent($eventId);
if (!$event) {
return false;
}
$canonical = $this->canonicalOccurrenceKey($occurrenceKey);
if ($canonical === null) {
return false;
}
if (in_array($canonical, $this->deletedKeysForEvent($eventId), true)) {
return true;
}
$now = gmdate('c');
$inserted = $this->db->insert(
$this->exceptionsTable,
[
'event_id' => $eventId,
'occurrence_key' => $canonical,
'exception_type' => 'deleted_occurrence',
'created_at' => $now,
'updated_at' => $now,
]
);
return $inserted !== false;
}
public function listEventOccurrences(int $eventId, string $fromDate, int $months = 3): ?array
{
$event = $this->getEvent($eventId);
if (!$event) {
return null;
}
$tz = new DateTimeZone('Europe/London');
$start = $this->safeDate($fromDate, $tz)->setTime(0, 0, 0);
$months = max(1, min($months, 24));
$end = $start->modify('+' . $months . ' month');
$deleted = $this->deletedKeysForEvent($eventId);
$items = RecurrenceExpander::expand($event, $start, $end, $deleted);
usort(
$items,
static fn(array $a, array $b): int => strcmp((string) $a['occurrence_start'], (string) $b['occurrence_start'])
);
return $items;
}
public function previewOccurrences(array $payload, string $fromDate, int $months = 3): array
{
$startRaw = (string) ($payload['start_datetime'] ?? '');
$endRaw = (string) ($payload['end_datetime'] ?? '');
$start = $this->toLondonDateTimeString($startRaw);
$end = $this->toLondonDateTimeString($endRaw);
if ($start === '' || $end === '') {
throw new \InvalidArgumentException('start_datetime and end_datetime are required');
}
if (new DateTimeImmutable($end) < new DateTimeImmutable($start)) {
throw new \InvalidArgumentException('end_datetime must be at or after start_datetime');
}
$repeatType = (string) ($payload['repeat_type'] ?? 'none');
if ($repeatType === 'none') {
return [];
}
$repeatInterval = max(1, (int) ($payload['repeat_interval'] ?? 1));
$repeatNthMode = (string) ($payload['repeat_nth_mode'] ?? '');
$repeatNthDay = array_key_exists('repeat_nth_day', $payload) && $payload['repeat_nth_day'] !== null && $payload['repeat_nth_day'] !== ''
? (int) $payload['repeat_nth_day']
: null;
$repeatNthPos = array_key_exists('repeat_nth_pos', $payload) && $payload['repeat_nth_pos'] !== null && $payload['repeat_nth_pos'] !== ''
? (int) $payload['repeat_nth_pos']
: null;
$repeatNthWeekday = array_key_exists('repeat_nth_weekday', $payload) && $payload['repeat_nth_weekday'] !== null && $payload['repeat_nth_weekday'] !== ''
? (int) $payload['repeat_nth_weekday']
: null;
[$start, $end] = $this->normalizeMonthlyAnchor(
$start,
$end,
$repeatType,
$repeatInterval,
$repeatNthMode,
$repeatNthDay,
$repeatNthPos,
$repeatNthWeekday
);
$event = [
'id' => 0,
'uid' => 'preview@calendar-plugin',
'title' => (string) ($payload['title'] ?? ''),
'description' => (string) ($payload['description'] ?? ''),
'location' => (string) ($payload['location'] ?? ''),
'category' => (string) ($payload['category'] ?? ''),
'all_day_event' => !empty($payload['all_day_event']),
'start_datetime' => $start,
'end_datetime' => $end,
'repeat_type' => $repeatType,
'repeat_interval' => $repeatInterval,
'repeat_nth_mode' => $repeatNthMode,
'repeat_nth_day' => $repeatNthDay,
'repeat_nth_pos' => $repeatNthPos,
'repeat_nth_weekday' => $repeatNthWeekday,
'repeat_range_mode' => $this->canonicalRangeMode((string) ($payload['repeat_range_mode'] ?? 'none')),
'repeat_count' => isset($payload['repeat_count']) ? (int) $payload['repeat_count'] : null,
'repeat_until' => !empty($payload['repeat_until']) ? (string) $payload['repeat_until'] : null,
'timezone' => 'Europe/London',
];
$tz = new DateTimeZone('Europe/London');
$startWindow = $this->safeDate($fromDate, $tz)->setTime(0, 0, 0);
$months = max(1, min($months, 24));
$endWindow = $startWindow->modify('+' . $months . ' month');
$deleted = [];
foreach ((array) ($payload['deleted_occurrence_keys'] ?? []) as $key) {
$canonical = $this->canonicalOccurrenceKey((string) $key);
if ($canonical !== null) {
$deleted[] = $canonical;
}
}
$items = RecurrenceExpander::expand($event, $startWindow, $endWindow, $deleted);
usort(
$items,
static fn(array $a, array $b): int => strcmp((string) $a['occurrence_start'], (string) $b['occurrence_start'])
);
return $items;
}
public function getEventByResource(string $resource): ?array
{
$sql = $this->db->prepare("SELECT * FROM {$this->eventsTable} WHERE caldav_resource = %s", $resource);
$row = $this->db->getRow($sql);
return $row ? $this->normalizeRow($row) : null;
}
public function getDeletedOccurrenceKeys(int $eventId): array
{
return $this->deletedKeysForEvent($eventId);
}
public function syncDeletedOccurrenceKeys(int $eventId, array $keys, bool $replace = true): void
{
if ($replace) {
$this->db->delete($this->exceptionsTable, ['event_id' => $eventId, 'exception_type' => 'deleted_occurrence']);
}
$now = gmdate('c');
foreach ($keys as $key) {
$canonical = $this->canonicalOccurrenceKey((string) $key);
if ($canonical === null) {
continue;
}
$this->db->insert(
$this->exceptionsTable,
[
'event_id' => $eventId,
'occurrence_key' => $canonical,
'exception_type' => 'deleted_occurrence',
'created_at' => $now,
'updated_at' => $now,
]
);
}
}
public function listPublicOccurrences(string $view, string $dateAnchor, bool $futureOnly = false): array
{
$tz = new DateTimeZone('Europe/London');
$anchor = $this->safeDate($dateAnchor, $tz);
if (strtolower($view) === 'list') {
$windowStart = $anchor->setTime(0, 0, 0);
if ($futureOnly) {
$today = new DateTimeImmutable('today', $tz);
if ($today > $windowStart) {
$windowStart = $today;
}
}
$windowEnd = $windowStart->modify('+18 months');
} else {
[$windowStart, $windowEnd] = $this->windowForView($view, $anchor);
}
$events = $this->listEvents();
$out = [];
foreach ($events as $event) {
$deleted = $this->deletedKeysForEvent((int) $event['id']);
$items = RecurrenceExpander::expand($event, $windowStart, $windowEnd, $deleted);
array_push($out, ...$items);
}
usort(
$out,
static fn(array $a, array $b): int => strcmp((string) $a['occurrence_start'], (string) $b['occurrence_start'])
);
return $out;
}
public function listSidebarUpcoming(int $days = 14): array
{
$tz = new DateTimeZone('Europe/London');
$start = new DateTimeImmutable('today', $tz);
$end = $start->modify('+' . max(1, $days) . ' days');
$events = $this->listEvents();
$out = [];
foreach ($events as $event) {
$deleted = $this->deletedKeysForEvent((int) $event['id']);
$items = RecurrenceExpander::expand($event, $start, $end, $deleted);
array_push($out, ...$items);
}
usort(
$out,
static fn(array $a, array $b): int => strcmp((string) $a['occurrence_start'], (string) $b['occurrence_start'])
);
return $out;
}
public function deleteAllEventsData(): int
{
$events = $this->listEvents();
$count = count($events);
$this->db->query("DELETE FROM {$this->exceptionsTable}");
$this->db->query("DELETE FROM {$this->eventsTable}");
return $count;
}
public function seedDefaultEvents(): int
{
$seed = [
[
'uid' => 'seed-ce-001@calendar-plugin',
'title' => 'Board Meeting',
'description' => 'Quarterly board review.',
'location' => 'Room A',
'category' => 'Governance',
'start_datetime' => '2026-04-01T10:00:00+01:00',
'end_datetime' => '2026-04-01T11:30:00+01:00',
'repeat_type' => 'none',
],
[
'uid' => 'seed-ce-002@calendar-plugin',
'title' => 'Office Closed',
'description' => 'Public holiday closure.',
'location' => 'HQ',
'category' => 'Operations',
'all_day_event' => true,
'start_datetime' => '2026-05-04T00:00:00+01:00',
'end_datetime' => '2026-05-05T00:00:00+01:00',
'repeat_type' => 'none',
],
[
'uid' => 'seed-ce-003@calendar-plugin',
'title' => 'Daily Standup',
'description' => '15 minute sync.',
'location' => 'Online',
'category' => 'Team',
'start_datetime' => '2026-04-06T09:00:00+01:00',
'end_datetime' => '2026-04-06T09:15:00+01:00',
'repeat_type' => 'daily',
'repeat_interval' => 1,
'repeat_range_mode' => 'until',
'repeat_until' => '2026-04-15',
],
[
'uid' => 'seed-ce-004@calendar-plugin',
'title' => 'Community Lunch',
'description' => 'Weekly community lunch.',
'location' => 'Cafeteria',
'category' => 'Community',
'start_datetime' => '2026-04-08T12:30:00+01:00',
'end_datetime' => '2026-04-08T13:30:00+01:00',
'repeat_type' => 'weekly',
'repeat_interval' => 1,
'repeat_range_mode' => 'until',
'repeat_until' => '2026-05-06',
],
[
'uid' => 'seed-ce-005@calendar-plugin',
'title' => 'Finance Close',
'description' => 'Month-end close process.',
'location' => 'Finance Office',
'category' => 'Finance',
'start_datetime' => '2026-03-31T17:00:00+01:00',
'end_datetime' => '2026-03-31T18:00:00+01:00',
'repeat_type' => 'monthly',
'repeat_interval' => 1,
'repeat_nth_mode' => 'day_of_month',
'repeat_nth_day' => 30,
'repeat_range_mode' => 'until',
'repeat_until' => '2026-06-30',
],
];
foreach ($seed as $event) {
$this->createEvent($event);
}
return count($seed);
}
private function normalizeRow(object $row): array
{
return [
'id' => (int) $row->id,
'uid' => (string) $row->uid,
'title' => (string) $row->title,
'description' => (string) $row->description,
'location' => (string) $row->location,
'category' => (string) $row->category,
'all_day_event' => (bool) $row->all_day_event,
'start_datetime' => (string) $row->start_datetime,
'end_datetime' => (string) $row->end_datetime,
'repeat_type' => (string) $row->repeat_type,
'repeat_interval' => (int) $row->repeat_interval,
'repeat_nth_mode' => property_exists($row, 'repeat_nth_mode') ? (string) ($row->repeat_nth_mode ?? '') : '',
'repeat_nth_day' => property_exists($row, 'repeat_nth_day') && $row->repeat_nth_day !== null ? (int) $row->repeat_nth_day : null,
'repeat_nth_pos' => property_exists($row, 'repeat_nth_pos') && $row->repeat_nth_pos !== null ? (int) $row->repeat_nth_pos : null,
'repeat_nth_weekday' => property_exists($row, 'repeat_nth_weekday') && $row->repeat_nth_weekday !== null ? (int) $row->repeat_nth_weekday : null,
'repeat_range_mode' => (string) $row->repeat_range_mode,
'repeat_count' => is_null($row->repeat_count) ? null : (int) $row->repeat_count,
'repeat_until' => $row->repeat_until === null ? null : (string) $row->repeat_until,
'timezone' => (string) $row->timezone,
'caldav_resource' => property_exists($row, 'caldav_resource') ? (string) ($row->caldav_resource ?? '') : '',
'etag' => property_exists($row, 'etag') ? (string) ($row->etag ?? '') : '',
'sync_version' => property_exists($row, 'sync_version') ? (int) ($row->sync_version ?? 1) : 1,
'last_modified_by_user_id' => property_exists($row, 'last_modified_by_user_id') && $row->last_modified_by_user_id !== null
? (int) $row->last_modified_by_user_id
: null,
'created_at' => (string) $row->created_at,
'updated_at' => (string) $row->updated_at,
];
}
private function deletedKeysForEvent(int $eventId): array
{
$sql = $this->db->prepare(
"SELECT occurrence_key FROM {$this->exceptionsTable} WHERE event_id = %d AND exception_type = 'deleted_occurrence'",
$eventId
);
$rows = $this->db->getResults($sql);
return array_map(static fn(object $r): string => (string) $r->occurrence_key, $rows);
}
private function safeDate(string $dateAnchor, DateTimeZone $tz): DateTimeImmutable
{
if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $dateAnchor)) {
return new DateTimeImmutable($dateAnchor . 'T00:00:00', $tz);
}
return new DateTimeImmutable('today', $tz);
}
private function windowForView(string $view, DateTimeImmutable $anchor): array
{
$view = strtolower($view);
if ($view === 'day') {
$start = $anchor->setTime(0, 0, 0);
return [$start, $start->modify('+1 day')];
}
if ($view === 'week') {
$weekday = (int) $anchor->format('w');
$start = $anchor->modify('-' . $weekday . ' day')->setTime(0, 0, 0);
return [$start, $start->modify('+7 days')];
}
if ($view === 'year') {
$start = $anchor->setDate((int) $anchor->format('Y'), 1, 1)->setTime(0, 0, 0);
return [$start, $start->modify('+1 year')];
}
$monthStart = $anchor->setDate((int) $anchor->format('Y'), (int) $anchor->format('m'), 1)->setTime(0, 0, 0);
$startWeekday = (int) $monthStart->format('w');
$gridStart = $monthStart->modify('-' . $startWeekday . ' day');
$gridEnd = $gridStart->modify('+42 days');
return [$gridStart, $gridEnd];
}
private function canonicalOccurrenceKey(string $value): ?string
{
try {
if (!str_contains($value, 'T') && preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
$dt = new DateTimeImmutable($value . 'T00:00:00', new DateTimeZone('Europe/London'));
return $dt->format('c');
}
$dt = new DateTimeImmutable($value, new DateTimeZone('Europe/London'));
return $dt->format('c');
} catch (\Throwable) {
return null;
}
}
private function makeEtag(string $uid, int $syncVersion, string $stamp): string
{
return '"' . substr(sha1($uid . ':' . $syncVersion . ':' . $stamp), 0, 16) . '"';
}
private function resourceFromUid(string $uid): string
{
$uid = trim($uid);
if ($uid === '') {
$uid = bin2hex(random_bytes(10)) . '@calendar-plugin';
}
return $uid . '.ics';
}
private function toLondonDateTimeString(string $value): string
{
$value = trim($value);
if ($value === '') {
return '';
}
try {
$dt = new DateTimeImmutable($value, new DateTimeZone('Europe/London'));
return $dt->setTimezone(new DateTimeZone('Europe/London'))->format('c');
} catch (\Throwable) {
throw new \InvalidArgumentException('invalid datetime value');
}
}
private function canonicalRangeMode(string $value): string
{
$v = strtolower(trim($value));
if ($v === 'no_end' || $v === '') {
return 'none';
}
return in_array($v, ['none', 'count', 'until'], true) ? $v : 'none';
}
private function normalizeMonthlyAnchor(
string $startIso,
string $endIso,
string $repeatType,
int $repeatInterval,
string $repeatNthMode,
?int $repeatNthDay,
?int $repeatNthPos,
?int $repeatNthWeekday
): array {
if ($repeatType !== 'monthly') {
return [$startIso, $endIso];
}
try {
$tz = new DateTimeZone('Europe/London');
$start = new DateTimeImmutable($startIso, $tz);
$end = new DateTimeImmutable($endIso, $tz);
$duration = $start->diff($end);
$targetDay = (int) $start->format('j');
if ($repeatNthMode === 'day_of_month' && $repeatNthDay !== null) {
$daysInMonth = (int) $start->format('t');
$targetDay = max(1, min($repeatNthDay, $daysInMonth));
} elseif ($repeatNthMode === 'weekday_of_month' && $repeatNthPos !== null && $repeatNthWeekday !== null) {
$nthDay = $this->nthWeekdayOfMonth((int) $start->format('Y'), (int) $start->format('n'), $repeatNthWeekday, $repeatNthPos);
if ($nthDay === null) {
$year = (int) $start->format('Y');
$month = (int) $start->format('n');
$step = max(1, $repeatInterval);
for ($i = 0; $i < 120; $i++) {
[$year, $month] = $this->addMonths($year, $month, $step);
$nthDay = $this->nthWeekdayOfMonth($year, $month, $repeatNthWeekday, $repeatNthPos);
if ($nthDay !== null) {
$start = $start->setDate($year, $month, $nthDay);
$targetDay = $nthDay;
break;
}
}
} else {
$targetDay = $nthDay;
}
}
$anchoredStart = $start->setDate((int) $start->format('Y'), (int) $start->format('n'), $targetDay);
$anchoredEnd = $anchoredStart->add($duration);
return [$anchoredStart->format('c'), $anchoredEnd->format('c')];
} catch (\Throwable) {
return [$startIso, $endIso];
}
}
private function nthWeekdayOfMonth(int $year, int $month, int $weekday, int $pos): ?int
{
$weekday = max(0, min(6, $weekday));
$tz = new DateTimeZone('Europe/London');
if ($pos === -1) {
$last = new DateTimeImmutable(sprintf('%04d-%02d-01T00:00:00', $year, $month), $tz);
$last = $last->modify('last day of this month');
for ($day = (int) $last->format('j'); $day >= 1; $day--) {
$d = $last->setDate($year, $month, $day);
if ((int) $d->format('w') === $weekday) {
return $day;
}
}
return null;
}
$first = new DateTimeImmutable(sprintf('%04d-%02d-01T00:00:00', $year, $month), $tz);
$daysInMonth = (int) $first->format('t');
$seen = 0;
for ($day = 1; $day <= $daysInMonth; $day++) {
$d = $first->setDate($year, $month, $day);
if ((int) $d->format('w') !== $weekday) {
continue;
}
$seen++;
if ($seen === $pos) {
return $day;
}
}
return null;
}
private function addMonths(int $year, int $month, int $delta): array
{
$index = ($year * 12) + ($month - 1) + $delta;
$newYear = (int) floor($index / 12);
$newMonth = ($index % 12) + 1;
if ($newMonth <= 0) {
$newMonth += 12;
$newYear -= 1;
}
return [$newYear, $newMonth];
}
}

View File

@ -0,0 +1,439 @@
<?php
declare(strict_types=1);
namespace CalendarPlugin\Domain;
use DateTimeImmutable;
use DateTimeZone;
final class IcsService
{
private const PRODID = '-//Calendar Plugin//EN';
public function buildCalendar(array $events, callable $deletedKeysProvider, string $calendarName = 'Calendar'): string
{
$lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:' . self::PRODID,
'CALSCALE:GREGORIAN',
'X-WR-CALNAME:' . $this->escapeText($calendarName),
'X-WR-TIMEZONE:Europe/London',
];
foreach ($events as $event) {
$lines = array_merge($lines, $this->eventToLines($event, (array) $deletedKeysProvider((int) ($event['id'] ?? 0))));
}
$lines[] = 'END:VCALENDAR';
return implode("\r\n", $this->foldLines($lines)) . "\r\n";
}
public function parseEventFromIcs(string $ics): ?array
{
$props = $this->extractVeventProperties($ics);
if ($props === null) {
return null;
}
$uid = (string) ($props['UID'][0] ?? '');
$summary = (string) ($props['SUMMARY'][0] ?? 'Untitled');
$description = (string) ($props['DESCRIPTION'][0] ?? '');
$location = (string) ($props['LOCATION'][0] ?? '');
$category = (string) ($props['CATEGORIES'][0] ?? '');
$dtstartRaw = (string) ($props['DTSTART'][0] ?? '');
$dtendRaw = (string) ($props['DTEND'][0] ?? '');
if ($dtstartRaw === '' || $dtendRaw === '') {
return null;
}
$allDay = str_contains((string) ($props['_DTSTART_PARAMS'][0] ?? ''), 'VALUE=DATE');
$start = $this->parseIcsDateTime($dtstartRaw, $allDay);
$end = $this->parseIcsDateTime($dtendRaw, $allDay);
if ($start === null || $end === null) {
return null;
}
$payload = [
'uid' => $uid !== '' ? $uid : bin2hex(random_bytes(10)) . '@calendar-plugin',
'title' => $summary,
'description' => $description,
'location' => $location,
'category' => $category,
'all_day_event' => $allDay,
'start_datetime' => $start,
'end_datetime' => $end,
'repeat_type' => 'none',
'repeat_interval' => 1,
'repeat_nth_mode' => '',
'repeat_nth_day' => null,
'repeat_nth_pos' => null,
'repeat_nth_weekday' => null,
'repeat_range_mode' => 'none',
'repeat_count' => null,
'repeat_until' => null,
'timezone' => 'Europe/London',
];
$rrule = (string) ($props['RRULE'][0] ?? '');
if ($rrule !== '') {
$payload = array_merge($payload, $this->parseRrule($rrule));
}
$exdates = [];
foreach (($props['EXDATE'] ?? []) as $exdateRaw) {
$chunks = array_filter(array_map('trim', explode(',', (string) $exdateRaw)));
foreach ($chunks as $chunk) {
$asDate = $this->parseIcsDateTime($chunk, false);
if ($asDate !== null) {
$exdates[] = $asDate;
}
}
}
$payload['deleted_occurrence_keys'] = $exdates;
return $payload;
}
private function eventToLines(array $event, array $deletedKeys): array
{
$uid = (string) ($event['uid'] ?? '');
$uid = $uid !== '' ? $uid : ('event-' . (string) ($event['id'] ?? 0) . '@calendar-plugin');
$start = $this->toDateTime((string) ($event['start_datetime'] ?? ''));
$end = $this->toDateTime((string) ($event['end_datetime'] ?? ''));
if ($start === null || $end === null) {
return [];
}
$allDay = (bool) ($event['all_day_event'] ?? false);
$updated = $this->toDateTime((string) ($event['updated_at'] ?? '')) ?? new DateTimeImmutable('now', new DateTimeZone('UTC'));
$lines = [
'BEGIN:VEVENT',
'UID:' . $this->escapeText($uid),
'SUMMARY:' . $this->escapeText((string) ($event['title'] ?? 'Untitled')),
'DESCRIPTION:' . $this->escapeText((string) ($event['description'] ?? '')),
'LOCATION:' . $this->escapeText((string) ($event['location'] ?? '')),
'CATEGORIES:' . $this->escapeText((string) ($event['category'] ?? '')),
'DTSTAMP:' . $this->toUtcIcs($updated),
'LAST-MODIFIED:' . $this->toUtcIcs($updated),
];
if ($allDay) {
$lines[] = 'DTSTART;VALUE=DATE:' . $start->setTimezone(new DateTimeZone('Europe/London'))->format('Ymd');
$lines[] = 'DTEND;VALUE=DATE:' . $end->setTimezone(new DateTimeZone('Europe/London'))->format('Ymd');
} else {
$lines[] = 'DTSTART;TZID=Europe/London:' . $start->setTimezone(new DateTimeZone('Europe/London'))->format('Ymd\\THis');
$lines[] = 'DTEND;TZID=Europe/London:' . $end->setTimezone(new DateTimeZone('Europe/London'))->format('Ymd\\THis');
}
$rrule = $this->eventToRrule($event);
if ($rrule !== null) {
$lines[] = 'RRULE:' . $rrule;
}
if ($deletedKeys) {
$parts = [];
foreach ($deletedKeys as $key) {
$dt = $this->toDateTime((string) $key);
if ($dt === null) {
continue;
}
$parts[] = $dt->setTimezone(new DateTimeZone('Europe/London'))->format('Ymd\\THis');
}
if ($parts) {
$lines[] = 'EXDATE;TZID=Europe/London:' . implode(',', $parts);
}
}
$lines[] = 'END:VEVENT';
return $lines;
}
private function eventToRrule(array $event): ?string
{
$type = strtolower((string) ($event['repeat_type'] ?? 'none'));
if ($type === 'none') {
return null;
}
$freq = match ($type) {
'daily' => 'DAILY',
'weekly', 'custom' => 'WEEKLY',
'monthly' => 'MONTHLY',
'yearly' => 'YEARLY',
default => null,
};
if ($freq === null) {
return null;
}
$interval = max(1, (int) ($event['repeat_interval'] ?? 1));
$parts = ['FREQ=' . $freq, 'INTERVAL=' . $interval];
if ($type === 'monthly') {
$nthMode = (string) ($event['repeat_nth_mode'] ?? '');
$nthDay = isset($event['repeat_nth_day']) && $event['repeat_nth_day'] !== null ? (int) $event['repeat_nth_day'] : null;
$nthPos = isset($event['repeat_nth_pos']) && $event['repeat_nth_pos'] !== null ? (int) $event['repeat_nth_pos'] : null;
$nthWeekday = isset($event['repeat_nth_weekday']) && $event['repeat_nth_weekday'] !== null ? (int) $event['repeat_nth_weekday'] : null;
if ($nthMode === 'day_of_month' && $nthDay !== null) {
$parts[] = 'BYMONTHDAY=' . max(1, min(31, $nthDay));
} elseif ($nthMode === 'weekday_of_month' && $nthPos !== null && $nthWeekday !== null) {
$byDay = $this->weekdayNumToToken($nthWeekday);
if ($byDay !== null) {
$parts[] = 'BYDAY=' . $byDay;
$parts[] = 'BYSETPOS=' . ($nthPos < 0 ? -1 : max(1, min(5, $nthPos)));
}
}
}
$rangeMode = strtolower((string) ($event['repeat_range_mode'] ?? 'none'));
if ($rangeMode === 'count' && !empty($event['repeat_count'])) {
$parts[] = 'COUNT=' . max(1, (int) $event['repeat_count']);
}
if ($rangeMode === 'until' && !empty($event['repeat_until'])) {
$until = $this->toDateTime((string) $event['repeat_until'] . 'T23:59:59');
if ($until !== null) {
$parts[] = 'UNTIL=' . $this->toUtcIcs($until);
}
}
return implode(';', $parts);
}
private function parseRrule(string $rrule): array
{
$parts = [];
foreach (explode(';', strtoupper(trim($rrule))) as $chunk) {
[$k, $v] = array_pad(explode('=', $chunk, 2), 2, '');
if ($k !== '') {
$parts[$k] = $v;
}
}
$repeatType = match ($parts['FREQ'] ?? '') {
'DAILY' => 'daily',
'WEEKLY' => 'weekly',
'MONTHLY' => 'monthly',
'YEARLY' => 'yearly',
default => 'none',
};
$payload = [
'repeat_type' => $repeatType,
'repeat_interval' => max(1, (int) ($parts['INTERVAL'] ?? 1)),
'repeat_nth_mode' => '',
'repeat_nth_day' => null,
'repeat_nth_pos' => null,
'repeat_nth_weekday' => null,
'repeat_range_mode' => 'none',
'repeat_count' => null,
'repeat_until' => null,
];
if ($repeatType === 'monthly') {
if (!empty($parts['BYMONTHDAY'])) {
$raw = trim(explode(',', (string) $parts['BYMONTHDAY'])[0]);
if (preg_match('/^-?\d+$/', $raw)) {
$payload['repeat_nth_mode'] = 'day_of_month';
$payload['repeat_nth_day'] = max(1, min(31, (int) $raw));
}
} elseif (!empty($parts['BYDAY'])) {
$byDayRaw = trim(explode(',', (string) $parts['BYDAY'])[0]);
$pos = null;
$token = $byDayRaw;
if (preg_match('/^(-?\d+)([A-Z]{2})$/', $byDayRaw, $m)) {
$pos = (int) $m[1];
$token = $m[2];
}
$weekday = $this->weekdayTokenToNum($token);
if ($weekday !== null) {
$payload['repeat_nth_mode'] = 'weekday_of_month';
$payload['repeat_nth_weekday'] = $weekday;
if (isset($parts['BYSETPOS']) && preg_match('/^-?\d+$/', (string) $parts['BYSETPOS'])) {
$pos = (int) $parts['BYSETPOS'];
}
$payload['repeat_nth_pos'] = $pos === null ? 1 : ($pos < 0 ? -1 : max(1, min(5, $pos)));
}
}
}
if (isset($parts['COUNT'])) {
$payload['repeat_range_mode'] = 'count';
$payload['repeat_count'] = max(1, (int) $parts['COUNT']);
} elseif (isset($parts['UNTIL'])) {
$until = $this->parseIcsDateTime($parts['UNTIL'], false);
if ($until !== null) {
$payload['repeat_range_mode'] = 'until';
$payload['repeat_until'] = substr($until, 0, 10);
}
}
return $payload;
}
private function extractVeventProperties(string $ics): ?array
{
$lines = preg_split('/\r\n|\n|\r/', $ics) ?: [];
$unfolded = [];
foreach ($lines as $line) {
if ($line === '') {
continue;
}
if (($line[0] ?? '') === ' ' && $unfolded) {
$unfolded[count($unfolded) - 1] .= substr($line, 1);
continue;
}
$unfolded[] = $line;
}
$in = false;
$props = [];
foreach ($unfolded as $line) {
$upper = strtoupper($line);
if ($upper === 'BEGIN:VEVENT') {
$in = true;
continue;
}
if ($upper === 'END:VEVENT') {
break;
}
if (!$in) {
continue;
}
[$left, $value] = array_pad(explode(':', $line, 2), 2, '');
if ($left === '') {
continue;
}
[$name, $params] = array_pad(explode(';', $left, 2), 2, '');
$name = strtoupper(trim($name));
if ($name === '') {
continue;
}
$props[$name][] = $this->unescapeText(trim($value));
if ($name === 'DTSTART') {
$props['_DTSTART_PARAMS'][] = strtoupper(trim($params));
}
}
return $in ? $props : null;
}
private function parseIcsDateTime(string $value, bool $dateOnly): ?string
{
$value = trim($value);
if ($value === '') {
return null;
}
try {
if ($dateOnly && preg_match('/^\d{8}$/', $value)) {
$dt = DateTimeImmutable::createFromFormat('Ymd H:i:s', $value . ' 00:00:00', new DateTimeZone('Europe/London'));
if ($dt instanceof DateTimeImmutable) {
return $dt->format('Y-m-d\\T00:00:00P');
}
}
if (preg_match('/^\d{8}T\d{6}Z$/', $value)) {
$dt = DateTimeImmutable::createFromFormat('Ymd\\THis\\Z', $value, new DateTimeZone('UTC'));
if ($dt instanceof DateTimeImmutable) {
return $dt->setTimezone(new DateTimeZone('Europe/London'))->format('c');
}
}
if (preg_match('/^\d{8}T\d{6}$/', $value)) {
$dt = DateTimeImmutable::createFromFormat('Ymd\\THis', $value, new DateTimeZone('Europe/London'));
if ($dt instanceof DateTimeImmutable) {
return $dt->format('c');
}
}
$dt = new DateTimeImmutable($value, new DateTimeZone('Europe/London'));
return $dt->format('c');
} catch (\Throwable) {
return null;
}
}
private function toDateTime(string $value): ?DateTimeImmutable
{
if ($value === '') {
return null;
}
try {
return new DateTimeImmutable($value, new DateTimeZone('Europe/London'));
} catch (\Throwable) {
return null;
}
}
private function toUtcIcs(DateTimeImmutable $dt): string
{
return $dt->setTimezone(new DateTimeZone('UTC'))->format('Ymd\\THis\\Z');
}
private function escapeText(string $value): string
{
return str_replace(
["\\", ";", ",", "\r\n", "\n", "\r"],
["\\\\", "\\;", "\\,", "\\n", "\\n", "\\n"],
$value
);
}
private function unescapeText(string $value): string
{
return str_replace(
["\\n", "\\N", "\\,", "\\;", "\\\\"],
["\n", "\n", ",", ";", "\\"],
$value
);
}
private function weekdayNumToToken(int $weekday): ?string
{
return match ($weekday) {
0 => 'SU',
1 => 'MO',
2 => 'TU',
3 => 'WE',
4 => 'TH',
5 => 'FR',
6 => 'SA',
default => null,
};
}
private function weekdayTokenToNum(string $token): ?int
{
return match (strtoupper(trim($token))) {
'SU' => 0,
'MO' => 1,
'TU' => 2,
'WE' => 3,
'TH' => 4,
'FR' => 5,
'SA' => 6,
default => null,
};
}
private function foldLines(array $lines): array
{
$out = [];
foreach ($lines as $line) {
if ($line === '') {
$out[] = $line;
continue;
}
while (strlen($line) > 73) {
$out[] = substr($line, 0, 73);
$line = ' ' . substr($line, 73);
}
$out[] = $line;
}
return $out;
}
}

View File

@ -0,0 +1,193 @@
<?php
declare(strict_types=1);
namespace CalendarPlugin\Domain;
use DateInterval;
use DateTimeImmutable;
use DateTimeZone;
final class RecurrenceExpander
{
private const MAX_ITERATIONS = 512;
public static function expand(array $event, DateTimeImmutable $windowStart, DateTimeImmutable $windowEnd, array $deletedKeys = []): array
{
$tz = new DateTimeZone('Europe/London');
$start = self::parseDateTime((string) ($event['start_datetime'] ?? ''), $tz);
$end = self::parseDateTime((string) ($event['end_datetime'] ?? ''), $tz);
if (!$start || !$end || $end < $start) {
return [];
}
$deletedMap = [];
foreach ($deletedKeys as $key) {
$deletedMap[(string) $key] = true;
}
$duration = $start->diff($end);
$repeatType = (string) ($event['repeat_type'] ?? 'none');
$interval = max(1, (int) ($event['repeat_interval'] ?? 1));
$rangeMode = (string) ($event['repeat_range_mode'] ?? 'none');
$repeatCount = isset($event['repeat_count']) ? (int) $event['repeat_count'] : null;
$repeatUntil = null;
if ($rangeMode === 'until' && !empty($event['repeat_until'])) {
$repeatUntil = self::parseDateTime((string) $event['repeat_until'] . 'T23:59:59', $tz);
}
if ($repeatType === 'none') {
if (self::overlaps($start, $end, $windowStart, $windowEnd)) {
return [self::occurrence($event, $start, $end)];
}
return [];
}
$occurrences = [];
$current = $start;
$produced = 0;
for ($i = 0; $i < self::MAX_ITERATIONS; $i++) {
if ($rangeMode === 'count' && $repeatCount !== null && $produced >= $repeatCount) {
break;
}
if ($repeatUntil && $current > $repeatUntil) {
break;
}
$currentEnd = $current->add($duration);
if (self::overlaps($current, $currentEnd, $windowStart, $windowEnd)) {
$key = $current->format('c');
if (!isset($deletedMap[$key])) {
$occurrences[] = self::occurrence($event, $current, $currentEnd);
}
}
if ($current > $windowEnd->modify('+400 days')) {
break;
}
$produced++;
$current = self::nextStart($current, $repeatType, $interval, $event);
if (!$current) {
break;
}
}
return $occurrences;
}
private static function nextStart(DateTimeImmutable $current, string $repeatType, int $interval, array $event): ?DateTimeImmutable
{
return match ($repeatType) {
'daily' => $current->add(new DateInterval('P' . $interval . 'D')),
'weekly', 'custom' => $current->add(new DateInterval('P' . $interval . 'W')),
'monthly' => self::nextMonthlyStart($current, $interval, $event),
'yearly' => $current->modify('+' . $interval . ' year') ?: null,
default => null,
};
}
private static function nextMonthlyStart(DateTimeImmutable $current, int $interval, array $event): ?DateTimeImmutable
{
$mode = (string) ($event['repeat_nth_mode'] ?? '');
$next = $current->modify('+' . $interval . ' month');
if (!$next) {
return null;
}
if ($mode === 'day_of_month' && isset($event['repeat_nth_day']) && $event['repeat_nth_day'] !== null) {
$day = max(1, (int) $event['repeat_nth_day']);
$daysInMonth = (int) $next->format('t');
return $next->setDate((int) $next->format('Y'), (int) $next->format('n'), min($day, $daysInMonth));
}
if ($mode === 'weekday_of_month' && isset($event['repeat_nth_pos'], $event['repeat_nth_weekday']) && $event['repeat_nth_pos'] !== null && $event['repeat_nth_weekday'] !== null) {
$year = (int) $current->format('Y');
$month = (int) $current->format('n');
for ($i = 0; $i < 120; $i++) {
[$year, $month] = self::addMonths($year, $month, max(1, $interval));
$day = self::nthWeekdayOfMonth($year, $month, (int) $event['repeat_nth_weekday'], (int) $event['repeat_nth_pos']);
if ($day !== null) {
return $current->setDate($year, $month, $day);
}
}
return null;
}
return $next;
}
private static function nthWeekdayOfMonth(int $year, int $month, int $weekday, int $pos): ?int
{
$weekday = max(0, min(6, $weekday));
$tz = new DateTimeZone('Europe/London');
if ($pos === -1) {
$last = new DateTimeImmutable(sprintf('%04d-%02d-01T00:00:00', $year, $month), $tz);
$last = $last->modify('last day of this month');
for ($day = (int) $last->format('j'); $day >= 1; $day--) {
$dt = $last->setDate($year, $month, $day);
if ((int) $dt->format('w') === $weekday) {
return $day;
}
}
return null;
}
$first = new DateTimeImmutable(sprintf('%04d-%02d-01T00:00:00', $year, $month), $tz);
$daysInMonth = (int) $first->format('t');
$seen = 0;
for ($day = 1; $day <= $daysInMonth; $day++) {
$dt = $first->setDate($year, $month, $day);
if ((int) $dt->format('w') !== $weekday) {
continue;
}
$seen++;
if ($seen === $pos) {
return $day;
}
}
return null;
}
private static function occurrence(array $event, DateTimeImmutable $start, DateTimeImmutable $end): array
{
return [
'event_id' => (int) ($event['id'] ?? 0),
'uid' => (string) ($event['uid'] ?? ''),
'title' => (string) ($event['title'] ?? ''),
'description' => (string) ($event['description'] ?? ''),
'location' => (string) ($event['location'] ?? ''),
'category' => (string) ($event['category'] ?? ''),
'all_day_event' => (bool) ($event['all_day_event'] ?? false),
'occurrence_start' => $start->format('c'),
'occurrence_end' => $end->format('c'),
'repeat_type' => (string) ($event['repeat_type'] ?? 'none'),
];
}
private static function overlaps(DateTimeImmutable $start, DateTimeImmutable $end, DateTimeImmutable $windowStart, DateTimeImmutable $windowEnd): bool
{
return $start < $windowEnd && $end > $windowStart;
}
private static function parseDateTime(string $value, DateTimeZone $tz): ?DateTimeImmutable
{
if ($value === '') {
return null;
}
if (!str_contains($value, 'T') && preg_match('/^\d{4}-\d{2}-\d{2}$/', $value)) {
return new DateTimeImmutable($value . 'T00:00:00', $tz);
}
try {
return new DateTimeImmutable($value, $tz);
} catch (\Throwable) {
return null;
}
}
private static function addMonths(int $year, int $month, int $delta): array
{
$index = ($year * 12) + ($month - 1) + $delta;
$newYear = (int) floor($index / 12);
$newMonth = ($index % 12) + 1;
if ($newMonth <= 0) {
$newMonth += 12;
$newYear -= 1;
}
return [$newYear, $newMonth];
}
}

View File

@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace CalendarPlugin\Domain;
use CalendarPlugin\Contracts\OptionsAdapterInterface;
final class SettingsService
{
private const TRUE_VALUES = ['1', 'true', 'yes', 'on'];
public const DEFAULTS = [
'caldav_calendar_name' => 'Public Calendar',
'url_slug' => '',
'verification_page_path' => '/calendar',
'ics_access_mode' => 'public_read',
'diagnostics_enabled' => '1',
'uninstall_cleanup_mode' => 'keep',
];
private const OPTION_PREFIX = 'calendar_plugin_';
public function __construct(private readonly OptionsAdapterInterface $options)
{
}
public function getAll(): array
{
$out = [];
foreach (self::DEFAULTS as $key => $default) {
$out[$key] = $this->get($key, $default);
}
return $out;
}
public function get(string $key, mixed $default = null): mixed
{
$fallback = $default ?? (self::DEFAULTS[$key] ?? null);
return $this->options->get(self::OPTION_PREFIX . $key, $fallback);
}
public function update(array $payload): array
{
$allowed = array_keys(self::DEFAULTS);
$updated = $this->getAll();
foreach ($allowed as $key) {
if (!array_key_exists($key, $payload)) {
continue;
}
$value = $this->sanitize($key, $payload[$key]);
$this->options->set(self::OPTION_PREFIX . $key, $value);
$updated[$key] = $value;
}
return $updated;
}
private function sanitize(string $key, mixed $value): mixed
{
return match ($key) {
'caldav_calendar_name' => trim((string) $value) ?: self::DEFAULTS[$key],
'url_slug' => trim((string) $value, " \t\n\r\0\x0B/"),
'verification_page_path' => $this->normalizePath((string) $value),
'ics_access_mode' => in_array((string) $value, ['public_read', 'authenticated_read'], true)
? (string) $value
: self::DEFAULTS['ics_access_mode'],
'diagnostics_enabled' => $this->isTruthy($value) ? '1' : '0',
'uninstall_cleanup_mode' => in_array((string) $value, ['keep', 'remove'], true) ? (string) $value : 'keep',
default => $value,
};
}
private function isTruthy(mixed $value): bool
{
return in_array(strtolower(trim((string) $value)), self::TRUE_VALUES, true);
}
private function normalizePath(string $value): string
{
$path = trim($value);
if ($path === '') {
return self::DEFAULTS['verification_page_path'];
}
if (!str_starts_with($path, '/')) {
$path = '/' . $path;
}
return '/' . trim($path, '/');
}
}

View File

@ -0,0 +1,473 @@
<?php
declare(strict_types=1);
namespace CalendarPlugin\Domain;
use CalendarPlugin\Contracts\DatabaseAdapterInterface;
use DateTimeImmutable;
use DateTimeZone;
final class UserService
{
private readonly string $usersTable;
private readonly string $tokensTable;
private readonly string $auditTable;
public function __construct(private readonly DatabaseAdapterInterface $db, string $tableStem = 'cs_calendar')
{
$prefix = $db->getPrefix();
$stem = trim($tableStem, '_');
$this->usersTable = $prefix . $stem . '_users';
$this->tokensTable = $prefix . $stem . '_user_tokens';
$this->auditTable = $prefix . $stem . '_audit_log';
}
public function register(string $email, string $password): array
{
$email = $this->normalizeEmail($email);
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
return $this->error('validation_error', 'email is required', 422);
}
if (strlen($password) < 8) {
return $this->error('validation_error', 'password must be at least 8 characters', 422);
}
if ($this->isRateLimited('register:' . $email, 10, 3600)) {
return $this->error('rate_limited', 'too many requests', 429);
}
if ($this->findUserByEmail($email)) {
return $this->error('conflict_error', 'account already exists', 409);
}
$now = gmdate('c');
$inserted = $this->db->insert(
$this->usersTable,
[
'email' => $email,
'password_hash' => password_hash($password, PASSWORD_DEFAULT),
'email_verified_at' => null,
'account_status' => 'pending_approval',
'created_at' => $now,
'updated_at' => $now,
]
);
if ($inserted === false) {
return $this->error('internal_error', 'unable to create account', 500);
}
$userId = $this->db->insertId();
$token = $this->issueToken($userId, 'email_verify', 24 * 3600);
$this->audit('user.register', (string) $userId, 'success', ['email' => $email]);
return [
'ok' => true,
'user' => $this->publicUser((array) $this->getUserById($userId)),
'verify_token' => $token,
'message' => 'registration submitted',
];
}
public function verifyEmail(string $token): array
{
if ($token === '') {
return $this->error('validation_error', 'token is required', 422);
}
$tok = $this->consumeToken($token, 'email_verify');
if (!$tok) {
return $this->error('validation_error', 'invalid or expired token', 422);
}
$user = $this->getUserById((int) $tok['user_id']);
if (!$user) {
return $this->error('not_found', 'user not found', 404);
}
$now = gmdate('c');
$this->db->update(
$this->usersTable,
['email_verified_at' => $now, 'updated_at' => $now],
['id' => (int) $user['id']]
);
$updated = $this->getUserById((int) $user['id']);
$this->audit('user.verify_email', (string) $user['id'], 'success', []);
return [
'ok' => true,
'user' => $updated ? $this->publicUser($updated) : $this->publicUser($user),
'message' => 'An admin will review your request and notify you if approved.',
];
}
public function login(string $email, string $password): array
{
$email = $this->normalizeEmail($email);
if ($this->isRateLimited('login:' . $email, 20, 3600)) {
return $this->error('rate_limited', 'too many requests', 429);
}
$user = $this->findUserByEmail($email);
if (!$user || !password_verify($password, (string) ($user['password_hash'] ?? ''))) {
return $this->error('auth_required', 'Login failure', 401);
}
if (empty($user['email_verified_at'])) {
return $this->error('auth_required', 'Login failure', 401);
}
if ((string) ($user['account_status'] ?? '') !== 'active') {
return $this->error('auth_required', 'Login failure', 401);
}
$this->audit('user.login', (string) $user['id'], 'success', []);
return [
'ok' => true,
'user' => $this->publicUser($user),
];
}
public function authenticateActiveUserCredentials(string $email, string $password): ?array
{
$email = $this->normalizeEmail($email);
if ($email === '' || $password === '') {
return null;
}
$user = $this->findUserByEmail($email);
if (!$user) {
return null;
}
if (!password_verify($password, (string) ($user['password_hash'] ?? ''))) {
return null;
}
if (empty($user['email_verified_at'])) {
return null;
}
if ((string) ($user['account_status'] ?? '') !== 'active') {
return null;
}
return $this->publicUser($user);
}
public function issueSessionToken(int $userId, int $ttlSeconds = 2592000): string
{
if ($userId <= 0) {
return '';
}
return $this->issueToken($userId, 'session', max(300, $ttlSeconds));
}
public function authenticateSessionToken(string $token): ?array
{
$row = $this->findValidToken($token, 'session');
if ($row === null) {
return null;
}
$user = $this->getUserById((int) ($row['user_id'] ?? 0));
if (!$user) {
return null;
}
if (empty($user['email_verified_at'])) {
return null;
}
if ((string) ($user['account_status'] ?? '') !== 'active') {
return null;
}
return $this->publicUser($user);
}
public function revokeSessionToken(string $token): void
{
$row = $this->findValidToken($token, 'session');
if ($row === null) {
return;
}
$this->db->update(
$this->tokensTable,
['used_at' => gmdate('c')],
['id' => (int) $row['id']]
);
}
public function requestPasswordReset(string $email): array
{
$email = $this->normalizeEmail($email);
if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
return $this->error('validation_error', 'email is required', 422);
}
if ($this->isRateLimited('reset:' . $email, 10, 3600)) {
return $this->error('rate_limited', 'too many requests', 429);
}
$user = $this->findUserByEmail($email);
if (!$user) {
return ['ok' => true, 'message' => 'if account exists, reset email will be sent'];
}
$token = $this->issueToken((int) $user['id'], 'password_reset', 30 * 60);
$this->audit('user.password_reset.request', (string) $user['id'], 'success', []);
return [
'ok' => true,
'reset_token' => $token,
'message' => 'password reset requested',
];
}
public function resetPassword(string $token, string $newPassword): array
{
if (strlen($newPassword) < 8) {
return $this->error('validation_error', 'password must be at least 8 characters', 422);
}
$tok = $this->consumeToken($token, 'password_reset');
if (!$tok) {
return $this->error('validation_error', 'invalid or expired token', 422);
}
$user = $this->getUserById((int) $tok['user_id']);
if (!$user) {
return $this->error('not_found', 'user not found', 404);
}
$this->db->update(
$this->usersTable,
['password_hash' => password_hash($newPassword, PASSWORD_DEFAULT), 'updated_at' => gmdate('c')],
['id' => (int) $user['id']]
);
// Invalidate persistent web sessions after password reset.
$this->db->delete($this->tokensTable, ['user_id' => (int) $user['id'], 'token_type' => 'session']);
$this->audit('user.password_reset.complete', (string) $user['id'], 'success', []);
return ['ok' => true, 'message' => 'password updated'];
}
public function listUsers(): array
{
$rows = $this->db->getResults("SELECT * FROM {$this->usersTable} ORDER BY id ASC");
return array_map(fn(object $r): array => $this->publicUser((array) $r), $rows);
}
public function approveUser(int $id): ?array
{
$user = $this->getUserById($id);
if (!$user) {
return null;
}
if (empty($user['email_verified_at'])) {
return null;
}
$this->db->update(
$this->usersTable,
['account_status' => 'active', 'updated_at' => gmdate('c')],
['id' => $id]
);
$updated = $this->getUserById($id);
$this->audit('user.approve', (string) $id, 'success', []);
return $updated ? $this->publicUser($updated) : null;
}
public function removeUser(int $id): bool
{
$deleted = $this->db->delete($this->usersTable, ['id' => $id]);
$this->db->delete($this->tokensTable, ['user_id' => $id]);
if ($deleted !== false) {
$this->audit('user.remove', (string) $id, 'success', []);
return true;
}
return false;
}
private function findUserByEmail(string $email): ?array
{
foreach ($this->listRawUsers() as $user) {
if (strtolower((string) ($user['email'] ?? '')) === strtolower($email)) {
return $user;
}
}
return null;
}
private function getUserById(int $id): ?array
{
$sql = $this->db->prepare("SELECT * FROM {$this->usersTable} WHERE id = %d", $id);
$row = $this->db->getRow($sql);
return $row ? (array) $row : null;
}
private function listRawUsers(): array
{
$rows = $this->db->getResults("SELECT * FROM {$this->usersTable} ORDER BY id ASC");
return array_map(static fn(object $r): array => (array) $r, $rows);
}
private function issueToken(int $userId, string $type, int $ttlSeconds): string
{
$token = bin2hex(random_bytes(16));
$hash = hash('sha256', $token);
$expiresAt = (new DateTimeImmutable('now', new DateTimeZone('UTC')))->modify('+' . $ttlSeconds . ' seconds')->format('c');
$this->db->insert(
$this->tokensTable,
[
'user_id' => $userId,
'token_type' => $type,
'token_hash' => $hash,
'expires_at' => $expiresAt,
'used_at' => null,
'created_at' => gmdate('c'),
]
);
return $token;
}
private function consumeToken(string $token, string $type): ?array
{
$row = $this->findValidToken($token, $type);
if ($row === null) {
return null;
}
$this->db->update(
$this->tokensTable,
['used_at' => gmdate('c')],
['id' => (int) $row['id']]
);
return $row;
}
private function findValidToken(string $token, string $type): ?array
{
$raw = trim($token);
if ($raw === '') {
return null;
}
$hash = hash('sha256', $raw);
$now = new DateTimeImmutable('now', new DateTimeZone('UTC'));
$rows = $this->db->getResults("SELECT * FROM {$this->tokensTable} ORDER BY id ASC");
foreach ($rows as $rowObj) {
$row = (array) $rowObj;
if ((string) ($row['token_type'] ?? '') !== $type) {
continue;
}
if ((string) ($row['token_hash'] ?? '') !== $hash) {
continue;
}
if (!empty($row['used_at'])) {
return null;
}
try {
$expires = new DateTimeImmutable((string) $row['expires_at'], new DateTimeZone('UTC'));
} catch (\Throwable) {
return null;
}
if ($expires < $now) {
return null;
}
return $row;
}
return null;
}
private function publicUser(array $user): array
{
return [
'id' => (int) ($user['id'] ?? 0),
'email' => (string) ($user['email'] ?? ''),
'email_verified_at' => $user['email_verified_at'] ?? null,
'account_status' => (string) ($user['account_status'] ?? 'pending_approval'),
'created_at' => (string) ($user['created_at'] ?? ''),
'updated_at' => (string) ($user['updated_at'] ?? ''),
];
}
private function error(string $code, string $message, int $status): array
{
return ['error' => ['code' => $code, 'message' => $message, 'status' => $status]];
}
private function normalizeEmail(string $email): string
{
return strtolower(trim($email));
}
private function audit(string $action, string $target, string $result, array $context): void
{
if (!$this->isDiagnosticsEnabled()) {
return;
}
$this->db->insert(
$this->auditTable,
[
'actor' => 'plugin',
'action' => $action,
'target' => $target,
'result' => $result,
'created_at' => gmdate('c'),
'context_json' => json_encode($context),
]
);
}
private function isDiagnosticsEnabled(): bool
{
$optionsTable = $this->db->getPrefix() . 'options';
$sql = $this->db->prepare(
"SELECT option_value FROM {$optionsTable} WHERE option_name = %s LIMIT 1",
'calendar_plugin_diagnostics_enabled'
);
$row = $this->db->getRow($sql);
if (!$row || !property_exists($row, 'option_value')) {
return true;
}
return in_array(strtolower(trim((string) $row->option_value)), ['1', 'true', 'yes', 'on'], true);
}
private function isRateLimited(string $bucket, int $limit, int $windowSeconds): bool
{
$now = new DateTimeImmutable('now', new DateTimeZone('UTC'));
$type = 'rate:' . substr(hash('sha256', $bucket), 0, 32);
$sql = $this->db->prepare("SELECT * FROM {$this->tokensTable} WHERE token_type = %s ORDER BY id ASC", $type);
$rows = $this->db->getResults($sql);
$activeCount = 0;
foreach ($rows as $rowObj) {
$row = (array) $rowObj;
$id = (int) ($row['id'] ?? 0);
$usedAt = (string) ($row['used_at'] ?? '');
$expiresAtRaw = (string) ($row['expires_at'] ?? '');
$expired = true;
try {
$expiresAt = new DateTimeImmutable($expiresAtRaw, new DateTimeZone('UTC'));
$expired = $expiresAt < $now;
} catch (\Throwable) {
$expired = true;
}
if ($id > 0 && ($usedAt !== '' || $expired)) {
$this->db->delete($this->tokensTable, ['id' => $id]);
continue;
}
if (!$expired && $usedAt === '') {
$activeCount++;
}
}
if ($activeCount >= $limit) {
return true;
}
$this->db->insert(
$this->tokensTable,
[
'user_id' => 0,
'token_type' => $type,
'token_hash' => hash('sha256', bin2hex(random_bytes(16))),
'expires_at' => $now->modify('+' . max(1, $windowSeconds) . ' seconds')->format('c'),
'used_at' => null,
'created_at' => gmdate('c'),
]
);
return false;
}
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace CalendarPlugin\Infrastructure;
final class ServiceContainer
{
/** @var array<string, object> */
private array $services = [];
public function set(string $id, object $service): void
{
$this->services[$id] = $service;
}
public function get(string $id): object
{
if (!isset($this->services[$id])) {
throw new \RuntimeException(sprintf('Service not found: %s', $id));
}
return $this->services[$id];
}
}

View File

@ -0,0 +1,222 @@
<?php
declare(strict_types=1);
namespace CalendarPlugin\Infrastructure\WordPress;
use CalendarPlugin\Contracts\DatabaseAdapterInterface;
use DateTimeImmutable;
use DateTimeZone;
final class MigrationManager
{
private const SCHEMA_VERSION = '3';
private const STEM_OPTION = 'calendar_plugin_table_stem';
public function __construct(private readonly DatabaseAdapterInterface $db, private readonly string $tableStem = 'cs_calendar')
{
}
public function migrate(): void
{
require_once ABSPATH . 'wp-admin/includes/upgrade.php';
global $wpdb;
$charsetCollate = $wpdb->get_charset_collate();
$prefix = $this->db->getPrefix();
$stem = trim($this->tableStem, '_');
$events = $prefix . $stem . '_events';
$exceptions = $prefix . $stem . '_recurrence_exceptions';
$users = $prefix . $stem . '_users';
$tokens = $prefix . $stem . '_user_tokens';
$audit = $prefix . $stem . '_audit_log';
$sqlEvents = "CREATE TABLE {$events} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
uid VARCHAR(191) NOT NULL,
title TEXT NOT NULL,
description LONGTEXT NOT NULL,
location TEXT NOT NULL,
category TEXT NOT NULL,
all_day_event TINYINT(1) NOT NULL DEFAULT 0,
start_datetime VARCHAR(64) NOT NULL,
end_datetime VARCHAR(64) NOT NULL,
repeat_type VARCHAR(24) NOT NULL DEFAULT 'none',
repeat_interval INT NOT NULL DEFAULT 1,
repeat_nth_mode VARCHAR(32) NOT NULL DEFAULT '',
repeat_nth_day INT NULL,
repeat_nth_pos INT NULL,
repeat_nth_weekday INT NULL,
repeat_range_mode VARCHAR(24) NOT NULL DEFAULT 'none',
repeat_count INT NULL,
repeat_until VARCHAR(16) NULL,
timezone VARCHAR(64) NOT NULL DEFAULT 'Europe/London',
caldav_resource VARCHAR(191) NULL,
etag VARCHAR(64) NULL,
sync_version INT NOT NULL DEFAULT 1,
last_modified_by_user_id BIGINT UNSIGNED NULL,
created_at VARCHAR(32) NOT NULL,
updated_at VARCHAR(32) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uid (uid(191)),
UNIQUE KEY caldav_resource (caldav_resource),
KEY start_datetime (start_datetime(32)),
KEY end_datetime (end_datetime(32))
) {$charsetCollate};";
$sqlExceptions = "CREATE TABLE {$exceptions} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
event_id BIGINT UNSIGNED NOT NULL,
occurrence_key VARCHAR(64) NOT NULL,
exception_type VARCHAR(32) NOT NULL,
created_at VARCHAR(32) NOT NULL,
updated_at VARCHAR(32) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY event_occurrence (event_id, occurrence_key),
KEY event_id (event_id)
) {$charsetCollate};";
$sqlUsers = "CREATE TABLE {$users} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
email VARCHAR(191) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
email_verified_at VARCHAR(32) NULL,
account_status VARCHAR(32) NOT NULL DEFAULT 'pending_approval',
created_at VARCHAR(32) NOT NULL,
updated_at VARCHAR(32) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY email (email)
) {$charsetCollate};";
$sqlTokens = "CREATE TABLE {$tokens} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED NOT NULL,
token_type VARCHAR(32) NOT NULL,
token_hash VARCHAR(255) NOT NULL,
expires_at VARCHAR(32) NOT NULL,
used_at VARCHAR(32) NULL,
created_at VARCHAR(32) NOT NULL,
PRIMARY KEY (id),
KEY user_id (user_id),
KEY token_type (token_type)
) {$charsetCollate};";
$sqlAudit = "CREATE TABLE {$audit} (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
actor VARCHAR(191) NOT NULL,
action VARCHAR(191) NOT NULL,
target VARCHAR(191) NOT NULL,
result VARCHAR(32) NOT NULL,
created_at VARCHAR(32) NOT NULL,
context_json LONGTEXT NULL,
PRIMARY KEY (id),
KEY action (action),
KEY created_at (created_at)
) {$charsetCollate};";
dbDelta($sqlEvents);
dbDelta($sqlExceptions);
dbDelta($sqlUsers);
dbDelta($sqlTokens);
dbDelta($sqlAudit);
// Ensure every event has a stable CalDAV object resource name.
$this->db->query(
"UPDATE {$events}
SET caldav_resource = CONCAT(uid, '.ics')
WHERE (caldav_resource IS NULL OR caldav_resource = '')
AND uid IS NOT NULL
AND uid <> ''"
);
$this->normalizeEventDateTimesToLondon($events);
update_option(self::STEM_OPTION, $stem);
update_option('calendar_plugin_schema_version', self::SCHEMA_VERSION);
}
public function assertActivationSafe(): void
{
$stem = trim($this->tableStem, '_');
$ownedStem = trim((string) get_option(self::STEM_OPTION, ''), '_');
if ($ownedStem !== '' && $ownedStem !== $stem) {
throw new \RuntimeException(
sprintf(
'Calendar Plugin is already initialized with table stem "%s". Requested stem "%s" is different.',
$ownedStem,
$stem
)
);
}
if ($ownedStem !== $stem && $this->anyTargetTablesExist($stem)) {
$legacySchemaVersion = trim((string) get_option('calendar_plugin_schema_version', ''));
if ($legacySchemaVersion === '') {
throw new \RuntimeException(
sprintf(
'Calendar Plugin activation blocked: target tables for stem "%s" already exist. Choose another table stem via CALENDAR_PLUGIN_TABLE_STEM.',
$stem
)
);
}
}
}
private function anyTargetTablesExist(string $stem): bool
{
$prefix = $this->db->getPrefix();
$tables = [
$prefix . $stem . '_events',
$prefix . $stem . '_recurrence_exceptions',
$prefix . $stem . '_users',
$prefix . $stem . '_user_tokens',
$prefix . $stem . '_audit_log',
];
foreach ($tables as $table) {
$sql = $this->db->prepare('SHOW TABLES LIKE %s', $table);
if (count($this->db->getResults($sql)) > 0) {
return true;
}
}
return false;
}
private function normalizeEventDateTimesToLondon(string $eventsTable): void
{
$rows = $this->db->getResults(
"SELECT id, start_datetime, end_datetime FROM {$eventsTable}"
);
$tz = new DateTimeZone('Europe/London');
foreach ($rows as $row) {
$id = (int) ($row->id ?? 0);
if ($id <= 0) {
continue;
}
$start = $this->normalizeDateTimeString((string) ($row->start_datetime ?? ''), $tz);
$end = $this->normalizeDateTimeString((string) ($row->end_datetime ?? ''), $tz);
if ($start === null || $end === null) {
continue;
}
$sql = $this->db->prepare(
"UPDATE {$eventsTable} SET start_datetime = %s, end_datetime = %s WHERE id = %d",
$start,
$end,
$id
);
$this->db->query($sql);
}
}
private function normalizeDateTimeString(string $value, DateTimeZone $tz): ?string
{
$value = trim($value);
if ($value === '') {
return null;
}
try {
$dt = new DateTimeImmutable($value, $tz);
return $dt->setTimezone($tz)->format('c');
} catch (\Throwable) {
return null;
}
}
}

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace CalendarPlugin\Infrastructure\WordPress;
use CalendarPlugin\Contracts\AuthAdapterInterface;
final class WordPressAuthAdapter implements AuthAdapterInterface
{
public function currentUserId(): int
{
return (int) get_current_user_id();
}
public function currentUserCan(string $capability): bool
{
return current_user_can($capability);
}
public function verifyNonce(string $nonce, string $action): bool
{
return wp_verify_nonce($nonce, $action) !== false;
}
public function currentUserEmail(): string
{
$user = wp_get_current_user();
return is_object($user) ? (string) ($user->user_email ?? '') : '';
}
}

View File

@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace CalendarPlugin\Infrastructure\WordPress;
use CalendarPlugin\Contracts\DatabaseAdapterInterface;
final class WordPressDatabaseAdapter implements DatabaseAdapterInterface
{
public function __construct(private readonly object $wpdb)
{
}
public function getPrefix(): string
{
return (string) $this->wpdb->prefix;
}
public function prepare(string $query, mixed ...$args): string
{
return (string) $this->wpdb->prepare($query, ...$args);
}
public function query(string $query): int|false
{
return $this->wpdb->query($query);
}
public function getResults(string $query): array
{
return $this->wpdb->get_results($query) ?: [];
}
public function getRow(string $query): ?object
{
$row = $this->wpdb->get_row($query);
return is_object($row) ? $row : null;
}
public function insert(string $table, array $data, array $formats = []): int|false
{
return $this->wpdb->insert($table, $data, $formats);
}
public function update(string $table, array $data, array $where, array $formats = [], array $whereFormats = []): int|false
{
return $this->wpdb->update($table, $data, $where, $formats, $whereFormats);
}
public function delete(string $table, array $where, array $whereFormats = []): int|false
{
return $this->wpdb->delete($table, $where, $whereFormats);
}
public function insertId(): int
{
return (int) $this->wpdb->insert_id;
}
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace CalendarPlugin\Infrastructure\WordPress;
use CalendarPlugin\Contracts\HttpAdapterInterface;
final class WordPressHttpAdapter implements HttpAdapterInterface
{
public function addAction(string $hook, callable $callback, int $priority = 10, int $acceptedArgs = 1): void
{
add_action($hook, $callback, $priority, $acceptedArgs);
}
public function addShortcode(string $tag, callable $callback): void
{
add_shortcode($tag, $callback);
}
public function registerRestRoute(string $namespace, string $route, array $args): void
{
register_rest_route($namespace, $route, $args);
}
}

View File

@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace CalendarPlugin\Infrastructure\WordPress;
use CalendarPlugin\Contracts\OptionsAdapterInterface;
final class WordPressOptionsAdapter implements OptionsAdapterInterface
{
public function get(string $key, mixed $default = false): mixed
{
return get_option($key, $default);
}
public function set(string $key, mixed $value, bool $autoload = true): bool
{
return update_option($key, $value, $autoload);
}
public function delete(string $key): bool
{
return delete_option($key);
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
spl_autoload_register(
static function (string $class): void {
$prefix = 'CalendarPlugin\\';
if (strncmp($class, $prefix, strlen($prefix)) !== 0) {
return;
}
$relative = substr($class, strlen($prefix));
$path = __DIR__ . '/' . str_replace('\\', '/', $relative) . '.php';
if (is_file($path)) {
require_once $path;
}
}
);

View File

@ -0,0 +1,51 @@
<?php
/**
* Calendar Plugin uninstall handler.
*/
declare(strict_types=1);
if (!defined('WP_UNINSTALL_PLUGIN')) {
exit;
}
global $wpdb;
if (!isset($wpdb)) {
return;
}
$cleanupMode = (string) get_option('calendar_plugin_uninstall_cleanup_mode', 'keep');
if ($cleanupMode !== 'remove') {
return;
}
$stem = trim((string) get_option('calendar_plugin_table_stem', 'cs_calendar'), '_');
if ($stem === '') {
$stem = 'cs_calendar';
}
$prefix = (string) $wpdb->prefix;
$tables = [
$prefix . $stem . '_events',
$prefix . $stem . '_recurrence_exceptions',
$prefix . $stem . '_users',
$prefix . $stem . '_user_tokens',
$prefix . $stem . '_audit_log',
];
foreach ($tables as $table) {
$wpdb->query("DROP TABLE IF EXISTS `{$table}`");
}
$options = [
'calendar_plugin_caldav_calendar_name',
'calendar_plugin_url_slug',
'calendar_plugin_ics_access_mode',
'calendar_plugin_diagnostics_enabled',
'calendar_plugin_uninstall_cleanup_mode',
'calendar_plugin_table_stem',
'calendar_plugin_schema_version',
];
foreach ($options as $optionName) {
delete_option($optionName);
}

9
read.me Normal file
View File

@ -0,0 +1,9 @@
Status as of 2026-03-31 when codex credit ran out:
1. Removal of plugin did not work. Need to set all files owner to www-data.
2. Deletion of event in ui doesn't delete event in thunderbird
3. Cannot subscribe to an empty calendar in thunderbird
4. Click inside month cell doesn't add event.
5. Login pane hidden under website hero/banner image
6. Updating an events description via caldav creates weird sequences, e.g.
a space ends up as: text/html,%C2%A0":

141
requirements/api.md Normal file
View File

@ -0,0 +1,141 @@
# API Requirements
## Purpose
Define the non-CalDAV HTTP API contract for calendar, user-access workflow, and operational endpoints used by the plugin UI and tests.
## Scope
This document covers:
- API base path and versioning
- Event CRUD endpoints
- CalDAV-user workflow endpoints
- Standard request/response and error contracts
- Authn/Authz expectations for API calls
## Normative Boundaries
- API authentication and token/session behavior are defined in `requirements/authentication.md`.
- Authorization matrix and role constraints are defined in `requirements/authorization.md`.
- Error payload/status normalization is defined in `requirements/error_model.md`.
- Recurrence exception behavior is defined in `requirements/recurrence_exceptions.md`.
- Data persistence schema is defined in `requirements/data_schema.md`.
## API Baseline
- Base path: `/wp-json/calendar/v1`
- If `url_slug` is configured in setup, canonical API paths are prefixed: `/<url_slug>/wp-json/calendar/v1`.
- Content type: `application/json; charset=utf-8`
- Time format: ISO 8601 with timezone offset
- All endpoints must be deterministic under `Europe/London` default timezone assumptions unless a timezone is explicitly supplied.
## Versioning
- Breaking changes require a new version namespace (`v2`, etc.).
- Non-breaking additions are permitted in the current version.
- Deprecated fields/endpoints must remain for at least one release cycle with documentation notice.
## Event Endpoints
### Create Event
- `POST /events`
- Requires calendar write capability.
- Request body supports fields in `requirements/editor.md`.
- Response: `201` with created event payload and identifiers.
### List Events
- `GET /events`
- Supports query params:
- `from` (optional)
- `to` (optional)
- `view` (optional: `list`, `day`, `week`, `month`, `year`)
- `page` and `per_page` (optional)
- Response: `200` with array plus pagination metadata if paged.
### Get Event
- `GET /events/{event_id}`
- Response: `200` with event payload or `404`.
### Update Event
- `PUT /events/{event_id}` or `PATCH /events/{event_id}`
- Requires calendar write capability.
- Must enforce optimistic concurrency via version/etag precondition checks.
- Response: `200` with updated event payload.
### Delete Event
- `DELETE /events/{event_id}`
- Requires calendar write capability.
- Response: `204` on success.
### Delete Single Occurrence
- `DELETE /events/{event_id}/occurrences/{occurrence_key}`
- Requires calendar write capability.
- Deletes only one occurrence in a recurring series by creating an exception.
- Must not split the underlying recurring series.
- Response: `204` on success.
## CalDAV User Workflow Endpoints
### Register
- `POST /users/register`
- Public endpoint with abuse controls.
- Creates account in `pending_approval`.
- Registration is implicitly a write-access request; no separate request endpoint exists.
### Verify Email
- `POST /users/verify`
- Consumes single-use verification token.
- Marks email as verified while account remains `pending_approval` until admin approval.
### Forgot Password
- `POST /users/forgot-password`
- Issues password reset token by email.
### Reset Password
- `POST /users/reset-password`
- Consumes single-use reset token.
### Admin User List/Update/Delete
- `GET /admin/users`
- `PATCH /admin/users/{user_id}`
- `DELETE /admin/users/{user_id}`
- Admin-only endpoints for approval state transitions and user removal.
### Admin Diagnostics
- `GET /admin/diagnostics?limit=20`
- Admin-only endpoint.
- Returns recent request/response trace entries for operational troubleshooting.
- Sensitive fields must remain redacted per `requirements/observability.md`.
## Response Contract
Success responses should include:
- `data`: endpoint payload
- `meta`: optional metadata (pagination, timestamps, version)
Error responses should include:
- `error.code` (stable machine-readable code)
- `error.message` (human-readable summary)
- `error.details` (optional field-level/context details)
## Validation and Error Statuses
- `400` malformed request
- `401` unauthenticated
- `403` unauthorized
- `404` not found
- `409` conflict (state transition conflict)
- `412` precondition failed (etag/version mismatch)
- `422` semantic validation failure
- `429` rate-limited
- `500` internal server error
## Security Requirements
- HTTPS required for all authenticated API operations.
- CSRF/nonce protections for cookie-authenticated endpoints.
- Rate limiting for register/verify/reset/login-like flows.
- Error responses must avoid user enumeration leakage.
## Verification Requirements
Acceptance should verify:
- Endpoint paths and methods behave as documented.
- Validation and error payloads are consistent.
- Single-occurrence delete creates recurrence exception rather than split series.
- Authz rules enforce role/access constraints.

View File

@ -0,0 +1,30 @@
# Architecture and Runtime Separation Requirements
## Purpose
Define strict boundaries between deployable plugin code and local WordPress emulation/testing support.
## Core Separation Rule
- `code/` contains deployable WordPress plugin code only.
- `compatibility-layer/` contains local emulation/shims/test harness support only.
## Deployable Code Requirements (`code/`)
- Code under `code/` must be deployable to real WordPress without modification.
- Code under `code/` must not import, include, or require files from `compatibility-layer/`.
- Runtime behavior in `code/` must not branch on "test vs production" environment flags.
- Plugin logic in `code/` should depend on WordPress APIs/contracts, not on harness-specific APIs.
## Compatibility Layer Requirements (`compatibility-layer/`)
- Provides stand-alone/local execution support by emulating required WordPress behavior.
- Must adapt to `code/` contracts; `code/` must not adapt to compatibility-layer internals.
- May include local HTTP harness, fake WP functions, and local data/bootstrap tooling for tests.
## Packaging and Deployment Boundary
- Build/package inputs for production come from `code/` (plus approved runtime assets) only.
- `compatibility-layer/`, `tests/`, and local tooling are excluded from production artifacts.
## Verification Requirements
Acceptance must verify:
1. The same `code/` content runs in real WordPress without source edits.
2. Production package contains no files from `compatibility-layer/`.
3. No `code/` references to `compatibility-layer/` paths/symbols are present.

View File

@ -0,0 +1,72 @@
# Authentication Requirements
## Purpose
Define authentication behavior for admin UI, API, CalDAV, and ICS access.
## Scope
This document covers:
- Credential types and login flows
- Session/token behavior
- Password rules and recovery
- Abuse controls and lockout behavior
## Credential Domains
Authentication domains:
- WordPress admin authentication (for plugin admin pages)
- Plugin CalDAV user authentication (for CalDAV access)
- API authentication (WordPress auth for admin API and plugin user auth where relevant)
Credentials must not be shared in plaintext between domains.
## Admin UI Authentication
- Admin pages (`Users`, `Setup`) require valid WordPress authenticated session.
- Capability checks are enforced after authentication.
- Non-authenticated access redirects/fails using WordPress-standard behavior.
## CalDAV Authentication
- CalDAV endpoints require authenticated plugin CalDAV user credentials over HTTPS.
- Supported first-pass mechanism: HTTP Basic over TLS against plugin user store.
- Password verification must use secure hash comparison.
- Unverified or non-approved (`pending_approval`) users cannot authenticate.
## API Authentication
- Admin API endpoints require WordPress-authenticated context plus nonce/CSRF protections where cookie auth is used.
- User lifecycle endpoints may allow public access only where explicitly required (`register`, `verify`, `forgot/reset`) with abuse controls.
- Authenticated user endpoints require either WordPress user context or plugin user context as documented per endpoint.
## Password Policy
- Minimum length: 8 characters.
- Passwords must be stored only as secure hashes (never reversible encryption).
- Password reset rotates credentials immediately.
## Token Policy
- Verification and reset tokens must be single-use, random, and time-limited.
- Token reuse must fail deterministically.
- Expired tokens must produce actionable but non-sensitive error messaging.
## Session and Revocation
- After password reset, prior login sessions/tokens for that identity should be invalidated.
- Users not in `active` status lose access immediately for new requests.
## Abuse Controls
- Rate limiting required for:
- registration
- verification attempts
- forgot/reset flows
- login attempts
- Lockout/backoff behavior must be documented in operational docs.
## Logging and Privacy
- Authentication failures should be logged with timestamp and source context.
- Logs must not contain plaintext passwords or tokens.
- Responses must avoid user enumeration details.
## Verification Requirements
Acceptance should verify:
- Valid credentials authenticate to intended surfaces only.
- Invalid credentials fail safely.
- Pending/unverified users are denied.
- Password reset and token expiry behavior works as specified.

View File

@ -0,0 +1,61 @@
# Authorization Requirements
## Purpose
Define role/capability-based authorization rules for admin UI, API, CalDAV, and ICS behavior.
## Scope
This document covers:
- Authorization matrix by actor and operation
- Admin override behavior
- Default-deny rules
## Authorization Model
- Authorization is explicit and default-deny.
- Authentication is required before capability checks.
- Least privilege is required for all operations.
## Actor Types
- `wp_admin`: WordPress user with calendar admin capability
- `wp_editor`: WordPress user with editor-level calendar capability (if enabled)
- `caldav_write`: plugin CalDAV user with approved write access
- `public`: unauthenticated user
## Capability Matrix
### Admin UI
- `Users`: `wp_admin` only
- `Setup`: `wp_admin` only
### Event Data Operations
- Create/update/delete events in admin/API: requires write calendar capability
- Delete one recurrence occurrence: requires write capability and must create exception
### CalDAV Operations
- Discovery/read/query (`OPTIONS`, `PROPFIND`, `REPORT`, `GET`): allowed for authenticated approved users
- Write operations (`PUT`, `DELETE`): allowed for authenticated approved users
### User Management
- Approve/remove users: `wp_admin` only
### ICS Access
- Public ICS endpoint visibility is governed by setup configuration.
- If endpoint is public: `public` may read only approved public events.
- If endpoint requires auth: enforce configured auth policy consistently.
## Ownership and Scope Rules
- CalDAV users operate against the single shared `public` calendar collection.
- Access control is enforced by account approval state (`pending_approval` vs `active`) rather than per-user private calendars.
- Admin actions must not silently grant broader rights than requested.
## State Transition Rules
- User transition `pending_approval` -> `active` requires explicit admin action.
- Unauthorized transition attempts return `403`.
## Verification Requirements
Acceptance should verify:
- Capability matrix is enforced across all surfaces.
- Pending/unverified users cannot perform CalDAV operations.
- `Users` and `Setup` cannot be accessed by unauthorized roles.
- Public/authorized ICS access respects configured policy.

190
requirements/caldav.md Normal file
View File

@ -0,0 +1,190 @@
# CalDAV Requirements
## Purpose
Define requirements for exposing the plugin calendar through a CalDAV endpoint with read/write behavior and standards-compatible resource representations.
## Standards and RFC
CalDAV behavior must be standards-compatible with:
- RFC 4791: Calendaring Extensions to WebDAV (CalDAV)
- RFC 4918: HTTP Extensions for Web Distributed Authoring and Versioning (WebDAV)
- RFC 5545: Internet Calendaring and Scheduling Core Object Specification (iCalendar)
- RFC 6578: Collection Synchronization for WebDAV (sync report)
- RFC 7232: HTTP conditional requests (ETag/If-Match semantics)
If additional CalDAV/WebDAV extensions are used, they must be documented and not break baseline client interoperability.
## Timezone Assumption
Unless explicitly overridden by a future requirement, all plugin dates and times are assumed to be in the `Europe/London` timezone.
## Scope
This document covers:
- CalDAV endpoint structure and resources
- Required WebDAV/CalDAV operations
- Mapping between plugin data and CalDAV/iCalendar resources
- Concurrency, sync, and error behavior
## Normative Boundaries
To avoid ambiguity across requirement documents:
- Exact CalDAV URI layout, method matrix, and DAV property/report behavior are defined in `requirements/caldav_endpoints.md`.
- Authentication and authorization policy is defined in `requirements/authentication.md` and `requirements/authorization.md`.
- Error response normalization is defined in `requirements/error_model.md`.
- Recurrence exception semantics are defined in `requirements/recurrence_exceptions.md`.
- Concrete persistence schema is defined in `requirements/data_schema.md`.
## Endpoint and Resource Model
The plugin must expose a CalDAV hierarchy with authenticated user principals and calendar collections.
Minimum resource model:
- Principal resource per authenticated CalDAV user
- Shared calendar home set
- Single shared `public` calendar collection
- Event resources as `text/calendar` (`VEVENT`-based `.ics` objects) within collections
URI requirements:
- Resource URIs must be stable for the lifetime of each object.
- Event resource URI should be derived from a stable internal event identifier.
- Recurrence exceptions must remain part of the same logical series and not create split series artifacts.
## Authentication and Authorization
- CalDAV access must require HTTPS and authenticated credentials.
- Authorization must enforce account approval state (`pending_approval` vs `active`).
- Unverified or non-approved users must be denied authentication/authorization.
- `active` users are write-enabled for the shared calendar.
- All authenticated users access the same shared calendar collection.
## Required CalDAV/WebDAV Operations
The endpoint must support these operations at minimum.
### Discovery and Collection Introspection
- `OPTIONS`: advertise DAV capabilities, including CalDAV support.
- `PROPFIND`:
- discover principals, calendar home sets, and calendar collections
- retrieve core properties (display name, resource type, ctag/sync metadata where available)
### Read and Query
- `REPORT` (`calendar-query`): return events in collection, including time-range filtering.
- `REPORT` (`calendar-multiget`): fetch specific event resources by href.
- `GET`: retrieve individual event resource (`text/calendar`).
### Create and Update
- `PUT`: create new event resource or replace an existing event resource.
- `PUT` updates must preserve recurrence/exception semantics from iCalendar input.
- Write operations must require approved authenticated access.
### Delete
- `DELETE`: remove event resource when permitted.
- Deleting a single occurrence of a recurring event must be represented as a recurrence exception in the series (`EXDATE` and/or `RECURRENCE-ID` override pattern), not by splitting into multiple independent series.
### Concurrency and Sync
- `ETag` must be emitted for event resources.
- `If-Match`/`If-None-Match` preconditions must be honored for safe updates/creates.
- `REPORT` (`sync-collection`) should be supported for incremental sync tokens.
- Sync token invalidation/rotation behavior must be deterministic and documented.
## iCalendar Representation Requirements
CalDAV event payloads must be standards-compatible `VCALENDAR` with `VEVENT` components.
Minimum mapping expectations:
- Internal stable event id -> `UID`
- Title -> `SUMMARY`
- Description -> `DESCRIPTION`
- Location -> `LOCATION`
- Category -> `CATEGORIES`
- Start/end -> `DTSTART` / `DTEND`
- Last modification timestamp -> `DTSTAMP` (and `LAST-MODIFIED` when available)
- Recurrence rules -> `RRULE`
- Recurrence exceptions -> `EXDATE` and/or additional `VEVENT` with matching `UID` plus `RECURRENCE-ID`
Recurrence behavior:
- Series-level recurrence remains a single logical event sequence keyed by `UID`.
- Exception instances must be represented as exceptions to that `UID`, not a new split sequence.
- Sequence/version metadata should be updated on write operations so clients detect changes.
- Monthly ordinal rules must round-trip accurately, including `BYDAY=2SA`-style forms and `BYSETPOS=-1` (`last` weekday in month).
## Plugin Data Mapping Requirements
Plugin persistence must represent CalDAV resources in a way that supports idempotent read/write sync.
Required persisted mapping fields (direct columns or normalized equivalents):
- Internal event id
- CalDAV resource path/name
- `UID`
- Current `ETag`
- Calendar collection id
- Last-modified-by user id (nullable for system/import operations)
- Serialized recurrence rule data
- Recurrence exception records (date-only exceptions and/or overridden instances)
- Created/updated timestamps
Behavior requirements:
- Importing/updating from CalDAV must map to existing records by stable identifiers (`UID` + resource identity rules).
- Duplicate creation from repeated client retries must be prevented.
- Data model must preserve enough metadata to regenerate standards-compliant responses.
## Error Handling Requirements
- Malformed iCalendar payloads must return appropriate client error responses.
- Authorization failures must return appropriate auth status without leaking sensitive details.
- Write precondition failures (etag mismatch) must return precondition errors and no partial write.
- Server errors must be logged with enough detail for troubleshooting.
## Security and Privacy
- Transport must be TLS-only for credentials and calendar data.
- Sensitive tokens/credentials must not be logged in plaintext.
- Responses must not leak admin-only or internal plugin metadata.
## Interoperability Targets
The implementation should interoperate with common CalDAV clients, including:
- Apple Calendar
- Thunderbird
- DAVx5-class clients
Client-specific workarounds, if required, must be documented.
## Majority-Client Compatibility Strategy
There is no single guaranteed feature set that satisfies every CalDAV client implementation, but interoperability for the majority can be managed by combining:
- strict baseline standards compliance (RFC 4791 + RFC 4918 + RFC 5545)
- a stable compatibility profile for discovery/auth/report/write behavior
- continuous regression tests against representative client patterns
The project must maintain a compatibility profile with three levels:
- Level A (required for release): standards-critical discovery/auth/read
- `401` with `WWW-Authenticate` on unauthenticated CalDAV access
- principal discovery (`current-user-principal`)
- principal `calendar-home-set`
- discoverable calendar collection with `<C:calendar/>`
- `REPORT` support for `calendar-query` and `calendar-multiget`
- Level B (required for release): practical write/sync interoperability
- `PUT`/`DELETE` with stable ETag behavior
- recurrence + exception round-trip fidelity
- sync collection stability for incremental updates
- Level C (best effort): client-specific ergonomics/extensions
- optional properties beyond baseline RFC surface
- minor behavior adjustments for specific client quirks that do not break A/B
Release gating must include at least:
- `fixture-tests/fixture_caldav_client_compat_smoke.sh` (legacy local discovery/auth compatibility; retained for archival comparison)
- existing fixture smoke + security smoke suites
- at least one real-client manual smoke (for example Thunderbird or Apple Calendar) for release candidates
## Verification Requirements
Acceptance should verify:
- Principal and calendar discovery works via `OPTIONS` and `PROPFIND`.
- Calendar query and multiget reports return correct data for time-range and href selection.
- `GET`, `PUT`, and `DELETE` behaviors match access level permissions.
- ETag and conditional writes prevent lost updates.
- Sync collection reports provide incremental changes.
- Recurring-event single-occurrence delete results in exception representation, not sequence split.
- Returned iCalendar validates against RFC 5545 expectations and is accepted by target clients.

View File

@ -0,0 +1,117 @@
# CalDAV Endpoint Requirements
## Purpose
Define exact CalDAV URI structure, required methods/reports/properties, and expected status behavior for interoperability.
## Scope
This document covers:
- CalDAV URI layout
- Method support by resource type
- Required WebDAV/CalDAV properties and reports
- Required status-code behavior
## Normative Boundaries
- This document is authoritative for CalDAV URI structure and method/property/report support.
- Event/recurrence data semantics are defined in `requirements/caldav.md` and `requirements/recurrence_exceptions.md`.
- Authentication/authorization and error behavior norms are defined in:
- `requirements/authentication.md`
- `requirements/authorization.md`
- `requirements/error_model.md`
## Endpoint Layout
Base CalDAV root:
- `/caldav/`
- If `url_slug` is configured in setup, canonical CalDAV root is `/<url_slug>/caldav/`.
Resource hierarchy:
- Principal collection: `/caldav/principals/`
- User principal: `/caldav/principals/{user_id}/`
- Calendar home set: `/caldav/calendars/`
- Shared public calendar collection: `/caldav/calendars/public/`
- Event object resource: `/caldav/calendars/public/{object_id}.ics`
URI rules:
- `{user_id}` is stable and URL-safe.
- `{object_id}` is stable for object lifetime.
- All authenticated principals discover the same shared calendar home set and `public` collection.
- Object rename/move behavior is unsupported in first pass unless explicitly implemented.
## Methods by Resource Type
### `/caldav/`
- `OPTIONS`
- `PROPFIND` (Depth 0/1)
### Principal resources
- `PROPFIND`
- `REPORT` where applicable for principal discovery support
### Calendar collection resources
- `OPTIONS`
- `PROPFIND`
- `REPORT` (`calendar-query`, `calendar-multiget`, `sync-collection`)
### Event object resources
- `GET`
- `PUT`
- `DELETE`
- `PROPFIND` (Depth 0)
## Required DAV/CalDAV Properties
Calendar collection and principal responses must support, at minimum, these properties (where applicable):
- `resourcetype`
- `displayname`
- `current-user-principal`
- `principal-URL`
- `calendar-home-set`
- `supported-calendar-component-set`
- `getctag` (or equivalent documented change tag)
- `getetag` for object resources
- `sync-token` for collections supporting sync
## REPORT Support
- `calendar-query` with time-range filtering
- `calendar-multiget` by href set
- `sync-collection` for incremental changes since sync token
If a report is unsupported for a resource, server returns standards-appropriate error status with DAV error body.
## Status Behavior
- `200` successful read/report/property retrieval
- `201` object created by `PUT`
- `204` successful delete/update with no body where applicable
- `207` multi-status for PROPFIND/REPORT responses
- `401` unauthenticated
- `403` authenticated but forbidden
- `404` resource not found
- `405` method not allowed on resource
- `409` parent/resource state conflict
- `412` precondition failed (etag/if-match semantics)
- `415` unsupported media type
## Content Handling
- Event objects use `text/calendar` payloads with RFC 5545-compatible `VCALENDAR`.
- Unsupported component types should be rejected unless explicitly mapped.
- Server should normalize line endings/content as required by iCalendar compatibility.
## Concurrency
- Object resources must emit `ETag`.
- `If-Match` and `If-None-Match` must be honored on `PUT`.
- Lost-update prevention is required on concurrent writes.
## Recurrence Exception Behavior
- Deleting one recurrence occurrence must be represented as an exception for the existing series.
- The resulting data must remain one logical series (same `UID`), without splitting into separate series resources unless explicitly required by standards-compatible override semantics.
## Verification Requirements
Acceptance should verify:
- URI layout and discovery flows are stable.
- Required methods return expected statuses.
- REPORT responses include correct event sets.
- Conditional write and etag behavior prevents stale overwrite.

View File

@ -0,0 +1,130 @@
# CalDAV User Access Requirements
## Purpose
Define requirements for CalDAV user accounts and permissions for the calendar plugin, with minimal maintenance burden for non-technical users.
## Scope
This document covers:
- Self-service account creation for calendar app access
- Email verification and password recovery
- WordPress admin approval and user state management
- CalDAV authentication and authorization behavior
## Normative Boundaries
- User lifecycle and state expectations are defined here.
- Detailed authentication controls are defined in `requirements/authentication.md`.
- Detailed authorization matrix is defined in `requirements/authorization.md`.
- Concrete persisted schema is defined in `requirements/data_schema.md`.
## Admin Navigation
CalDAV user administration must be provided through the shared calendar plugin admin menu.
Requirements:
- User administration appears in a `Users` sub-entry under the calendar plugin main menu.
- User administration is not implemented as a separate top-level plugin menu.
- Access to `Users` is restricted to authorized WordPress admin roles/capabilities.
## User Experience Goals
The access model must prioritize low-friction onboarding and low ongoing maintenance.
Requirements:
- A user can create a calendar access account without admin intervention.
- A user can recover access independently using password reset.
- A user does not need to understand WordPress internals to use CalDAV access.
- Error messages and emails must be plain language and action-oriented.
## Account Model
CalDAV access must use plugin-managed user accounts tied to identity and approval state.
Required account fields:
- Unique login identifier (email address)
- Password hash (never plaintext)
- Email verification status
- Account status (`pending_approval`, `active`)
- Created/updated timestamps
Notes:
- Registration is implicitly a request for write access.
- There is no separate `read_only` approval tier in the user model.
## Registration and Email Verification
Users must be able to self-register and verify their email before admin approval.
Requirements:
- Registration requires email address and password.
- Registration creates account state `pending_approval`.
- A verification email is sent with a single-use, time-limited token/link.
- CalDAV authentication must fail until email verification is complete.
- Successful verification marks account email as verified while remaining `pending_approval`.
- Expired or invalid verification links must return a deterministic validation error.
## Password Recovery
Users must be able to recover account access without administrator support.
Requirements:
- "Forgot password" flow is available from the login/access page.
- Password reset uses a single-use, time-limited token sent by email.
- Password policy minimum length is 8 characters.
- Reset token invalidation occurs immediately after successful password change.
- Existing sessions/tokens should be revoked after password reset.
- Clear success/failure messaging is required.
## Approval Workflow
WordPress admins must have a dedicated interface to manage pending and approved users.
Requirements:
- Admin view lists users with email, verification state, and account status.
- Admin can approve a verified pending user by setting status to `active`.
- Admin can remove defunct users.
- Admin actions must be logged with actor, timestamp, and action outcome.
- State transitions must be explicit and validated.
## CalDAV Authentication and Authorization
CalDAV endpoint access must authenticate users and authorize operations by approval state.
Requirements:
- CalDAV endpoint requires HTTPS and authenticated credentials.
- Authentication uses plugin account credentials.
- Authorization checks apply on every CalDAV request.
- Accounts that are unverified or not `active` must be denied.
- `active` users are write-enabled for the shared calendar.
## Email Delivery Requirements
Email-dependent workflows must be reliable and understandable.
Requirements:
- Emails are sent for verification and password reset.
- Admin notification email is sent when a user completes verification and is pending approval.
- Templates must include clear subject lines and concise next-step instructions.
- Email sends must fail closed (no auth bypass) when delivery is unavailable.
## Security and Abuse Controls
The account system must include baseline controls to reduce abuse risk.
Requirements:
- Passwords must meet minimum strength requirements.
- Registration, login, and password-reset request endpoints should include rate limiting.
- Tokens must be cryptographically strong and time-limited.
- Sensitive actions must use CSRF protections in web forms.
- User enumeration should be minimized in public responses.
## Verification Requirements
Acceptance should verify:
- A new user can register, verify email, and become pending approval.
- Unverified or pending users cannot authenticate to CalDAV.
- Password reset succeeds end-to-end and invalidates prior sessions/tokens.
- Admin can approve (`active`) and remove users.
- Approved users can authenticate and perform CalDAV writes.
- Admin user list accurately reflects user states and recent actions.

91
requirements/data.md Normal file
View File

@ -0,0 +1,91 @@
# Data Requirements
## Purpose
Define requirements for calendar data storage in WordPress and for local testing data behavior.
## Scope
This document covers:
- Persistent plugin data in WordPress database
- Data model expectations for calendar entries and recurrence
- Data handling for local compatibility-harness testing
## Normative Boundaries
- This document defines high-level data requirements and test-data expectations.
- Concrete schema fields, constraints, and indexing are defined in `requirements/data_schema.md`.
- Recurrence exception semantics are defined in `requirements/recurrence_exceptions.md`.
- Runtime/code separation constraints are defined in `requirements/architecture.md`.
## Timezone Assumption
Unless explicitly overridden by a future requirement, all plugin dates and times are assumed to be in the `Europe/London` timezone.
## WordPress Database Storage
Plugin data must be stored using WordPress-compatible database access patterns.
Requirements:
- Plugin-owned tables use WordPress table prefix conventions (`$wpdb->prefix`).
- Schema creation/migration uses WordPress APIs (see lifecycle requirements).
- Data access uses WordPress database APIs (`$wpdb`) with prepared/safe queries.
- Stored records support full CRUD and recurrence features required by editor/web UI.
## Minimum Event Data Model
The stored model must support these fields (direct columns or normalized equivalents):
- Event identifier (internal primary key)
- `title`
- `location`
- `category`
- `all_day_event` flag
- `start_datetime`
- `end_datetime`
- `repeat_type` (`none`, `daily`, `weekly`, `monthly`, `yearly`, `custom`)
- Recurrence configuration (`interval`, `range_mode`, `count`, `until_date`, base unit)
- `description`
- Created/updated timestamps
## Data Integrity Requirements
- `title` and start time/date must be required.
- End must not be before start.
- Recurrence values must be internally consistent (e.g., positive interval/count).
- Deletes must not leave orphaned recurrence metadata or exception records.
- Migrations must preserve existing user data.
## Query and Retrieval Requirements
- Data model must support efficient retrieval by date range for day/week/month/year UI windows.
- Data model must support generation of recurrence occurrences for both web UI and ICS output.
- Queries for public UI must return only intended public event data.
## Local Testing Data Requirements
The local compatibility-harness test environment must support deterministic data setup and teardown.
Requirements:
- Provide seeded test data covering:
- Single non-recurring events
- All-day events
- Each recurrence type
- Custom recurrence with each range mode
- Edge cases (month boundaries, leap year, DST transitions where relevant)
- Test runs must isolate state between cases (clean database state or controlled fixtures).
- Test data setup scripts/fixtures must be version-controlled under `tests/` and/or `compatibility-layer/`.
## Test Validation Data Requirements
Automated/local verification should include assertions for:
- CRUD persistence correctness
- Recurrence expansion correctness in 3-month preview windows
- Correct behavior of keep/remove data paths during uninstall
- Correct mapping readiness for ICS export fields
## Backup and Recovery (Server Data)
- Production/test server data changes should be recoverable via normal backup processes.
- Destructive operations (e.g., uninstall data removal) require explicit intent and must be test-covered.
## Documentation Requirements
Project docs must include:
- Current schema description
- Migration/versioning approach
- Local compatibility-harness usage
- Data retention behavior across install/upgrade/uninstall

138
requirements/data_schema.md Normal file
View File

@ -0,0 +1,138 @@
# Data Schema Requirements
## Purpose
Define concrete schema requirements for events, recurrence exceptions, CalDAV metadata, and user-access lifecycle state.
## Scope
This document covers:
- Required tables/entities and key fields
- Indexing and uniqueness rules
- Migration/versioning expectations
- Data integrity constraints
## Schema Baseline
- All plugin tables must use WordPress prefix (`$wpdb->prefix`).
- Schema creation/migration uses WordPress mechanisms (`dbDelta`, controlled migrations).
- Charset/collation should follow WordPress defaults.
## Required Logical Entities
### Events
Required fields:
- `id` (PK)
- `uid` (stable iCalendar UID)
- `title`
- `description`
- `location`
- `category`
- `all_day_event`
- `start_datetime`
- `end_datetime`
- `repeat_type`
- `repeat_interval`
- `repeat_range_mode`
- `repeat_count` (nullable)
- `repeat_until` (nullable)
- `timezone` (default `Europe/London`)
- `created_at`
- `updated_at`
Constraints:
- `title`, `start_datetime` required
- `end_datetime >= start_datetime`
- recurrence fields internally consistent
### Recurrence Exceptions
Required fields:
- `id` (PK)
- `event_id` (FK -> events.id)
- `occurrence_key` (canonical occurrence datetime key)
- `exception_type` (`deleted_occurrence`, `override_occurrence`)
- `override_payload` (nullable structured data for modified occurrence)
- `created_at`
- `updated_at`
Constraints:
- unique (`event_id`, `occurrence_key`)
- deleted-occurrence exception must suppress that one occurrence without splitting series
### CalDAV Objects
Required fields:
- `id` (PK)
- `event_id` (FK -> events.id)
- `calendar_id`
- `resource_path`
- `etag`
- `sync_version` or equivalent change sequence
- `last_modified_by_user_id` (FK -> caldav_users.id, nullable)
- `created_at`
- `updated_at`
Constraints:
- unique (`calendar_id`, `resource_path`)
- unique `etag` progression by object version
### CalDAV Users
Required fields:
- `id` (PK)
- `email` (unique)
- `password_hash`
- `email_verified_at` (nullable)
- `account_status` (`pending_approval`, `active`)
- `access_level` (implementation detail; approved users are write-enabled)
- `request_state` (implementation detail; tracks approval pipeline when present)
- `created_at`
- `updated_at`
### User Tokens
Required fields:
- `id` (PK)
- `user_id` (FK -> caldav_users.id)
- `token_type` (`verify_email`, `reset_password`)
- `token_hash`
- `expires_at`
- `used_at` (nullable)
- `created_at`
Constraints:
- tokens are single-use
- expired/used tokens are invalid
### Audit Log
Required fields:
- `id` (PK)
- `actor_type` (`wp_user`, `caldav_user`, `system`)
- `actor_id`
- `action`
- `target_type`
- `target_id`
- `result` (`success`, `failure`)
- `context_json`
- `created_at`
## Indexing Requirements
- Events index on `start_datetime`, `end_datetime`
- Events unique index on `uid` where logical uniqueness is required
- Exceptions index on `event_id`
- CalDAV objects index on `calendar_id`, `resource_path`, `etag`
- Users unique index on `email`
- Tokens index on `user_id`, `token_type`, `expires_at`
- Audit log index on `created_at`, `actor_id`, `action`
## Migration and Versioning
- Schema version must be stored in plugin options.
- Upgrades must be incremental, idempotent, and logged.
- Downgrade strategy must be documented; if unsupported, explicit warning required.
## Data Retention
- Behavior on uninstall follows lifecycle requirements.
- If removal is selected, plugin-owned tables and options are removed safely.
- If retention is selected, schema/data remains for future reactivation.
## Verification Requirements
Acceptance should verify:
- Fresh install creates expected schema.
- Upgrade applies required structural changes without data loss.
- Constraints enforce recurrence exception uniqueness and no split-series artifacts.

105
requirements/deployment.md Normal file
View File

@ -0,0 +1,105 @@
# Deployment Requirements
## Purpose
Define how the plugin is deployed to the remote WordPress host and how deployment correctness is verified, including an exact-match validation between the approved artifact and deployed runtime files.
## Scope
This document covers:
- Remote host deployment target and access assumptions
- Artifact-only deployment model
- Pre-deploy checks
- Post-deploy validation
- Exact-match validation requirements
- Rollback requirements
This document does not define packaging rules (see `requirements/packaging.md`) or runtime feature behavior.
## Normative References
- Packaging requirements: `requirements/packaging.md`
- Environment/runtime requirements: `requirements/environment.md`
- Architecture/runtime separation requirements: `requirements/architecture.md`
- Smoke/regression expectations: `tests/smoke_tests.md`
## Deployment Model
- Deployments must use a built plugin artifact (zip) produced from approved repository content.
- Direct ad-hoc editing of production plugin files is not permitted.
- Deployment target path must point to the active WordPress plugin directory.
- Deployable runtime must come from `code/` only; compatibility/emulation assets are not deployable.
For the current remote test target:
- WordPress root: `/var/www/wordpress`
- Plugin directory root: `/var/www/wordpress/wp-content/plugins`
- Plugin deploy directory: `/var/www/wordpress/wp-content/plugins/calendar-plugin`
## Pre-Deployment Requirements
Before deployment:
1. Package artifact has passed packaging validation.
2. Local and remote smoke checks required for the release scope are green.
3. Remote path existence/permissions are verified.
4. Backup or rollback artifact for currently deployed version is available.
5. Deployment record includes target host, artifact name, version, timestamp, and operator.
## Deployment Procedure Requirements
Required high-level procedure:
1. Transfer approved artifact to remote host staging area.
2. Extract artifact to a clean temporary directory on remote host.
3. Validate extracted plugin directory structure.
4. Synchronize extracted plugin directory to deploy directory.
5. Run post-deploy verification checks.
## 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).
Validation must include:
1. File set equality:
- No missing files in deployment compared to artifact.
- No extra files in deployment compared to artifact.
2. File content equality:
- Each deployed file content hash must match artifact file hash.
3. Optional metadata check (recommended):
- File mode/permissions match expected deployment policy.
Accepted implementation options:
- Manifest-based validation: generate a sorted list of `<relative-path> <sha256>` from extracted artifact and deployed directory and compare byte-for-byte.
- Rsync dry-run checksum validation (for example `rsync -avznc --delete`) plus explicit failure on any reported delta.
Any mismatch must fail deployment validation and trigger rollback decision.
## Post-Deployment Verification Requirements
After exact-match validation:
1. Plugin is present and loadable by WordPress.
2. Plugin activation state is verified (as required by release process).
3. CalDAV endpoint discovery and ICS endpoint health checks pass.
4. Critical UI/API smoke checks pass.
## Rollback Requirements
If deployment validation or post-deploy checks fail:
1. Revert to previous known-good plugin artifact.
2. Re-run minimum smoke checks to confirm recovery.
3. Record incident details and remediation before next deploy attempt.
## Audit and Traceability Requirements
Each deployment must record:
- artifact name/version
- source revision/tag
- target host/path
- deploy timestamp
- validation result (including exact-match evidence)
- rollback status if applicable
## Acceptance Criteria
Deployment process is acceptable only if:
1. Artifact-only deployment is enforced.
2. Exact-match file and hash validation is performed and passes.
3. Required post-deploy smoke checks pass.
4. Deployment record contains all traceability fields.

Some files were not shown because too many files have changed in this diff Show More