Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.15% covered (success)
94.15%
161 / 171
56.25% covered (warning)
56.25%
9 / 16
CRAP
0.00% covered (danger)
0.00%
0 / 1
VCardConverter
94.12% covered (success)
94.12%
160 / 170
56.25% covered (warning)
56.25%
9 / 16
64.83
0.00% covered (danger)
0.00%
0 / 1
 toVcf
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
2.00
 toRecord
100.00% covered (success)
100.00%
37 / 37
100.00% covered (success)
100.00%
1 / 1
6
 resolveFormattedName
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 appendEmailsToVCard
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 appendPhonesToVCard
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 appendOrganizationAndTitle
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 appendAddressToVCard
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
10
 appendMetadataToVCard
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 extractNameParts
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 extractPhones
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
5.15
 extractEmails
80.00% covered (warning)
80.00%
8 / 10
0.00% covered (danger)
0.00%
0 / 1
4.13
 propertyToString
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 extractAddress
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 extractGeo
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
6.05
 extractPrimaryAndSecondary
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
5.15
 extractOrganization
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Modules\Dav\Infrastructure\Converter;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use Sabre\VObject\Component\VCard;
12use 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 */
21final 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}