Calendar Sync V1
This commit is contained in:
466
src/CalDavClient.php
Normal file
466
src/CalDavClient.php
Normal file
@@ -0,0 +1,466 @@
|
||||
<?php
|
||||
|
||||
namespace dokuwiki\plugin\luxtools;
|
||||
|
||||
use Sabre\VObject\Component\VCalendar;
|
||||
use Sabre\VObject\Reader;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* CalDAV client for remote calendar operations.
|
||||
*
|
||||
* Supports:
|
||||
* - Downloading a full calendar collection into a local ICS file (sync)
|
||||
* - Updating the STATUS of a single event/task occurrence on the remote server
|
||||
*
|
||||
* Uses plain PHP curl for HTTP. No additional dependencies required.
|
||||
*/
|
||||
class CalDavClient
|
||||
{
|
||||
/** @var int HTTP timeout in seconds */
|
||||
protected const TIMEOUT = 30;
|
||||
|
||||
/**
|
||||
* Update the STATUS of a specific event or task on the remote CalDAV server.
|
||||
*
|
||||
* Fetches the calendar object containing the UID, modifies its status,
|
||||
* and PUTs it back using the ETag for conflict detection.
|
||||
*
|
||||
* @param string $caldavUrl CalDAV collection URL
|
||||
* @param string $username HTTP Basic auth username
|
||||
* @param string $password HTTP Basic auth password
|
||||
* @param string $uid Event/task 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
|
||||
* @return bool True if the remote update succeeded
|
||||
*/
|
||||
public static function updateEventStatus(
|
||||
string $caldavUrl,
|
||||
string $username,
|
||||
string $password,
|
||||
string $uid,
|
||||
string $recurrenceId,
|
||||
string $newStatus,
|
||||
string $dateIso
|
||||
): bool {
|
||||
if ($caldavUrl === '' || $uid === '') return false;
|
||||
|
||||
try {
|
||||
// Find the calendar object href for this UID via REPORT
|
||||
$objectInfo = self::findObjectByUid($caldavUrl, $username, $password, $uid);
|
||||
if ($objectInfo === null) return false;
|
||||
|
||||
$objectHref = $objectInfo['href'];
|
||||
$etag = $objectInfo['etag'];
|
||||
$calendarData = $objectInfo['data'];
|
||||
|
||||
// Parse and update the status
|
||||
$calendar = Reader::read($calendarData, Reader::OPTION_FORGIVING);
|
||||
if (!($calendar instanceof VCalendar)) return false;
|
||||
|
||||
$updated = IcsWriter::applyStatusUpdateToCalendar(
|
||||
$calendar, $uid, $recurrenceId, $newStatus, $dateIso
|
||||
);
|
||||
if (!$updated) return false;
|
||||
|
||||
$newData = $calendar->serialize();
|
||||
|
||||
// PUT the updated object back with If-Match for conflict detection
|
||||
return self::putCalendarObject($objectHref, $username, $password, $newData, $etag);
|
||||
} catch (Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync a remote CalDAV calendar collection into the slot's local ICS file.
|
||||
*
|
||||
* Downloads all calendar objects from the collection and merges them
|
||||
* into a single ICS file at the slot's configured file path.
|
||||
*
|
||||
* @param CalendarSlot $slot
|
||||
* @return bool True if sync succeeded
|
||||
*/
|
||||
public static function syncSlot(CalendarSlot $slot): bool
|
||||
{
|
||||
if (!$slot->hasRemoteSource()) return false;
|
||||
|
||||
$caldavUrl = $slot->getCaldavUrl();
|
||||
$username = $slot->getUsername();
|
||||
$password = $slot->getPassword();
|
||||
$localFile = $slot->getFile();
|
||||
|
||||
if ($localFile === '') {
|
||||
// No local file configured - nothing to sync into
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$objects = self::fetchAllCalendarObjects($caldavUrl, $username, $password);
|
||||
if ($objects === null) return false;
|
||||
|
||||
$merged = self::mergeCalendarObjects($objects);
|
||||
if ($merged === '') return false;
|
||||
|
||||
return IcsWriter::atomicWritePublic($localFile, $merged);
|
||||
} catch (Throwable $e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a specific calendar object by UID using a REPORT request.
|
||||
*
|
||||
* @param string $caldavUrl
|
||||
* @param string $username
|
||||
* @param string $password
|
||||
* @param string $uid
|
||||
* @return array{href: string, etag: string, data: string}|null
|
||||
*/
|
||||
protected static function findObjectByUid(
|
||||
string $caldavUrl,
|
||||
string $username,
|
||||
string $password,
|
||||
string $uid
|
||||
): ?array {
|
||||
$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="VEVENT">' .
|
||||
'<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) {
|
||||
// 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',
|
||||
]);
|
||||
}
|
||||
|
||||
if ($response === null) return null;
|
||||
|
||||
return self::parseReportResponse($response, $caldavUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all calendar objects from a CalDAV collection.
|
||||
*
|
||||
* @param string $caldavUrl
|
||||
* @param string $username
|
||||
* @param string $password
|
||||
* @return string[]|null Array of ICS data strings, or null on failure
|
||||
*/
|
||||
protected static function fetchAllCalendarObjects(
|
||||
string $caldavUrl,
|
||||
string $username,
|
||||
string $password
|
||||
): ?array {
|
||||
$body = '<?xml version="1.0" encoding="utf-8" ?>' .
|
||||
'<C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">' .
|
||||
'<D:prop>' .
|
||||
'<C:calendar-data/>' .
|
||||
'</D:prop>' .
|
||||
'<C:filter>' .
|
||||
'<C:comp-filter name="VCALENDAR"/>' .
|
||||
'</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::parseCalendarDataFromMultistatus($response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge multiple ICS calendar objects into a single calendar string.
|
||||
*
|
||||
* @param string[] $objects Array of ICS data strings
|
||||
* @return string Merged ICS content
|
||||
*/
|
||||
protected static function mergeCalendarObjects(array $objects): string
|
||||
{
|
||||
if ($objects === []) return '';
|
||||
|
||||
$merged = new VCalendar();
|
||||
$merged->PRODID = '-//LuxTools DokuWiki Plugin//CalDAV Sync//EN';
|
||||
$merged->VERSION = '2.0';
|
||||
|
||||
foreach ($objects as $icsData) {
|
||||
if (trim($icsData) === '') continue;
|
||||
try {
|
||||
$cal = Reader::read($icsData, Reader::OPTION_FORGIVING);
|
||||
if (!($cal instanceof VCalendar)) continue;
|
||||
|
||||
// Copy VTIMEZONE components first
|
||||
foreach ($cal->select('VTIMEZONE') as $tz) {
|
||||
// Check if this timezone already exists in merged
|
||||
$tzid = (string)($tz->TZID ?? '');
|
||||
$exists = false;
|
||||
foreach ($merged->select('VTIMEZONE') as $existingTz) {
|
||||
if ((string)($existingTz->TZID ?? '') === $tzid) {
|
||||
$exists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$exists) {
|
||||
$merged->add(clone $tz);
|
||||
}
|
||||
}
|
||||
|
||||
// Copy VEVENT and VTODO 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;
|
||||
}
|
||||
}
|
||||
|
||||
return $merged->serialize();
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT a calendar object back to the server.
|
||||
*
|
||||
* @param string $href Full URL of the calendar object
|
||||
* @param string $username
|
||||
* @param string $password
|
||||
* @param string $data ICS data to write
|
||||
* @param string $etag ETag for If-Match header (empty to skip)
|
||||
* @return bool
|
||||
*/
|
||||
protected static function putCalendarObject(
|
||||
string $href,
|
||||
string $username,
|
||||
string $password,
|
||||
string $data,
|
||||
string $etag
|
||||
): bool {
|
||||
$headers = [
|
||||
'Content-Type: text/calendar; charset=utf-8',
|
||||
];
|
||||
if ($etag !== '') {
|
||||
$headers[] = 'If-Match: ' . $etag;
|
||||
}
|
||||
|
||||
$response = self::request('PUT', $href, $username, $password, $data, $headers);
|
||||
// PUT returns null body but we check by HTTP status via the request method
|
||||
// A successful PUT returns 2xx
|
||||
return $response !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a REPORT multistatus response to extract href, etag, and calendar data
|
||||
* for the first matching object.
|
||||
*
|
||||
* @param string $xml
|
||||
* @param string $baseUrl
|
||||
* @return array{href: string, etag: string, data: string}|null
|
||||
*/
|
||||
protected static function parseReportResponse(string $xml, string $baseUrl): ?array
|
||||
{
|
||||
$doc = self::parseXml($xml);
|
||||
if ($doc === null) return null;
|
||||
|
||||
$doc->registerXPathNamespace('d', 'DAV:');
|
||||
$doc->registerXPathNamespace('cal', 'urn:ietf:params:xml:ns:caldav');
|
||||
|
||||
$responses = $doc->xpath('//d:response');
|
||||
if (!$responses || count($responses) === 0) return null;
|
||||
|
||||
foreach ($responses as $resp) {
|
||||
$resp->registerXPathNamespace('d', 'DAV:');
|
||||
$resp->registerXPathNamespace('cal', 'urn:ietf:params:xml:ns:caldav');
|
||||
|
||||
$hrefs = $resp->xpath('d:href');
|
||||
$href = ($hrefs && count($hrefs) > 0) ? trim((string)$hrefs[0]) : '';
|
||||
|
||||
$etags = $resp->xpath('.//d:getetag');
|
||||
$etag = ($etags && count($etags) > 0) ? trim((string)$etags[0]) : '';
|
||||
// Strip surrounding quotes from etag if present
|
||||
$etag = trim($etag, '"');
|
||||
|
||||
$caldata = $resp->xpath('.//cal:calendar-data');
|
||||
$data = ($caldata && count($caldata) > 0) ? trim((string)$caldata[0]) : '';
|
||||
|
||||
if ($href === '' || $data === '') continue;
|
||||
|
||||
// Resolve relative href to absolute URL
|
||||
if (strpos($href, 'http') !== 0) {
|
||||
$parsed = parse_url($baseUrl);
|
||||
$scheme = ($parsed['scheme'] ?? 'https');
|
||||
$host = ($parsed['host'] ?? '');
|
||||
$port = isset($parsed['port']) ? (':' . $parsed['port']) : '';
|
||||
$href = $scheme . '://' . $host . $port . $href;
|
||||
}
|
||||
|
||||
return [
|
||||
'href' => $href,
|
||||
'etag' => $etag,
|
||||
'data' => $data,
|
||||
];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse calendar-data elements from a CalDAV multistatus response.
|
||||
*
|
||||
* @param string $xml
|
||||
* @return string[]
|
||||
*/
|
||||
protected static function parseCalendarDataFromMultistatus(string $xml): array
|
||||
{
|
||||
$doc = self::parseXml($xml);
|
||||
if ($doc === null) return [];
|
||||
|
||||
$doc->registerXPathNamespace('d', 'DAV:');
|
||||
$doc->registerXPathNamespace('cal', 'urn:ietf:params:xml:ns:caldav');
|
||||
|
||||
$results = [];
|
||||
$responses = $doc->xpath('//d:response');
|
||||
if (!$responses) return [];
|
||||
|
||||
foreach ($responses as $resp) {
|
||||
$resp->registerXPathNamespace('cal', 'urn:ietf:params:xml:ns:caldav');
|
||||
$caldata = $resp->xpath('.//cal:calendar-data');
|
||||
if ($caldata && count($caldata) > 0) {
|
||||
$data = trim((string)$caldata[0]);
|
||||
if ($data !== '') {
|
||||
$results[] = $data;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an XML string safely.
|
||||
*
|
||||
* @param string $xml
|
||||
* @return \SimpleXMLElement|null
|
||||
*/
|
||||
protected static function parseXml(string $xml): ?\SimpleXMLElement
|
||||
{
|
||||
if (trim($xml) === '') return null;
|
||||
|
||||
// Disable external entity loading for security
|
||||
$prev = libxml_use_internal_errors(true);
|
||||
try {
|
||||
$doc = simplexml_load_string($xml, 'SimpleXMLElement', LIBXML_NOENT | LIBXML_NONET);
|
||||
libxml_clear_errors();
|
||||
return ($doc !== false) ? $doc : null;
|
||||
} finally {
|
||||
libxml_use_internal_errors($prev);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform an HTTP request to a CalDAV server.
|
||||
*
|
||||
* @param string $method HTTP method (GET, PUT, REPORT, PROPFIND, etc.)
|
||||
* @param string $url Full URL
|
||||
* @param string $username
|
||||
* @param string $password
|
||||
* @param string $body Request body (empty for GET)
|
||||
* @param string[] $headers Additional HTTP headers
|
||||
* @return string|null Response body, or null on failure
|
||||
*/
|
||||
protected static function request(
|
||||
string $method,
|
||||
string $url,
|
||||
string $username,
|
||||
string $password,
|
||||
string $body = '',
|
||||
array $headers = []
|
||||
): ?string {
|
||||
if (!function_exists('curl_init')) return null;
|
||||
|
||||
$ch = curl_init();
|
||||
if ($ch === false) return null;
|
||||
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, self::TIMEOUT);
|
||||
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
|
||||
curl_setopt($ch, CURLOPT_MAXREDIRS, 5);
|
||||
|
||||
// Authentication
|
||||
if ($username !== '') {
|
||||
curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC);
|
||||
curl_setopt($ch, CURLOPT_USERPWD, $username . ':' . $password);
|
||||
}
|
||||
|
||||
// Request body
|
||||
if ($body !== '') {
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
}
|
||||
|
||||
// HTTP headers
|
||||
if ($headers !== []) {
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
}
|
||||
|
||||
// Capture HTTP status code
|
||||
$responseBody = curl_exec($ch);
|
||||
$httpCode = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if (!is_string($responseBody)) return null;
|
||||
|
||||
// Accept 2xx and 207 (multistatus) responses
|
||||
if ($httpCode >= 200 && $httpCode < 300) {
|
||||
return $responseBody;
|
||||
}
|
||||
if ($httpCode === 207) {
|
||||
return $responseBody;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
86
src/CalendarEvent.php
Normal file
86
src/CalendarEvent.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace dokuwiki\plugin\luxtools;
|
||||
|
||||
/**
|
||||
* Normalized calendar event/task for internal use.
|
||||
*
|
||||
* All calendar data (from any slot, any source) is converted into this
|
||||
* structure before rendering or querying.
|
||||
*/
|
||||
class CalendarEvent
|
||||
{
|
||||
/** @var string Calendar slot key (e.g. 'general', 'maintenance') */
|
||||
public $slotKey;
|
||||
|
||||
/** @var string Unique source event UID */
|
||||
public $uid;
|
||||
|
||||
/** @var string Recurrence ID (empty for non-recurring or master) */
|
||||
public $recurrenceId;
|
||||
|
||||
/** @var string Event summary/title */
|
||||
public $summary;
|
||||
|
||||
/** @var string ISO 8601 start date/time */
|
||||
public $startIso;
|
||||
|
||||
/** @var string ISO 8601 end date/time (may be empty) */
|
||||
public $endIso;
|
||||
|
||||
/** @var bool Whether this is an all-day event */
|
||||
public $allDay;
|
||||
|
||||
/** @var string Formatted time string (HH:MM) or empty for all-day */
|
||||
public $time;
|
||||
|
||||
/** @var string Location (may be empty) */
|
||||
public $location;
|
||||
|
||||
/** @var string Description (may be empty) */
|
||||
public $description;
|
||||
|
||||
/**
|
||||
* Status: empty, CONFIRMED, TENTATIVE, CANCELLED, TODO, COMPLETED,
|
||||
* IN-PROCESS, NEEDS-ACTION.
|
||||
* @var string
|
||||
*/
|
||||
public $status;
|
||||
|
||||
/** @var string Component type from source: VEVENT or VTODO */
|
||||
public $componentType;
|
||||
|
||||
/** @var string The date (YYYY-MM-DD) this event applies to */
|
||||
public $dateIso;
|
||||
|
||||
/**
|
||||
* Build a stable completion key for maintenance task tracking.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function completionKey(): string
|
||||
{
|
||||
return implode('|', [$this->slotKey, $this->uid, $this->dateIso]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this event/task is marked as completed.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isCompleted(): bool
|
||||
{
|
||||
$s = strtoupper($this->status);
|
||||
return $s === 'COMPLETED';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this event/task is open (for maintenance filtering).
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isOpen(): bool
|
||||
{
|
||||
return !$this->isCompleted();
|
||||
}
|
||||
}
|
||||
553
src/CalendarService.php
Normal file
553
src/CalendarService.php
Normal file
@@ -0,0 +1,553 @@
|
||||
<?php
|
||||
|
||||
namespace dokuwiki\plugin\luxtools;
|
||||
|
||||
use DateInterval;
|
||||
use DateTimeImmutable;
|
||||
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;
|
||||
|
||||
/**
|
||||
* Slot-aware calendar service.
|
||||
*
|
||||
* Provides normalized event data grouped by slot for rendering,
|
||||
* widget indicators, task list queries, and completion tracking.
|
||||
*/
|
||||
class CalendarService
|
||||
{
|
||||
/** @var array<string,CalendarEvent[]> In-request cache keyed by "slotKey|dateIso" */
|
||||
protected static $dayCache = [];
|
||||
|
||||
/** @var array<string,CalendarEvent[]> In-request cache keyed by "slotKey|all" for open tasks */
|
||||
protected static $taskCache = [];
|
||||
|
||||
/**
|
||||
* Get all normalized events for a given date across all enabled slots.
|
||||
*
|
||||
* @param CalendarSlot[] $slots Keyed by slot key
|
||||
* @param string $dateIso YYYY-MM-DD
|
||||
* @return CalendarEvent[] Sorted: all-day first, then by time, then by title
|
||||
*/
|
||||
public static function eventsForDate(array $slots, string $dateIso): array
|
||||
{
|
||||
if (!ChronoID::isIsoDate($dateIso)) return [];
|
||||
|
||||
$all = [];
|
||||
foreach ($slots as $slot) {
|
||||
if (!$slot->isEnabled()) continue;
|
||||
$events = self::slotEventsForDate($slot, $dateIso);
|
||||
foreach ($events as $event) {
|
||||
$all[] = $event;
|
||||
}
|
||||
}
|
||||
|
||||
usort($all, [self::class, 'compareEvents']);
|
||||
return $all;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get events for a specific slot and date.
|
||||
*
|
||||
* @param CalendarSlot $slot
|
||||
* @param string $dateIso
|
||||
* @return CalendarEvent[]
|
||||
*/
|
||||
public static function slotEventsForDate(CalendarSlot $slot, string $dateIso): array
|
||||
{
|
||||
if (!ChronoID::isIsoDate($dateIso)) return [];
|
||||
if (!$slot->isEnabled()) return [];
|
||||
|
||||
$cacheKey = $slot->getKey() . '|' . $dateIso;
|
||||
if (isset(self::$dayCache[$cacheKey])) {
|
||||
return self::$dayCache[$cacheKey];
|
||||
}
|
||||
|
||||
$events = [];
|
||||
$file = $slot->getFile();
|
||||
if ($file !== '' && is_file($file) && is_readable($file)) {
|
||||
$events = self::parseEventsFromFile($file, $slot->getKey(), $dateIso);
|
||||
}
|
||||
|
||||
self::$dayCache[$cacheKey] = $events;
|
||||
return $events;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get events for a specific slot on a date, grouped by slot key.
|
||||
*
|
||||
* @param CalendarSlot[] $slots
|
||||
* @param string $dateIso
|
||||
* @return array<string,CalendarEvent[]> Keyed by slot key
|
||||
*/
|
||||
public static function eventsForDateGrouped(array $slots, string $dateIso): array
|
||||
{
|
||||
$grouped = [];
|
||||
foreach ($slots as $slot) {
|
||||
if (!$slot->isEnabled()) continue;
|
||||
$events = self::slotEventsForDate($slot, $dateIso);
|
||||
if ($events !== []) {
|
||||
$grouped[$slot->getKey()] = $events;
|
||||
}
|
||||
}
|
||||
return $grouped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check whether a slot has any events on a given date.
|
||||
*
|
||||
* @param CalendarSlot $slot
|
||||
* @param string $dateIso
|
||||
* @return bool
|
||||
*/
|
||||
public static function slotHasEventsOnDate(CalendarSlot $slot, string $dateIso): bool
|
||||
{
|
||||
return self::slotEventsForDate($slot, $dateIso) !== [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all open maintenance tasks due up to (and including) today.
|
||||
*
|
||||
* @param CalendarSlot $maintenanceSlot
|
||||
* @param string $todayIso YYYY-MM-DD
|
||||
* @return CalendarEvent[] Sorted: overdue first, then today, then by title
|
||||
*/
|
||||
public static function openMaintenanceTasks(CalendarSlot $maintenanceSlot, string $todayIso): array
|
||||
{
|
||||
if (!$maintenanceSlot->isEnabled()) return [];
|
||||
if (!ChronoID::isIsoDate($todayIso)) return [];
|
||||
|
||||
$file = $maintenanceSlot->getFile();
|
||||
if ($file === '' || !is_file($file) || !is_readable($file)) return [];
|
||||
|
||||
$cacheKey = $maintenanceSlot->getKey() . '|tasks|' . $todayIso;
|
||||
if (isset(self::$taskCache[$cacheKey])) {
|
||||
return self::$taskCache[$cacheKey];
|
||||
}
|
||||
|
||||
$tasks = self::parseAllTasksFromFile($file, $maintenanceSlot->getKey(), $todayIso);
|
||||
|
||||
// Filter: only non-completed, due today or earlier
|
||||
$open = [];
|
||||
foreach ($tasks as $task) {
|
||||
if ($task->isCompleted()) continue;
|
||||
// dateIso is the date the task falls on
|
||||
if ($task->dateIso > $todayIso) continue;
|
||||
$open[] = $task;
|
||||
}
|
||||
|
||||
// Sort: overdue first, then today, then by time, then by title
|
||||
usort($open, static function (CalendarEvent $a, CalendarEvent $b) use ($todayIso): int {
|
||||
$aOverdue = $a->dateIso < $todayIso;
|
||||
$bOverdue = $b->dateIso < $todayIso;
|
||||
if ($aOverdue !== $bOverdue) {
|
||||
return $aOverdue ? -1 : 1;
|
||||
}
|
||||
$dateCmp = strcmp($a->dateIso, $b->dateIso);
|
||||
if ($dateCmp !== 0) return $dateCmp;
|
||||
|
||||
$timeCmp = strcmp($a->time, $b->time);
|
||||
if ($timeCmp !== 0) return $timeCmp;
|
||||
|
||||
return strcmp($a->summary, $b->summary);
|
||||
});
|
||||
|
||||
self::$taskCache[$cacheKey] = $open;
|
||||
return $open;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get slot-level day indicator data for a whole month.
|
||||
*
|
||||
* @param CalendarSlot[] $slots
|
||||
* @param int $year
|
||||
* @param int $month
|
||||
* @return array<string,string[]> date => [slotKey, ...]
|
||||
*/
|
||||
public static function monthIndicators(array $slots, int $year, int $month): array
|
||||
{
|
||||
$daysInMonth = (int)date('t', mktime(0, 0, 0, $month, 1, $year));
|
||||
$indicators = [];
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $indicators;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse events from a local ICS file for a specific date.
|
||||
*
|
||||
* @param string $file
|
||||
* @param string $slotKey
|
||||
* @param string $dateIso
|
||||
* @return CalendarEvent[]
|
||||
*/
|
||||
protected static function parseEventsFromFile(string $file, string $slotKey, string $dateIso): array
|
||||
{
|
||||
$raw = @file_get_contents($file);
|
||||
if (!is_string($raw) || trim($raw) === '') 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);
|
||||
if (!($expanded instanceof VCalendar)) return [];
|
||||
|
||||
return self::collectFromCalendar($expanded, $slotKey, $dateIso);
|
||||
} catch (Throwable $e) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse all tasks (VEVENT with STATUS or VTODO) from a maintenance file,
|
||||
* expanding recurrences up to the given date.
|
||||
*
|
||||
* @param string $file
|
||||
* @param string $slotKey
|
||||
* @param string $todayIso
|
||||
* @return CalendarEvent[]
|
||||
*/
|
||||
protected static function parseAllTasksFromFile(string $file, string $slotKey, string $todayIso): array
|
||||
{
|
||||
$raw = @file_get_contents($file);
|
||||
if (!is_string($raw) || trim($raw) === '') return [];
|
||||
|
||||
try {
|
||||
$component = Reader::read($raw, Reader::OPTION_FORGIVING);
|
||||
if (!($component instanceof VCalendar)) return [];
|
||||
|
||||
// Expand from a reasonable lookback to tomorrow
|
||||
$utc = new DateTimeZone('UTC');
|
||||
$rangeStart = new DateTimeImmutable('2020-01-01 00:00:00', $utc);
|
||||
$rangeEnd = new DateTimeImmutable($todayIso . ' 00:00:00', $utc);
|
||||
$rangeEnd = $rangeEnd->add(new DateInterval('P1D'));
|
||||
|
||||
$expanded = $component->expand($rangeStart, $rangeEnd);
|
||||
if (!($expanded instanceof VCalendar)) return [];
|
||||
|
||||
$tasks = [];
|
||||
|
||||
// Collect VEVENTs
|
||||
foreach ($expanded->select('VEVENT') as $vevent) {
|
||||
if (!($vevent instanceof VEvent)) continue;
|
||||
$event = self::normalizeVEvent($vevent, $slotKey);
|
||||
if ($event !== null) {
|
||||
$tasks[] = $event;
|
||||
}
|
||||
}
|
||||
|
||||
// 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 [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect normalized events from an expanded VCalendar for a specific date.
|
||||
*
|
||||
* @param VCalendar $calendar
|
||||
* @param string $slotKey
|
||||
* @param string $dateIso
|
||||
* @return CalendarEvent[]
|
||||
*/
|
||||
protected static function collectFromCalendar(VCalendar $calendar, string $slotKey, string $dateIso): array
|
||||
{
|
||||
$result = [];
|
||||
$seen = [];
|
||||
|
||||
// VEVENTs
|
||||
foreach ($calendar->select('VEVENT') as $vevent) {
|
||||
if (!($vevent instanceof VEvent)) continue;
|
||||
$event = self::normalizeVEventForDay($vevent, $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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a VEVENT for a specific day into a CalendarEvent.
|
||||
*
|
||||
* @param VEvent $vevent
|
||||
* @param string $slotKey
|
||||
* @param string $dateIso
|
||||
* @return CalendarEvent|null
|
||||
*/
|
||||
protected static function normalizeVEventForDay(VEvent $vevent, string $slotKey, string $dateIso): ?CalendarEvent
|
||||
{
|
||||
if (!isset($vevent->DTSTART)) return null;
|
||||
|
||||
$isAllDay = strtoupper((string)($vevent->DTSTART['VALUE'] ?? '')) === 'DATE';
|
||||
$start = self::toImmutable($vevent->DTSTART->getDateTime());
|
||||
if ($start === null) return null;
|
||||
|
||||
$end = self::resolveEnd($vevent, $start, $isAllDay);
|
||||
|
||||
if (!self::intersectsDay($start, $end, $isAllDay, $dateIso)) return null;
|
||||
|
||||
$event = new CalendarEvent();
|
||||
$event->slotKey = $slotKey;
|
||||
$event->uid = trim((string)($vevent->UID ?? ''));
|
||||
$event->recurrenceId = isset($vevent->{'RECURRENCE-ID'}) ? trim((string)$vevent->{'RECURRENCE-ID'}) : '';
|
||||
$event->summary = trim((string)($vevent->SUMMARY ?? ''));
|
||||
if ($event->summary === '') $event->summary = '(ohne Titel)';
|
||||
$event->startIso = $start->format(DateTimeInterface::ATOM);
|
||||
$event->endIso = $end->format(DateTimeInterface::ATOM);
|
||||
$event->allDay = $isAllDay;
|
||||
$event->time = $isAllDay ? '' : $start->format('H:i');
|
||||
$event->location = trim((string)($vevent->LOCATION ?? ''));
|
||||
$event->description = trim((string)($vevent->DESCRIPTION ?? ''));
|
||||
$event->status = strtoupper(trim((string)($vevent->STATUS ?? '')));
|
||||
$event->componentType = 'VEVENT';
|
||||
$event->dateIso = $dateIso;
|
||||
|
||||
return $event;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a VEVENT into a CalendarEvent (without day filtering).
|
||||
*
|
||||
* @param VEvent $vevent
|
||||
* @param string $slotKey
|
||||
* @return CalendarEvent|null
|
||||
*/
|
||||
protected static function normalizeVEvent(VEvent $vevent, string $slotKey): ?CalendarEvent
|
||||
{
|
||||
if (!isset($vevent->DTSTART)) return null;
|
||||
|
||||
$isAllDay = strtoupper((string)($vevent->DTSTART['VALUE'] ?? '')) === 'DATE';
|
||||
$start = self::toImmutable($vevent->DTSTART->getDateTime());
|
||||
if ($start === null) return null;
|
||||
|
||||
$end = self::resolveEnd($vevent, $start, $isAllDay);
|
||||
|
||||
$event = new CalendarEvent();
|
||||
$event->slotKey = $slotKey;
|
||||
$event->uid = trim((string)($vevent->UID ?? ''));
|
||||
$event->recurrenceId = isset($vevent->{'RECURRENCE-ID'}) ? trim((string)$vevent->{'RECURRENCE-ID'}) : '';
|
||||
$event->summary = trim((string)($vevent->SUMMARY ?? ''));
|
||||
if ($event->summary === '') $event->summary = '(ohne Titel)';
|
||||
$event->startIso = $start->format(DateTimeInterface::ATOM);
|
||||
$event->endIso = $end->format(DateTimeInterface::ATOM);
|
||||
$event->allDay = $isAllDay;
|
||||
$event->time = $isAllDay ? '' : $start->format('H:i');
|
||||
$event->location = trim((string)($vevent->LOCATION ?? ''));
|
||||
$event->description = trim((string)($vevent->DESCRIPTION ?? ''));
|
||||
$event->status = strtoupper(trim((string)($vevent->STATUS ?? '')));
|
||||
$event->componentType = 'VEVENT';
|
||||
$event->dateIso = $start->format('Y-m-d');
|
||||
|
||||
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.
|
||||
*
|
||||
* @param VEvent $vevent
|
||||
* @param DateTimeImmutable $start
|
||||
* @param bool $isAllDay
|
||||
* @return DateTimeImmutable
|
||||
*/
|
||||
protected static function resolveEnd(VEvent $vevent, DateTimeImmutable $start, bool $isAllDay): DateTimeImmutable
|
||||
{
|
||||
if (isset($vevent->DTEND)) {
|
||||
$end = self::toImmutable($vevent->DTEND->getDateTime());
|
||||
if ($end !== null) return $end;
|
||||
}
|
||||
|
||||
if (isset($vevent->DURATION)) {
|
||||
try {
|
||||
$duration = $vevent->DURATION->getDateInterval();
|
||||
if ($duration instanceof DateInterval) {
|
||||
return $start->add($duration);
|
||||
}
|
||||
} catch (Throwable $e) {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
return $isAllDay ? $start->add(new DateInterval('P1D')) : $start;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a date range intersects a given day.
|
||||
*
|
||||
* @param DateTimeImmutable $start
|
||||
* @param DateTimeImmutable $end
|
||||
* @param bool $isAllDay
|
||||
* @param string $dateIso
|
||||
* @return bool
|
||||
*/
|
||||
protected static function intersectsDay(
|
||||
DateTimeImmutable $start,
|
||||
DateTimeImmutable $end,
|
||||
bool $isAllDay,
|
||||
string $dateIso
|
||||
): bool {
|
||||
$eventTimezone = $start->getTimezone();
|
||||
$dayStart = new DateTimeImmutable($dateIso . ' 00:00:00', $eventTimezone);
|
||||
$dayEnd = $dayStart->add(new DateInterval('P1D'));
|
||||
|
||||
if ($end <= $start) {
|
||||
$end = $isAllDay ? $start->add(new DateInterval('P1D')) : $start;
|
||||
}
|
||||
|
||||
$intersects = ($start < $dayEnd) && ($end > $dayStart);
|
||||
if (!$intersects && !$isAllDay && $start >= $dayStart && $start < $dayEnd && $end == $start) {
|
||||
$intersects = true;
|
||||
}
|
||||
|
||||
return $intersects;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two CalendarEvents for sorting.
|
||||
*
|
||||
* @param CalendarEvent $a
|
||||
* @param CalendarEvent $b
|
||||
* @return int
|
||||
*/
|
||||
protected static function compareEvents(CalendarEvent $a, CalendarEvent $b): int
|
||||
{
|
||||
if ($a->allDay !== $b->allDay) {
|
||||
return $a->allDay ? -1 : 1;
|
||||
}
|
||||
$timeCmp = strcmp($a->time, $b->time);
|
||||
if ($timeCmp !== 0) return $timeCmp;
|
||||
return strcmp($a->summary, $b->summary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a DateTimeInterface to DateTimeImmutable.
|
||||
*
|
||||
* @param DateTimeInterface $dt
|
||||
* @return DateTimeImmutable|null
|
||||
*/
|
||||
protected static function toImmutable(DateTimeInterface $dt): ?DateTimeImmutable
|
||||
{
|
||||
if ($dt instanceof DateTimeImmutable) return $dt;
|
||||
$immutable = DateTimeImmutable::createFromFormat('U', (string)$dt->getTimestamp());
|
||||
if (!($immutable instanceof DateTimeImmutable)) return null;
|
||||
return $immutable->setTimezone($dt->getTimezone());
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all runtime caches.
|
||||
*/
|
||||
public static function clearCache(): void
|
||||
{
|
||||
self::$dayCache = [];
|
||||
self::$taskCache = [];
|
||||
}
|
||||
}
|
||||
171
src/CalendarSlot.php
Normal file
171
src/CalendarSlot.php
Normal file
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
namespace dokuwiki\plugin\luxtools;
|
||||
|
||||
/**
|
||||
* Represents one calendar slot configuration.
|
||||
*
|
||||
* Each slot has a stable key, a human-readable label, local/remote source
|
||||
* configuration, a display color, and a derived enabled state.
|
||||
*/
|
||||
class CalendarSlot
|
||||
{
|
||||
/** @var string[] Ordered list of all supported slot keys */
|
||||
public const SLOT_KEYS = ['general', 'maintenance', 'slot3', 'slot4'];
|
||||
|
||||
/** @var array<string,string> Human-readable labels for slot keys */
|
||||
public const SLOT_LABELS = [
|
||||
'general' => 'General',
|
||||
'maintenance' => 'Maintenance',
|
||||
'slot3' => 'Slot 3',
|
||||
'slot4' => 'Slot 4',
|
||||
];
|
||||
|
||||
/** @var string */
|
||||
protected $key;
|
||||
|
||||
/** @var string */
|
||||
protected $label;
|
||||
|
||||
/** @var string Local ICS file path */
|
||||
protected $file;
|
||||
|
||||
/** @var string CalDAV URL */
|
||||
protected $caldavUrl;
|
||||
|
||||
/** @var string CalDAV username */
|
||||
protected $username;
|
||||
|
||||
/** @var string CalDAV password */
|
||||
protected $password;
|
||||
|
||||
/** @var string CSS color for widget indicators */
|
||||
protected $color;
|
||||
|
||||
/**
|
||||
* @param string $key
|
||||
* @param string $file
|
||||
* @param string $caldavUrl
|
||||
* @param string $username
|
||||
* @param string $password
|
||||
* @param string $color
|
||||
*/
|
||||
public function __construct(
|
||||
string $key,
|
||||
string $file = '',
|
||||
string $caldavUrl = '',
|
||||
string $username = '',
|
||||
string $password = '',
|
||||
string $color = ''
|
||||
) {
|
||||
$this->key = $key;
|
||||
$this->label = self::SLOT_LABELS[$key] ?? $key;
|
||||
$this->file = trim($file);
|
||||
$this->caldavUrl = trim($caldavUrl);
|
||||
$this->username = trim($username);
|
||||
$this->password = trim($password);
|
||||
$this->color = trim($color);
|
||||
}
|
||||
|
||||
public function getKey(): string
|
||||
{
|
||||
return $this->key;
|
||||
}
|
||||
|
||||
public function getLabel(): string
|
||||
{
|
||||
return $this->label;
|
||||
}
|
||||
|
||||
public function getFile(): string
|
||||
{
|
||||
return $this->file;
|
||||
}
|
||||
|
||||
public function getCaldavUrl(): string
|
||||
{
|
||||
return $this->caldavUrl;
|
||||
}
|
||||
|
||||
public function getUsername(): string
|
||||
{
|
||||
return $this->username;
|
||||
}
|
||||
|
||||
public function getPassword(): string
|
||||
{
|
||||
return $this->password;
|
||||
}
|
||||
|
||||
public function getColor(): string
|
||||
{
|
||||
return $this->color;
|
||||
}
|
||||
|
||||
/**
|
||||
* A slot is enabled if it has a local file path or a CalDAV URL.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->file !== '' || $this->caldavUrl !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this slot has a usable local ICS file.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function hasLocalFile(): bool
|
||||
{
|
||||
if ($this->file === '') return false;
|
||||
return is_file($this->file) && is_readable($this->file);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this slot has a configured remote CalDAV source.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function hasRemoteSource(): bool
|
||||
{
|
||||
return $this->caldavUrl !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all configured calendar slots from plugin configuration.
|
||||
*
|
||||
* @param object $plugin Plugin instance with getConf() method
|
||||
* @return CalendarSlot[] Keyed by slot key
|
||||
*/
|
||||
public static function loadAll($plugin): array
|
||||
{
|
||||
$slots = [];
|
||||
foreach (self::SLOT_KEYS as $key) {
|
||||
$slots[$key] = new self(
|
||||
$key,
|
||||
(string)$plugin->getConf('calendar_' . $key . '_file'),
|
||||
(string)$plugin->getConf('calendar_' . $key . '_caldav_url'),
|
||||
(string)$plugin->getConf('calendar_' . $key . '_username'),
|
||||
(string)$plugin->getConf('calendar_' . $key . '_password'),
|
||||
(string)$plugin->getConf('calendar_' . $key . '_color')
|
||||
);
|
||||
}
|
||||
return $slots;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load only enabled calendar slots.
|
||||
*
|
||||
* @param object $plugin Plugin instance with getConf() method
|
||||
* @return CalendarSlot[] Keyed by slot key
|
||||
*/
|
||||
public static function loadEnabled($plugin): array
|
||||
{
|
||||
$all = self::loadAll($plugin);
|
||||
return array_filter($all, static function (CalendarSlot $slot): bool {
|
||||
return $slot->isEnabled();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,17 @@ class ChronologicalCalendarWidget
|
||||
* @param int $year
|
||||
* @param int $month
|
||||
* @param string $baseNs
|
||||
* @param array<string,string[]> $indicators date => [slotKey, ...] from CalendarService::monthIndicators()
|
||||
* @param array<string,string> $slotColors slotKey => CSS color
|
||||
* @return string
|
||||
*/
|
||||
public static function render(int $year, int $month, string $baseNs = 'chronological'): string
|
||||
{
|
||||
public static function render(
|
||||
int $year,
|
||||
int $month,
|
||||
string $baseNs = 'chronological',
|
||||
array $indicators = [],
|
||||
array $slotColors = []
|
||||
): string {
|
||||
if (!self::isValidMonth($year, $month)) return '';
|
||||
|
||||
$firstDayTs = mktime(0, 0, 0, $month, 1, $year);
|
||||
@@ -109,6 +116,19 @@ class ChronologicalCalendarWidget
|
||||
$classes = 'luxtools-calendar-day';
|
||||
|
||||
$html .= '<td class="' . hsc($classes) . '" data-luxtools-date="' . hsc($date) . '">';
|
||||
|
||||
// Render slot indicators if any
|
||||
$dayIndicators = $indicators[$date] ?? [];
|
||||
if ($dayIndicators !== []) {
|
||||
$html .= '<div class="luxtools-calendar-indicators">';
|
||||
foreach ($dayIndicators as $slotKey) {
|
||||
$color = $slotColors[$slotKey] ?? '';
|
||||
$style = ($color !== '') ? ' style="background-color:' . hsc($color) . '"' : '';
|
||||
$html .= '<span class="luxtools-calendar-indicator luxtools-indicator-' . hsc($slotKey) . '"' . $style . '></span>';
|
||||
}
|
||||
$html .= '</div>';
|
||||
}
|
||||
|
||||
if ($dayId !== null && function_exists('html_wikilink')) {
|
||||
$html .= (string)html_wikilink($dayId, (string)$dayNumber);
|
||||
} else {
|
||||
|
||||
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