Calendar Sync V1

This commit is contained in:
2026-03-11 11:44:32 +01:00
parent 87f6839b0d
commit 2d5e9541c2
17 changed files with 3011 additions and 64 deletions

434
src/IcsWriter.php Normal file
View File

@@ -0,0 +1,434 @@
<?php
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.
*/
class IcsWriter
{
/**
* Update the STATUS of an event or task 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.
*
* For recurring events, this writes an override/exception for the specific
* occurrence rather than modifying the master event.
*
* @param string $filePath Absolute path to the local ICS file
* @param string $uid Event UID
* @param string $recurrenceId Recurrence ID (empty for non-recurring)
* @param string $newStatus New status value (e.g. COMPLETED, TODO)
* @param string $dateIso Occurrence date YYYY-MM-DD (for recurring event identification)
* @return bool True if the file was updated successfully
*/
public static function updateEventStatus(
string $filePath,
string $uid,
string $recurrenceId,
string $newStatus,
string $dateIso
): bool {
if ($uid === '' || $filePath === '') return false;
if (!is_file($filePath) || !is_writable($filePath)) return false;
$raw = @file_get_contents($filePath);
if (!is_string($raw) || trim($raw) === '') return false;
try {
$calendar = Reader::read($raw, Reader::OPTION_FORGIVING);
if (!($calendar instanceof VCalendar)) return false;
$updated = self::applyStatusUpdate($calendar, $uid, $recurrenceId, $newStatus, $dateIso);
if (!$updated) return false;
$output = $calendar->serialize();
return self::atomicWrite($filePath, $output);
} catch (Throwable $e) {
return false;
}
}
/**
* Apply a status update to the matching component in the calendar.
*
* Public alias for use by CalDavClient when modifying remote calendar data.
*
* @param VCalendar $calendar
* @param string $uid
* @param string $recurrenceId
* @param string $newStatus
* @param string $dateIso
* @return bool True if a component was updated
*/
public static function applyStatusUpdateToCalendar(
VCalendar $calendar,
string $uid,
string $recurrenceId,
string $newStatus,
string $dateIso
): bool {
return self::applyStatusUpdate($calendar, $uid, $recurrenceId, $newStatus, $dateIso);
}
/**
* Apply a status update to the matching component in the calendar.
*
* @param VCalendar $calendar
* @param string $uid
* @param string $recurrenceId
* @param string $newStatus
* @param string $dateIso
* @return bool True if a component was updated
*/
protected static function applyStatusUpdate(
VCalendar $calendar,
string $uid,
string $recurrenceId,
string $newStatus,
string $dateIso
): bool {
// Try VEVENT first
foreach ($calendar->select('VEVENT') as $component) {
if (!($component instanceof VEvent)) continue;
if (self::matchesComponent($component, $uid, $recurrenceId, $dateIso)) {
self::setVEventStatus($component, $newStatus);
return true;
}
}
// 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;
$componentUid = trim((string)($component->UID ?? ''));
if ($componentUid !== $uid) continue;
// This is the master event; check if it has RRULE (recurring)
if (!isset($component->RRULE)) continue;
// Create an occurrence override
$override = self::createOccurrenceOverride($calendar, $component, $newStatus, $dateIso);
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 string $uid
* @param string $recurrenceId
* @param string $dateIso
* @return bool
*/
protected static function matchesComponent($component, string $uid, string $recurrenceId, string $dateIso): bool
{
$componentUid = trim((string)($component->UID ?? ''));
if ($componentUid !== $uid) return false;
// If we have a specific recurrence ID, match it
if ($recurrenceId !== '') {
$componentRid = isset($component->{'RECURRENCE-ID'}) ? trim((string)$component->{'RECURRENCE-ID'}) : '';
return $componentRid === $recurrenceId;
}
// For non-recurring events (no RRULE, no RECURRENCE-ID), match by UID alone
if (!isset($component->RRULE) && !isset($component->{'RECURRENCE-ID'})) {
return true;
}
// For a specific occurrence override already in the file
if (isset($component->{'RECURRENCE-ID'})) {
$ridDt = $component->{'RECURRENCE-ID'}->getDateTime();
if ($ridDt !== null && $ridDt->format('Y-m-d') === $dateIso) {
return true;
}
}
return false;
}
/**
* Set the STATUS property on a VEVENT.
*
* @param VEvent $vevent
* @param string $newStatus
*/
protected static function setVEventStatus(VEvent $vevent, string $newStatus): void
{
$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.
*
* @param VCalendar $calendar
* @param VEvent $master
* @param string $newStatus
* @param string $dateIso
* @return VEvent|null
*/
protected static function createOccurrenceOverride(
VCalendar $calendar,
VEvent $master,
string $newStatus,
string $dateIso
): ?VEvent {
try {
$isAllDay = strtoupper((string)($master->DTSTART['VALUE'] ?? '')) === 'DATE';
$props = [
'UID' => (string)$master->UID,
'SUMMARY' => (string)($master->SUMMARY ?? ''),
'STATUS' => $newStatus,
];
if ($isAllDay) {
$recurrenceValue = str_replace('-', '', $dateIso);
$props['DTSTART'] = $recurrenceValue;
$props['RECURRENCE-ID'] = $recurrenceValue;
// Set VALUE=DATE on the properties
$override = $calendar->add('VEVENT', $props);
$override->DTSTART['VALUE'] = 'DATE';
$override->{'RECURRENCE-ID'}['VALUE'] = 'DATE';
} else {
// Use the master's time for the occurrence
$masterStart = $master->DTSTART->getDateTime();
$recurrenceValue = $dateIso . 'T' . $masterStart->format('His');
$tz = $masterStart->getTimezone();
if ($tz && $tz->getName() !== 'UTC') {
$props['DTSTART'] = $recurrenceValue;
$props['RECURRENCE-ID'] = $recurrenceValue;
$override = $calendar->add('VEVENT', $props);
$override->DTSTART['TZID'] = $tz->getName();
$override->{'RECURRENCE-ID'}['TZID'] = $tz->getName();
} else {
$recurrenceValue .= 'Z';
$props['DTSTART'] = $recurrenceValue;
$props['RECURRENCE-ID'] = $recurrenceValue;
$override = $calendar->add('VEVENT', $props);
}
}
// Copy duration or DTEND if present
if (isset($master->DTEND)) {
$duration = $master->DTSTART->getDateTime()->diff($master->DTEND->getDateTime());
$startDt = $override->DTSTART->getDateTime();
$endDt = $startDt->add($duration);
if ($isAllDay) {
$override->add('DTEND', $endDt->format('Ymd'));
$override->DTEND['VALUE'] = 'DATE';
} else {
$override->add('DTEND', $endDt);
}
} elseif (isset($master->DURATION)) {
$override->DURATION = (string)$master->DURATION;
}
// Copy LOCATION and DESCRIPTION if present
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;
}
}
/**
* 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.
*
* @param string $filePath
* @param string $content
* @return bool
*/
public static function atomicWritePublic(string $filePath, string $content): bool
{
$dir = dirname($filePath);
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
return self::atomicWrite($filePath, $content);
}
/**
* Atomic file write using a temp file and rename.
*
* @param string $filePath
* @param string $content
* @return bool
*/
protected static function atomicWrite(string $filePath, string $content): bool
{
$dir = dirname($filePath);
$tmpFile = $dir . '/.luxtools_tmp_' . getmypid() . '_' . mt_rand();
if (@file_put_contents($tmpFile, $content, LOCK_EX) === false) {
@unlink($tmpFile);
return false;
}
if (!@rename($tmpFile, $filePath)) {
@unlink($tmpFile);
return false;
}
return true;
}
}