Calendar Sync V1
This commit is contained in:
434
src/IcsWriter.php
Normal file
434
src/IcsWriter.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user