Calendar Sync V2

This commit is contained in:
2026-03-11 12:18:02 +01:00
parent 2d5e9541c2
commit a4815fc672
6 changed files with 114 additions and 284 deletions

View File

@@ -147,32 +147,9 @@ class CalDavClient
]);
if ($response === null) {
// Try VTODO filter as well
$body = '<?xml version="1.0" encoding="utf-8" ?>' .
'<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">' .
'<D:prop>' .
'<D:getetag/>' .
'<C:calendar-data/>' .
'</D:prop>' .
'<C:filter>' .
'<C:comp-filter name="VCALENDAR">' .
'<C:comp-filter name="VTODO">' .
'<C:prop-filter name="UID">' .
'<C:text-match collation="i;octet">' . htmlspecialchars($uid, ENT_XML1, 'UTF-8') . '</C:text-match>' .
'</C:prop-filter>' .
'</C:comp-filter>' .
'</C:comp-filter>' .
'</C:filter>' .
'</C:calendar-query>';
$response = self::request('REPORT', $caldavUrl, $username, $password, $body, [
'Content-Type: application/xml; charset=utf-8',
'Depth: 1',
]);
return null;
}
if ($response === null) return null;
return self::parseReportResponse($response, $caldavUrl);
}
@@ -245,13 +222,10 @@ class CalDavClient
}
}
// Copy VEVENT and VTODO components
// Copy VEVENT components
foreach ($cal->select('VEVENT') as $component) {
$merged->add(clone $component);
}
foreach ($cal->select('VTODO') as $component) {
$merged->add(clone $component);
}
} catch (Throwable $e) {
// Skip malformed objects
continue;

View File

@@ -47,7 +47,7 @@ class CalendarEvent
*/
public $status;
/** @var string Component type from source: VEVENT or VTODO */
/** @var string Component type from source: VEVENT */
public $componentType;
/** @var string The date (YYYY-MM-DD) this event applies to */

View File

@@ -8,7 +8,6 @@ use DateTimeInterface;
use DateTimeZone;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\Component\VEvent;
use Sabre\VObject\Component\VTodo;
use Sabre\VObject\Reader;
use Throwable;
@@ -26,6 +25,9 @@ class CalendarService
/** @var array<string,CalendarEvent[]> In-request cache keyed by "slotKey|all" for open tasks */
protected static $taskCache = [];
/** @var array<string,VCalendar|null> In-request cache keyed by file path */
protected static $vcalCache = [];
/**
* Get all normalized events for a given date across all enabled slots.
*
@@ -163,6 +165,9 @@ class CalendarService
/**
* Get slot-level day indicator data for a whole month.
*
* Expands each slot's ICS calendar once for the full month range,
* then buckets events by day — instead of 31 individual expand calls.
*
* @param CalendarSlot[] $slots
* @param int $year
* @param int $month
@@ -173,19 +178,76 @@ class CalendarService
$daysInMonth = (int)date('t', mktime(0, 0, 0, $month, 1, $year));
$indicators = [];
$utc = new DateTimeZone('UTC');
// Expand from 1 day before month start to 1 day after month end
$rangeStart = new DateTimeImmutable(sprintf('%04d-%02d-01 00:00:00', $year, $month), $utc);
$rangeStart = $rangeStart->sub(new DateInterval('P1D'));
$rangeEnd = $rangeStart->add(new DateInterval('P' . ($daysInMonth + 2) . 'D'));
foreach ($slots as $slot) {
if (!$slot->isEnabled()) continue;
for ($day = 1; $day <= $daysInMonth; $day++) {
$dateIso = sprintf('%04d-%02d-%02d', $year, $month, $day);
if (self::slotHasEventsOnDate($slot, $dateIso)) {
$indicators[$dateIso][] = $slot->getKey();
$file = $slot->getFile();
if ($file === '' || !is_file($file) || !is_readable($file)) continue;
$calendar = self::readCalendar($file);
if ($calendar === null) continue;
try {
$expanded = $calendar->expand($rangeStart, $rangeEnd);
if (!($expanded instanceof VCalendar)) continue;
for ($day = 1; $day <= $daysInMonth; $day++) {
$dateIso = sprintf('%04d-%02d-%02d', $year, $month, $day);
$events = self::collectFromCalendar($expanded, $slot->getKey(), $dateIso);
if ($events !== []) {
$indicators[$dateIso][] = $slot->getKey();
// Pre-populate the day cache
$cacheKey = $slot->getKey() . '|' . $dateIso;
if (!isset(self::$dayCache[$cacheKey])) {
self::$dayCache[$cacheKey] = $events;
}
}
}
} catch (Throwable $e) {
continue;
}
}
return $indicators;
}
/**
* Read and parse an ICS file, caching the parsed VCalendar per file path.
*
* @param string $file
* @return VCalendar|null
*/
protected static function readCalendar(string $file): ?VCalendar
{
if (array_key_exists($file, self::$vcalCache)) {
return self::$vcalCache[$file];
}
$raw = @file_get_contents($file);
if (!is_string($raw) || trim($raw) === '') {
self::$vcalCache[$file] = null;
return null;
}
try {
$component = Reader::read($raw, Reader::OPTION_FORGIVING);
if (!($component instanceof VCalendar)) {
self::$vcalCache[$file] = null;
return null;
}
self::$vcalCache[$file] = $component;
return $component;
} catch (Throwable $e) {
self::$vcalCache[$file] = null;
return null;
}
}
/**
* Parse events from a local ICS file for a specific date.
*
@@ -196,19 +258,16 @@ class CalendarService
*/
protected static function parseEventsFromFile(string $file, string $slotKey, string $dateIso): array
{
$raw = @file_get_contents($file);
if (!is_string($raw) || trim($raw) === '') return [];
$calendar = self::readCalendar($file);
if ($calendar === null) return [];
try {
$component = Reader::read($raw, Reader::OPTION_FORGIVING);
if (!($component instanceof VCalendar)) return [];
$utc = new DateTimeZone('UTC');
$rangeStart = new DateTimeImmutable($dateIso . ' 00:00:00', $utc);
$rangeStart = $rangeStart->sub(new DateInterval('P1D'));
$rangeEnd = $rangeStart->add(new DateInterval('P3D'));
$expanded = $component->expand($rangeStart, $rangeEnd);
$expanded = $calendar->expand($rangeStart, $rangeEnd);
if (!($expanded instanceof VCalendar)) return [];
return self::collectFromCalendar($expanded, $slotKey, $dateIso);
@@ -218,7 +277,7 @@ class CalendarService
}
/**
* Parse all tasks (VEVENT with STATUS or VTODO) from a maintenance file,
* Parse all tasks (VEVENT with STATUS) from a maintenance file,
* expanding recurrences up to the given date.
*
* @param string $file
@@ -228,12 +287,11 @@ class CalendarService
*/
protected static function parseAllTasksFromFile(string $file, string $slotKey, string $todayIso): array
{
$raw = @file_get_contents($file);
if (!is_string($raw) || trim($raw) === '') return [];
$calendar = self::readCalendar($file);
if ($calendar === null) return [];
try {
$component = Reader::read($raw, Reader::OPTION_FORGIVING);
if (!($component instanceof VCalendar)) return [];
$component = $calendar;
// Expand from a reasonable lookback to tomorrow
$utc = new DateTimeZone('UTC');
@@ -255,15 +313,6 @@ class CalendarService
}
}
// Collect VTODOs
foreach ($expanded->select('VTODO') as $vtodo) {
if (!($vtodo instanceof VTodo)) continue;
$event = self::normalizeVTodo($vtodo, $slotKey);
if ($event !== null) {
$tasks[] = $event;
}
}
return $tasks;
} catch (Throwable $e) {
return [];
@@ -295,18 +344,6 @@ class CalendarService
$result[] = $event;
}
// VTODOs
foreach ($calendar->select('VTODO') as $vtodo) {
if (!($vtodo instanceof VTodo)) continue;
$event = self::normalizeVTodoForDay($vtodo, $slotKey, $dateIso);
if ($event === null) continue;
$dedupeKey = $event->uid . '|' . $event->recurrenceId . '|' . $event->startIso . '|' . $event->summary;
if (isset($seen[$dedupeKey])) continue;
$seen[$dedupeKey] = true;
$result[] = $event;
}
usort($result, [self::class, 'compareEvents']);
return $result;
}
@@ -386,71 +423,6 @@ class CalendarService
return $event;
}
/**
* Normalize a VTODO for a specific day.
*
* @param VTodo $vtodo
* @param string $slotKey
* @param string $dateIso
* @return CalendarEvent|null
*/
protected static function normalizeVTodoForDay(VTodo $vtodo, string $slotKey, string $dateIso): ?CalendarEvent
{
$event = self::normalizeVTodo($vtodo, $slotKey);
if ($event === null) return null;
// Check if the VTODO's due/start date matches this day
if ($event->dateIso !== $dateIso) return null;
return $event;
}
/**
* Normalize a VTODO into a CalendarEvent.
*
* @param VTodo $vtodo
* @param string $slotKey
* @return CalendarEvent|null
*/
protected static function normalizeVTodo(VTodo $vtodo, string $slotKey): ?CalendarEvent
{
// VTODO uses DUE or DTSTART for date
$dateProperty = $vtodo->DUE ?? $vtodo->DTSTART ?? null;
if ($dateProperty === null) return null;
$isAllDay = strtoupper((string)($dateProperty['VALUE'] ?? '')) === 'DATE';
$dt = self::toImmutable($dateProperty->getDateTime());
if ($dt === null) return null;
$event = new CalendarEvent();
$event->slotKey = $slotKey;
$event->uid = trim((string)($vtodo->UID ?? ''));
$event->recurrenceId = isset($vtodo->{'RECURRENCE-ID'}) ? trim((string)$vtodo->{'RECURRENCE-ID'}) : '';
$event->summary = trim((string)($vtodo->SUMMARY ?? ''));
if ($event->summary === '') $event->summary = '(ohne Titel)';
$event->startIso = $dt->format(DateTimeInterface::ATOM);
$event->endIso = '';
$event->allDay = $isAllDay;
$event->time = $isAllDay ? '' : $dt->format('H:i');
$event->location = trim((string)($vtodo->LOCATION ?? ''));
$event->description = trim((string)($vtodo->DESCRIPTION ?? ''));
$status = strtoupper(trim((string)($vtodo->STATUS ?? '')));
// Map VTODO statuses to our model
if ($status === 'COMPLETED') {
$event->status = 'COMPLETED';
} elseif ($status === 'IN-PROCESS' || $status === 'NEEDS-ACTION' || $status === '') {
$event->status = 'TODO';
} else {
$event->status = $status;
}
$event->componentType = 'VTODO';
$event->dateIso = $dt->format('Y-m-d');
return $event;
}
/**
* Resolve the end date/time for a VEVENT.
*
@@ -549,5 +521,6 @@ class CalendarService
{
self::$dayCache = [];
self::$taskCache = [];
self::$vcalCache = [];
}
}

View File

@@ -4,24 +4,21 @@ namespace dokuwiki\plugin\luxtools;
use Sabre\VObject\Component\VCalendar;
use Sabre\VObject\Component\VEvent;
use Sabre\VObject\Component\VTodo;
use Sabre\VObject\Reader;
use Throwable;
/**
* Write-back support for local ICS files.
*
* Handles updating event/task status (completion, reopening) in local
* ICS files while preserving the original component type and other properties.
* Handles updating event status (completion, reopening) in local
* ICS files while preserving other properties.
*/
class IcsWriter
{
/**
* Update the STATUS of an event or task occurrence in a local ICS file.
* Update the STATUS of an event occurrence in a local ICS file.
*
* For VEVENT: sets STATUS to the given value (TODO or COMPLETED).
* For VTODO: sets STATUS and, when completing, sets COMPLETED timestamp;
* when reopening, removes the COMPLETED property.
* Sets STATUS to the given value (TODO or COMPLETED).
*
* For recurring events, this writes an override/exception for the specific
* occurrence rather than modifying the master event.
@@ -108,15 +105,6 @@ class IcsWriter
}
}
// Try VTODO
foreach ($calendar->select('VTODO') as $component) {
if (!($component instanceof VTodo)) continue;
if (self::matchesComponent($component, $uid, $recurrenceId, $dateIso)) {
self::setVTodoStatus($component, $newStatus);
return true;
}
}
// For recurring events without a matching override, create one
foreach ($calendar->select('VEVENT') as $component) {
if (!($component instanceof VEvent)) continue;
@@ -131,25 +119,13 @@ class IcsWriter
if ($override !== null) return true;
}
// Same for VTODO with RRULE
foreach ($calendar->select('VTODO') as $component) {
if (!($component instanceof VTodo)) continue;
$componentUid = trim((string)($component->UID ?? ''));
if ($componentUid !== $uid) continue;
if (!isset($component->RRULE)) continue;
$override = self::createVTodoOccurrenceOverride($calendar, $component, $newStatus, $dateIso);
if ($override !== null) return true;
}
return false;
}
/**
* Check if a component matches the given UID and recurrence criteria.
*
* @param VEvent|VTodo $component
* @param VEvent $component
* @param string $uid
* @param string $recurrenceId
* @param string $dateIso
@@ -193,33 +169,6 @@ class IcsWriter
$vevent->STATUS = $newStatus;
}
/**
* Set the STATUS property on a VTODO with native completion semantics.
*
* @param VTodo $vtodo
* @param string $newStatus
*/
protected static function setVTodoStatus(VTodo $vtodo, string $newStatus): void
{
if ($newStatus === 'COMPLETED') {
$vtodo->STATUS = 'COMPLETED';
// Set COMPLETED timestamp per RFC 5545
$vtodo->COMPLETED = gmdate('Ymd\THis\Z');
// Set PERCENT-COMPLETE to 100
$vtodo->{'PERCENT-COMPLETE'} = '100';
} else {
// Reopening
$vtodo->STATUS = 'NEEDS-ACTION';
// Remove COMPLETED timestamp
if (isset($vtodo->COMPLETED)) {
unset($vtodo->COMPLETED);
}
if (isset($vtodo->{'PERCENT-COMPLETE'})) {
unset($vtodo->{'PERCENT-COMPLETE'});
}
}
}
/**
* Create an occurrence override for a recurring VEVENT.
*
@@ -300,97 +249,6 @@ class IcsWriter
}
}
/**
* Create an occurrence override for a recurring VTODO.
*
* @param VCalendar $calendar
* @param VTodo $master
* @param string $newStatus
* @param string $dateIso
* @return VTodo|null
*/
protected static function createVTodoOccurrenceOverride(
VCalendar $calendar,
VTodo $master,
string $newStatus,
string $dateIso
): ?VTodo {
try {
$dateProperty = $master->DUE ?? $master->DTSTART ?? null;
$isAllDay = $dateProperty !== null && strtoupper((string)($dateProperty['VALUE'] ?? '')) === 'DATE';
$props = [
'UID' => (string)$master->UID,
'SUMMARY' => (string)($master->SUMMARY ?? ''),
];
if ($isAllDay) {
$recurrenceValue = str_replace('-', '', $dateIso);
if (isset($master->DTSTART)) {
$props['DTSTART'] = $recurrenceValue;
}
if (isset($master->DUE)) {
$props['DUE'] = $recurrenceValue;
}
$props['RECURRENCE-ID'] = $recurrenceValue;
$override = $calendar->add('VTODO', $props);
if (isset($override->DTSTART)) {
$override->DTSTART['VALUE'] = 'DATE';
}
if (isset($override->DUE)) {
$override->DUE['VALUE'] = 'DATE';
}
$override->{'RECURRENCE-ID'}['VALUE'] = 'DATE';
} else {
$dt = $dateProperty->getDateTime();
$recurrenceValue = $dateIso . 'T' . $dt->format('His');
$tz = $dt->getTimezone();
if ($tz && $tz->getName() !== 'UTC') {
if (isset($master->DTSTART)) {
$props['DTSTART'] = $recurrenceValue;
}
if (isset($master->DUE)) {
$props['DUE'] = $recurrenceValue;
}
$props['RECURRENCE-ID'] = $recurrenceValue;
$override = $calendar->add('VTODO', $props);
if (isset($override->DTSTART)) {
$override->DTSTART['TZID'] = $tz->getName();
}
if (isset($override->DUE)) {
$override->DUE['TZID'] = $tz->getName();
}
$override->{'RECURRENCE-ID'}['TZID'] = $tz->getName();
} else {
$recurrenceValue .= 'Z';
if (isset($master->DTSTART)) {
$props['DTSTART'] = $recurrenceValue;
}
if (isset($master->DUE)) {
$props['DUE'] = $recurrenceValue;
}
$props['RECURRENCE-ID'] = $recurrenceValue;
$override = $calendar->add('VTODO', $props);
}
}
self::setVTodoStatus($override, $newStatus);
if (isset($master->LOCATION)) {
$override->LOCATION = (string)$master->LOCATION;
}
if (isset($master->DESCRIPTION)) {
$override->DESCRIPTION = (string)$master->DESCRIPTION;
}
return $override;
} catch (Throwable $e) {
return null;
}
}
/**
* Public atomic file write for use by CalDavClient sync.
*