From 3d1a07e0ad829033664da3b5c4baf5e3f20ac62e Mon Sep 17 00:00:00 2001 From: Sebastian Krupinski Date: Fri, 17 Jul 2026 19:13:08 -0400 Subject: [PATCH] feat: 2 way conversion and export Signed-off-by: Sebastian Krupinski --- lib/Controllers/DefaultController.php | 43 +++ lib/Conversion/IcalDecoder.php | 310 +++++++++++++++++++++ lib/Conversion/IcalEncoder.php | 387 ++++++++++++++++++++++++++ lib/Export/ExportService.php | 93 +++++++ lib/Import/ImportService.php | 301 +------------------- lib/Manager.php | 5 +- 6 files changed, 843 insertions(+), 296 deletions(-) create mode 100644 lib/Conversion/IcalDecoder.php create mode 100644 lib/Conversion/IcalEncoder.php create mode 100644 lib/Export/ExportService.php diff --git a/lib/Controllers/DefaultController.php b/lib/Controllers/DefaultController.php index 11e7942..9fa614c 100644 --- a/lib/Controllers/DefaultController.php +++ b/lib/Controllers/DefaultController.php @@ -13,6 +13,7 @@ use InvalidArgumentException; use KTXC\Http\Response\JsonResponse; use KTXC\Http\Response\Response; use KTXC\Http\Response\StreamedNdJsonResponse; +use KTXC\Http\Response\StreamedResponse; use KTXC\SessionIdentity; use KTXC\SessionTenant; use KTXF\Controller\ControllerAbstract; @@ -23,6 +24,7 @@ use KTXF\Resource\Identifier\ResourceIdentifier; use KTXF\Resource\Identifier\ResourceIdentifiers; use KTXF\Resource\Identifier\ServiceIdentifier; use KTXF\Routing\Attributes\AuthenticatedRoute; +use KTXM\ChronoManager\Export\ExportService; use KTXM\ChronoManager\Import\ImportOptions; use KTXM\ChronoManager\Import\ImportService; use KTXM\ChronoManager\Manager; @@ -53,6 +55,7 @@ class DefaultController extends ControllerAbstract { private readonly SessionIdentity $userIdentity, private readonly Manager $manager, private readonly ImportService $importService, + private readonly ExportService $exportService, private readonly LoggerInterface $logger, ) {} @@ -156,6 +159,7 @@ class DefaultController extends ControllerAbstract { 'entity.delete' => $this->entityDelete($tenantId, $userId, $data), 'entity.move' => $this->entityMove($tenantId, $userId, $data), 'entity.import' => $this->entityImport($tenantId, $userId, $data, $version, $transaction), + 'entity.export' => $this->entityExport($tenantId, $userId, $data), default => throw new InvalidArgumentException(self::ERR_INVALID_OPERATION . $operation) }; @@ -607,6 +611,45 @@ class DefaultController extends ControllerAbstract { return new StreamedNdJsonResponse($response, 1, 200); } + /** + * Export calendar entities as a streamed iCalendar document. + * + * Request data: { sources: ["provider:service:collection", "provider:service:collection:entity", ...], filename?: "calendar.ics" } + */ + private function entityExport(string $tenantId, string $userId, array $data): StreamedResponse { + + if (!isset($data['sources'])) { + throw new InvalidArgumentException(self::ERR_MISSING_SOURCES); + } + if (!is_array($data['sources']) || $data['sources'] === []) { + throw new InvalidArgumentException(self::ERR_INVALID_SOURCES); + } + + $sources = ResourceIdentifiers::fromArray($data['sources']); + foreach ($sources as $source) { + if (!$source instanceof CollectionIdentifier && !$source instanceof EntityIdentifier) { + throw new InvalidArgumentException('Invalid parameter: sources must contain provider:service:collection or provider:service:collection:entity identifiers'); + } + } + + $filename = 'export.ics'; + if (isset($data['filename']) && is_string($data['filename'])) { + $sanitized = preg_replace('/[^A-Za-z0-9._-]+/', '_', trim($data['filename'])); + if ($sanitized !== '' && $sanitized !== null) { + $filename = str_ends_with(strtolower($sanitized), '.ics') ? $sanitized : $sanitized . '.ics'; + } + } + + return new StreamedResponse( + $this->exportService->export($sources, $tenantId, $userId), + 200, + [ + 'Content-Type' => 'text/calendar; charset=utf-8', + 'Content-Disposition' => 'attachment; filename="' . $filename . '"', + ], + ); + } + /** * Wrap a generator of JsonSerializable domain objects in the canonical NDJSON * stream envelope shared by every streaming operation: diff --git a/lib/Conversion/IcalDecoder.php b/lib/Conversion/IcalDecoder.php new file mode 100644 index 0000000..6f25b0f --- /dev/null +++ b/lib/Conversion/IcalDecoder.php @@ -0,0 +1,310 @@ + + * 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)); } +} diff --git a/lib/Conversion/IcalEncoder.php b/lib/Conversion/IcalEncoder.php new file mode 100644 index 0000000..11525d5 --- /dev/null +++ b/lib/Conversion/IcalEncoder.php @@ -0,0 +1,387 @@ + + * 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')); + } +} diff --git a/lib/Export/ExportService.php b/lib/Export/ExportService.php new file mode 100644 index 0000000..cc08c82 --- /dev/null +++ b/lib/Export/ExportService.php @@ -0,0 +1,93 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ChronoManager\Export; + +use Generator; +use KTXF\Chrono\Entity\EventObject; +use KTXF\Chrono\Entity\JournalObject; +use KTXF\Chrono\Entity\TaskObject; +use KTXF\Resource\Identifier\CollectionIdentifier; +use KTXF\Resource\Identifier\EntityIdentifier; +use KTXF\Resource\Identifier\ResourceIdentifiers; +use KTXM\ChronoManager\Manager; +use KTXM\ChronoManager\Conversion\IcalEncoder; +use Psr\Log\LoggerInterface; +use Throwable; + +/** Exports Chrono entities as a streamed RFC 5545 iCalendar document. */ +class ExportService { + + public function __construct( + private readonly Manager $manager, + private readonly IcalEncoder $encoder, + private readonly LoggerInterface $logger, + ) {} + + /** + * @param ResourceIdentifiers $sources collection and/or entity identifiers + * @return Generator iCalendar document chunks + */ + public function export(ResourceIdentifiers $sources, string $tenantId, string $userId): Generator { + yield from $this->encoder->toIcs($this->entities($sources, $tenantId, $userId)); + } + + /** @return Generator */ + private function entities(ResourceIdentifiers $sources, string $tenantId, string $userId): Generator { + $collections = []; + $entities = []; + foreach ($sources as $source) { + if ($source instanceof EntityIdentifier) { + $entities[] = $source; + } elseif ($source instanceof CollectionIdentifier) { + $collections[] = $source; + } + } + + if ($collections !== []) { + $listed = $this->manager->entityListBulk($tenantId, $userId, new ResourceIdentifiers($collections)); + foreach ($listed as $services) { + foreach ($services as $collectionSets) { + foreach ($collectionSets as $set) { + foreach ((array)$set as $entity) { + if (($hydrated = $this->hydrate($entity)) !== null) { + yield $hydrated; + } + } + } + } + } + } + + if ($entities !== []) { + foreach ($this->manager->entityFetchBulk($tenantId, $userId, ...$entities) as $entity) { + if (($hydrated = $this->hydrate($entity)) !== null) { + yield $hydrated; + } + } + } + } + + /** Skip-and-continue on per-entity failure, matching import's error-tolerant posture. */ + private function hydrate(mixed $entity): EventObject|TaskObject|JournalObject|null { + if (!is_object($entity)) { + return null; + } + try { + $hydrated = $entity->getProperties(); + // DAV-created entities may carry no urid; the provider identifier + // keeps exported UIDs stable in that case. + $hydrated->urid ??= (string)$entity->identifier(); + return $hydrated; + } catch (Throwable $t) { + $this->logger->warning('Skipping entity during export', ['exception' => $t]); + return null; + } + } +} diff --git a/lib/Import/ImportService.php b/lib/Import/ImportService.php index da6b8a3..beb37d4 100644 --- a/lib/Import/ImportService.php +++ b/lib/Import/ImportService.php @@ -4,16 +4,11 @@ declare(strict_types=1); namespace KTXM\ChronoManager\Import; -use DateInterval; -use DateTimeImmutable; -use DateTimeZone; use Generator; use InvalidArgumentException; -use KTXF\Chrono\Entity as E; -use KTXF\Chrono\Entity\Property as P; use KTXF\Resource\Identifier\CollectionIdentifier; use KTXM\ChronoManager\Manager; -use Sabre\VObject\Component; +use KTXM\ChronoManager\Conversion\IcalDecoder; use Sabre\VObject\Component\VCalendar; use Sabre\VObject\Component\VEvent; use Sabre\VObject\Component\VJournal; @@ -25,7 +20,10 @@ use Throwable; /** Imports the three RFC 5545 calendar object types into Chrono entities. */ class ImportService { - public function __construct(private readonly Manager $manager) {} + public function __construct( + private readonly Manager $manager, + private readonly IcalDecoder $decoder, + ) {} /** * @param resource $source readable, seekable iCalendar resource @@ -62,10 +60,9 @@ class ImportService { } foreach ($components as $component) { - $uid = $this->text($component, 'UID'); + $uid = isset($component->UID) ? (trim((string)$component->UID) ?: null) : null; try { - $properties = $this->mapComponent($component)->jsonSerialize(); - $this->manager->entityCreate($tenantId, $userId, $target, $properties); + $this->manager->entityCreate($tenantId, $userId, $target, $this->decoder->fromComponent($component)); yield new ImportObjectEvent($uid, ImportDisposition::Created); } catch (Throwable $e) { yield new ImportObjectEvent($uid, ImportDisposition::Error, [$e->getMessage()]); @@ -75,288 +72,4 @@ class ImportService { } } } - - private function mapComponent(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)); } } diff --git a/lib/Manager.php b/lib/Manager.php index fbb91f5..fb62f2d 100644 --- a/lib/Manager.php +++ b/lib/Manager.php @@ -15,6 +15,7 @@ use KTXF\Chrono\Service\ServiceCollectionMutableInterface; use KTXF\Chrono\Service\ServiceEntityMutableInterface; use KTXF\Chrono\Entity\EntityBaseInterface; use KTXF\Chrono\Entity\EntityPropertiesMutableInterface; +use KTXF\Chrono\Entity\EntityType; use KTXF\Chrono\Service\ServiceMutableInterface; use KTXF\Resource\Filter\IFilter; use KTXF\Resource\Identifier\CollectionIdentifier; @@ -862,7 +863,7 @@ class Manager { } // convert properties if necessary if ($properties instanceof EntityPropertiesMutableInterface === false) { - $properties = $service->entityFresh()->getProperties()->jsonDeserialize($properties); + $properties = $service->entityFresh(EntityType::fromData($properties))->getProperties()->jsonDeserialize($properties); } // create entity return $service->entityCreate($target, $properties, $options); @@ -894,7 +895,7 @@ class Manager { } // convert properties if necessary if ($properties instanceof EntityPropertiesMutableInterface === false) { - $properties = $service->entityFresh()->getProperties()->jsonDeserialize($properties); + $properties = $service->entityFresh(EntityType::fromData($properties))->getProperties()->jsonDeserialize($properties); } // modify entity return $service->entityModify($target, $properties);