* SPDX-License-Identifier: AGPL-3.0-or-later */ namespace KTXM\ChronoManager\Conversion; use DateInterval; use DateTimeImmutable; use DateTimeZone; use InvalidArgumentException; use KTXF\Chrono\Entity as E; use KTXF\Chrono\Entity\Property as P; use Sabre\VObject\Component; use Sabre\VObject\Component\VEvent; use Sabre\VObject\Component\VJournal; use Sabre\VObject\Component\VTodo; use Throwable; /** Decodes the three RFC 5545 calendar object types into Chrono entities. */ class IcalDecoder { public function fromComponent(Component $component): E\EventObject|E\TaskObject|E\JournalObject { return match (true) { $component instanceof VEvent => $this->mapEvent($component), $component instanceof VTodo => $this->mapTask($component), $component instanceof VJournal => $this->mapJournal($component), default => throw new InvalidArgumentException('Unsupported iCalendar component: ' . $component->name), }; } private function mapEvent(VEvent $event): E\EventObject { $object = new E\EventObject(); $this->mapCommon($event, $object); $startProperty = $event->DTSTART ?? null; $object->startsOn = $this->dateTime($event, 'DTSTART'); $object->endsOn = $this->dateTime($event, 'DTEND'); $object->duration = $this->duration($event); if ($object->endsOn === null && $object->startsOn !== null && $object->duration !== null) { $object->endsOn = $object->startsOn->add($object->duration); } $object->timeless = $startProperty !== null && !$startProperty->hasTime(); $object->sequence = isset($event->SEQUENCE) ? (int)(string)$event->SEQUENCE : null; $object->timeZone = $this->timeZone($startProperty); $object->startsTZ = $this->timeZone($startProperty); $object->endsTZ = $this->timeZone($event->DTEND ?? null); $object->priority = isset($event->PRIORITY) ? (int)(string)$event->PRIORITY : null; $object->color = $this->text($event, 'COLOR'); $object->availability = strtoupper($this->text($event, 'TRANSP') ?? '') === 'TRANSPARENT' ? P\EventAvailabilityTypes::Free : P\EventAvailabilityTypes::Busy; $object->sensitivity = $this->eventSensitivity($event); if ($location = $this->text($event, 'LOCATION')) { $physical = new P\EventLocationPhysicalObject(); $physical->label = $location; $physical->relation = 'start'; $object->locationsPhysical->add($physical, $this->key()); } if ($url = $this->text($event, 'URL')) { $virtual = new P\EventLocationVirtualObject(); $virtual->location = $url; $object->locationsVirtual->add($virtual, $this->key()); } $this->mapOrganizer($event, $object); $this->mapParticipants($event, $object); $object->pattern = $this->eventRecurrence($event); return $object; } private function mapTask(VTodo $task): E\TaskObject { $object = new E\TaskObject(); $this->mapCommon($task, $object); $object->startsOn = $this->dateTime($task, 'DTSTART'); $object->dueOn = $this->dateTime($task, 'DUE'); $object->completedOn = $this->dateTime($task, 'COMPLETED'); if ($object->dueOn === null && $object->startsOn !== null && ($duration = $this->duration($task)) !== null) { $object->dueOn = $object->startsOn->add($duration); } $object->status = match (strtoupper($this->text($task, 'STATUS') ?? '')) { 'NEEDS-ACTION' => P\TaskStatusTypes::NeedsAction, 'IN-PROCESS' => P\TaskStatusTypes::InProcess, 'COMPLETED' => P\TaskStatusTypes::Completed, 'CANCELLED' => P\TaskStatusTypes::Cancelled, default => null, }; $object->priority = isset($task->PRIORITY) ? (int)(string)$task->PRIORITY : null; $progress = $task->{'PERCENT-COMPLETE'} ?? $task->PERCENT ?? null; $object->progress = $progress !== null ? max(0, min(100, (int)(string)$progress)) : null; $object->color = $this->text($task, 'COLOR'); $object->notes = $this->joinedText($task, 'COMMENT'); $object->recurrence = $this->taskRecurrence($task); $this->mapAttachments($task, $object->attachments); return $object; } private function mapJournal(VJournal $journal): E\JournalObject { $object = new E\JournalObject(); $this->mapCommon($journal, $object); $object->content = $this->joinedText($journal, 'DESCRIPTION'); $object->startsOn = $this->dateTime($journal, 'DTSTART'); $object->endsOn = $this->dateTime($journal, 'DTEND'); if ($object->endsOn === null && $object->startsOn !== null && isset($journal->DTSTART) && !$journal->DTSTART->hasTime()) { $object->endsOn = $object->startsOn->modify('+1 day'); } $object->status = match (strtoupper($this->text($journal, 'STATUS') ?? '')) { 'DRAFT' => P\JournalStatusTypes::Draft, 'FINAL' => P\JournalStatusTypes::Final, 'CANCELLED' => P\JournalStatusTypes::Cancelled, default => null, }; $object->visibility = match (strtoupper($this->text($journal, 'CLASS') ?? '')) { 'PUBLIC' => P\JournalVisibilityTypes::Public, 'PRIVATE' => P\JournalVisibilityTypes::Private, 'CONFIDENTIAL' => P\JournalVisibilityTypes::Confidential, default => null, }; $this->mapAttachments($journal, $object->attachments); return $object; } private function mapCommon( Component $component, E\EventObject|E\TaskObject|E\JournalObject $object, ): void { $object->urid = $this->text($component, 'UID'); $object->created = $this->dateTime($component, 'CREATED'); $object->modified = $this->dateTime($component, 'LAST-MODIFIED'); $object->label = $this->text($component, 'SUMMARY'); $object->description = $this->text($component, 'DESCRIPTION'); foreach ($this->categories($component) as $tag) { $object->tags->add($tag); } } private function mapOrganizer(VEvent $event, E\EventObject $object): void { if (!isset($event->ORGANIZER)) return; $object->organizer->realm = P\EventParticipantRealm::External; $object->organizer->address = $this->address((string)$event->ORGANIZER); $object->organizer->name = $this->parameter($event->ORGANIZER, 'CN'); } private function mapParticipants(VEvent $event, E\EventObject $object): void { foreach ($event->select('ATTENDEE') as $attendee) { $participant = new P\EventParticipantObject(); $participant->realm = P\EventParticipantRealm::External; $participant->address = $this->address((string)$attendee); $participant->name = $this->parameter($attendee, 'CN'); $participant->language = $this->parameter($attendee, 'LANGUAGE'); $participant->type = match (strtoupper($this->parameter($attendee, 'CUTYPE') ?? '')) { 'GROUP' => P\EventParticipantTypes::Group, 'RESOURCE' => P\EventParticipantTypes::Resource, 'ROOM' => P\EventParticipantTypes::Location, default => P\EventParticipantTypes::Individual, }; $participant->status = match (strtoupper($this->parameter($attendee, 'PARTSTAT') ?? '')) { 'ACCEPTED' => P\EventParticipantStatusTypes::Accepted, 'DECLINED' => P\EventParticipantStatusTypes::Declined, 'TENTATIVE' => P\EventParticipantStatusTypes::Tentative, 'DELEGATED' => P\EventParticipantStatusTypes::Delegated, default => P\EventParticipantStatusTypes::None, }; $participant->roles->add(match (strtoupper($this->parameter($attendee, 'ROLE') ?? '')) { 'CHAIR' => P\EventParticipantRoleTypes::Chair, 'OPT-PARTICIPANT' => P\EventParticipantRoleTypes::Optional, 'NON-PARTICIPANT' => P\EventParticipantRoleTypes::Informational, default => P\EventParticipantRoleTypes::Attendee, }); $object->participants->add($participant, $this->key()); } } private function eventRecurrence(VEvent $event): ?P\EventOccurrenceObject { if (!isset($event->RRULE)) return null; $rule = $event->RRULE->getParts(); $precision = match (strtoupper((string)($rule['FREQ'] ?? ''))) { 'YEARLY' => P\EventOccurrencePrecisionTypes::Yearly, 'MONTHLY' => P\EventOccurrencePrecisionTypes::Monthly, 'WEEKLY' => P\EventOccurrencePrecisionTypes::Weekly, 'DAILY' => P\EventOccurrencePrecisionTypes::Daily, 'HOURLY' => P\EventOccurrencePrecisionTypes::Hourly, 'MINUTELY' => P\EventOccurrencePrecisionTypes::Minutely, 'SECONDLY' => P\EventOccurrencePrecisionTypes::Secondly, default => null, }; if ($precision === null) return null; $object = new P\EventOccurrenceObject(); $object->pattern = P\EventOccurrencePatternTypes::Relative; $object->precision = $precision; $object->interval = (int)($rule['INTERVAL'] ?? 1); $object->iterations = isset($rule['COUNT']) ? (int)$rule['COUNT'] : null; $object->concludes = isset($rule['UNTIL']) ? $this->parseDate((string)$rule['UNTIL']) : null; $object->onDayOfWeek = $this->weekdays($rule['BYDAY'] ?? []); $object->onDayOfMonth = $this->integers($rule['BYMONTHDAY'] ?? []); $object->onDayOfYear = $this->integers($rule['BYYEARDAY'] ?? []); $object->onWeekOfYear = $this->integers($rule['BYWEEKNO'] ?? []); $object->onMonthOfYear = $this->integers($rule['BYMONTH'] ?? []); $object->onHour = $this->integers($rule['BYHOUR'] ?? []); $object->onMinute = $this->integers($rule['BYMINUTE'] ?? []); $object->onSecond = $this->integers($rule['BYSECOND'] ?? []); $object->onPosition = $this->integers($rule['BYSETPOS'] ?? []); return $object; } private function taskRecurrence(VTodo $task): ?P\TaskRecurrenceObject { if (!isset($task->RRULE)) return null; $rule = $task->RRULE->getParts(); $frequency = strtolower((string)($rule['FREQ'] ?? '')); if ($frequency === '') return null; $object = new P\TaskRecurrenceObject(); $object->frequency = $frequency; $object->interval = (int)($rule['INTERVAL'] ?? 1); $object->count = isset($rule['COUNT']) ? (int)$rule['COUNT'] : null; $object->until = isset($rule['UNTIL']) ? $this->parseDate((string)$rule['UNTIL']) : null; $object->byDay = array_map('strval', $this->values($rule['BYDAY'] ?? [])); $object->byMonthDay = $this->integers($rule['BYMONTHDAY'] ?? []); $object->byMonth = $this->integers($rule['BYMONTH'] ?? []); return $object; } private function mapAttachments(Component $component, $attachments): void { foreach ($component->select('ATTACH') as $property) { $attachment = new P\AttachmentObject(); $attachment->uri = trim((string)$property) ?: null; $attachment->type = $this->parameter($property, 'FMTTYPE'); $attachment->label = $this->parameter($property, 'FILENAME') ?? $this->parameter($property, 'X-FILENAME'); $attachments->add($attachment, $this->key()); } } private function eventSensitivity(VEvent $event): ?P\EventSensitivityTypes { return match (strtoupper($this->text($event, 'CLASS') ?? '')) { 'PUBLIC' => P\EventSensitivityTypes::Public, 'PRIVATE', 'CONFIDENTIAL' => P\EventSensitivityTypes::Private, default => null, }; } private function dateTime(Component $component, string $name): ?DateTimeImmutable { if (!isset($component->$name)) return null; try { return $component->$name->getDateTime(); } catch (Throwable) { return $this->parseDate((string)$component->$name); } } private function duration(Component $component): ?DateInterval { if (!isset($component->DURATION)) return null; try { return $component->DURATION->getDateInterval(); } catch (Throwable) { try { return new DateInterval((string)$component->DURATION); } catch (Throwable) { return null; } } } private function timeZone($property): ?DateTimeZone { $tzid = $property?->offsetGet('TZID'); if ($tzid === null || trim((string)$tzid) === '') return null; try { return new DateTimeZone((string)$tzid); } catch (Throwable) { return null; } } private function parseDate(string $value): ?DateTimeImmutable { try { return new DateTimeImmutable($value); } catch (Throwable) { return null; } } private function text(Component $component, string $name): ?string { $value = isset($component->$name) ? trim((string)$component->$name) : ''; return $value !== '' ? $value : null; } private function joinedText(Component $component, string $name): ?string { $values = array_values(array_filter(array_map(static fn($property): string => trim((string)$property), $component->select($name)))); return $values !== [] ? implode("\n\n", $values) : null; } private function parameter($property, string $name): ?string { $value = $property[$name] ?? null; return $value !== null && trim((string)$value) !== '' ? trim((string)$value) : null; } private function address(string $value): string { return preg_replace('/^mailto:/i', '', trim($value)) ?? trim($value); } /** @return list */ private function categories(Component $component): array { $result = []; foreach ($component->select('CATEGORIES') as $property) { foreach ($property->getParts() as $part) { if (trim((string)$part) !== '') $result[] = trim((string)$part); } } return array_values(array_unique($result)); } private function values(string|array $values): array { return is_array($values) ? $values : [$values]; } private function integers(string|array $values): array { return array_map('intval', $this->values($values)); } private function weekdays(string|array $values): array { $days = ['MO' => 1, 'TU' => 2, 'WE' => 3, 'TH' => 4, 'FR' => 5, 'SA' => 6, 'SU' => 7]; $result = []; foreach ($this->values($values) as $value) { $day = strtoupper(substr((string)$value, -2)); if (isset($days[$day])) $result[] = $days[$day]; } return $result; } private function key(): string { return bin2hex(random_bytes(8)); } }