Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
94.15% |
161 / 171 |
|
56.25% |
9 / 16 |
CRAP | |
0.00% |
0 / 1 |
| VCardConverter | |
94.12% |
160 / 170 |
|
56.25% |
9 / 16 |
64.83 | |
0.00% |
0 / 1 |
| toVcf | |
94.74% |
18 / 19 |
|
0.00% |
0 / 1 |
2.00 | |||
| toRecord | |
100.00% |
37 / 37 |
|
100.00% |
1 / 1 |
6 | |||
| resolveFormattedName | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
3.04 | |||
| appendEmailsToVCard | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
| appendPhonesToVCard | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
| appendOrganizationAndTitle | |
100.00% |
9 / 9 |
|
100.00% |
1 / 1 |
4 | |||
| appendAddressToVCard | |
100.00% |
13 / 13 |
|
100.00% |
1 / 1 |
10 | |||
| appendMetadataToVCard | |
100.00% |
6 / 6 |
|
100.00% |
1 / 1 |
3 | |||
| extractNameParts | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
2 | |||
| extractPhones | |
81.82% |
9 / 11 |
|
0.00% |
0 / 1 |
5.15 | |||
| extractEmails | |
80.00% |
8 / 10 |
|
0.00% |
0 / 1 |
4.13 | |||
| propertyToString | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
4.07 | |||
| extractAddress | |
100.00% |
10 / 10 |
|
100.00% |
1 / 1 |
2 | |||
| extractGeo | |
88.89% |
8 / 9 |
|
0.00% |
0 / 1 |
6.05 | |||
| extractPrimaryAndSecondary | |
81.82% |
9 / 11 |
|
0.00% |
0 / 1 |
5.15 | |||
| extractOrganization | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | /** @license For full copyright and license information, please see the LICENSE.md file. */ |
| 6 | |
| 7 | namespace App\Modules\Dav\Infrastructure\Converter; |
| 8 | |
| 9 | defined('AMMONLY_APP') || exit('Direct script access is forbidden.'); |
| 10 | |
| 11 | use Sabre\VObject\Component\VCard; |
| 12 | use Sabre\VObject\Reader; |
| 13 | |
| 14 | /** |
| 15 | * vCard RFC 6350 Domain Model Converter. |
| 16 | * |
| 17 | * Bi-directionally maps CRM contact records to standard vCard 4.0 (.vcf) VCARD components. |
| 18 | * |
| 19 | * @package App\Modules\Dav\Infrastructure\Converter |
| 20 | */ |
| 21 | final class VCardConverter |
| 22 | { |
| 23 | private const string PRODID = '-//Ammonly//Ammonly CRM Groupware 1.0//EN'; |
| 24 | |
| 25 | /** |
| 26 | * Converts an Ammonly CRM contact database record into an RFC 6350 vCard string. |
| 27 | * |
| 28 | * @param array<string, mixed> $record Contact record database attributes. |
| 29 | * @return string Serialized VCARD (.vcf) content. |
| 30 | */ |
| 31 | public function toVcf(array $record): string |
| 32 | { |
| 33 | $uid = (string) ($record['c_name'] ?? ''); |
| 34 | if ($uid === '') { |
| 35 | $uid = sprintf('urn:uuid:%s', bin2hex(random_bytes(16))); |
| 36 | } |
| 37 | |
| 38 | $vcard = new VCard([ |
| 39 | 'VERSION' => '4.0', |
| 40 | 'PRODID' => self::PRODID, |
| 41 | 'UID' => $uid, |
| 42 | ]); |
| 43 | |
| 44 | $firstName = (string) ($record['first_name'] ?? ''); |
| 45 | $lastName = (string) ($record['last_name'] ?? ''); |
| 46 | $formattedName = $this->resolveFormattedName($record, $firstName, $lastName); |
| 47 | |
| 48 | $vcard->add('FN', $formattedName); |
| 49 | $vcard->add('N', [$lastName, $firstName, '', '', '']); |
| 50 | |
| 51 | $this->appendEmailsToVCard($vcard, $record); |
| 52 | $this->appendPhonesToVCard($vcard, $record); |
| 53 | $this->appendOrganizationAndTitle($vcard, $record); |
| 54 | $this->appendAddressToVCard($vcard, $record); |
| 55 | $this->appendMetadataToVCard($vcard, $record); |
| 56 | |
| 57 | return $vcard->serialize(); |
| 58 | } |
| 59 | |
| 60 | /** |
| 61 | * Converts raw RFC 6350 vCard content into an Ammonly CRM record array. |
| 62 | * |
| 63 | * @param string $vcfContent Raw vCard text string. |
| 64 | * @param string $defaultEtag Optional ETag to assign to the record. |
| 65 | * @return array<string, mixed> Mapped contact database columns. |
| 66 | */ |
| 67 | public function toRecord(string $vcfContent, string $defaultEtag = ''): array |
| 68 | { |
| 69 | $vcard = Reader::read($vcfContent); |
| 70 | if (!($vcard instanceof VCard)) { |
| 71 | return []; |
| 72 | } |
| 73 | |
| 74 | $uid = (string) ($vcard->UID ?? ''); |
| 75 | $formattedName = (string) ($vcard->FN ?? ''); |
| 76 | [$firstName, $lastName] = $this->extractNameParts($vcard); |
| 77 | [$email, $secondaryEmail] = $this->extractEmails($vcard); |
| 78 | [$phone, $secondaryPhone] = $this->extractPhones($vcard); |
| 79 | $organization = $this->extractOrganization($vcard); |
| 80 | $address = $this->extractAddress($vcard); |
| 81 | [$lat, $lng] = $this->extractGeo($vcard); |
| 82 | |
| 83 | $jobTitle = isset($vcard->TITLE) ? (string) $vcard->TITLE : ''; |
| 84 | $website = isset($vcard->URL) ? (string) $vcard->URL : ''; |
| 85 | $birthday = isset($vcard->BDAY) ? (string) $vcard->BDAY : null; |
| 86 | $description = isset($vcard->NOTE) ? (string) $vcard->NOTE : ''; |
| 87 | |
| 88 | return [ |
| 89 | 'c_name' => $uid, |
| 90 | 'c_etag' => $defaultEtag, |
| 91 | 'first_name' => $firstName, |
| 92 | 'last_name' => $lastName, |
| 93 | 'formatted_name' => $formattedName, |
| 94 | 'job_title' => $jobTitle, |
| 95 | 'email' => $email, |
| 96 | 'secondary_email' => $secondaryEmail, |
| 97 | 'phone' => $phone, |
| 98 | 'secondary_phone' => $secondaryPhone, |
| 99 | 'website' => $website, |
| 100 | 'c_o' => $organization, |
| 101 | 'birthday' => $birthday, |
| 102 | 'address_street' => $address['street'], |
| 103 | 'address_city' => $address['city'], |
| 104 | 'address_postal_code' => $address['postal_code'], |
| 105 | 'address_country' => $address['country'], |
| 106 | 'address_latitude' => $lat, |
| 107 | 'address_longitude' => $lng, |
| 108 | 'description' => $description, |
| 109 | ]; |
| 110 | } |
| 111 | |
| 112 | /** |
| 113 | * Resolves the contact formatted name. |
| 114 | * |
| 115 | * @param array<string, mixed> $record Source record. |
| 116 | * @param string $firstName First name. |
| 117 | * @param string $lastName Last name. |
| 118 | * @return string Formatted name string. |
| 119 | */ |
| 120 | private function resolveFormattedName(array $record, string $firstName, string $lastName): string |
| 121 | { |
| 122 | $fn = (string) ($record['formatted_name'] ?? ''); |
| 123 | if ($fn === '') { |
| 124 | $fn = trim(sprintf('%s %s', $firstName, $lastName)); |
| 125 | } |
| 126 | if ($fn === '') { |
| 127 | $fn = (string) ($record['email'] ?? 'Unnamed Contact'); |
| 128 | } |
| 129 | return $fn; |
| 130 | } |
| 131 | |
| 132 | /** |
| 133 | * Appends email addresses to the vCard. |
| 134 | * |
| 135 | * @param VCard $vcard Target vCard component. |
| 136 | * @param array<string, mixed> $record Source contact record. |
| 137 | */ |
| 138 | private function appendEmailsToVCard(VCard $vcard, array $record): void |
| 139 | { |
| 140 | $email = (string) ($record['email'] ?? ''); |
| 141 | if ($email !== '') { |
| 142 | $vcard->add('EMAIL', $email, ['type' => 'work']); |
| 143 | } |
| 144 | $secondaryEmail = (string) ($record['secondary_email'] ?? ''); |
| 145 | if ($secondaryEmail !== '') { |
| 146 | $vcard->add('EMAIL', $secondaryEmail, ['type' => 'home']); |
| 147 | } |
| 148 | } |
| 149 | |
| 150 | /** |
| 151 | * Appends phone numbers to the vCard. |
| 152 | * |
| 153 | * @param VCard $vcard Target vCard component. |
| 154 | * @param array<string, mixed> $record Source contact record. |
| 155 | */ |
| 156 | private function appendPhonesToVCard(VCard $vcard, array $record): void |
| 157 | { |
| 158 | $phone = (string) ($record['phone'] ?? ''); |
| 159 | if ($phone !== '') { |
| 160 | $vcard->add('TEL', $phone, ['type' => 'work']); |
| 161 | } |
| 162 | $secondaryPhone = (string) ($record['secondary_phone'] ?? ''); |
| 163 | if ($secondaryPhone !== '') { |
| 164 | $vcard->add('TEL', $secondaryPhone, ['type' => 'home']); |
| 165 | } |
| 166 | } |
| 167 | |
| 168 | /** |
| 169 | * Appends organization, title and website. |
| 170 | * |
| 171 | * @param VCard $vcard Target vCard component. |
| 172 | * @param array<string, mixed> $record Source contact record. |
| 173 | */ |
| 174 | private function appendOrganizationAndTitle(VCard $vcard, array $record): void |
| 175 | { |
| 176 | $organization = (string) ($record['c_o'] ?? ''); |
| 177 | if ($organization !== '') { |
| 178 | $vcard->add('ORG', [$organization]); |
| 179 | } |
| 180 | $jobTitle = (string) ($record['job_title'] ?? ''); |
| 181 | if ($jobTitle !== '') { |
| 182 | $vcard->add('TITLE', $jobTitle); |
| 183 | } |
| 184 | $website = (string) ($record['website'] ?? ''); |
| 185 | if ($website !== '') { |
| 186 | $vcard->add('URL', $website); |
| 187 | } |
| 188 | } |
| 189 | |
| 190 | /** |
| 191 | * Appends postal address and geo coordinates to vCard. |
| 192 | * |
| 193 | * @param VCard $vcard Target vCard component. |
| 194 | * @param array<string, mixed> $record Source contact record. |
| 195 | */ |
| 196 | private function appendAddressToVCard(VCard $vcard, array $record): void |
| 197 | { |
| 198 | $street = (string) ($record['address_street'] ?? ''); |
| 199 | $building = (string) ($record['address_building_number'] ?? ''); |
| 200 | $apt = (string) ($record['address_apartment_number'] ?? ''); |
| 201 | $city = (string) ($record['address_city'] ?? ''); |
| 202 | $postalCode = (string) ($record['address_postal_code'] ?? ''); |
| 203 | $country = (string) ($record['address_country'] ?? ''); |
| 204 | |
| 205 | $fullStreet = trim($street . ($building !== '' ? ' ' . $building : '')); |
| 206 | if ($fullStreet !== '' || $city !== '' || $postalCode !== '' || $country !== '') { |
| 207 | $vcard->add('ADR', ['', $apt, $fullStreet, $city, '', $postalCode, $country], ['type' => 'work']); |
| 208 | } |
| 209 | |
| 210 | $lat = $record['address_latitude'] ?? null; |
| 211 | $lng = $record['address_longitude'] ?? null; |
| 212 | if ($lat !== null && $lng !== null && (string) $lat !== '' && (string) $lng !== '') { |
| 213 | $vcard->add('GEO', sprintf('geo:%s,%s', (string) $lat, (string) $lng)); |
| 214 | } |
| 215 | } |
| 216 | |
| 217 | /** |
| 218 | * Appends birthday and description metadata. |
| 219 | * |
| 220 | * @param VCard $vcard Target vCard component. |
| 221 | * @param array<string, mixed> $record Source contact record. |
| 222 | */ |
| 223 | private function appendMetadataToVCard(VCard $vcard, array $record): void |
| 224 | { |
| 225 | $birthday = (string) ($record['birthday'] ?? ''); |
| 226 | if ($birthday !== '') { |
| 227 | $vcard->add('BDAY', $birthday); |
| 228 | } |
| 229 | $description = (string) ($record['description'] ?? ''); |
| 230 | if ($description !== '') { |
| 231 | $vcard->add('NOTE', $description); |
| 232 | } |
| 233 | } |
| 234 | |
| 235 | /** |
| 236 | * Extracts first and last name from VCard N property. |
| 237 | * |
| 238 | * @param VCard $vcard Source vCard. |
| 239 | * @return array{0: string, 1: string} [firstName, lastName] |
| 240 | */ |
| 241 | private function extractNameParts(VCard $vcard): array |
| 242 | { |
| 243 | if (!isset($vcard->N)) { |
| 244 | return ['', '']; |
| 245 | } |
| 246 | $parts = $vcard->N->getParts(); |
| 247 | return [ |
| 248 | (string) ($parts[1] ?? ''), |
| 249 | (string) ($parts[0] ?? ''), |
| 250 | ]; |
| 251 | } |
| 252 | |
| 253 | /** |
| 254 | * Extracts phone numbers with smart type resolution (WORK, CELL, HOME). |
| 255 | * |
| 256 | * @param VCard $vcard Source vCard. |
| 257 | * @return array{0: string, 1: string} [workPhone, secondaryPhone] |
| 258 | */ |
| 259 | private function extractPhones(VCard $vcard): array |
| 260 | { |
| 261 | $workProp = $vcard->getByTypes('TEL', ['WORK']); |
| 262 | $cellProp = $vcard->getByTypes('TEL', ['CELL']); |
| 263 | $homeProp = $vcard->getByTypes('TEL', ['HOME']); |
| 264 | |
| 265 | $primary = $this->propertyToString($workProp); |
| 266 | $secondary = $cellProp !== null ? $this->propertyToString($cellProp) : $this->propertyToString($homeProp); |
| 267 | |
| 268 | if ($primary === '' && $secondary === '') { |
| 269 | return $this->extractPrimaryAndSecondary($vcard, 'TEL'); |
| 270 | } |
| 271 | if ($primary === '') { |
| 272 | $primary = $secondary; |
| 273 | $secondary = ''; |
| 274 | } |
| 275 | |
| 276 | return [$primary, $secondary]; |
| 277 | } |
| 278 | |
| 279 | /** |
| 280 | * Extracts email addresses with smart type resolution (WORK, HOME). |
| 281 | * |
| 282 | * @param VCard $vcard Source vCard. |
| 283 | * @return array{0: string, 1: string} [workEmail, secondaryEmail] |
| 284 | */ |
| 285 | private function extractEmails(VCard $vcard): array |
| 286 | { |
| 287 | $workProp = $vcard->getByTypes('EMAIL', ['WORK']); |
| 288 | $homeProp = $vcard->getByTypes('EMAIL', ['HOME']); |
| 289 | |
| 290 | $primary = $this->propertyToString($workProp); |
| 291 | $secondary = $this->propertyToString($homeProp); |
| 292 | |
| 293 | if ($primary === '' && $secondary === '') { |
| 294 | return $this->extractPrimaryAndSecondary($vcard, 'EMAIL'); |
| 295 | } |
| 296 | if ($primary === '') { |
| 297 | $primary = $secondary; |
| 298 | $secondary = ''; |
| 299 | } |
| 300 | |
| 301 | return [$primary, $secondary]; |
| 302 | } |
| 303 | |
| 304 | /** |
| 305 | * Converts a VObject property or node into a clean string value. |
| 306 | * |
| 307 | * @param mixed $property Property node. |
| 308 | * @return string Extracted string. |
| 309 | */ |
| 310 | private function propertyToString(mixed $property): string |
| 311 | { |
| 312 | if ($property instanceof \Sabre\VObject\Property) { |
| 313 | $val = $property->getValue(); |
| 314 | if (is_string($val)) { |
| 315 | return $val; |
| 316 | } |
| 317 | return is_scalar($val) ? (string) $val : ''; |
| 318 | } |
| 319 | return ''; |
| 320 | } |
| 321 | |
| 322 | /** |
| 323 | * Extracts postal address components from VCard ADR property. |
| 324 | * |
| 325 | * @param VCard $vcard Source vCard. |
| 326 | * @return array{street: string, city: string, postal_code: string, country: string} |
| 327 | */ |
| 328 | private function extractAddress(VCard $vcard): array |
| 329 | { |
| 330 | /** @var \Sabre\VObject\Property|null $adr */ |
| 331 | $adr = $vcard->getByTypes('ADR', ['WORK']) ?? ($vcard->ADR ?? null); |
| 332 | if ($adr === null) { |
| 333 | return ['street' => '', 'city' => '', 'postal_code' => '', 'country' => '']; |
| 334 | } |
| 335 | |
| 336 | $parts = $adr->getParts(); |
| 337 | return [ |
| 338 | 'street' => (string) ($parts[2] ?? ''), |
| 339 | 'city' => (string) ($parts[3] ?? ''), |
| 340 | 'postal_code' => (string) ($parts[5] ?? ''), |
| 341 | 'country' => (string) ($parts[6] ?? ''), |
| 342 | ]; |
| 343 | } |
| 344 | |
| 345 | /** |
| 346 | * Extracts latitude and longitude from VCard GEO property. |
| 347 | * |
| 348 | * @param VCard $vcard Source vCard. |
| 349 | * @return array{0: ?string, 1: ?string} [latitude, longitude] |
| 350 | */ |
| 351 | private function extractGeo(VCard $vcard): array |
| 352 | { |
| 353 | if (!isset($vcard->GEO)) { |
| 354 | return [null, null]; |
| 355 | } |
| 356 | $geoStr = (string) $vcard->GEO; |
| 357 | if (str_starts_with($geoStr, 'geo:')) { |
| 358 | $geoStr = substr($geoStr, 4); |
| 359 | } |
| 360 | $coords = explode(',', $geoStr); |
| 361 | if (count($coords) === 2 && is_numeric(trim($coords[0])) && is_numeric(trim($coords[1]))) { |
| 362 | return [trim($coords[0]), trim($coords[1])]; |
| 363 | } |
| 364 | return [null, null]; |
| 365 | } |
| 366 | |
| 367 | /** |
| 368 | * Extracts up to two string values from repeated VCard properties (e.g. EMAIL, TEL). |
| 369 | * |
| 370 | * @param VCard $vcard Source vCard. |
| 371 | * @param string $property Property name. |
| 372 | * @return array{0: string, 1: string} [primary, secondary] |
| 373 | */ |
| 374 | private function extractPrimaryAndSecondary(VCard $vcard, string $property): array |
| 375 | { |
| 376 | if (!isset($vcard->{$property})) { |
| 377 | return ['', '']; |
| 378 | } |
| 379 | |
| 380 | $first = ''; |
| 381 | $second = ''; |
| 382 | foreach ($vcard->{$property} as $item) { |
| 383 | $val = (string) $item; |
| 384 | if ($first === '') { |
| 385 | $first = $val; |
| 386 | } elseif ($second === '') { |
| 387 | $second = $val; |
| 388 | } |
| 389 | } |
| 390 | |
| 391 | return [$first, $second]; |
| 392 | } |
| 393 | |
| 394 | /** |
| 395 | * Extracts organization name from VCard ORG property. |
| 396 | */ |
| 397 | private function extractOrganization(VCard $vcard): string |
| 398 | { |
| 399 | if (!isset($vcard->ORG)) { |
| 400 | return ''; |
| 401 | } |
| 402 | $parts = $vcard->ORG->getParts(); |
| 403 | return (string) ($parts[0] ?? ''); |
| 404 | } |
| 405 | } |