feat: 2 way conversion and export
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* 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<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)); }
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* 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<E\EventObject|E\TaskObject|E\JournalObject> $entities
|
||||
* @return Generator<string>
|
||||
*/
|
||||
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<string> 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'));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
||||
* 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<string> 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<EventObject|TaskObject|JournalObject> */
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<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)); }
|
||||
}
|
||||
|
||||
+3
-2
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user