b7e12621cd
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
363 lines
17 KiB
PHP
363 lines
17 KiB
PHP
<?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)); }
|
|
}
|