5fbc217edb
Signed-off-by: Sebastian Krupinski <krupinski01@gmail.com>
373 lines
13 KiB
PHP
373 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* SPDX-FileCopyrightText: Sebastian Krupinski <krupinski01@gmail.com>
|
|
* SPDX-License-Identifier: AGPL-3.0-or-later
|
|
*/
|
|
|
|
namespace KTXM\PeopleManager\Import;
|
|
|
|
use Generator;
|
|
use InvalidArgumentException;
|
|
use KTXF\People\Entity as E;
|
|
use KTXF\People\Entity\Property as P;
|
|
use KTXF\Resource\Identifier\CollectionIdentifier;
|
|
use KTXM\PeopleManager\Manager;
|
|
use Sabre\VObject\Component\VCard as VCardComponent;
|
|
use Sabre\VObject\ParseException;
|
|
use Sabre\VObject\Parser\MimeDir;
|
|
use Sabre\VObject\Property;
|
|
use Sabre\VObject\Splitter\VCard as VCardSplitter;
|
|
use Throwable;
|
|
|
|
/**
|
|
* Streams vCards from a file resource, maps each to a people entity, and creates
|
|
* it in the target collection — yielding one ImportEvent per card.
|
|
*
|
|
* Memory profile: one vCard is held at a time (sabre's streaming splitter), the
|
|
* generator keeps no running collection, and the discovered count is obtained by a
|
|
* cheap byte scan rather than a second parse. See modules/people/.ideas/vcf-import.md.
|
|
*
|
|
* v1 scope: every card is created (disposition `created`) or fails (`error`). The
|
|
* vCard UID is mapped to the entity `urid` so future supersede/exists detection is
|
|
* possible, but the local provider keys entities on a fresh server id and exposes no
|
|
* UID lookup, so supersede/updated/exists are deferred. Individual ORG affiliation
|
|
* mapping is also deferred (the ORG property on individual cards is not yet mapped).
|
|
*/
|
|
class ImportService {
|
|
|
|
public function __construct(
|
|
private readonly Manager $manager,
|
|
) {}
|
|
|
|
/**
|
|
* @param resource $source readable, seekable file resource containing one or more vCards
|
|
*
|
|
* @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');
|
|
}
|
|
|
|
// Discovered count — cheap O(1)-memory byte scan, no second parse.
|
|
if ($options->getCounts()) {
|
|
yield new ImportCountEvent($this->countCards($source));
|
|
}
|
|
|
|
rewind($source);
|
|
$splitter = new VCardSplitter($source, MimeDir::OPTION_FORGIVING | MimeDir::OPTION_IGNORE_INVALID_LINES);
|
|
|
|
while (true) {
|
|
try {
|
|
$vCard = $splitter->getNext();
|
|
} catch (ParseException $e) {
|
|
// The parser position is unreliable after a parse error, so stop here.
|
|
yield new ImportObjectEvent(null, ImportDisposition::Error, ['Malformed vCard: ' . $e->getMessage()]);
|
|
return;
|
|
}
|
|
|
|
if ($vCard === null) {
|
|
break;
|
|
}
|
|
|
|
$uid = isset($vCard->UID) ? (string)$vCard->UID : null;
|
|
|
|
try {
|
|
$properties = $this->mapVCard($vCard);
|
|
$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;
|
|
}
|
|
}
|
|
|
|
unset($vCard);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Count vCards by scanning for BEGIN:VCARD lines — no parsing, O(1) memory.
|
|
*
|
|
* @param resource $source
|
|
*/
|
|
private function countCards($source): int {
|
|
rewind($source);
|
|
$count = 0;
|
|
while (($line = fgets($source)) !== false) {
|
|
if (stripos($line, 'BEGIN:VCARD') === 0) {
|
|
$count++;
|
|
}
|
|
}
|
|
rewind($source);
|
|
return $count;
|
|
}
|
|
|
|
/**
|
|
* Map a sabre vCard to an entity property array (the shape entity.create consumes).
|
|
*
|
|
* @return array<string,mixed>
|
|
*/
|
|
private function mapVCard(VCardComponent $vCard): array {
|
|
$kind = isset($vCard->KIND) ? strtolower((string)$vCard->KIND) : 'individual';
|
|
|
|
return match ($kind) {
|
|
'org', 'organization' => $this->mapOrganization($vCard)->jsonSerialize(),
|
|
'group' => $this->mapGroup($vCard)->jsonSerialize(),
|
|
default => $this->mapIndividual($vCard)->jsonSerialize(),
|
|
};
|
|
}
|
|
|
|
private function mapIndividual(VCardComponent $vCard): E\IndividualObject {
|
|
$object = new E\IndividualObject();
|
|
$object->urid = $this->uid($vCard);
|
|
$object->label = $this->text($vCard, 'FN');
|
|
|
|
// Structured name: N = family;given;additional;prefix;suffix
|
|
if (isset($vCard->N)) {
|
|
$parts = $vCard->N->getParts();
|
|
$object->names->family = $this->part($parts, 0);
|
|
$object->names->given = $this->part($parts, 1);
|
|
$object->names->additional = $this->part($parts, 2);
|
|
$object->names->prefix = $this->part($parts, 3);
|
|
$object->names->suffix = $this->part($parts, 4);
|
|
}
|
|
|
|
foreach ($vCard->select('EMAIL') as $prop) {
|
|
$email = new P\EmailObject();
|
|
$email->address = (string)$prop;
|
|
$email->context = $this->firstType($prop);
|
|
$object->emails->add($email, $this->key());
|
|
}
|
|
|
|
foreach ($vCard->select('TEL') as $prop) {
|
|
$phone = new P\PhoneObject();
|
|
$phone->number = (string)$prop;
|
|
$phone->context = $this->firstType($prop);
|
|
$object->phones->add($phone, $this->key());
|
|
}
|
|
|
|
foreach ($vCard->select('ADR') as $prop) {
|
|
$object->physicalLocations->add($this->address(new P\PhysicalLocationObject(), $prop), $this->key());
|
|
}
|
|
|
|
foreach ($vCard->select('URL') as $prop) {
|
|
$location = new P\VirtualLocationObject();
|
|
$location->location = (string)$prop;
|
|
$location->context = $this->firstType($prop);
|
|
$object->virtualLocations->add($location, $this->key());
|
|
}
|
|
|
|
foreach ($vCard->select('NOTE') as $prop) {
|
|
$note = new P\NoteObject();
|
|
$note->content = (string)$prop;
|
|
$object->notes->add($note, $this->key());
|
|
}
|
|
|
|
foreach ($vCard->select('TITLE') as $prop) {
|
|
$title = new P\TitleObject();
|
|
$title->kind = P\TitleTypes::Title;
|
|
$title->label = (string)$prop;
|
|
$object->titles->add($title, $this->key());
|
|
}
|
|
foreach ($vCard->select('ROLE') as $prop) {
|
|
$title = new P\TitleObject();
|
|
$title->kind = P\TitleTypes::Role;
|
|
$title->label = (string)$prop;
|
|
$object->titles->add($title, $this->key());
|
|
}
|
|
|
|
if (isset($vCard->BDAY)) {
|
|
$this->anniversary($object, P\AnniversaryTypes::Birth, (string)$vCard->BDAY);
|
|
}
|
|
if (isset($vCard->ANNIVERSARY)) {
|
|
$this->anniversary($object, P\AnniversaryTypes::Nuptial, (string)$vCard->ANNIVERSARY);
|
|
}
|
|
|
|
foreach ($this->categories($vCard) as $tag) {
|
|
$object->tags->add($tag);
|
|
}
|
|
|
|
return $object;
|
|
}
|
|
|
|
private function mapOrganization(VCardComponent $vCard): E\OrganizationObject {
|
|
$object = new E\OrganizationObject();
|
|
$object->urid = $this->uid($vCard);
|
|
$object->label = $this->text($vCard, 'FN') ?? $this->text($vCard, 'ORG');
|
|
$object->names->full = $object->label;
|
|
$object->names->sort = $this->text($vCard, 'SORT-STRING');
|
|
|
|
foreach ($vCard->select('EMAIL') as $prop) {
|
|
$email = new P\EmailObject();
|
|
$email->address = (string)$prop;
|
|
$email->context = $this->firstType($prop);
|
|
$object->emails->add($email, $this->key());
|
|
}
|
|
|
|
foreach ($vCard->select('TEL') as $prop) {
|
|
$phone = new P\PhoneObject();
|
|
$phone->number = (string)$prop;
|
|
$phone->context = $this->firstType($prop);
|
|
$object->phones->add($phone, $this->key());
|
|
}
|
|
|
|
foreach ($vCard->select('ADR') as $prop) {
|
|
$object->physicalLocations->add($this->address(new P\PhysicalLocationObject(), $prop), $this->key());
|
|
}
|
|
|
|
foreach ($vCard->select('URL') as $prop) {
|
|
$location = new P\VirtualLocationObject();
|
|
$location->location = (string)$prop;
|
|
$location->context = $this->firstType($prop);
|
|
$object->virtualLocations->add($location, $this->key());
|
|
}
|
|
|
|
foreach ($vCard->select('NOTE') as $prop) {
|
|
$note = new P\NoteObject();
|
|
$note->content = (string)$prop;
|
|
$object->notes->add($note, $this->key());
|
|
}
|
|
|
|
return $object;
|
|
}
|
|
|
|
private function mapGroup(VCardComponent $vCard): E\GroupObject {
|
|
$object = new E\GroupObject();
|
|
$object->urid = $this->uid($vCard);
|
|
$object->label = $this->text($vCard, 'FN');
|
|
$object->names->full = $object->label;
|
|
|
|
foreach ($vCard->select('MEMBER') as $prop) {
|
|
$member = new P\MemberObject();
|
|
// MEMBER values are URIs, commonly urn:uuid:<id>.
|
|
$member->entityId = preg_replace('/^urn:uuid:/i', '', (string)$prop);
|
|
$object->members->add($member, $this->key());
|
|
}
|
|
|
|
foreach ($vCard->select('URL') as $prop) {
|
|
$location = new P\VirtualLocationObject();
|
|
$location->location = (string)$prop;
|
|
$location->context = $this->firstType($prop);
|
|
$object->virtualLocations->add($location, $this->key());
|
|
}
|
|
|
|
foreach ($vCard->select('NOTE') as $prop) {
|
|
$note = new P\NoteObject();
|
|
$note->content = (string)$prop;
|
|
$object->notes->add($note, $this->key());
|
|
}
|
|
|
|
return $object;
|
|
}
|
|
|
|
// ==================== Helpers ====================
|
|
|
|
/**
|
|
* Populate a physical-location object from an ADR property.
|
|
* ADR = pobox;ext(unit);street;locality;region;code;country
|
|
*/
|
|
private function address(P\PhysicalLocationObject $location, Property $prop): P\PhysicalLocationObject {
|
|
$parts = $prop->getParts();
|
|
$location->box = $this->part($parts, 0);
|
|
$location->unit = $this->part($parts, 1);
|
|
$location->street = $this->part($parts, 2);
|
|
$location->locality = $this->part($parts, 3);
|
|
$location->region = $this->part($parts, 4);
|
|
$location->code = $this->part($parts, 5);
|
|
$location->country = $this->part($parts, 6);
|
|
$location->context = $this->firstType($prop);
|
|
return $location;
|
|
}
|
|
|
|
private function anniversary(E\IndividualObject $object, P\AnniversaryTypes $type, string $value): void {
|
|
$date = $this->date($value);
|
|
if ($date === null) {
|
|
return;
|
|
}
|
|
$anniversary = new P\AnniversaryObject();
|
|
$anniversary->type = $type;
|
|
$anniversary->when = $date;
|
|
$object->anniversaries->add($anniversary);
|
|
}
|
|
|
|
private function uid(VCardComponent $vCard): ?string {
|
|
return isset($vCard->UID) ? (string)$vCard->UID : null;
|
|
}
|
|
|
|
private function text(VCardComponent $vCard, string $name): ?string {
|
|
if (!isset($vCard->$name)) {
|
|
return null;
|
|
}
|
|
$value = trim((string)$vCard->$name);
|
|
return $value === '' ? null : $value;
|
|
}
|
|
|
|
/**
|
|
* First TYPE parameter value, lowercased (used as `context`).
|
|
*/
|
|
private function firstType(Property $prop): ?string {
|
|
$type = $prop['TYPE'] ?? null;
|
|
if ($type === null) {
|
|
return null;
|
|
}
|
|
$parts = $type->getParts();
|
|
return isset($parts[0]) && $parts[0] !== '' ? strtolower((string)$parts[0]) : null;
|
|
}
|
|
|
|
/**
|
|
* @param array<int,string> $parts
|
|
*/
|
|
private function part(array $parts, int $index): ?string {
|
|
if (!isset($parts[$index])) {
|
|
return null;
|
|
}
|
|
$value = trim((string)$parts[$index]);
|
|
return $value === '' ? null : $value;
|
|
}
|
|
|
|
/**
|
|
* @return list<string>
|
|
*/
|
|
private function categories(VCardComponent $vCard): array {
|
|
$tags = [];
|
|
foreach ($vCard->select('CATEGORIES') as $prop) {
|
|
foreach ($prop->getParts() as $part) {
|
|
$part = trim((string)$part);
|
|
if ($part !== '') {
|
|
$tags[] = $part;
|
|
}
|
|
}
|
|
}
|
|
return $tags;
|
|
}
|
|
|
|
/**
|
|
* Best-effort parse of a vCard date value; null if unparseable.
|
|
*/
|
|
private function date(string $value): ?\DateTimeImmutable {
|
|
$value = trim($value);
|
|
if ($value === '') {
|
|
return null;
|
|
}
|
|
// Common compact form YYYYMMDD.
|
|
if (preg_match('/^\d{8}$/', $value)) {
|
|
$date = \DateTimeImmutable::createFromFormat('!Ymd', $value);
|
|
return $date ?: null;
|
|
}
|
|
try {
|
|
return new \DateTimeImmutable($value);
|
|
} catch (Throwable) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
private function key(): string {
|
|
return bin2hex(random_bytes(8));
|
|
}
|
|
}
|