* 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 */ 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 */ 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:. $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 $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 */ 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)); } }