diff --git a/README.md b/README.md
index 9f3d11a..c4896a4 100644
--- a/README.md
+++ b/README.md
@@ -359,8 +359,6 @@ Completed tasks can be reopened with a "Reopen" button.
Write-back rules:
- `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
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
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
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:
diff --git a/admin/main.php b/admin/main.php
index 61388d6..695b88c 100644
--- a/admin/main.php
+++ b/admin/main.php
@@ -300,6 +300,37 @@ class admin_plugin_luxtools_main extends DokuWiki_Admin_Plugin
echo ' ';
}
+ // CalDAV Sync button (outside the save form, separate action)
+ $ajaxUrl = DOKU_BASE . 'lib/exe/ajax.php';
+ $sectok = getSecurityToken();
+ echo '
';
+ echo ''
+ . hsc($this->getLang('calendar_sync_button'))
+ . ' ';
+ echo ' ';
+ echo '
';
+ echo '';
+
// pagelink_search_depth
echo '' . hsc($this->getLang('pagelink_search_depth')) . ' ';
echo ' ';
diff --git a/src/CalDavClient.php b/src/CalDavClient.php
index e4c0ef4..501a0bf 100644
--- a/src/CalDavClient.php
+++ b/src/CalDavClient.php
@@ -147,32 +147,9 @@ class CalDavClient
]);
if ($response === null) {
- // Try VTODO filter as well
- $body = '' .
- '' .
- '' .
- ' ' .
- ' ' .
- ' ' .
- '' .
- '' .
- '' .
- '' .
- '' . htmlspecialchars($uid, ENT_XML1, 'UTF-8') . ' ' .
- ' ' .
- ' ' .
- ' ' .
- ' ' .
- ' ';
-
- $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;
diff --git a/src/CalendarEvent.php b/src/CalendarEvent.php
index a0e222c..44a8c01 100644
--- a/src/CalendarEvent.php
+++ b/src/CalendarEvent.php
@@ -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 */
diff --git a/src/CalendarService.php b/src/CalendarService.php
index b2c892d..15ddc70 100644
--- a/src/CalendarService.php
+++ b/src/CalendarService.php
@@ -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 In-request cache keyed by "slotKey|all" for open tasks */
protected static $taskCache = [];
+ /** @var array 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 = [];
}
}
diff --git a/src/IcsWriter.php b/src/IcsWriter.php
index 9b008d7..4f24ce8 100644
--- a/src/IcsWriter.php
+++ b/src/IcsWriter.php
@@ -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.
*