moving from svn
This commit is contained in:
commit
8d805ca8dc
|
|
@ -0,0 +1 @@
|
||||||
|
.svn
|
||||||
|
|
@ -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__);
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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, '/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 ?? '') : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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.
|
|
@ -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" "$@"
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
"$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/../fixture/init.sh" "$@"
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
"$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/../fixture/reset.sh" "$@"
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
"$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/../fixture/run.sh" "$@"
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
"$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/../fixture-tests/fixture_security_smoke.sh" "$@"
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
"$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/../fixture/seed.sh" "$@"
|
||||||
|
|
@ -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__")
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
"$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/../fixture-tests/fixture_smoke.sh" "$@"
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
"$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)/ui_e2e_wp_emulation.php" "$@"
|
||||||
|
|
@ -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";
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
<?php
|
||||||
|
if (!function_exists('dbDelta')) { function dbDelta(string $sql): array { return []; } }
|
||||||
|
|
@ -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";
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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-----
|
||||||
|
|
@ -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/`.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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"
|
||||||
|
|
@ -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.
Binary file not shown.
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,4 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
python3 fixture/server.py init
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
python3 fixture/server.py reset
|
||||||
|
|
@ -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}"
|
||||||
|
|
@ -0,0 +1,4 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
python3 fixture/server.py seed
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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.
|
|
@ -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.
|
|
@ -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.
|
|
@ -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.
|
|
@ -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.
|
|
@ -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.
|
|
@ -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.
|
|
@ -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.
|
|
@ -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`
|
||||||
|
|
@ -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__);
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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, '/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 ?? '') : '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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":
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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.
|
||||||
|
|
@ -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
Loading…
Reference in New Issue