* 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 $entities * @return Generator */ 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 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')); } }