3d1a07e0ad
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
388 lines
16 KiB
PHP
388 lines
16 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
*/
|
|
|
|
namespace KTXM\ChronoManager\Conversion;
|
|
|
|
use DateTime;
|
|
use DateTimeInterface;
|
|
use DateTimeZone;
|
|
use Generator;
|
|
use KTXF\Chrono\Entity as E;
|
|
use KTXF\Chrono\Entity\Property as P;
|
|
use Sabre\VObject\Component;
|
|
use Sabre\VObject\Component\VCalendar;
|
|
|
|
/**
|
|
* Encodes Chrono entities into the three RFC 5545 calendar object types.
|
|
*
|
|
* Mirror image of {@see IcalDecoder}: every property the decoder reads is
|
|
* written back, so decode -> encode -> decode is stable.
|
|
*/
|
|
class IcalEncoder {
|
|
|
|
private const PRODID = '-//KTX//Chrono//EN';
|
|
|
|
/**
|
|
* Streams a complete iCalendar document, one chunk per component, so large
|
|
* exports never hold the whole document in memory.
|
|
*
|
|
* @param iterable<E\EventObject|E\TaskObject|E\JournalObject> $entities
|
|
* @return Generator<string>
|
|
*/
|
|
public function toIcs(iterable $entities): Generator {
|
|
yield "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:" . self::PRODID . "\r\nCALSCALE:GREGORIAN\r\n";
|
|
$document = new VCalendar();
|
|
foreach ($entities as $entity) {
|
|
$component = $this->toComponent($entity, $document);
|
|
yield $component->serialize();
|
|
$document->remove($component);
|
|
}
|
|
yield "END:VCALENDAR\r\n";
|
|
}
|
|
|
|
/** Convenience wrapper around {@see toIcs()} for single-document callers. */
|
|
public function toIcsString(iterable $entities): string {
|
|
return implode('', iterator_to_array($this->toIcs($entities), false));
|
|
}
|
|
|
|
/**
|
|
* Adds the entity to $document as a VEVENT/VTODO/VJOURNAL and returns it.
|
|
*
|
|
* $uidFallback is used when the entity carries no urid (e.g. entities that
|
|
* were never imported); DAV passes the provider entity identifier here so
|
|
* object UIDs stay stable across requests.
|
|
*/
|
|
public function toComponent(
|
|
E\EventObject|E\TaskObject|E\JournalObject $entity,
|
|
VCalendar $document,
|
|
?string $uidFallback = null,
|
|
): Component {
|
|
return match (true) {
|
|
$entity instanceof E\EventObject => $this->encodeEvent($entity, $document, $uidFallback),
|
|
$entity instanceof E\TaskObject => $this->encodeTask($entity, $document, $uidFallback),
|
|
$entity instanceof E\JournalObject => $this->encodeJournal($entity, $document, $uidFallback),
|
|
};
|
|
}
|
|
|
|
private function encodeEvent(E\EventObject $entity, VCalendar $document, ?string $uidFallback): Component {
|
|
$event = $this->createComponent($document, 'VEVENT', $entity, $uidFallback);
|
|
|
|
$this->addDate($event, 'DTSTART', $entity->startsOn, (bool)$entity->timeless);
|
|
$this->addDate($event, 'DTEND', $entity->endsOn, (bool)$entity->timeless);
|
|
if ($entity->sequence !== null) {
|
|
$event->add('SEQUENCE', (string)$entity->sequence);
|
|
}
|
|
if ($entity->priority !== null) {
|
|
$event->add('PRIORITY', (string)$entity->priority);
|
|
}
|
|
if ($entity->color !== null) {
|
|
$event->add('COLOR', $entity->color);
|
|
}
|
|
// OPAQUE is the RFC 5545 default and what the decoder assumes when
|
|
// TRANSP is absent, so only the non-default value is emitted.
|
|
if ($entity->availability === P\EventAvailabilityTypes::Free) {
|
|
$event->add('TRANSP', 'TRANSPARENT');
|
|
}
|
|
if ($entity->sensitivity !== null) {
|
|
$event->add('CLASS', match ($entity->sensitivity) {
|
|
P\EventSensitivityTypes::Public => 'PUBLIC',
|
|
P\EventSensitivityTypes::Private => 'PRIVATE',
|
|
P\EventSensitivityTypes::Secret => 'CONFIDENTIAL',
|
|
});
|
|
}
|
|
|
|
foreach ($entity->locationsPhysical as $location) {
|
|
if ($location->label !== null && $location->label !== '') {
|
|
$event->add('LOCATION', $location->label);
|
|
break;
|
|
}
|
|
}
|
|
foreach ($entity->locationsVirtual as $location) {
|
|
if ($location->location !== null && $location->location !== '') {
|
|
$event->add('URL', $location->location);
|
|
break;
|
|
}
|
|
}
|
|
|
|
$this->encodeOrganizer($event, $entity->organizer);
|
|
foreach ($entity->participants as $participant) {
|
|
$this->encodeParticipant($event, $participant);
|
|
}
|
|
if ($entity->pattern !== null && ($rule = $this->eventRecurrenceRule($entity->pattern)) !== null) {
|
|
$event->add('RRULE', $rule);
|
|
}
|
|
|
|
return $event;
|
|
}
|
|
|
|
private function encodeTask(E\TaskObject $entity, VCalendar $document, ?string $uidFallback): Component {
|
|
$task = $this->createComponent($document, 'VTODO', $entity, $uidFallback);
|
|
|
|
$this->addDate($task, 'DTSTART', $entity->startsOn);
|
|
$this->addDate($task, 'DUE', $entity->dueOn);
|
|
$this->addDate($task, 'COMPLETED', $entity->completedOn);
|
|
if ($entity->status !== null) {
|
|
$task->add('STATUS', strtoupper($entity->status->value));
|
|
}
|
|
if ($entity->priority !== null) {
|
|
$task->add('PRIORITY', (string)$entity->priority);
|
|
}
|
|
if ($entity->progress !== null) {
|
|
$task->add('PERCENT-COMPLETE', (string)$entity->progress);
|
|
}
|
|
if ($entity->color !== null) {
|
|
$task->add('COLOR', $entity->color);
|
|
}
|
|
if ($entity->notes !== null) {
|
|
$task->add('COMMENT', $entity->notes);
|
|
}
|
|
if ($entity->recurrence !== null && ($rule = $this->taskRecurrenceRule($entity->recurrence)) !== null) {
|
|
$task->add('RRULE', $rule);
|
|
}
|
|
$this->encodeAttachments($task, $entity->attachments);
|
|
|
|
return $task;
|
|
}
|
|
|
|
private function encodeJournal(E\JournalObject $entity, VCalendar $document, ?string $uidFallback): Component {
|
|
$journal = $this->createComponent($document, 'VJOURNAL', $entity, $uidFallback, description: false);
|
|
|
|
// The decoder fills both content and description from DESCRIPTION;
|
|
// content is the journal body and wins on the way out.
|
|
if (($body = $entity->content ?? $entity->description) !== null) {
|
|
$journal->add('DESCRIPTION', $body);
|
|
}
|
|
$this->addDate($journal, 'DTSTART', $entity->startsOn);
|
|
if ($entity->status !== null) {
|
|
$journal->add('STATUS', strtoupper($entity->status->value));
|
|
}
|
|
if ($entity->visibility !== null) {
|
|
$journal->add('CLASS', strtoupper($entity->visibility->value));
|
|
}
|
|
$this->encodeAttachments($journal, $entity->attachments);
|
|
|
|
return $journal;
|
|
}
|
|
|
|
/**
|
|
* Creates the bare component and writes the properties common to all
|
|
* three types: UID, DTSTAMP, CREATED, LAST-MODIFIED, SUMMARY,
|
|
* DESCRIPTION, CATEGORIES.
|
|
*/
|
|
private function createComponent(
|
|
VCalendar $document,
|
|
string $name,
|
|
E\EventObject|E\TaskObject|E\JournalObject $entity,
|
|
?string $uidFallback,
|
|
bool $description = true,
|
|
): Component {
|
|
// Suppress sabre's auto-generated UID/DTSTAMP defaults: UID is set
|
|
// explicitly below and DTSTAMP must be deterministic — the DAV ETag is
|
|
// md5 of the serialized output, and a "now" DTSTAMP makes an unchanged
|
|
// entity produce a new ETag on every request (perpetual client syncs).
|
|
/** @var Component $component */
|
|
$component = $document->add($name, [], false);
|
|
|
|
$uid = $entity->urid ?? $uidFallback;
|
|
if ($uid !== null && $uid !== '') {
|
|
$component->add('UID', $uid);
|
|
}
|
|
$stamp = $entity->modified ?? $entity->created;
|
|
$component->add('DTSTAMP', $stamp !== null ? $this->utc($stamp) : (new DateTime('@0'))->setTimezone(new DateTimeZone('UTC')));
|
|
|
|
if ($entity->created !== null) {
|
|
$component->add('CREATED', $this->utc($entity->created));
|
|
}
|
|
if ($entity->modified !== null) {
|
|
$component->add('LAST-MODIFIED', $this->utc($entity->modified));
|
|
}
|
|
if ($entity->label !== null) {
|
|
$component->add('SUMMARY', $entity->label);
|
|
}
|
|
if ($description && $entity->description !== null) {
|
|
$component->add('DESCRIPTION', $entity->description);
|
|
}
|
|
$tags = array_values(array_filter(array_map('strval', $entity->tags->getArrayCopy()), static fn(string $tag): bool => $tag !== ''));
|
|
if ($tags !== []) {
|
|
$component->add('CATEGORIES', $tags);
|
|
}
|
|
|
|
return $component;
|
|
}
|
|
|
|
private function encodeOrganizer(Component $component, P\EventOrganizerObject $organizer): void {
|
|
if ($organizer->address === null || $organizer->address === '') {
|
|
return;
|
|
}
|
|
$parameters = [];
|
|
if ($organizer->name !== null && $organizer->name !== '') {
|
|
$parameters['CN'] = $organizer->name;
|
|
}
|
|
$component->add('ORGANIZER', 'mailto:' . $organizer->address, $parameters);
|
|
}
|
|
|
|
private function encodeParticipant(Component $component, P\EventParticipantObject $participant): void {
|
|
if ($participant->address === null || $participant->address === '') {
|
|
return;
|
|
}
|
|
$parameters = [];
|
|
if ($participant->name !== null && $participant->name !== '') {
|
|
$parameters['CN'] = $participant->name;
|
|
}
|
|
if ($participant->language !== null && $participant->language !== '') {
|
|
$parameters['LANGUAGE'] = $participant->language;
|
|
}
|
|
// Defaults (INDIVIDUAL / no PARTSTAT / ATTENDEE role) are what the
|
|
// decoder assumes for absent parameters, so only non-defaults are emitted.
|
|
$type = match ($participant->type) {
|
|
P\EventParticipantTypes::Group => 'GROUP',
|
|
P\EventParticipantTypes::Resource => 'RESOURCE',
|
|
P\EventParticipantTypes::Location => 'ROOM',
|
|
default => null,
|
|
};
|
|
if ($type !== null) {
|
|
$parameters['CUTYPE'] = $type;
|
|
}
|
|
$status = match ($participant->status) {
|
|
P\EventParticipantStatusTypes::Accepted => 'ACCEPTED',
|
|
P\EventParticipantStatusTypes::Declined => 'DECLINED',
|
|
P\EventParticipantStatusTypes::Tentative => 'TENTATIVE',
|
|
P\EventParticipantStatusTypes::Delegated => 'DELEGATED',
|
|
default => null,
|
|
};
|
|
if ($status !== null) {
|
|
$parameters['PARTSTAT'] = $status;
|
|
}
|
|
foreach ($participant->roles as $role) {
|
|
$token = match ($role) {
|
|
P\EventParticipantRoleTypes::Chair => 'CHAIR',
|
|
P\EventParticipantRoleTypes::Optional => 'OPT-PARTICIPANT',
|
|
P\EventParticipantRoleTypes::Informational => 'NON-PARTICIPANT',
|
|
default => null,
|
|
};
|
|
if ($token !== null) {
|
|
$parameters['ROLE'] = $token;
|
|
}
|
|
break;
|
|
}
|
|
$component->add('ATTENDEE', 'mailto:' . $participant->address, $parameters);
|
|
}
|
|
|
|
private function eventRecurrenceRule(P\EventOccurrenceObject $pattern): ?string {
|
|
$frequency = match ($pattern->precision) {
|
|
P\EventOccurrencePrecisionTypes::Yearly => 'YEARLY',
|
|
P\EventOccurrencePrecisionTypes::Monthly => 'MONTHLY',
|
|
P\EventOccurrencePrecisionTypes::Weekly => 'WEEKLY',
|
|
P\EventOccurrencePrecisionTypes::Daily => 'DAILY',
|
|
P\EventOccurrencePrecisionTypes::Hourly => 'HOURLY',
|
|
P\EventOccurrencePrecisionTypes::Minutely => 'MINUTELY',
|
|
P\EventOccurrencePrecisionTypes::Secondly => 'SECONDLY',
|
|
default => null,
|
|
};
|
|
if ($frequency === null) {
|
|
return null;
|
|
}
|
|
$parts = ['FREQ=' . $frequency];
|
|
if ($pattern->interval !== null && $pattern->interval > 1) {
|
|
$parts[] = 'INTERVAL=' . $pattern->interval;
|
|
}
|
|
if ($pattern->iterations !== null) {
|
|
$parts[] = 'COUNT=' . $pattern->iterations;
|
|
}
|
|
if ($pattern->concludes !== null) {
|
|
$parts[] = 'UNTIL=' . $this->utc($pattern->concludes)->format('Ymd\THis\Z');
|
|
}
|
|
$this->appendRulePart($parts, 'BYDAY', $this->weekdayTokens($pattern->onDayOfWeek));
|
|
$this->appendRulePart($parts, 'BYMONTHDAY', $pattern->onDayOfMonth);
|
|
$this->appendRulePart($parts, 'BYYEARDAY', $pattern->onDayOfYear);
|
|
$this->appendRulePart($parts, 'BYWEEKNO', $pattern->onWeekOfYear);
|
|
$this->appendRulePart($parts, 'BYMONTH', $pattern->onMonthOfYear);
|
|
$this->appendRulePart($parts, 'BYHOUR', $pattern->onHour);
|
|
$this->appendRulePart($parts, 'BYMINUTE', $pattern->onMinute);
|
|
$this->appendRulePart($parts, 'BYSECOND', $pattern->onSecond);
|
|
$this->appendRulePart($parts, 'BYSETPOS', $pattern->onPosition);
|
|
return implode(';', $parts);
|
|
}
|
|
|
|
private function taskRecurrenceRule(P\TaskRecurrenceObject $recurrence): ?string {
|
|
if ($recurrence->frequency === null || $recurrence->frequency === '') {
|
|
return null;
|
|
}
|
|
$parts = ['FREQ=' . strtoupper($recurrence->frequency)];
|
|
if ($recurrence->interval !== null && $recurrence->interval > 1) {
|
|
$parts[] = 'INTERVAL=' . $recurrence->interval;
|
|
}
|
|
if ($recurrence->count !== null) {
|
|
$parts[] = 'COUNT=' . $recurrence->count;
|
|
}
|
|
if ($recurrence->until !== null) {
|
|
$parts[] = 'UNTIL=' . $this->utc($recurrence->until)->format('Ymd\THis\Z');
|
|
}
|
|
$this->appendRulePart($parts, 'BYDAY', array_map('strval', $recurrence->byDay));
|
|
$this->appendRulePart($parts, 'BYMONTHDAY', $recurrence->byMonthDay);
|
|
$this->appendRulePart($parts, 'BYMONTH', $recurrence->byMonth);
|
|
return implode(';', $parts);
|
|
}
|
|
|
|
private function appendRulePart(array &$parts, string $name, array $values): void {
|
|
if ($values !== []) {
|
|
$parts[] = $name . '=' . implode(',', $values);
|
|
}
|
|
}
|
|
|
|
/** @return list<string> inverse of the decoder's MO..SU => 1..7 map */
|
|
private function weekdayTokens(array $days): array {
|
|
$tokens = [1 => 'MO', 2 => 'TU', 3 => 'WE', 4 => 'TH', 5 => 'FR', 6 => 'SA', 7 => 'SU'];
|
|
$result = [];
|
|
foreach ($days as $day) {
|
|
if (isset($tokens[(int)$day])) {
|
|
$result[] = $tokens[(int)$day];
|
|
}
|
|
}
|
|
return $result;
|
|
}
|
|
|
|
private function encodeAttachments(Component $component, $attachments): void {
|
|
foreach ($attachments as $attachment) {
|
|
if ($attachment->uri === null || $attachment->uri === '') {
|
|
continue;
|
|
}
|
|
$parameters = [];
|
|
if ($attachment->type !== null && $attachment->type !== '') {
|
|
$parameters['FMTTYPE'] = $attachment->type;
|
|
}
|
|
if ($attachment->label !== null && $attachment->label !== '') {
|
|
$parameters['FILENAME'] = $attachment->label;
|
|
}
|
|
$component->add('ATTACH', $attachment->uri, $parameters);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Date-times are always emitted in UTC ("...Z"): a value carrying a fixed
|
|
* numeric offset (e.g. from an RFC3339 string) would otherwise serialize
|
|
* as a TZID with no matching VTIMEZONE, which clients reject. Timeless
|
|
* (all-day) values are emitted as DATE.
|
|
*/
|
|
private function addDate(Component $component, string $name, ?DateTimeInterface $value, bool $dateOnly = false): void {
|
|
if ($value === null) {
|
|
return;
|
|
}
|
|
if ($dateOnly) {
|
|
$component->add($name, $value->format('Ymd'), ['VALUE' => 'DATE']);
|
|
} else {
|
|
$component->add($name, $this->utc($value));
|
|
}
|
|
}
|
|
|
|
private function utc(DateTimeInterface $value): DateTime {
|
|
return (new DateTime('@' . $value->getTimestamp()))->setTimezone(new DateTimeZone('UTC'));
|
|
}
|
|
}
|