feat: import
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
+2
-1
@@ -16,7 +16,8 @@
|
|||||||
"vendor-dir": "lib/vendor"
|
"vendor-dir": "lib/vendor"
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"php": ">=8.2 <=8.5"
|
"php": ">=8.2 <=8.5",
|
||||||
|
"sabre/vobject": "^4.5"
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
|
|||||||
Generated
+244
-5
@@ -4,20 +4,259 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "d301e50c47a83d5bf3a8a07ddeb43301",
|
"content-hash": "0a59afe25a51a7b30c15c48e908cc4dc",
|
||||||
"packages": [],
|
"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": [],
|
"packages-dev": [],
|
||||||
"aliases": [],
|
"aliases": [],
|
||||||
"minimum-stability": "stable",
|
"minimum-stability": "stable",
|
||||||
"stability-flags": [],
|
"stability-flags": {},
|
||||||
"prefer-stable": false,
|
"prefer-stable": false,
|
||||||
"prefer-lowest": false,
|
"prefer-lowest": false,
|
||||||
"platform": {
|
"platform": {
|
||||||
"php": ">=8.2 <=8.5"
|
"php": ">=8.2 <=8.5"
|
||||||
},
|
},
|
||||||
"platform-dev": [],
|
"platform-dev": {},
|
||||||
"platform-overrides": {
|
"platform-overrides": {
|
||||||
"php": "8.2"
|
"php": "8.2"
|
||||||
},
|
},
|
||||||
"plugin-api-version": "2.3.0"
|
"plugin-api-version": "2.6.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ use KTXF\Resource\Identifier\ResourceIdentifier;
|
|||||||
use KTXF\Resource\Identifier\ResourceIdentifiers;
|
use KTXF\Resource\Identifier\ResourceIdentifiers;
|
||||||
use KTXF\Resource\Identifier\ServiceIdentifier;
|
use KTXF\Resource\Identifier\ServiceIdentifier;
|
||||||
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
use KTXF\Routing\Attributes\AuthenticatedRoute;
|
||||||
|
use KTXM\ChronoManager\Import\ImportOptions;
|
||||||
|
use KTXM\ChronoManager\Import\ImportService;
|
||||||
use KTXM\ChronoManager\Manager;
|
use KTXM\ChronoManager\Manager;
|
||||||
use KTXM\ChronoManager\Stream\ExpectedTotal;
|
use KTXM\ChronoManager\Stream\ExpectedTotal;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
@@ -50,6 +52,7 @@ class DefaultController extends ControllerAbstract {
|
|||||||
private readonly SessionTenant $tenantIdentity,
|
private readonly SessionTenant $tenantIdentity,
|
||||||
private readonly SessionIdentity $userIdentity,
|
private readonly SessionIdentity $userIdentity,
|
||||||
private readonly Manager $manager,
|
private readonly Manager $manager,
|
||||||
|
private readonly ImportService $importService,
|
||||||
private readonly LoggerInterface $logger,
|
private readonly LoggerInterface $logger,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -152,6 +155,7 @@ class DefaultController extends ControllerAbstract {
|
|||||||
'entity.update' => $this->entityUpdate($tenantId, $userId, $data),
|
'entity.update' => $this->entityUpdate($tenantId, $userId, $data),
|
||||||
'entity.delete' => $this->entityDelete($tenantId, $userId, $data),
|
'entity.delete' => $this->entityDelete($tenantId, $userId, $data),
|
||||||
'entity.move' => $this->entityMove($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)
|
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: "<raw vCard string>", options?: {...} }
|
* Request data: { target: "provider:service:collection", data: "<raw iCalendar string>", options?: {...} }
|
||||||
*/
|
*/
|
||||||
private function entityImport(string $tenantId, string $userId, array $data, int $version, string $transaction): StreamedNdJsonResponse {
|
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'] ?? []);
|
$options = ImportOptions::fromArray($data['options'] ?? []);
|
||||||
|
|
||||||
// Spill the payload to a temp file and release the in-memory copy before the
|
// 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();
|
$tempFile = tmpfile();
|
||||||
if ($tempFile === false) {
|
if ($tempFile === false) {
|
||||||
throw new \RuntimeException('Unable to allocate temporary file for import');
|
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());
|
return $this->manager->entityMove($tenantId, $userId, $target, ...$sources->all());
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace KTXM\ChronoManager\Import;
|
||||||
|
|
||||||
|
use KTXM\ChronoManager\Stream\ExpectedTotal;
|
||||||
|
|
||||||
|
final readonly class ImportCountEvent implements ImportEvent, ExpectedTotal {
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public int $total,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function expectedTotal(): int {
|
||||||
|
return $this->total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{total: int}
|
||||||
|
*/
|
||||||
|
public function jsonSerialize(): array {
|
||||||
|
return ['total' => $this->total];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||||
|
* 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';
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace KTXM\ChronoManager\Import;
|
||||||
|
|
||||||
|
use JsonSerializable;
|
||||||
|
|
||||||
|
interface ImportEvent extends JsonSerializable {
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||||
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
namespace KTXM\ChronoManager\Import;
|
||||||
|
|
||||||
|
final readonly class ImportObjectEvent implements ImportEvent {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $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<string>}
|
||||||
|
*/
|
||||||
|
public function jsonSerialize(): array {
|
||||||
|
return [
|
||||||
|
'identifier' => $this->identifier,
|
||||||
|
'disposition' => $this->disposition->value,
|
||||||
|
'errors' => $this->errors,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
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 Sabre\VObject\Component\VCalendar;
|
||||||
|
use Sabre\VObject\Component\VEvent;
|
||||||
|
use Sabre\VObject\Component\VJournal;
|
||||||
|
use Sabre\VObject\Component\VTodo;
|
||||||
|
use Sabre\VObject\Parser\MimeDir;
|
||||||
|
use Sabre\VObject\Reader;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/** Imports the three RFC 5545 calendar object types into Chrono entities. */
|
||||||
|
class ImportService {
|
||||||
|
|
||||||
|
public function __construct(private readonly Manager $manager) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param resource $source readable, seekable iCalendar resource
|
||||||
|
* @return Generator<int, ImportEvent>
|
||||||
|
*/
|
||||||
|
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<string> */
|
||||||
|
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)); }
|
||||||
|
}
|
||||||
@@ -14,6 +14,15 @@ class Module extends ModuleInstanceAbstract implements ModuleBrowserInterface
|
|||||||
public function __construct()
|
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
|
public function handle(): string
|
||||||
{
|
{
|
||||||
return 'chrono_manager';
|
return 'chrono_manager';
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ import type {
|
|||||||
EntityDeltaResponse,
|
EntityDeltaResponse,
|
||||||
EntityMoveRequest,
|
EntityMoveRequest,
|
||||||
EntityMoveResponse,
|
EntityMoveResponse,
|
||||||
|
EntityImportRequest,
|
||||||
|
EntityImportResponse,
|
||||||
EntityInterface,
|
EntityInterface,
|
||||||
} from '../types/entity';
|
} from '../types/entity';
|
||||||
import { useIntegrationStore } from '@KTXC/stores/integrationStore';
|
import { useIntegrationStore } from '@KTXC/stores/integrationStore';
|
||||||
@@ -139,6 +141,28 @@ export const entityService = {
|
|||||||
return await transceivePost<EntityMoveRequest, EntityMoveResponse>('entity.move', request);
|
return await transceivePost<EntityMoveRequest, EntityMoveResponse>('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<EntityImportRequest, EntityImportResponse>(
|
||||||
|
'entity.import',
|
||||||
|
request,
|
||||||
|
onObject,
|
||||||
|
{ onStart: (expected) => { if (expected !== undefined) onDiscovered(expected); } },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default entityService;
|
export default entityService;
|
||||||
|
|||||||
@@ -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<ImportFileEntry[]>([])
|
||||||
|
const stage = ref<ImportSessionStage>('idle')
|
||||||
|
const running = ref(false)
|
||||||
|
const sessions = ref<Record<number, ImportSession>>({})
|
||||||
|
const order = ref<number[]>([])
|
||||||
|
|
||||||
|
// 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<ImportFileOptions>) {
|
||||||
|
const entry = entryFor(id);
|
||||||
|
if (entry) entry.options = { ...entry.options, ...options };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startImport(): Promise<ImportCounters> {
|
||||||
|
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 }
|
||||||
|
})
|
||||||
@@ -2,3 +2,4 @@ export { useProvidersStore } from './providersStore';
|
|||||||
export { useServicesStore } from './servicesStore';
|
export { useServicesStore } from './servicesStore';
|
||||||
export { useCollectionsStore } from './collectionsStore';
|
export { useCollectionsStore } from './collectionsStore';
|
||||||
export { useEntitiesStore } from './entitiesStore';
|
export { useEntitiesStore } from './entitiesStore';
|
||||||
|
export { useImportStore } from './importStore';
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
import type { EventInterface } from './event';
|
import type { EventInterface } from './event';
|
||||||
import type { TaskInterface } from './task';
|
import type { TaskInterface } from './task';
|
||||||
import type { JournalInterface } from './journal';
|
import type { JournalInterface } from './journal';
|
||||||
|
import type { ImportDisposition, ImportFileOptions } from './import';
|
||||||
|
|
||||||
export type EntityPropertiesInterface = EventInterface | TaskInterface | JournalInterface;
|
export type EntityPropertiesInterface = EventInterface | TaskInterface | JournalInterface;
|
||||||
|
|
||||||
@@ -167,3 +168,15 @@ export interface EntityMoveResponse {
|
|||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EntityImportRequest {
|
||||||
|
target: CollectionIdentifier;
|
||||||
|
data: string;
|
||||||
|
options: ImportFileOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntityImportResponse {
|
||||||
|
identifier: string | null;
|
||||||
|
disposition: ImportDisposition;
|
||||||
|
errors: string[];
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<ImportFileSource, 'id'>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
+2
-1
@@ -3,6 +3,7 @@ export type * from './common';
|
|||||||
export type * from './entity';
|
export type * from './entity';
|
||||||
export type * from './event';
|
export type * from './event';
|
||||||
export type * from './journal';
|
export type * from './journal';
|
||||||
|
export type * from './import';
|
||||||
export type * from './provider';
|
export type * from './provider';
|
||||||
export type * from './service';
|
export type * from './service';
|
||||||
export type * from './task';
|
export type * from './task';
|
||||||
|
|||||||
Reference in New Issue
Block a user