diff --git a/composer.json b/composer.json index 490fa58..98d869e 100644 --- a/composer.json +++ b/composer.json @@ -16,7 +16,8 @@ "vendor-dir": "lib/vendor" }, "require": { - "php": ">=8.2 <=8.5" + "php": ">=8.2 <=8.5", + "sabre/vobject": "^4.5" }, "autoload": { "psr-4": { diff --git a/composer.lock b/composer.lock index f95ba02..3fba700 100644 --- a/composer.lock +++ b/composer.lock @@ -4,20 +4,259 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "d301e50c47a83d5bf3a8a07ddeb43301", - "packages": [], + "content-hash": "0a59afe25a51a7b30c15c48e908cc4dc", + "packages": [ + { + "name": "sabre/uri", + "version": "3.1.0", + "source": { + "type": "git", + "url": "https://github.com/sabre-io/uri.git", + "reference": "a926c749dddfb289b8a9b5218d16ac06affdc631" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sabre-io/uri/zipball/a926c749dddfb289b8a9b5218d16ac06affdc631", + "reference": "a926c749dddfb289b8a9b5218d16ac06affdc631", + "shasum": "" + }, + "require": { + "php": "^8.2" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.95", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^10.5", + "rector/rector": "^2.4" + }, + "type": "library", + "autoload": { + "files": [ + "lib/functions.php" + ], + "psr-4": { + "Sabre\\Uri\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Evert Pot", + "email": "me@evertpot.com", + "homepage": "http://evertpot.com/", + "role": "Developer" + } + ], + "description": "Functions for making sense out of URIs.", + "homepage": "http://sabre.io/uri/", + "keywords": [ + "rfc3986", + "uri", + "url" + ], + "support": { + "forum": "https://groups.google.com/group/sabredav-discuss", + "issues": "https://github.com/sabre-io/uri/issues", + "source": "https://github.com/fruux/sabre-uri" + }, + "time": "2026-04-26T04:19:03+00:00" + }, + { + "name": "sabre/vobject", + "version": "4.6.0", + "source": { + "type": "git", + "url": "https://github.com/sabre-io/vobject.git", + "reference": "9432544fc369851fb8202c5d91159b2e669f0c88" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sabre-io/vobject/zipball/9432544fc369851fb8202c5d91159b2e669f0c88", + "reference": "9432544fc369851fb8202c5d91159b2e669f0c88", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": "^7.1 || ^8.0", + "sabre/xml": "^2.1 || ^3.0 || ^4.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "~2.17.1", + "phpstan/phpstan": "^0.12 || ^1.12 || ^2.0", + "phpunit/php-invoker": "^2.0 || ^3.1", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6" + }, + "suggest": { + "hoa/bench": "If you would like to run the benchmark scripts" + }, + "bin": [ + "bin/vobject", + "bin/generate_vcards" + ], + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Sabre\\VObject\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Evert Pot", + "email": "me@evertpot.com", + "homepage": "http://evertpot.com/", + "role": "Developer" + }, + { + "name": "Dominik Tobschall", + "email": "dominik@fruux.com", + "homepage": "http://tobschall.de/", + "role": "Developer" + }, + { + "name": "Ivan Enderlin", + "email": "ivan.enderlin@hoa-project.net", + "homepage": "http://mnt.io/", + "role": "Developer" + } + ], + "description": "The VObject library for PHP allows you to easily parse and manipulate iCalendar and vCard objects", + "homepage": "http://sabre.io/vobject/", + "keywords": [ + "availability", + "freebusy", + "iCalendar", + "ical", + "ics", + "jCal", + "jCard", + "recurrence", + "rfc2425", + "rfc2426", + "rfc2739", + "rfc4770", + "rfc5545", + "rfc5546", + "rfc6321", + "rfc6350", + "rfc6351", + "rfc6474", + "rfc6638", + "rfc6715", + "rfc6868", + "vCalendar", + "vCard", + "vcf", + "xCal", + "xCard" + ], + "support": { + "forum": "https://groups.google.com/group/sabredav-discuss", + "issues": "https://github.com/sabre-io/vobject/issues", + "source": "https://github.com/fruux/sabre-vobject" + }, + "time": "2026-05-31T13:04:55+00:00" + }, + { + "name": "sabre/xml", + "version": "4.1.0", + "source": { + "type": "git", + "url": "https://github.com/sabre-io/xml.git", + "reference": "bec83cbe2f4e1e1ca4254ed8d7caa7e32cc74b05" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sabre-io/xml/zipball/bec83cbe2f4e1e1ca4254ed8d7caa7e32cc74b05", + "reference": "bec83cbe2f4e1e1ca4254ed8d7caa7e32cc74b05", + "shasum": "" + }, + "require": { + "ext-dom": "*", + "ext-xmlreader": "*", + "ext-xmlwriter": "*", + "lib-libxml": ">=2.6.20", + "php": "^8.2", + "sabre/uri": ">=2.0,<4.0.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.95", + "phpstan/extension-installer": "^1.4", + "phpstan/phpstan": "^2.1", + "phpstan/phpstan-phpunit": "^2.0", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^10.5", + "rector/rector": "^2.4" + }, + "type": "library", + "autoload": { + "files": [ + "lib/Deserializer/functions.php", + "lib/Serializer/functions.php" + ], + "psr-4": { + "Sabre\\Xml\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Evert Pot", + "email": "me@evertpot.com", + "homepage": "http://evertpot.com/", + "role": "Developer" + }, + { + "name": "Markus Staab", + "email": "markus.staab@redaxo.de", + "role": "Developer" + } + ], + "description": "sabre/xml is an XML library that you may not hate.", + "homepage": "https://sabre.io/xml/", + "keywords": [ + "XMLReader", + "XMLWriter", + "dom", + "xml" + ], + "support": { + "forum": "https://groups.google.com/group/sabredav-discuss", + "issues": "https://github.com/sabre-io/xml/issues", + "source": "https://github.com/fruux/sabre-xml" + }, + "time": "2026-04-27T10:56:01+00:00" + } + ], "packages-dev": [], "aliases": [], "minimum-stability": "stable", - "stability-flags": [], + "stability-flags": {}, "prefer-stable": false, "prefer-lowest": false, "platform": { "php": ">=8.2 <=8.5" }, - "platform-dev": [], + "platform-dev": {}, "platform-overrides": { "php": "8.2" }, - "plugin-api-version": "2.3.0" + "plugin-api-version": "2.6.0" } diff --git a/lib/Controllers/DefaultController.php b/lib/Controllers/DefaultController.php index c0d80f4..11e7942 100644 --- a/lib/Controllers/DefaultController.php +++ b/lib/Controllers/DefaultController.php @@ -23,6 +23,8 @@ use KTXF\Resource\Identifier\ResourceIdentifier; use KTXF\Resource\Identifier\ResourceIdentifiers; use KTXF\Resource\Identifier\ServiceIdentifier; use KTXF\Routing\Attributes\AuthenticatedRoute; +use KTXM\ChronoManager\Import\ImportOptions; +use KTXM\ChronoManager\Import\ImportService; use KTXM\ChronoManager\Manager; use KTXM\ChronoManager\Stream\ExpectedTotal; use Psr\Log\LoggerInterface; @@ -50,6 +52,7 @@ class DefaultController extends ControllerAbstract { private readonly SessionTenant $tenantIdentity, private readonly SessionIdentity $userIdentity, private readonly Manager $manager, + private readonly ImportService $importService, private readonly LoggerInterface $logger, ) {} @@ -152,6 +155,7 @@ class DefaultController extends ControllerAbstract { 'entity.update' => $this->entityUpdate($tenantId, $userId, $data), 'entity.delete' => $this->entityDelete($tenantId, $userId, $data), 'entity.move' => $this->entityMove($tenantId, $userId, $data), + 'entity.import' => $this->entityImport($tenantId, $userId, $data, $version, $transaction), default => throw new InvalidArgumentException(self::ERR_INVALID_OPERATION . $operation) }; @@ -554,9 +558,9 @@ class DefaultController extends ControllerAbstract { } /** - * Import vCards into a collection, streaming one NDJSON event per contact. + * Import iCalendar events into a calendar, streaming one NDJSON event per event. * - * Request data: { target: "provider:service:collection", data: "", options?: {...} } + * Request data: { target: "provider:service:collection", data: "", options?: {...} } */ private function entityImport(string $tenantId, string $userId, array $data, int $version, string $transaction): StreamedNdJsonResponse { @@ -578,7 +582,7 @@ class DefaultController extends ControllerAbstract { $options = ImportOptions::fromArray($data['options'] ?? []); // Spill the payload to a temp file and release the in-memory copy before the - // (potentially long) streaming parse, so peak memory stays at one contact object. + // (potentially long) parse and create pass. $tempFile = tmpfile(); if ($tempFile === false) { throw new \RuntimeException('Unable to allocate temporary file for import'); @@ -803,4 +807,4 @@ class DefaultController extends ControllerAbstract { return $this->manager->entityMove($tenantId, $userId, $target, ...$sources->all()); } -} \ No newline at end of file +} diff --git a/lib/Import/ImportCountEvent.php b/lib/Import/ImportCountEvent.php new file mode 100644 index 0000000..121a84e --- /dev/null +++ b/lib/Import/ImportCountEvent.php @@ -0,0 +1,25 @@ +total; + } + + /** + * @return array{total: int} + */ + public function jsonSerialize(): array { + return ['total' => $this->total]; + } +} diff --git a/lib/Import/ImportDisposition.php b/lib/Import/ImportDisposition.php new file mode 100644 index 0000000..506844d --- /dev/null +++ b/lib/Import/ImportDisposition.php @@ -0,0 +1,17 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ChronoManager\Import; + +enum ImportDisposition: string { + case Created = 'created'; + case Updated = 'updated'; + case Exists = 'exists'; + case Error = 'error'; +} diff --git a/lib/Import/ImportEvent.php b/lib/Import/ImportEvent.php new file mode 100644 index 0000000..5066cdd --- /dev/null +++ b/lib/Import/ImportEvent.php @@ -0,0 +1,15 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ChronoManager\Import; + +use JsonSerializable; + +interface ImportEvent extends JsonSerializable { +} diff --git a/lib/Import/ImportObjectEvent.php b/lib/Import/ImportObjectEvent.php new file mode 100644 index 0000000..72926ab --- /dev/null +++ b/lib/Import/ImportObjectEvent.php @@ -0,0 +1,37 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ChronoManager\Import; + +final readonly class ImportObjectEvent implements ImportEvent { + + /** + * @param list $errors + */ + public function __construct( + public ?string $identifier, + public ImportDisposition $disposition, + public array $errors = [], + ) {} + + public function isError(): bool { + return $this->disposition === ImportDisposition::Error; + } + + /** + * @return array{identifier: ?string, disposition: string, errors: list} + */ + public function jsonSerialize(): array { + return [ + 'identifier' => $this->identifier, + 'disposition' => $this->disposition->value, + 'errors' => $this->errors, + ]; + } +} \ No newline at end of file diff --git a/lib/Import/ImportOptions.php b/lib/Import/ImportOptions.php new file mode 100644 index 0000000..e7feea7 --- /dev/null +++ b/lib/Import/ImportOptions.php @@ -0,0 +1,66 @@ + + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +namespace KTXM\ChronoManager\Import; + +use InvalidArgumentException; + +final class ImportOptions { + + public const ERROR_CONTINUE = 0; + public const ERROR_FAIL = 1; + public const ERROR_OPTIONS = [self::ERROR_CONTINUE, self::ERROR_FAIL]; + + /** Overwrite an existing entity when the same UID is already present. */ + private bool $supersede = false; + + /** Emit an ImportCountEvent (discovered total) before the object stream. */ + private bool $counts = true; + + /** How to handle per-object errors. */ + private int $errors = self::ERROR_CONTINUE; + + public function getSupersede(): bool { + return $this->supersede; + } + + public function setSupersede(bool $value): void { + $this->supersede = $value; + } + + public function getCounts(): bool { + return $this->counts; + } + + public function setCounts(bool $value): void { + $this->counts = $value; + } + + public function getErrors(): int { + return $this->errors; + } + + public function setErrors(int $value): void { + if (!in_array($value, self::ERROR_OPTIONS, true)) { + throw new InvalidArgumentException('Invalid errors option specified'); + } + $this->errors = $value; + } + + public static function fromArray(array $data): self { + $options = new self(); + $options->supersede = (bool)($data['supersede'] ?? false); + $options->counts = (bool)($data['counts'] ?? true); + $options->errors = (int)($data['errors'] ?? self::ERROR_CONTINUE); + if (!in_array($options->errors, [self::ERROR_CONTINUE, self::ERROR_FAIL], true)) { + throw new InvalidArgumentException('Invalid errors option specified'); + } + return $options; + } +} diff --git a/lib/Import/ImportService.php b/lib/Import/ImportService.php new file mode 100644 index 0000000..da6b8a3 --- /dev/null +++ b/lib/Import/ImportService.php @@ -0,0 +1,362 @@ + + */ + public function import($source, CollectionIdentifier $target, ImportOptions $options, string $tenantId, string $userId): Generator { + if (!is_resource($source)) { + throw new InvalidArgumentException('Invalid import source: must be a file resource'); + } + + try { + rewind($source); + $calendar = Reader::read($source, MimeDir::OPTION_FORGIVING | MimeDir::OPTION_IGNORE_INVALID_LINES); + if (!$calendar instanceof VCalendar) { + throw new InvalidArgumentException('Supplied input is not an iCalendar document'); + } + } catch (Throwable $e) { + if ($options->getCounts()) { + yield new ImportCountEvent(0); + } + yield new ImportObjectEvent(null, ImportDisposition::Error, ['Malformed iCalendar: ' . $e->getMessage()]); + return; + } + + $components = array_values(array_filter( + $calendar->children(), + static fn($component): bool => $component instanceof VEvent + || $component instanceof VTodo + || $component instanceof VJournal, + )); + + if ($options->getCounts()) { + yield new ImportCountEvent(count($components)); + } + + foreach ($components as $component) { + $uid = $this->text($component, 'UID'); + try { + $properties = $this->mapComponent($component)->jsonSerialize(); + $this->manager->entityCreate($tenantId, $userId, $target, $properties); + yield new ImportObjectEvent($uid, ImportDisposition::Created); + } catch (Throwable $e) { + yield new ImportObjectEvent($uid, ImportDisposition::Error, [$e->getMessage()]); + if ($options->getErrors() === ImportOptions::ERROR_FAIL) { + return; + } + } + } + } + + 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/Module.php b/lib/Module.php index ff4bf40..3fddcd4 100644 --- a/lib/Module.php +++ b/lib/Module.php @@ -14,6 +14,15 @@ class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface public function __construct() { } + public function boot(): void + { + // Load module-local vendored dependencies used by iCalendar import. + $vendorAutoload = __DIR__ . '/vendor/autoload.php'; + if (file_exists($vendorAutoload)) { + require_once $vendorAutoload; + } + } + public function handle(): string { return 'chrono_manager'; diff --git a/src/services/entityService.ts b/src/services/entityService.ts index 3518696..b6e19bf 100644 --- a/src/services/entityService.ts +++ b/src/services/entityService.ts @@ -22,6 +22,8 @@ import type { EntityDeltaResponse, EntityMoveRequest, EntityMoveResponse, + EntityImportRequest, + EntityImportResponse, EntityInterface, } from '../types/entity'; import { useIntegrationStore } from '@KTXC/stores/integrationStore'; @@ -139,6 +141,28 @@ export const entityService = { return await transceivePost('entity.move', request); }, + /** + * Import vCards into a collection, streaming progress as it arrives. + * + * @param request - import request (target collection, raw data, options) + * @param onObject - called synchronously for each per-contact result frame + * @param onDiscovered - called once with the discovered total (progress denominator) + * + * @returns Promise resolving to { total } (objects processed) when the stream completes + */ + async import( + request: EntityImportRequest, + onObject: (object: EntityImportResponse) => void, + onDiscovered: (expected: number) => void, + ): Promise<{ total: number }> { + return await transceiveStream( + 'entity.import', + request, + onObject, + { onStart: (expected) => { if (expected !== undefined) onDiscovered(expected); } }, + ); + }, + }; export default entityService; diff --git a/src/stores/importStore.ts b/src/stores/importStore.ts new file mode 100644 index 0000000..4f47293 --- /dev/null +++ b/src/stores/importStore.ts @@ -0,0 +1,124 @@ +/** + * Contact Import Store + */ + +import { computed, ref } from 'vue' +import { defineStore } from 'pinia' +import { entityService } from '../services/entityService' +import type { CollectionIdentifier } from '../types/common' +import type { EntityImportResponse } from '../types/entity' +import type { + ImportCounters, + ImportFileAdd, + ImportFileEntry, + ImportFileOptions, + ImportSession, + ImportSessionStage +} from '../types/import' + +const RECENT_RESULTS_CAP = 25 +const emptyCounters = (): ImportCounters => ({ discovered: 0, processed: 0, created: 0, updated: 0, exists: 0, error: 0 }) + +export const useImportStore = defineStore('chronoImportStore', () => { + + // State + + const nextId = ref(0) + const files = ref([]) + const stage = ref('idle') + const running = ref(false) + const sessions = ref>({}) + const order = ref([]) + + // Computed + + const totals = computed(() => order.value.reduce((total, id) => { + const counters = sessions.value[id]?.counters + if (counters) Object.keys(total).forEach(key => { + total[key as keyof ImportCounters] += counters[key as keyof ImportCounters] + }) + return total + }, emptyCounters())) + + // Actions + + function addFile(file: ImportFileAdd): number { + const id = nextId.value++ + files.value.push({ + file: { id, ...file }, + collectionId: null, + options: { supersede: false } + }) + return id + } + + function removeAllFiles() { + files.value = [] + } + + function reset() { + stage.value = 'idle'; + running.value = false; + sessions.value = {}; + order.value = []; + } + + function entryFor(id: number) { + return files.value.find(entry => entry.file.id === id) + } + + /** Set the destination collection for a queued file. */ + function setCollectionForFile(id: number, collectionId: CollectionIdentifier | null) { + const entry = entryFor(id); + if (entry) entry.collectionId = collectionId; + } + + /** Merge option changes for a queued file without dropping untouched keys. */ + function setOptionsForFile(id: number, options: Partial) { + const entry = entryFor(id); + if (entry) entry.options = { ...entry.options, ...options }; + } + + async function startImport(): Promise { + const entries = [...files.value] + sessions.value = {} + order.value = entries.map(entry => entry.file.id) + for (const entry of entries) sessions.value[entry.file.id] = { + fileId: entry.file.id, fileName: entry.file.name, targetIdentifier: entry.collectionId, + status: 'pending', counters: emptyCounters(), recentResults: [], lastError: null, + } + + running.value = true + stage.value = 'importing' + try { + for (const entry of entries) { + const session = sessions.value[entry.file.id]! + if (!entry.collectionId) throw new Error(`No calendar selected for "${entry.file.name}"`) + session.status = 'importing' + try { + await entityService.import( + { target: entry.collectionId, data: entry.file.contents, options: entry.options }, + (object: EntityImportResponse) => { + session.counters.processed++ + session.counters[object.disposition]++ + session.recentResults = [object, ...session.recentResults].slice(0, RECENT_RESULTS_CAP) + }, + expected => { session.counters.discovered = expected }, + ) + session.status = session.counters.error ? 'error' : 'completed' + } catch (error) { + session.status = 'error' + session.lastError = error instanceof Error ? error.message : String(error) + throw error + } + } + stage.value = 'completed' + return totals.value + } catch (error) { + stage.value = 'error' + throw error + } finally { running.value = false } + } + + return { files, stage, running, sessions, order, totals, addFile, removeAllFiles, reset, setCollectionForFile, setOptionsForFile, startImport } +}) diff --git a/src/stores/index.ts b/src/stores/index.ts index 800c024..9a4c988 100644 --- a/src/stores/index.ts +++ b/src/stores/index.ts @@ -2,3 +2,4 @@ export { useProvidersStore } from './providersStore'; export { useServicesStore } from './servicesStore'; export { useCollectionsStore } from './collectionsStore'; export { useEntitiesStore } from './entitiesStore'; +export { useImportStore } from './importStore'; diff --git a/src/types/entity.ts b/src/types/entity.ts index 317ac1a..d06428d 100644 --- a/src/types/entity.ts +++ b/src/types/entity.ts @@ -11,6 +11,7 @@ import type { import type { EventInterface } from './event'; import type { TaskInterface } from './task'; import type { JournalInterface } from './journal'; +import type { ImportDisposition, ImportFileOptions } from './import'; export type EntityPropertiesInterface = EventInterface | TaskInterface | JournalInterface; @@ -167,3 +168,15 @@ export interface EntityMoveResponse { error?: string; }; } + +export interface EntityImportRequest { + target: CollectionIdentifier; + data: string; + options: ImportFileOptions; +} + +export interface EntityImportResponse { + identifier: string | null; + disposition: ImportDisposition; + errors: string[]; +} diff --git a/src/types/import.ts b/src/types/import.ts new file mode 100644 index 0000000..875e3ce --- /dev/null +++ b/src/types/import.ts @@ -0,0 +1,19 @@ +import type { CollectionIdentifier } from './common'; +import type { EntityImportResponse } from './entity'; + +export type ImportDisposition = 'created' | 'updated' | 'exists' | 'error'; +export type ImportSessionStage = 'idle' | 'selecting' | 'importing' | 'completed' | 'error'; +export interface ImportFileOptions { supersede: boolean; } +export interface ImportFileSource { id: number; name: string; contents: string; size: number; type: string; } +export type ImportFileAdd = Omit; +export interface ImportFileEntry { file: ImportFileSource; collectionId: CollectionIdentifier | null; options: ImportFileOptions; } +export interface ImportCounters { discovered: number; processed: number; created: number; updated: number; exists: number; error: number; } +export interface ImportSession { + fileId: number; + fileName: string; + targetIdentifier: CollectionIdentifier | null; + status: 'pending' | 'importing' | 'completed' | 'error'; + counters: ImportCounters; + recentResults: EntityImportResponse[]; + lastError: string | null; +} diff --git a/src/types/index.ts b/src/types/index.ts index 97a2bbd..b15c906 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -3,6 +3,7 @@ export type * from './common'; export type * from './entity'; export type * from './event'; export type * from './journal'; +export type * from './import'; export type * from './provider'; export type * from './service'; -export type * from './task'; \ No newline at end of file +export type * from './task';