Collection (within a provider service) * Card <=> Entity (individual / organization / group) * * Address book identifiers use the composite key "{providerId}:{serviceId}:{collectionId}". * Card URIs are client-chosen names (e.g. "contact.vcf") mapped to provider * entity identifiers via DavUriMap. */ class CardDavBackend extends AbstractBackend { private const ID_SEP = ':'; public function __construct( private readonly PeopleManager $manager, private readonly SessionIdentity $identity, private readonly SessionTenant $tenant, private readonly DavUriMap $uriMap, ) {} // ------------------------------------------------------------------------- // Required: address book listing // ------------------------------------------------------------------------- public function getAddressBooksForUser($principalUri): array { $result = []; // collectionList returns [providerId][serviceId][collectionId] => CollectionBaseInterface $allCollections = $this->manager->collectionList($this->tenant->identifier(), $this->identity->identifier()); foreach ($allCollections as $providerId => $providerServices) { foreach ($providerServices as $serviceId => $collections) { foreach ($collections as $collectionId => $collection) { if (!is_object($collection)) { continue; } $props = $collection->getProperties(); $label = $props->getLabel(); $abId = implode(self::ID_SEP, [(string) $providerId, (string) $serviceId, (string) $collectionId]); $result[] = [ DavProperties::ID => $abId, DavProperties::URI => $this->slugify($label), DavProperties::DISPLAYNAME => $label, DavProperties::CARDDAV_DESCRIPTION => $props->getDescription() ?? '', DavProperties::CS_CTAG => $collection->signature() ?? '', DavProperties::PRINCIPAL_URI => $principalUri, ]; } } } return $result; } public function updateAddressBook($addressBookId, \Sabre\DAV\PropPatch $propPatch): void { // not supported } public function createAddressBook($principalUri, $url, array $properties): string { throw new \Sabre\DAV\Exception\Forbidden('Address book creation is not supported via DAV'); } public function deleteAddressBook($addressBookId): void { throw new \Sabre\DAV\Exception\Forbidden('Address book deletion is not supported via DAV'); } // ------------------------------------------------------------------------- // Required: cards // ------------------------------------------------------------------------- public function getCards($addressBookId): array { $tenantId = $this->tenant->identifier(); $userId = $this->identity->identifier(); $targets = ResourceIdentifiers::fromArray([$addressBookId]); // entityListBulk returns [providerId][serviceId][collectionId] => [entityId => EntityBaseInterface] $entitiesByLocation = $this->manager->entityListBulk($tenantId, $userId, $targets); $mapId = $this->mapId($tenantId, $userId, $addressBookId); $cards = []; foreach ($entitiesByLocation as $providerServices) { foreach ($providerServices as $serviceCollections) { foreach ($serviceCollections as $entities) { foreach ($entities as $entity) { if (!is_object($entity)) { continue; } $entityId = (string) $entity->identifier(); $objectUri = $this->uriMap->getObjectUri($mapId, $entityId) ?? ($entityId . '.vcf'); $data = $this->entityToVcard($entity); $cards[] = [ DavProperties::ID => $entityId, DavProperties::URI => $objectUri, DavProperties::LAST_MODIFIED => $entity->modified()?->getTimestamp() ?? time(), DavProperties::ETAG => '"' . md5($data) . '"', DavProperties::SIZE => strlen($data), DavProperties::CARD_DATA => null, // lazy ]; } } } } return $cards; } public function getCard($addressBookId, $cardUri): ?array { [$providerId, $serviceId, $collectionId] = $this->parseId($addressBookId); $tenantId = $this->tenant->identifier(); $userId = $this->identity->identifier(); $mapId = $this->mapId($tenantId, $userId, $addressBookId); $entityId = $this->uriMap->getEntityId($mapId, $cardUri) ?? preg_replace('/\.vcf$/i', '', $cardUri); $identifier = new EntityIdentifier($providerId, $serviceId, $collectionId, $entityId); $fetched = $this->manager->entityFetchBulk($tenantId, $userId, $identifier); $entity = reset($fetched) ?: null; if ($entity === null) { return null; } $data = $this->entityToVcard($entity); return [ DavProperties::ID => $entityId, DavProperties::URI => $cardUri, DavProperties::LAST_MODIFIED => $entity->modified()?->getTimestamp() ?? time(), DavProperties::ETAG => '"' . md5($data) . '"', DavProperties::SIZE => strlen($data), DavProperties::CARD_DATA => $data, ]; } public function getMultipleCards($addressBookId, array $uris): array { return array_values(array_filter(array_map( fn($uri) => $this->getCard($addressBookId, $uri), $uris, ))); } public function createCard($addressBookId, $cardUri, $cardData): ?string { [$providerId, $serviceId, $collectionId] = $this->parseId($addressBookId); $tenantId = $this->tenant->identifier(); $userId = $this->identity->identifier(); $vcard = Reader::read($cardData); $props = $this->vcardToEntityProps($vcard); $target = new CollectionIdentifier($providerId, $serviceId, $collectionId); $entity = $this->manager->entityCreate($tenantId, $userId, $target, $props); $this->uriMap->set( $this->mapId($tenantId, $userId, $addressBookId), $cardUri, (string) $entity->identifier(), ); return '"' . md5($this->entityToVcard($entity)) . '"'; } public function updateCard($addressBookId, $cardUri, $cardData): ?string { [$providerId, $serviceId, $collectionId] = $this->parseId($addressBookId); $tenantId = $this->tenant->identifier(); $userId = $this->identity->identifier(); $mapId = $this->mapId($tenantId, $userId, $addressBookId); $entityId = $this->uriMap->getEntityId($mapId, $cardUri) ?? preg_replace('/\.vcf$/i', '', $cardUri); $vcard = Reader::read($cardData); $props = $this->vcardToEntityProps($vcard); $target = new EntityIdentifier($providerId, $serviceId, $collectionId, $entityId); $entity = $this->manager->entityModify($tenantId, $userId, $target, $props); return '"' . md5($this->entityToVcard($entity)) . '"'; } public function deleteCard($addressBookId, $cardUri): bool { [$providerId, $serviceId, $collectionId] = $this->parseId($addressBookId); $tenantId = $this->tenant->identifier(); $userId = $this->identity->identifier(); $mapId = $this->mapId($tenantId, $userId, $addressBookId); $entityId = $this->uriMap->getEntityId($mapId, $cardUri) ?? preg_replace('/\.vcf$/i', '', $cardUri); $target = new EntityIdentifier($providerId, $serviceId, $collectionId, $entityId); $outcome = $this->manager->entityDelete($tenantId, $userId, $target); // entityDelete returns outcomes keyed by source identifier; a non-error // disposition ('deleted'|'moved') indicates the card no longer exists here. $disposition = $outcome[(string) $target]['disposition'] ?? 'error'; $deleted = $disposition !== 'error'; if ($deleted) { $this->uriMap->delete($mapId, $cardUri); } return $deleted; } // ------------------------------------------------------------------------- // Helpers // ------------------------------------------------------------------------- private function parseId(string $id): array { return explode(self::ID_SEP, $id, 3); } private function mapId(string $tenantId, string $userId, string $addressBookId): string { return 'card:' . $tenantId . ':' . $userId . ':' . $addressBookId; } private function slugify(string $label): string { return strtolower(preg_replace('/[^a-z0-9]+/i', '-', trim($label))); } /** * Serialize a domain people entity to a vCard 4.0 string. * * The raw data from getDataRaw() is a serialized IndividualObject array with * fields: label, names (given/family/...), phones, emails, organizations, ... */ private function entityToVcard(object $entity): string { $raw = (array) ($entity->getProperties()->getDataRaw() ?? []); $vcard = new VCard(['VERSION' => '4.0', 'UID' => (string) $entity->identifier()]); // Full name (FN is required in vCard 4.0) $fn = (string) ($raw['label'] ?? ''); $vcard->add('FN', $fn ?: 'Unknown'); // Structured name: N;family;given;additional;prefix;suffix $names = (array) ($raw['names'] ?? []); $family = (string) ($names['family'] ?? ''); $given = (string) ($names['given'] ?? ''); if ($family || $given) { $vcard->add('N', [ 'value' => [ $family, $given, (string) ($names['additional'] ?? ''), (string) ($names['prefix'] ?? ''), (string) ($names['suffix'] ?? ''), ], ]); } // E-mail addresses foreach ((array) ($raw['emails'] ?? []) as $email) { if ($address = (string) ($email['address'] ?? '')) { $vcard->add('EMAIL', $address); } } // Phone numbers foreach ((array) ($raw['phones'] ?? []) as $phone) { if ($number = (string) ($phone['number'] ?? '')) { $vcard->add('TEL', $number); } } // Organization (first entry only for simplicity) foreach ((array) ($raw['organizations'] ?? []) as $org) { $orgName = (string) ($org['Label'] ?? $org['label'] ?? ''); if ($orgName) { $vcard->add('ORG', $orgName); break; } } return $vcard->serialize(); } /** * Convert a parsed vCard into an array for entityCreate/entityUpdate. * Field names match IndividualObject property names used in serialization. */ private function vcardToEntityProps(VCard $vcard): array { $props = []; if ($fn = (string) ($vcard->FN ?? '')) { $props['label'] = $fn; } if ($n = $vcard->N) { $parts = $n->getParts(); $names = []; if (!empty($parts[0])) { $names['family'] = $parts[0]; } if (!empty($parts[1])) { $names['given'] = $parts[1]; } if (!empty($parts[2])) { $names['additional'] = $parts[2]; } if (!empty($parts[3])) { $names['prefix'] = $parts[3]; } if (!empty($parts[4])) { $names['suffix'] = $parts[4]; } if ($names) { $props['names'] = $names; } } $emails = []; foreach ($vcard->select('EMAIL') as $email) { $emails[] = ['address' => (string) $email]; } if ($emails) { $props['emails'] = $emails; } $phones = []; foreach ($vcard->select('TEL') as $tel) { $phones[] = ['number' => (string) $tel]; } if ($phones) { $props['phones'] = $phones; } $orgs = []; foreach ($vcard->select('ORG') as $org) { $orgs[] = ['Label' => (string) $org]; } if ($orgs) { $props['organizations'] = $orgs; } return $props; } }