Calendar Sync V2
This commit is contained in:
@@ -359,8 +359,6 @@ Completed tasks can be reopened with a "Reopen" button.
|
|||||||
|
|
||||||
Write-back rules:
|
Write-back rules:
|
||||||
- `VEVENT` components: `STATUS:TODO` for open, `STATUS:COMPLETED` for completed.
|
- `VEVENT` components: `STATUS:TODO` for open, `STATUS:COMPLETED` for completed.
|
||||||
- `VTODO` components: Uses native completion semantics (`STATUS:COMPLETED`,
|
|
||||||
`COMPLETED` timestamp, `PERCENT-COMPLETE:100`).
|
|
||||||
- Recurring events: Completion writes an occurrence override/exception to preserve
|
- Recurring events: Completion writes an occurrence override/exception to preserve
|
||||||
per-occurrence state rather than modifying the master event.
|
per-occurrence state rather than modifying the master event.
|
||||||
|
|
||||||
@@ -412,10 +410,6 @@ a cron job that triggers the sync via the DokuWiki AJAX endpoint.
|
|||||||
still has stale data. The completion write-back updates the remote immediately
|
still has stale data. The completion write-back updates the remote immediately
|
||||||
to mitigate this.
|
to mitigate this.
|
||||||
|
|
||||||
- **VTODO recurrence**: sabre/vobject's recurrence expansion has limited support
|
|
||||||
for `VTODO` components. Recurring `VTODO` items may not expand as expected.
|
|
||||||
Non-recurring `VTODO` items work correctly.
|
|
||||||
|
|
||||||
### 1) List files by glob pattern
|
### 1) List files by glob pattern
|
||||||
|
|
||||||
The `{{directory>...}}` syntax (or `{{files>...}}` for backwards compatibility) can handle both directory listings and glob patterns. When a glob pattern is used, it renders as a table:
|
The `{{directory>...}}` syntax (or `{{files>...}}` for backwards compatibility) can handle both directory listings and glob patterns. When a glob pattern is used, it renders as a table:
|
||||||
|
|||||||
@@ -300,6 +300,37 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
|
|||||||
echo '</label><br />';
|
echo '</label><br />';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CalDAV Sync button (outside the save form, separate action)
|
||||||
|
$ajaxUrl = DOKU_BASE . 'lib/exe/ajax.php';
|
||||||
|
$sectok = getSecurityToken();
|
||||||
|
echo '<div class="luxtools-admin-sync" style="margin: 1em 0;">';
|
||||||
|
echo '<button type="button" class="button" id="luxtools-sync-btn">'
|
||||||
|
. hsc($this->getLang('calendar_sync_button'))
|
||||||
|
. '</button>';
|
||||||
|
echo '<span id="luxtools-sync-status" style="margin-left: 1em;"></span>';
|
||||||
|
echo '</div>';
|
||||||
|
echo '<script>';
|
||||||
|
echo 'document.getElementById("luxtools-sync-btn").addEventListener("click", function() {';
|
||||||
|
echo ' var btn = this;';
|
||||||
|
echo ' var status = document.getElementById("luxtools-sync-status");';
|
||||||
|
echo ' btn.disabled = true;';
|
||||||
|
echo ' status.textContent = "Syncing...";';
|
||||||
|
echo ' var xhr = new XMLHttpRequest();';
|
||||||
|
echo ' xhr.open("POST", ' . json_encode($ajaxUrl) . ', true);';
|
||||||
|
echo ' xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");';
|
||||||
|
echo ' xhr.onload = function() {';
|
||||||
|
echo ' btn.disabled = false;';
|
||||||
|
echo ' try {';
|
||||||
|
echo ' var r = JSON.parse(xhr.responseText);';
|
||||||
|
echo ' status.textContent = r.message || (r.ok ? "Done" : "Failed");';
|
||||||
|
echo ' status.style.color = r.ok ? "green" : "red";';
|
||||||
|
echo ' } catch(e) { status.textContent = "Error"; status.style.color = "red"; }';
|
||||||
|
echo ' };';
|
||||||
|
echo ' xhr.onerror = function() { btn.disabled = false; status.textContent = "Network error"; status.style.color = "red"; };';
|
||||||
|
echo ' xhr.send("call=luxtools_calendar_sync§ok=" + encodeURIComponent(' . json_encode($sectok) . '));';
|
||||||
|
echo '});';
|
||||||
|
echo '</script>';
|
||||||
|
|
||||||
// pagelink_search_depth
|
// pagelink_search_depth
|
||||||
echo '<label class="block"><span>' . hsc($this->getLang('pagelink_search_depth')) . '</span> ';
|
echo '<label class="block"><span>' . hsc($this->getLang('pagelink_search_depth')) . '</span> ';
|
||||||
echo '<input type="number" class="edit" min="0" name="pagelink_search_depth" value="' . hsc((string)$this->getConf('pagelink_search_depth')) . '" />';
|
echo '<input type="number" class="edit" min="0" name="pagelink_search_depth" value="' . hsc((string)$this->getConf('pagelink_search_depth')) . '" />';
|
||||||
|
|||||||
@@ -147,32 +147,9 @@ class CalDavClient
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
if ($response === null) {
|
if ($response === null) {
|
||||||
// Try VTODO filter as well
|
return null;
|
||||||
$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',
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($response === null) return null;
|
|
||||||
|
|
||||||
return self::parseReportResponse($response, $caldavUrl);
|
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) {
|
foreach ($cal->select('VEVENT') as $component) {
|
||||||
$merged->add(clone $component);
|
$merged->add(clone $component);
|
||||||
}
|
}
|
||||||
foreach ($cal->select('VTODO') as $component) {
|
|
||||||
$merged->add(clone $component);
|
|
||||||
}
|
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
// Skip malformed objects
|
// Skip malformed objects
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ class CalendarEvent
|
|||||||
*/
|
*/
|
||||||
public $status;
|
public $status;
|
||||||
|
|
||||||
/** @var string Component type from source: VEVENT or VTODO */
|
/** @var string Component type from source: VEVENT */
|
||||||
public $componentType;
|
public $componentType;
|
||||||
|
|
||||||
/** @var string The date (YYYY-MM-DD) this event applies to */
|
/** @var string The date (YYYY-MM-DD) this event applies to */
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ use DateTimeInterface;
|
|||||||
use DateTimeZone;
|
use DateTimeZone;
|
||||||
use Sabre\VObject\Component\VCalendar;
|
use Sabre\VObject\Component\VCalendar;
|
||||||
use Sabre\VObject\Component\VEvent;
|
use Sabre\VObject\Component\VEvent;
|
||||||
use Sabre\VObject\Component\VTodo;
|
|
||||||
use Sabre\VObject\Reader;
|
use Sabre\VObject\Reader;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
@@ -26,6 +25,9 @@ class CalendarService
|
|||||||
/** @var array<string,CalendarEvent[]> In-request cache keyed by "slotKey|all" for open tasks */
|
/** @var array<string,CalendarEvent[]> In-request cache keyed by "slotKey|all" for open tasks */
|
||||||
protected static $taskCache = [];
|
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.
|
* 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.
|
* 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 CalendarSlot[] $slots
|
||||||
* @param int $year
|
* @param int $year
|
||||||
* @param int $month
|
* @param int $month
|
||||||
@@ -173,19 +178,76 @@ class CalendarService
|
|||||||
$daysInMonth = (int)date('t', mktime(0, 0, 0, $month, 1, $year));
|
$daysInMonth = (int)date('t', mktime(0, 0, 0, $month, 1, $year));
|
||||||
$indicators = [];
|
$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) {
|
foreach ($slots as $slot) {
|
||||||
if (!$slot->isEnabled()) continue;
|
if (!$slot->isEnabled()) continue;
|
||||||
|
$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++) {
|
for ($day = 1; $day <= $daysInMonth; $day++) {
|
||||||
$dateIso = sprintf('%04d-%02d-%02d', $year, $month, $day);
|
$dateIso = sprintf('%04d-%02d-%02d', $year, $month, $day);
|
||||||
if (self::slotHasEventsOnDate($slot, $dateIso)) {
|
$events = self::collectFromCalendar($expanded, $slot->getKey(), $dateIso);
|
||||||
|
if ($events !== []) {
|
||||||
$indicators[$dateIso][] = $slot->getKey();
|
$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;
|
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.
|
* 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
|
protected static function parseEventsFromFile(string $file, string $slotKey, string $dateIso): array
|
||||||
{
|
{
|
||||||
$raw = @file_get_contents($file);
|
$calendar = self::readCalendar($file);
|
||||||
if (!is_string($raw) || trim($raw) === '') return [];
|
if ($calendar === null) return [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$component = Reader::read($raw, Reader::OPTION_FORGIVING);
|
|
||||||
if (!($component instanceof VCalendar)) return [];
|
|
||||||
|
|
||||||
$utc = new DateTimeZone('UTC');
|
$utc = new DateTimeZone('UTC');
|
||||||
$rangeStart = new DateTimeImmutable($dateIso . ' 00:00:00', $utc);
|
$rangeStart = new DateTimeImmutable($dateIso . ' 00:00:00', $utc);
|
||||||
$rangeStart = $rangeStart->sub(new DateInterval('P1D'));
|
$rangeStart = $rangeStart->sub(new DateInterval('P1D'));
|
||||||
$rangeEnd = $rangeStart->add(new DateInterval('P3D'));
|
$rangeEnd = $rangeStart->add(new DateInterval('P3D'));
|
||||||
|
|
||||||
$expanded = $component->expand($rangeStart, $rangeEnd);
|
$expanded = $calendar->expand($rangeStart, $rangeEnd);
|
||||||
if (!($expanded instanceof VCalendar)) return [];
|
if (!($expanded instanceof VCalendar)) return [];
|
||||||
|
|
||||||
return self::collectFromCalendar($expanded, $slotKey, $dateIso);
|
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.
|
* expanding recurrences up to the given date.
|
||||||
*
|
*
|
||||||
* @param string $file
|
* @param string $file
|
||||||
@@ -228,12 +287,11 @@ class CalendarService
|
|||||||
*/
|
*/
|
||||||
protected static function parseAllTasksFromFile(string $file, string $slotKey, string $todayIso): array
|
protected static function parseAllTasksFromFile(string $file, string $slotKey, string $todayIso): array
|
||||||
{
|
{
|
||||||
$raw = @file_get_contents($file);
|
$calendar = self::readCalendar($file);
|
||||||
if (!is_string($raw) || trim($raw) === '') return [];
|
if ($calendar === null) return [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$component = Reader::read($raw, Reader::OPTION_FORGIVING);
|
$component = $calendar;
|
||||||
if (!($component instanceof VCalendar)) return [];
|
|
||||||
|
|
||||||
// Expand from a reasonable lookback to tomorrow
|
// Expand from a reasonable lookback to tomorrow
|
||||||
$utc = new DateTimeZone('UTC');
|
$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;
|
return $tasks;
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
return [];
|
return [];
|
||||||
@@ -295,18 +344,6 @@ class CalendarService
|
|||||||
$result[] = $event;
|
$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']);
|
usort($result, [self::class, 'compareEvents']);
|
||||||
return $result;
|
return $result;
|
||||||
}
|
}
|
||||||
@@ -386,71 +423,6 @@ class CalendarService
|
|||||||
return $event;
|
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.
|
* Resolve the end date/time for a VEVENT.
|
||||||
*
|
*
|
||||||
@@ -549,5 +521,6 @@ class CalendarService
|
|||||||
{
|
{
|
||||||
self::$dayCache = [];
|
self::$dayCache = [];
|
||||||
self::$taskCache = [];
|
self::$taskCache = [];
|
||||||
|
self::$vcalCache = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,24 +4,21 @@ namespace dokuwiki\plugin\luxtools;
|
|||||||
|
|
||||||
use Sabre\VObject\Component\VCalendar;
|
use Sabre\VObject\Component\VCalendar;
|
||||||
use Sabre\VObject\Component\VEvent;
|
use Sabre\VObject\Component\VEvent;
|
||||||
use Sabre\VObject\Component\VTodo;
|
|
||||||
use Sabre\VObject\Reader;
|
use Sabre\VObject\Reader;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Write-back support for local ICS files.
|
* Write-back support for local ICS files.
|
||||||
*
|
*
|
||||||
* Handles updating event/task status (completion, reopening) in local
|
* Handles updating event status (completion, reopening) in local
|
||||||
* ICS files while preserving the original component type and other properties.
|
* ICS files while preserving other properties.
|
||||||
*/
|
*/
|
||||||
class IcsWriter
|
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).
|
* 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
|
* For recurring events, this writes an override/exception for the specific
|
||||||
* occurrence rather than modifying the master event.
|
* 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
|
// For recurring events without a matching override, create one
|
||||||
foreach ($calendar->select('VEVENT') as $component) {
|
foreach ($calendar->select('VEVENT') as $component) {
|
||||||
if (!($component instanceof VEvent)) continue;
|
if (!($component instanceof VEvent)) continue;
|
||||||
@@ -131,25 +119,13 @@ class IcsWriter
|
|||||||
if ($override !== null) return true;
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a component matches the given UID and recurrence criteria.
|
* Check if a component matches the given UID and recurrence criteria.
|
||||||
*
|
*
|
||||||
* @param VEvent|VTodo $component
|
* @param VEvent $component
|
||||||
* @param string $uid
|
* @param string $uid
|
||||||
* @param string $recurrenceId
|
* @param string $recurrenceId
|
||||||
* @param string $dateIso
|
* @param string $dateIso
|
||||||
@@ -193,33 +169,6 @@ class IcsWriter
|
|||||||
$vevent->STATUS = $newStatus;
|
$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.
|
* 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.
|
* Public atomic file write for use by CalDavClient sync.
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user