File Coverage

lib/Text/JSContact.pm
Criterion Covered Total %
statement 916 1026 89.2
branch 310 514 60.3
condition 185 358 51.6
subroutine 81 81 100.0
pod 0 3 0.0
total 1492 1982 75.2


line stmt bran cond sub pod time code
1             #!/usr/bin/perl -cw
2              
3 5     5   527596 use strict;
  5         5  
  5         142  
4 5     5   16 use warnings;
  5         7  
  5         361  
5              
6             package Text::JSContact;
7              
8             our $VERSION = '0.01';
9              
10             # vCard <=> JSContact (RFC 9553) conversion
11             # Follows RFC 9555 for vCard mapping and RFC 9554 for extensions
12              
13 5     5   2085 use Text::VCardFast qw(vcard2hash hash2vcard);
  5         97037  
  5         328  
14 5     5   32 use Encode qw(decode_utf8 encode_utf8);
  5         8  
  5         176  
15 5     5   20 use MIME::Base64 qw(decode_base64 encode_base64);
  5         5  
  5         159  
16 5     5   656 use JSON;
  5         3742  
  5         28  
17 5     5   592 use Scalar::Util qw(looks_like_number);
  5         6  
  5         209  
18              
19 5     5   38 use Exporter 'import';
  5         19  
  5         52354  
20             our @EXPORT_OK = qw(vcard_to_jscontact jscontact_to_vcard patch_vcard);
21              
22             ###############################################################################
23             # HELPERS
24             ###############################################################################
25              
26             sub _first_prop {
27 453     453   418 my ($props, $name) = @_;
28 453         395 my $items = $props->{$name};
29 453 100 66     759 return undef unless $items && @$items;
30 98         119 return $items->[0];
31             }
32              
33             sub _first_value {
34 418     418   422 my ($props, $name) = @_;
35 418         410 my $prop = _first_prop($props, $name);
36 418 100       721 return undef unless $prop;
37 87         201 return $prop->{value};
38             }
39              
40             sub _param {
41 297     297   273 my ($prop, $name) = @_;
42 297         311 my $val = $prop->{params}{$name};
43 297 100       412 return undef unless defined $val;
44 65 50       132 return ref $val eq 'ARRAY' ? $val->[0] : $val;
45             }
46              
47             sub _params_list {
48 143     143   143 my ($prop, $name) = @_;
49 143         132 my $val = $prop->{params}{$name};
50 143 100       182 return () unless defined $val;
51             # Split comma-separated values (e.g. TYPE=work,voice)
52 81 50       139 return map { split /,/ } (ref $val eq 'ARRAY' ? @$val : ($val));
  94         185  
53             }
54              
55             sub _prop_id {
56 110     110   113 my ($prop, $new_id) = @_;
57 110         117 my $pid = _param($prop, 'prop-id');
58 110 100 66     201 return $pid if defined $pid && $pid ne '';
59 74         90 return $new_id->();
60             }
61              
62             sub _decode_value {
63 145     145   132 my $val = shift;
64 145 50       151 return undef unless defined $val;
65 145 50       251 if ($val =~ /[\x80-\xff]/) {
66 0   0     0 $val = eval { decode_utf8($val) } // $val;
  0         0  
67             }
68 145         349 return $val;
69             }
70              
71             sub _make_contexts {
72 77     77   124 my @types = @_;
73 77         61 my %ctx;
74 77         84 for my $t (@types) {
75 50         93 my $lt = lc $t;
76 50 100       79 if ($lt eq 'home') { $ctx{private} = JSON::true }
  21 100       48  
77 8         24 elsif ($lt eq 'work') { $ctx{work} = JSON::true }
78             }
79 77 100       297 return %ctx ? \%ctx : undef;
80             }
81              
82             sub _make_pref {
83 73     73   76 my $prop = shift;
84             # vCard 4: PREF parameter
85 73         74 my $pref = _param($prop, 'pref');
86 73 100 66     152 if (defined $pref && looks_like_number($pref)) {
87 14         49 return $pref + 0;
88             }
89             # vCard 3: TYPE=pref
90 59         69 for my $t (_params_list($prop, 'type')) {
91 40 100       89 return 1 if lc $t eq 'pref';
92             }
93 55         106 return undef;
94             }
95              
96             sub _to_utc_datetime {
97 5     5   6 my $val = shift;
98 5 50       12 return undef unless defined $val;
99             # Normalize compact timestamps (e.g. 19940930T143510Z -> 1994-09-30T14:35:10Z)
100 5 50       51 if ($val =~ /^(\d{4})-?(\d{2})-?(\d{2})T(\d{2}):?(\d{2}):?(\d{2})/) {
101 5         44 return "$1-$2-$3T$4:$5:$6Z";
102             }
103 0         0 return $val;
104             }
105              
106             sub _generate_uid {
107 4     4   16 my @hex = ('0'..'9', 'a'..'f');
108 4         6 my $uuid = '';
109 4         9 for my $i (1..32) {
110 128         249 $uuid .= $hex[int(rand(16))];
111 128 100 100     490 $uuid .= '-' if $i == 8 || $i == 12 || $i == 16 || $i == 20;
      100        
      100        
112             }
113 4         10 substr($uuid, 14, 1) = '4';
114 4         9 substr($uuid, 19, 1) = $hex[8 + int(rand(4))];
115 4         24 return "urn:uuid:$uuid";
116             }
117              
118             sub _set_if {
119 175     175   191 my ($hash, $key, $val, $transform) = @_;
120 175 100 66     299 return unless defined $val && $val ne '';
121 18 100       40 $val = $transform->($val) if $transform;
122 18 50       50 $hash->{$key} = $val if defined $val;
123             }
124              
125             # Collect X-ABLabel associations (Apple extension)
126             sub _collect_labels {
127 443     443   413 my $props = shift;
128 443         324 my %labels;
129 443   100     303 for my $lp (@{$props->{'x-ablabel'} // []}) {
  443         706  
130 270 50       323 my $group = $lp->{group} or next;
131 270   50     286 my $val = $lp->{value} // '';
132 270 100       482 $val = $1 if $val =~ m{^_\$\!<([^>]*)>\!\$_$};
133 270         376 $labels{$group} = $val;
134             }
135 443         449 return \%labels;
136             }
137              
138             sub _apply_label {
139 63     63   66 my ($obj, $prop, $labels) = @_;
140 63 100       118 if (my $group = $prop->{group}) {
141 11 100       21 if (my $label = $labels->{$group}) {
142 10         16 $obj->{label} = $label;
143             }
144             }
145             }
146              
147             sub _convert_vcard_date {
148 13     13   15 my $val = shift;
149 13 50       22 return undef unless defined $val;
150              
151             # vCard 4 compact: YYYYMMDD or --MMDD
152 13 50       52 if ($val =~ /^(\d{4}|--)-?(\d{2})-?(\d{2})(?:T|$)/) {
153 13         45 my ($y, $m, $d) = ($1, $2, $3);
154 13 50       24 $y = '0000' if $y eq '--';
155 13 50       71 $y = '0000' if $y eq '1604'; # iOS "no year" magic
156 13         56 return "$y-$m-$d";
157             }
158              
159             # Already ISO format
160 0 0       0 return $val if $val =~ /^\d{4}-\d{2}-\d{2}/;
161              
162 0         0 return $val;
163             }
164              
165             sub _adr_values {
166 42     42   40 my ($values, $idx) = @_;
167 42         36 my $val = $values->[$idx];
168 42 50       51 return () unless defined $val;
169 42 50       80 if (ref $val eq 'ARRAY') {
170 0 0       0 return grep { defined $_ && $_ ne '' } @$val;
  0         0  
171             }
172 42 100       74 return ($val) if $val ne '';
173 7         11 return ();
174             }
175              
176             ###############################################################################
177             # VCARD -> JSCONTACT
178             ###############################################################################
179              
180             sub vcard_to_jscontact {
181 35     35 0 542642 my ($vcard_string) = @_;
182              
183 35         47 my $parsed = eval { vcard2hash($vcard_string, multival => [qw(n adr org)]) };
  35         141  
184 35 50 33     1662 return undef unless $parsed && $parsed->{objects} && @{$parsed->{objects}};
  35   33     123  
185              
186 35         53 my $vcard = $parsed->{objects}[0];
187 35 50 33     119 return undef unless $vcard && $vcard->{type} eq 'vcard';
188              
189 35         35 my $props = $vcard->{properties};
190 35         38 my $id_counter = 0;
191 35     74   114 my $new_id = sub { return '' . ++$id_counter };
  74         135  
192              
193 35         95 my $card = {
194             '@type' => 'Card',
195             version => '1.0',
196             };
197              
198             # UID (required)
199 35   66     71 $card->{uid} = _first_value($props, 'uid') // _generate_uid();
200              
201             # Simple scalar properties
202             _set_if($card, 'kind', _first_value($props, 'kind')
203             // _first_value($props, 'x-addressbookserver-kind'),
204 35   100 5   79 sub { lc $_[0] });
  5         9  
205 35         93 _set_if($card, 'prodId', _first_value($props, 'prodid'));
206 35         47 _set_if($card, 'updated', _first_value($props, 'rev'), \&_to_utc_datetime);
207 35         42 _set_if($card, 'created', _first_value($props, 'created'), \&_to_utc_datetime);
208 35         42 _set_if($card, 'language', _first_value($props, 'language'));
209              
210             # Complex properties
211 35         57 _convert_name($card, $props);
212 35         51 _convert_nicknames($card, $props, $new_id);
213 35         57 _convert_emails($card, $props, $new_id);
214 35         72 _convert_phones($card, $props, $new_id);
215 35         61 _convert_addresses($card, $props, $new_id);
216 35         62 _convert_organizations($card, $props, $new_id);
217 35         54 _convert_titles($card, $props, $new_id);
218 35         57 _convert_anniversaries($card, $props, $new_id);
219 35         59 _convert_notes($card, $props, $new_id);
220 35         60 _convert_online_services($card, $props, $new_id);
221 35         66 _convert_media($card, $props, $new_id);
222 35         60 _convert_links($card, $props, $new_id);
223 35         49 _convert_calendars($card, $props, $new_id);
224 35         53 _convert_scheduling_addresses($card, $props, $new_id);
225 35         82 _convert_crypto_keys($card, $props, $new_id);
226 35         57 _convert_directories($card, $props, $new_id);
227 35         57 _convert_preferred_languages($card, $props, $new_id);
228 35         54 _convert_personal_info($card, $props, $new_id);
229 35         64 _convert_keywords($card, $props);
230 35         60 _convert_members($card, $props);
231 35         57 _convert_related($card, $props);
232 35         62 _convert_speak_to_as($card, $props, $new_id);
233              
234 35         354 return $card;
235             }
236              
237             ###############################################################################
238             # INDIVIDUAL VCARD -> JSCONTACT CONVERTERS
239             ###############################################################################
240              
241             # FN + N -> name
242             sub _convert_name {
243 35     35   39 my ($card, $props) = @_;
244 35         37 my $name = {};
245              
246 35         40 my $fn = _first_value($props, 'fn');
247 35 50       78 $name->{full} = _decode_value($fn) if defined $fn;
248              
249 35         56 my $n_prop = _first_prop($props, 'n');
250 35 100       58 if ($n_prop) {
251 11   50     27 my $values = $n_prop->{values} || [];
252             # N component order: surname, given, given2, title(prefix), credential(suffix), surname2, generation
253 11         29 my @kind_map = qw(surname given given2 title credential surname2 generation);
254              
255 11         12 my @components;
256 11         29 for my $idx (0..$#kind_map) {
257 77         71 my $raw = $values->[$idx];
258 77 100       94 next unless defined $raw;
259             # Handle both arrayref and string; split comma-separated values
260 42         33 my @vals;
261 42 50       61 for my $v (ref $raw eq 'ARRAY' ? @$raw : ($raw)) {
262 42         75 push @vals, split /,/, $v;
263             }
264 42         44 for my $v (@vals) {
265 38 50 33     73 next unless defined $v && $v ne '';
266 38         46 push @components, {
267             '@type' => 'NameComponent',
268             kind => $kind_map[$idx],
269             value => _decode_value($v),
270             };
271             }
272             }
273              
274 11 50       38 $name->{components} = \@components if @components;
275              
276             # SORT-AS parameter
277 11         27 my $sort_as_param = _param($n_prop, 'sort-as');
278 11 100       36 if ($sort_as_param) {
279 1         3 my @sa = split /,/, $sort_as_param;
280 1         1 my %sort_map;
281 1         1 for my $i (0..$#sa) {
282 2 50 33     25 $sort_map{$kind_map[$i]} = $sa[$i]
      33        
283             if $i <= $#kind_map && defined $sa[$i] && $sa[$i] ne '';
284             }
285 1 50       3 $name->{sortAs} = \%sort_map if %sort_map;
286             }
287             }
288              
289             # Apple X-PHONETIC-*-NAME -> phonetic name components
290 35         39 my $phonFirst = _first_value($props, 'x-phonetic-first-name');
291 35         38 my $phonLast = _first_value($props, 'x-phonetic-last-name');
292 35         37 my $phonMid = _first_value($props, 'x-phonetic-middle-name');
293 35 50 66     130 if ($phonFirst || $phonLast || $phonMid) {
      66        
294 1         2 my @phon;
295 1 50       2 push @phon, { '@type' => 'NameComponent', kind => 'given', value => _decode_value($phonFirst), phonetic => 'ipa' } if $phonFirst;
296 1 50       6 push @phon, { '@type' => 'NameComponent', kind => 'given2', value => _decode_value($phonMid), phonetic => 'ipa' } if $phonMid;
297 1 50       2 push @phon, { '@type' => 'NameComponent', kind => 'surname', value => _decode_value($phonLast), phonetic => 'ipa' } if $phonLast;
298 1         2 $name->{phoneticComponents} = \@phon;
299             }
300              
301 35 50       91 $card->{name} = $name if %$name;
302             }
303              
304             # NICKNAME -> nicknames
305             sub _convert_nicknames {
306 35     35   43 my ($card, $props, $new_id) = @_;
307 35   100     88 my $items = $props->{nickname} || return;
308              
309 5         6 my %map;
310 5         9 for my $item (@$items) {
311 5         10 my $val = _decode_value($item->{value});
312 5 50 33     19 next unless defined $val && $val ne '';
313 5         14 my $id = _prop_id($item, $new_id);
314 5         9 my $obj = { '@type' => 'Nickname', name => $val };
315 5 50       12 if (my $ctx = _make_contexts(_params_list($item, 'type'))) {
316 0         0 $obj->{contexts} = $ctx;
317             }
318 5 50       10 if (my $pref = _make_pref($item)) { $obj->{pref} = $pref }
  0         0  
319 5         21 $map{$id} = $obj;
320             }
321              
322 5 50       13 $card->{nicknames} = \%map if %map;
323             }
324              
325             # EMAIL -> emails
326             sub _convert_emails {
327 35     35   39 my ($card, $props, $new_id) = @_;
328 35   100     57 my $items = $props->{email} || return;
329 8         15 my $labels = _collect_labels($props);
330              
331 8         57 my %map;
332 8         13 for my $item (@$items) {
333 19         25 my $val = $item->{value};
334 19 50 33     49 next unless defined $val && $val ne '';
335 19         30 my $id = _prop_id($item, $new_id);
336 19         43 my $obj = { '@type' => 'EmailAddress', address => $val };
337 19 100       29 if (my $ctx = _make_contexts(_params_list($item, 'type'))) {
338 11         20 $obj->{contexts} = $ctx;
339             }
340 19 100       27 if (my $pref = _make_pref($item)) { $obj->{pref} = $pref }
  5         7  
341 19         37 _apply_label($obj, $item, $labels);
342 19         61 $map{$id} = $obj;
343             }
344              
345 8 50       44 $card->{emails} = \%map if %map;
346             }
347              
348             # TEL -> phones
349             sub _convert_phones {
350 35     35   65 my ($card, $props, $new_id) = @_;
351 35   100     54 my $items = $props->{tel} || return;
352 8         47 my $labels = _collect_labels($props);
353              
354 8         54 my %tel_feature_map = (
355             cell => 'mobile', voice => 'voice', fax => 'fax',
356             video => 'video', pager => 'pager', text => 'text',
357             textphone => 'textphone',
358             );
359              
360 8         7 my %map;
361 8         12 for my $item (@$items) {
362 13         17 my $val = $item->{value};
363 13 50 33     40 next unless defined $val && $val ne '';
364 13         18 my $id = _prop_id($item, $new_id);
365 13         55 my $obj = { '@type' => 'Phone', number => $val };
366              
367 13         31 my @types = _params_list($item, 'type');
368 13 100       21 if (my $ctx = _make_contexts(@types)) {
369 6         8 $obj->{contexts} = $ctx;
370             }
371              
372 13         12 my %features;
373 13         18 for my $t (@types) {
374 17         26 my $feat = $tel_feature_map{lc $t};
375 17 100       30 $features{$feat} = JSON::true if $feat;
376             }
377 13 100       59 $obj->{features} = \%features if %features;
378              
379 13 100       18 if (my $pref = _make_pref($item)) { $obj->{pref} = $pref }
  3         7  
380 13         21 _apply_label($obj, $item, $labels);
381 13         28 $map{$id} = $obj;
382             }
383              
384 8 50       44 $card->{phones} = \%map if %map;
385             }
386              
387             # ADR -> addresses (+ GEO, TZ by group association)
388             sub _convert_addresses {
389 35     35   50 my ($card, $props, $new_id) = @_;
390 35   100     73 my $items = $props->{adr} || return;
391 7         11 my $labels = _collect_labels($props);
392              
393             # Collect grouped GEO/TZ for association with ADR
394 7         12 my %geo_by_group;
395             my %tz_by_group;
396 7   50     8 for my $geo (@{$props->{geo} // []}) {
  7         25  
397 0 0       0 $geo_by_group{$geo->{group}} = $geo->{value} if $geo->{group};
398             }
399 7   50     9 for my $tz (@{$props->{tz} // []}) {
  7         24  
400 0 0       0 $tz_by_group{$tz->{group}} = $tz->{value} if $tz->{group};
401             }
402              
403 7         10 my %map;
404 7         11 for my $item (@$items) {
405 7         11 my $id = _prop_id($item, $new_id);
406 7   50     22 my $values = $item->{values} || [];
407 7         12 my $obj = { '@type' => 'Address' };
408              
409             # Build components from ADR structured value
410             # Indices: 0=POBox 1=Extended 2=Street 3=Locality 4=Region 5=PostCode 6=Country
411 7         9 my @components;
412 7         16 for my $v (_adr_values($values, 2)) {
413 7         11 push @components, { '@type' => 'AddressComponent', kind => 'name', value => _decode_value($v) };
414             }
415 7         17 for my $v (_adr_values($values, 1)) {
416 0         0 push @components, { '@type' => 'AddressComponent', kind => 'apartment', value => _decode_value($v) };
417             }
418 7         10 for my $v (_adr_values($values, 3)) {
419 7         26 push @components, { '@type' => 'AddressComponent', kind => 'locality', value => _decode_value($v) };
420             }
421 7         15 for my $v (_adr_values($values, 4)) {
422 7         15 push @components, { '@type' => 'AddressComponent', kind => 'region', value => _decode_value($v) };
423             }
424 7         14 for my $v (_adr_values($values, 5)) {
425 7         10 push @components, { '@type' => 'AddressComponent', kind => 'postcode', value => _decode_value($v) };
426             }
427 7         19 for my $v (_adr_values($values, 6)) {
428 7         14 push @components, { '@type' => 'AddressComponent', kind => 'country', value => _decode_value($v) };
429             }
430 7 50       22 $obj->{components} = \@components if @components;
431              
432             # Parameters
433 7         12 my $label_param = _param($item, 'label');
434 7 50       26 $obj->{full} = $label_param if defined $label_param;
435              
436 7         8 my $geo = _param($item, 'geo');
437 7 100 33     18 $geo //= $geo_by_group{$item->{group}} if $item->{group};
438 7 50       16 $obj->{coordinates} = $geo if defined $geo;
439              
440 7         9 my $tz = _param($item, 'tz');
441 7 100 33     15 $tz //= $tz_by_group{$item->{group}} if $item->{group};
442 7 50       8 $obj->{timeZone} = $tz if defined $tz;
443              
444 7         11 my $cc = _param($item, 'cc');
445             # Apple X-ABADR grouped with ADR
446 7 100 100     29 if (!$cc && $item->{group}) {
447 1   50     1 for my $xabadr (@{$props->{'x-abadr'} // []}) {
  1         10  
448 1 50 50     23 if (($xabadr->{group} // '') eq $item->{group}) {
449 1         2 $cc = $xabadr->{value};
450 1         2 last;
451             }
452             }
453             }
454 7 100       13 $obj->{countryCode} = $cc if defined $cc;
455              
456 7         16 my @types = _params_list($item, 'type');
457 7 50       10 if (my $ctx = _make_contexts(@types)) {
458 7         11 $obj->{contexts} = $ctx;
459             }
460 7 100       10 if (my $pref = _make_pref($item)) { $obj->{pref} = $pref }
  2         4  
461 7         13 _apply_label($obj, $item, $labels);
462              
463 7         18 $map{$id} = $obj;
464             }
465              
466 7 50       29 $card->{addresses} = \%map if %map;
467             }
468              
469             # ORG -> organizations
470             sub _convert_organizations {
471 35     35   37 my ($card, $props, $new_id) = @_;
472 35   100     62 my $items = $props->{org} || return;
473              
474 8         9 my %map;
475 8         14 for my $item (@$items) {
476 8         14 my $id = _prop_id($item, $new_id);
477 8   50     39 my $values = $item->{values} || [];
478 8         18 my $obj = { '@type' => 'Organization' };
479              
480             # First component -> name
481 8 50 33     36 my @name_vals = ref $values->[0] eq 'ARRAY' ? @{$values->[0]} : ($values->[0] // ());
  0         0  
482 8 50       13 my $org_name = join(' ', grep { defined $_ && $_ ne '' } @name_vals);
  8         43  
483 8 50       24 $obj->{name} = $org_name if $org_name ne '';
484              
485             # Subsequent components -> units
486 8         11 my @units;
487 8         20 for my $i (1..$#$values) {
488 6 50       16 my @vals = ref $values->[$i] eq 'ARRAY' ? @{$values->[$i]} : ($values->[$i]);
  0         0  
489 6         6 for my $v (@vals) {
490 6 50 33     17 next unless defined $v && $v ne '';
491 6         14 push @units, { '@type' => 'OrgUnit', name => _decode_value($v) };
492             }
493             }
494 8 100       32 $obj->{units} = \@units if @units;
495              
496             # SORT-AS
497 8         13 my $sort_as = _param($item, 'sort-as');
498 8 100       15 if (defined $sort_as) {
499 1         2 my @sa = split /,/, $sort_as;
500 1 50 33     5 $obj->{sortAs} = $sa[0] if defined $sa[0] && $sa[0] ne '';
501 1         2 for my $i (0..$#units) {
502 2 50 33     5 $units[$i]{sortAs} = $sa[$i+1] if defined $sa[$i+1] && $sa[$i+1] ne '';
503             }
504             }
505              
506 8         26 my @types = _params_list($item, 'type');
507 8 50       12 if (my $ctx = _make_contexts(@types)) {
508 0         0 $obj->{contexts} = $ctx;
509             }
510              
511 8         21 $map{$id} = $obj;
512             }
513              
514 8 50       31 $card->{organizations} = \%map if %map;
515             }
516              
517             # TITLE + ROLE -> titles
518             sub _convert_titles {
519 35     35   39 my ($card, $props, $new_id) = @_;
520              
521 35         29 my %map;
522 35         98 for my $spec (['title', 'title'], ['role', 'role']) {
523 70         111 my ($propname, $kind) = @$spec;
524 70   100     55 for my $item (@{$props->{$propname} // []}) {
  70         178  
525 6         11 my $val = _decode_value($item->{value});
526 6 50 33     18 next unless defined $val && $val ne '';
527 6         8 my $id = _prop_id($item, $new_id);
528 6         30 $map{$id} = { '@type' => 'Title', kind => $kind, name => $val };
529             }
530             }
531              
532 35 100       84 $card->{titles} = \%map if %map;
533             }
534              
535             # BDAY, ANNIVERSARY, DEATHDATE, X-ABDATE -> anniversaries
536             sub _convert_anniversaries {
537 35     35   36 my ($card, $props, $new_id) = @_;
538 35         41 my $labels = _collect_labels($props);
539              
540 35         28 my %map;
541 35         90 for my $spec (['bday', 'birth'], ['anniversary', 'wedding'], ['deathdate', 'death']) {
542 105         113 my ($propname, $kind) = @$spec;
543 105   100     78 for my $item (@{$props->{$propname} // []}) {
  105         198  
544 11         16 my $val = $item->{value};
545 11 50 33     40 next unless defined $val && $val ne '';
546 11         17 my $id = _prop_id($item, $new_id);
547 11         32 $map{$id} = {
548             '@type' => 'Anniversary',
549             kind => $kind,
550             date => _convert_vcard_date($val),
551             };
552             }
553             }
554              
555             # Apple X-ABDATE + X-ABLabel -> anniversaries
556 35   100     51 for my $item (@{$props->{'x-abdate'} // []}) {
  35         73  
557 2         4 my $val = $item->{value};
558 2 50 33     7 next unless defined $val && $val ne '';
559 2         4 my $id = _prop_id($item, $new_id);
560              
561 2         3 my $kind = 'other';
562 2 50       4 if (my $group = $item->{group}) {
563 2         2 my $label = $labels->{$group};
564 2 50       3 if ($label) {
565 2         3 my $lt = lc $label;
566 2 100       5 if ($lt eq 'anniversary') { $kind = 'wedding' }
  1 50       2  
567 0         0 elsif ($lt eq 'other') { $kind = 'other' }
568 1         2 else { $kind = $lt }
569             }
570             }
571              
572 2         3 $map{$id} = {
573             '@type' => 'Anniversary',
574             kind => $kind,
575             date => _convert_vcard_date($val),
576             };
577             }
578              
579 35 100       74 $card->{anniversaries} = \%map if %map;
580             }
581              
582             # NOTE -> notes
583             sub _convert_notes {
584 35     35   34 my ($card, $props, $new_id) = @_;
585 35   100     55 my $items = $props->{note} || return;
586              
587 8         11 my %map;
588 8         12 for my $item (@$items) {
589 8         14 my $val = _decode_value($item->{value});
590 8 50 33     26 next unless defined $val && $val ne '';
591 8         31 my $id = _prop_id($item, $new_id);
592 8         17 my $obj = { '@type' => 'Note', note => $val };
593              
594 8         12 my $created = _param($item, 'created');
595 8 100       15 $obj->{created} = _to_utc_datetime($created) if defined $created;
596              
597 8         50 my $author_name = _param($item, 'author-name');
598 8         35 my $author_uri = _param($item, 'author');
599 8 100 66     28 if ($author_name || $author_uri) {
600 1         1 my $author = {};
601 1 50       16 $author->{name} = $author_name if $author_name;
602 1 50       2 $author->{uri} = $author_uri if $author_uri;
603 1         2 $obj->{author} = $author;
604             }
605              
606 8         19 $map{$id} = $obj;
607             }
608              
609 8 50       17 $card->{notes} = \%map if %map;
610             }
611              
612             # IMPP, SOCIALPROFILE, X-SOCIALPROFILE -> onlineServices
613             sub _convert_online_services {
614 35     35   36 my ($card, $props, $new_id) = @_;
615 35         60 my $labels = _collect_labels($props);
616              
617 35         32 my %map;
618              
619 35   100     31 for my $item (@{$props->{impp} // []}) {
  35         76  
620 7         9 my $val = $item->{value};
621 7 50 33     15 next unless defined $val && $val ne '';
622 7         10 my $id = _prop_id($item, $new_id);
623 7         11 my $obj = { '@type' => 'OnlineService', uri => $val };
624 7         7 my $service = _param($item, 'x-service-type');
625 7 100       8 $obj->{service} = $service if defined $service;
626 7         9 my $user = _param($item, 'x-user');
627 7 50       8 $obj->{user} = $user if defined $user;
628 7         8 my @types = _params_list($item, 'type');
629 7 50       10 if (my $ctx = _make_contexts(@types)) { $obj->{contexts} = $ctx }
  0         0  
630 7 100       8 if (my $pref = _make_pref($item)) { $obj->{pref} = $pref }
  3         6  
631 7         15 _apply_label($obj, $item, $labels);
632 7         10 $map{$id} = $obj;
633             }
634              
635 35         42 for my $propname (qw(socialprofile x-socialprofile)) {
636 70   100     51 for my $item (@{$props->{$propname} // []}) {
  70         141  
637 3         4 my $val = $item->{value};
638 3 50 33     11 next unless defined $val && $val ne '';
639 3         4 my $id = _prop_id($item, $new_id);
640 3         6 my $obj = { '@type' => 'OnlineService' };
641              
642 3 50       15 if ($val =~ m{://}) { $obj->{uri} = $val }
  3         10  
643 0         0 else { $obj->{user} = $val }
644              
645 3   66     5 my $service = _param($item, 'service-type') // _param($item, 'x-service-type');
646 3 100       7 unless ($service) {
647 2         2 for my $t (_params_list($item, 'type')) {
648 2         2 my $lt = lc $t;
649 2 50 33     8 if ($lt ne 'home' && $lt ne 'work' && $lt ne 'pref') {
      33        
650 2         2 $service = $lt;
651 2         3 last;
652             }
653             }
654             }
655 3 50       6 $obj->{service} = $service if defined $service;
656              
657 3   66     5 my $user = _param($item, 'x-user') // _param($item, 'username');
658 3 100 66     9 $obj->{user} = $user if defined $user && !$obj->{user};
659              
660 3         4 my @types = _params_list($item, 'type');
661 3 50       4 if (my $ctx = _make_contexts(@types)) { $obj->{contexts} = $ctx }
  0         0  
662 3 50       5 if (my $pref = _make_pref($item)) { $obj->{pref} = $pref }
  0         0  
663 3         4 _apply_label($obj, $item, $labels);
664 3         7 $map{$id} = $obj;
665             }
666             }
667              
668             # Legacy X-service IM properties (Apple/others)
669 35         177 my %xservice_map = (
670             'x-aim' => 'AIM',
671             'x-icq' => 'ICQ',
672             'x-msn' => 'MSN',
673             'x-yahoo' => 'Yahoo',
674             'x-jabber' => 'Jabber',
675             'x-skype' => 'Skype',
676             'x-skype-username' => 'Skype',
677             'x-twitter' => 'Twitter',
678             'x-google-talk' => 'GoogleTalk',
679             );
680 35         169 for my $propname (sort keys %xservice_map) {
681 315   100     202 for my $item (@{$props->{$propname} // []}) {
  315         542  
682 2         2 my $val = $item->{value};
683 2 50 33     5 next unless defined $val && $val ne '';
684 2         3 my $id = _prop_id($item, $new_id);
685 2         5 my $obj = { '@type' => 'OnlineService', user => $val, service => $xservice_map{$propname} };
686 2         3 my @types = _params_list($item, 'type');
687 2 50       5 if (my $ctx = _make_contexts(@types)) { $obj->{contexts} = $ctx }
  0         0  
688 2 50       2 if (my $pref = _make_pref($item)) { $obj->{pref} = $pref }
  0         0  
689 2         5 _apply_label($obj, $item, $labels);
690 2         2 $map{$id} = $obj;
691             }
692             }
693              
694 35 100       122 $card->{onlineServices} = \%map if %map;
695             }
696              
697             # PHOTO, LOGO, SOUND -> media
698             sub _convert_media {
699 35     35   38 my ($card, $props, $new_id) = @_;
700 35         39 my $labels = _collect_labels($props);
701              
702 35         40 my %map;
703 35         63 for my $spec (['photo', 'photo'], ['logo', 'logo'], ['sound', 'sound']) {
704 105         99 my ($propname, $kind) = @$spec;
705 105   100     76 for my $item (@{$props->{$propname} // []}) {
  105         235  
706 4         8 my $val = $item->{value};
707 4 50 33     15 next unless defined $val && $val ne '';
708 4         8 my $id = _prop_id($item, $new_id);
709 4         11 my $obj = { '@type' => 'Media', kind => $kind };
710              
711 4         6 my $encoding = _param($item, 'encoding');
712 4 50 33     11 if ($encoding && lc($encoding) eq 'b') {
713 0   0     0 my $mediatype = _param($item, 'mediatype') // _param($item, 'type');
714 0 0 0     0 $mediatype = "image/$mediatype" if $mediatype && $mediatype !~ m{/};
715 0   0     0 $mediatype //= 'application/octet-stream';
716 0         0 $obj->{uri} = "data:$mediatype;base64," . encode_base64($val, '');
717 0         0 $obj->{mediaType} = $mediatype;
718             } else {
719 4         7 $obj->{uri} = $val;
720 4         5 my $mt = _param($item, 'mediatype');
721 4 100       10 $obj->{mediaType} = $mt if defined $mt;
722             }
723              
724 4 50       11 if (my $pref = _make_pref($item)) { $obj->{pref} = $pref }
  0         0  
725 4         14 _apply_label($obj, $item, $labels);
726 4         28 $map{$id} = $obj;
727             }
728             }
729              
730 35 100       101 $card->{media} = \%map if %map;
731             }
732              
733             # Generic resource converter for URI-based properties
734             sub _convert_resource_to {
735 280     280   328 my ($card, $key, $props, $propname, $kind, $new_id, $type) = @_;
736 280         232 my $labels = _collect_labels($props);
737              
738 280         197 my %map;
739 280   100     212 for my $item (@{$props->{$propname} // []}) {
  280         501  
740 8         10 my $val = $item->{value};
741 8 50 33     26 next unless defined $val && $val ne '';
742 8         12 my $id = _prop_id($item, $new_id);
743 8         17 my $obj = { '@type' => $type, uri => $val };
744 8 100       12 $obj->{kind} = $kind if defined $kind;
745 8         12 my $mt = _param($item, 'mediatype');
746 8 50       25 $obj->{mediaType} = $mt if defined $mt;
747 8         11 my @types = _params_list($item, 'type');
748 8 100       11 if (my $ctx = _make_contexts(@types)) { $obj->{contexts} = $ctx }
  2         2  
749 8 100       13 if (my $pref = _make_pref($item)) { $obj->{pref} = $pref }
  1         2  
750 8         14 _apply_label($obj, $item, $labels);
751 8         13 $map{$id} = $obj;
752             }
753              
754 280 100       447 if (%map) {
755 8   50     7 $card->{$key} = { %{$card->{$key} // {}}, %map };
  8         50  
756             }
757             }
758              
759             # URL, FBURL, CONTACT-URI -> links
760             sub _convert_links {
761 35     35   60 my ($card, $props, $new_id) = @_;
762 35         68 _convert_resource_to($card, 'links', $props, 'url', undef, $new_id, 'Link');
763 35         64 _convert_resource_to($card, 'links', $props, 'fburl', 'freeBusy', $new_id, 'Link');
764 35         43 _convert_resource_to($card, 'links', $props, 'contact-uri', 'contact', $new_id, 'Link');
765             }
766              
767             # CALURI -> calendars
768             sub _convert_calendars {
769 35     35   38 my ($card, $props, $new_id) = @_;
770 35         41 _convert_resource_to($card, 'calendars', $props, 'caluri', undef, $new_id, 'Calendar');
771             }
772              
773             # CALADRURI -> schedulingAddresses
774             sub _convert_scheduling_addresses {
775 35     35   38 my ($card, $props, $new_id) = @_;
776 35         58 _convert_resource_to($card, 'schedulingAddresses', $props, 'caladruri', undef, $new_id, 'SchedulingAddress');
777             }
778              
779             # KEY -> cryptoKeys
780             sub _convert_crypto_keys {
781 35     35   35 my ($card, $props, $new_id) = @_;
782 35         42 _convert_resource_to($card, 'cryptoKeys', $props, 'key', undef, $new_id, 'CryptoKey');
783             }
784              
785             # SOURCE, ORG-DIRECTORY -> directories
786             sub _convert_directories {
787 35     35   38 my ($card, $props, $new_id) = @_;
788 35         48 _convert_resource_to($card, 'directories', $props, 'source', 'entry', $new_id, 'Directory');
789 35         38 _convert_resource_to($card, 'directories', $props, 'org-directory', 'directory', $new_id, 'Directory');
790             }
791              
792             # LANG -> preferredLanguages
793             sub _convert_preferred_languages {
794 35     35   36 my ($card, $props, $new_id) = @_;
795 35   100     72 my $items = $props->{lang} || return;
796              
797 1         2 my %map;
798 1         2 for my $item (@$items) {
799 3         4 my $val = $item->{value};
800 3 50 33     9 next unless defined $val && $val ne '';
801 3         3 my $id = _prop_id($item, $new_id);
802 3         19 my $obj = { '@type' => 'LanguagePref', language => $val };
803 3         4 my @types = _params_list($item, 'type');
804 3 50       4 if (my $ctx = _make_contexts(@types)) { $obj->{contexts} = $ctx }
  3         2  
805 3 100       4 if (my $pref = _make_pref($item)) { $obj->{pref} = $pref }
  2         2  
806 3         6 $map{$id} = $obj;
807             }
808              
809 1 50       32 $card->{preferredLanguages} = \%map if %map;
810             }
811              
812             # EXPERTISE, HOBBY, INTEREST -> personalInfo
813             sub _convert_personal_info {
814 35     35   33 my ($card, $props, $new_id) = @_;
815              
816 35         82 my %level_map = (beginner => 'low', average => 'medium', expert => 'high');
817 35         38 my %map;
818              
819 35         66 for my $spec (['expertise', 'expertise'], ['hobby', 'hobby'], ['interest', 'interest']) {
820 105         150 my ($propname, $kind) = @$spec;
821 105   100     90 for my $item (@{$props->{$propname} // []}) {
  105         200  
822 2         4 my $val = _decode_value($item->{value});
823 2 50 33     19 next unless defined $val && $val ne '';
824 2         3 my $id = _prop_id($item, $new_id);
825 2         4 my $obj = { '@type' => 'PersonalInfo', kind => $kind, value => $val };
826              
827 2         4 my $level = _param($item, 'level');
828 2 50       4 if ($level) {
829 2   33     6 $obj->{level} = $level_map{lc $level} // lc $level;
830             }
831              
832 2         2 my $index = _param($item, 'index');
833 2 50       5 $obj->{listAs} = $index + 0 if defined $index;
834              
835 2         5 $map{$id} = $obj;
836             }
837             }
838              
839 35 100       89 $card->{personalInfo} = \%map if %map;
840             }
841              
842             # CATEGORIES -> keywords
843             sub _convert_keywords {
844 35     35   38 my ($card, $props) = @_;
845 35   100     55 my $items = $props->{categories} || return;
846              
847 3         3 my %keywords;
848 3         6 for my $item (@$items) {
849 3         5 my $val = $item->{value};
850 3 50       5 next unless defined $val;
851 3         9 for my $cat (split /,/, $val) {
852 8         26 $cat =~ s/^\s+//;
853 8         12 $cat =~ s/\s+$//;
854 8 50       18 $keywords{$cat} = JSON::true if $cat ne '';
855             }
856             }
857              
858 3 50       14 $card->{keywords} = \%keywords if %keywords;
859             }
860              
861             # MEMBER / X-ADDRESSBOOKSERVER-MEMBER -> members
862             sub _convert_members {
863 35     35   37 my ($card, $props) = @_;
864              
865 35         31 my %members;
866 35         34 for my $propname (qw(member x-addressbookserver-member)) {
867 70   100     63 for my $item (@{$props->{$propname} // []}) {
  70         175  
868 10         26 my $val = $item->{value};
869 10 50 33     29 next unless defined $val && $val ne '';
870 10         19 $members{$val} = JSON::true;
871             }
872             }
873              
874 35 100       61 $card->{members} = \%members if %members;
875             }
876              
877             # RELATED / X-ABRELATEDNAMES -> relatedTo
878             sub _convert_related {
879 35     35   39 my ($card, $props) = @_;
880 35         40 my $labels = _collect_labels($props);
881              
882 35         32 my %related;
883              
884             # Standard RELATED property
885 35   100     28 for my $item (@{$props->{related} // []}) {
  35         87  
886 5         7 my $val = $item->{value};
887 5 50 33     19 next unless defined $val && $val ne '';
888              
889 5         9 my $rel = { '@type' => 'Relation' };
890 5         4 my %relation;
891 5         9 for my $t (_params_list($item, 'type')) {
892 5         9 my $lt = lc $t;
893 5 50       27 $relation{$lt} = JSON::true
894             if $lt =~ /^(acquaintance|agent|child|co-resident|co-worker|colleague|contact|crush|date|emergency|friend|kin|me|met|muse|neighbor|parent|sibling|spouse|sweetheart)$/;
895             }
896 5 50       23 $rel->{relation} = \%relation if %relation;
897 5         40 $related{$val} = $rel;
898             }
899              
900             # Apple X-ABRELATEDNAMES + X-ABLabel -> relatedTo
901 35         205 my %ablabel_relation = (
902             'mother' => 'parent', 'father' => 'parent', 'parent' => 'parent',
903             'brother' => 'sibling', 'sister' => 'sibling',
904             'child' => 'child',
905             'friend' => 'friend',
906             'spouse' => 'spouse', 'partner' => 'spouse',
907             'assistant' => 'colleague', 'manager' => 'colleague',
908             );
909 35   100     33 for my $item (@{$props->{'x-abrelatednames'} // []}) {
  35         103  
910 6         11 my $val = _decode_value($item->{value});
911 6 50 33     24 next unless defined $val && $val ne '';
912              
913 6         9 my $rel = { '@type' => 'Relation' };
914 6         7 my %relation;
915              
916             # Get relation type from X-ABLabel
917             my $label;
918 6 50       15 if (my $group = $item->{group}) {
919 6         20 $label = $labels->{$group};
920             }
921 6 50       9 if ($label) {
922 6         8 my $lt = lc $label;
923 6 50       28 if (my $mapped = $ablabel_relation{$lt}) {
924 6         11 $relation{$mapped} = JSON::true;
925             }
926             }
927 6 50       23 $rel->{relation} = \%relation if %relation;
928 6         13 $related{$val} = $rel;
929             }
930              
931 35 100       112 $card->{relatedTo} = \%related if %related;
932             }
933              
934             # GRAMGENDER, PRONOUNS (RFC 9554) -> speakToAs
935             sub _convert_speak_to_as {
936 35     35   38 my ($card, $props, $new_id) = @_;
937              
938 35         32 my $speak = {};
939              
940 35 100       46 if (my $gg = _first_value($props, 'gramgender')) {
941 1         4 $speak->{grammaticalGender} = lc $gg;
942             }
943              
944 35 100       60 if (my $items = $props->{pronouns}) {
945 1         2 my %pronouns;
946 1         1 for my $item (@$items) {
947 2         5 my $val = _decode_value($item->{value});
948 2 50 33     7 next unless defined $val && $val ne '';
949 2         3 my $id = _prop_id($item, $new_id);
950 2         3 my $obj = { '@type' => 'Pronouns', pronouns => $val };
951 2 50       4 if (my $ctx = _make_contexts(_params_list($item, 'type'))) {
952 0         0 $obj->{contexts} = $ctx;
953             }
954 2 50       3 if (my $pref = _make_pref($item)) { $obj->{pref} = $pref }
  2         2  
955 2         3 $pronouns{$id} = $obj;
956             }
957 1 50       3 $speak->{pronouns} = \%pronouns if %pronouns;
958             }
959              
960 35 100       79 $card->{speakToAs} = $speak if %$speak;
961             }
962              
963             ###############################################################################
964             # JSCONTACT -> VCARD
965             ###############################################################################
966              
967             sub jscontact_to_vcard {
968 2     2 0 41825 my ($card) = @_;
969              
970 2         9 _reset_groups();
971 2         3 my @props;
972              
973             # VERSION (always 4.0)
974 2         11 push @props, { name => 'version', value => '4.0' };
975              
976             # UID
977 2 50       12 push @props, { name => 'uid', value => $card->{uid} } if $card->{uid};
978              
979             # KIND
980             push @props, { name => 'kind', value => $card->{kind} }
981 2 50 33     9 if $card->{kind} && $card->{kind} ne 'individual';
982              
983             # PRODID
984 2 50       10 push @props, { name => 'prodid', value => $card->{prodId} } if $card->{prodId};
985              
986             # REV
987 2 100       10 push @props, { name => 'rev', value => $card->{updated} } if $card->{updated};
988              
989             # CREATED (RFC 9554)
990 2 50       6 push @props, { name => 'created', value => $card->{created} } if $card->{created};
991              
992             # LANGUAGE (RFC 9554)
993 2 50       6 push @props, { name => 'language', value => $card->{language} } if $card->{language};
994              
995             # Name -> N + FN
996 2         10 _unconvert_name(\@props, $card);
997              
998             # All map-based properties
999 2         8 _unconvert_nicknames(\@props, $card);
1000 2         7 _unconvert_emails(\@props, $card);
1001 2         6 _unconvert_phones(\@props, $card);
1002 2         6 _unconvert_addresses(\@props, $card);
1003 2         6 _unconvert_organizations(\@props, $card);
1004 2         6 _unconvert_titles(\@props, $card);
1005 2         6 _unconvert_anniversaries(\@props, $card);
1006 2         68 _unconvert_notes(\@props, $card);
1007 2         5 _unconvert_online_services(\@props, $card);
1008 2         6 _unconvert_media(\@props, $card);
1009 2         5 _unconvert_links(\@props, $card);
1010 2         6 _unconvert_calendars(\@props, $card);
1011 2         5 _unconvert_scheduling_addresses(\@props, $card);
1012 2         16 _unconvert_crypto_keys(\@props, $card);
1013 2         5 _unconvert_directories(\@props, $card);
1014 2         7 _unconvert_preferred_languages(\@props, $card);
1015 2         12 _unconvert_personal_info(\@props, $card);
1016 2         6 _unconvert_keywords(\@props, $card);
1017 2         5 _unconvert_members(\@props, $card);
1018 2         6 _unconvert_related(\@props, $card);
1019 2         6 _unconvert_speak_to_as(\@props, $card);
1020              
1021             # Build vcard hash structure
1022 2         3 my %by_name;
1023 2         2 for my $prop (@props) {
1024 51   100     66 $prop->{params} //= {};
1025 51         37 push @{$by_name{$prop->{name}}}, $prop;
  51         65  
1026             }
1027              
1028 2         7 my $vcard = {
1029             type => 'vcard',
1030             properties => \%by_name,
1031             };
1032              
1033 2         13 return hash2vcard({ objects => [$vcard] });
1034             }
1035              
1036             ###############################################################################
1037             # INDIVIDUAL JSCONTACT -> VCARD CONVERTERS
1038             ###############################################################################
1039              
1040             sub _contexts_to_types {
1041 10     10   9 my $contexts = shift;
1042 10 50 33     30 return () unless $contexts && ref $contexts eq 'HASH';
1043 10         11 my @types;
1044 10 100       48 push @types, 'home' if $contexts->{private};
1045 10 100       61 push @types, 'work' if $contexts->{work};
1046 10         35 return @types;
1047             }
1048              
1049             sub _add_pref_params {
1050 26     26   48 my ($params, $pref) = @_;
1051 26 100       34 $params->{pref} = [$pref] if defined $pref;
1052             }
1053              
1054             {
1055             my $group_counter = 0;
1056 7     7   15 sub _reset_groups { $group_counter = 0 }
1057              
1058             sub _add_label {
1059 13     13   20 my ($out, $prop, $obj) = @_;
1060 13         13 my $label = $obj->{label};
1061 13 100 66     34 return unless defined $label && $label ne '';
1062              
1063 4         5 $group_counter++;
1064 4         6 my $group = "item$group_counter";
1065 4         5 $prop->{group} = $group;
1066 4         10 push @$out, {
1067             name => 'x-ablabel',
1068             value => $label,
1069             params => {},
1070             group => $group,
1071             };
1072             }
1073             }
1074              
1075             sub _add_context_params {
1076 27     27   25 my ($params, $obj) = @_;
1077 27 100       61 if ($obj->{contexts}) {
1078 10         35 my @types = _contexts_to_types($obj->{contexts});
1079 10 50       17 push @{$params->{type}}, @types if @types;
  10         23  
1080             }
1081             }
1082              
1083             sub _unconvert_name {
1084 3     3   6 my ($out, $card) = @_;
1085 3   50     11 my $name = $card->{name} || return;
1086              
1087             # FN
1088 3 50       10 if (my $full = $name->{full}) {
1089 3         19 push @$out, { name => 'fn', value => $full, params => {} };
1090             }
1091              
1092             # N from components
1093 3 50       9 if (my $components = $name->{components}) {
1094 3         4 my %by_kind;
1095 3         7 for my $c (@$components) {
1096 12         12 push @{$by_kind{$c->{kind}}}, $c->{value};
  12         41  
1097             }
1098              
1099             # N order: surname, given, given2(additional), title(prefix), credential(suffix), surname2, generation
1100 3         6 my @n_values;
1101 3         7 for my $kind (qw(surname given given2 title credential surname2 generation)) {
1102 21         26 my $vals = $by_kind{$kind};
1103 21 100       37 push @n_values, $vals ? join(',', grep { defined $_ } @$vals) : '';
  12         31  
1104             }
1105              
1106             # Trim trailing empty components
1107 3   66     33 pop @n_values while @n_values && $n_values[-1] eq '';
1108              
1109 3         5 my $params = {};
1110 3 50 33     30 if ($name->{sortAs} && ref $name->{sortAs} eq 'HASH') {
1111 0         0 my @sa;
1112 0         0 for my $kind (qw(surname given given2 title credential surname2 generation)) {
1113 0   0     0 push @sa, $name->{sortAs}{$kind} // '';
1114             }
1115 0   0     0 pop @sa while @sa && $sa[-1] eq '';
1116 0 0       0 $params->{'sort-as'} = [join(',', @sa)] if @sa;
1117             }
1118              
1119 3         27 push @$out, { name => 'n', values => \@n_values, params => $params };
1120             }
1121              
1122             # Generate FN from components if no full name given
1123 3 0 33     12 if (!$name->{full} && $name->{components}) {
1124 0         0 my @parts;
1125 0         0 for my $c (@{$name->{components}}) {
  0         0  
1126 0 0 0     0 next if ($c->{kind} // '') eq 'separator';
1127 0         0 push @parts, $c->{value};
1128             }
1129 0 0       0 push @$out, { name => 'fn', value => join(' ', @parts), params => {} } if @parts;
1130             }
1131             }
1132              
1133             sub _unconvert_nicknames {
1134 2     2   5 my ($out, $card) = @_;
1135 2   50     7 my $nicknames = $card->{nicknames} || return;
1136              
1137 2         7 for my $id (sort keys %$nicknames) {
1138 2         5 my $nn = $nicknames->{$id};
1139 2         29 my $params = { 'prop-id' => [$id] };
1140 2         10 _add_context_params($params, $nn);
1141 2         9 _add_pref_params($params, $nn->{pref});
1142 2         10 push @$out, { name => 'nickname', value => $nn->{name}, params => $params };
1143             }
1144             }
1145              
1146             sub _unconvert_emails {
1147 3     3   6 my ($out, $card) = @_;
1148 3   50     8 my $emails = $card->{emails} || return;
1149              
1150 3         11 for my $id (sort keys %$emails) {
1151 8         10 my $em = $emails->{$id};
1152 8         13 my $params = { 'prop-id' => [$id] };
1153 8         15 _add_context_params($params, $em);
1154 8         40 _add_pref_params($params, $em->{pref});
1155 8         16 my $prop = { name => 'email', value => $em->{address}, params => $params };
1156 8         15 _add_label($out, $prop, $em);
1157 8         11 push @$out, $prop;
1158             }
1159             }
1160              
1161             sub _unconvert_phones {
1162 3     3   6 my ($out, $card) = @_;
1163 3   50     24 my $phones = $card->{phones} || return;
1164              
1165 3         20 my %feature_to_type = (
1166             mobile => 'cell', voice => 'voice', fax => 'fax',
1167             video => 'video', pager => 'pager', text => 'text',
1168             textphone => 'textphone',
1169             );
1170              
1171 3         10 for my $id (sort keys %$phones) {
1172 5         5 my $ph = $phones->{$id};
1173 5         10 my $params = { 'prop-id' => [$id] };
1174 5         12 _add_context_params($params, $ph);
1175 5         12 _add_pref_params($params, $ph->{pref});
1176              
1177 5 100       11 if ($ph->{features}) {
1178 4         5 for my $feat (sort keys %{$ph->{features}}) {
  4         9  
1179 4         5 my $type = $feature_to_type{$feat};
1180 4 50       14 push @{$params->{type}}, $type if $type;
  4         7  
1181             }
1182             }
1183              
1184 5         12 my $prop = { name => 'tel', value => $ph->{number}, params => $params };
1185 5         24 _add_label($out, $prop, $ph);
1186 5         33 push @$out, $prop;
1187             }
1188             }
1189              
1190             sub _unconvert_addresses {
1191 2     2   4 my ($out, $card) = @_;
1192 2   50     10 my $addresses = $card->{addresses} || return;
1193              
1194 2         16 my %kind_to_idx = (
1195             name => 2, apartment => 1,
1196             locality => 3, region => 4, postcode => 5, country => 6,
1197             );
1198              
1199 2         5 for my $id (sort keys %$addresses) {
1200 2         2 my $adr = $addresses->{$id};
1201 2         6 my $params = { 'prop-id' => [$id] };
1202 2         4 _add_context_params($params, $adr);
1203 2         4 _add_pref_params($params, $adr->{pref});
1204              
1205 2 50       5 $params->{label} = [$adr->{full}] if $adr->{full};
1206 2 50       6 $params->{geo} = [$adr->{coordinates}] if $adr->{coordinates};
1207 2 50       8 $params->{tz} = [$adr->{timeZone}] if $adr->{timeZone};
1208 2 100       7 $params->{cc} = [$adr->{countryCode}] if $adr->{countryCode};
1209              
1210             # Build ADR values (7 standard components)
1211 2         7 my @values = ('') x 7;
1212              
1213 2 50       6 if (my $components = $adr->{components}) {
1214 2         2 my %by_idx;
1215 2         4 for my $c (@$components) {
1216 10 50 50     27 next if ($c->{kind} // '') eq 'separator';
1217 10         14 my $idx = $kind_to_idx{$c->{kind}};
1218 10 50       12 push @{$by_idx{$idx}}, $c->{value} if defined $idx;
  10         50  
1219             }
1220 2         5 for my $idx (keys %by_idx) {
1221 10         7 $values[$idx] = join(',', @{$by_idx{$idx}});
  10         21  
1222             }
1223             }
1224              
1225 2         10 push @$out, { name => 'adr', values => \@values, params => $params };
1226             }
1227             }
1228              
1229             sub _unconvert_organizations {
1230 2     2   4 my ($out, $card) = @_;
1231 2   50     6 my $orgs = $card->{organizations} || return;
1232              
1233 2         4 for my $id (sort keys %$orgs) {
1234 2         3 my $org = $orgs->{$id};
1235 2         4 my $params = { 'prop-id' => [$id] };
1236 2         4 _add_context_params($params, $org);
1237              
1238 2   50     5 my @values = ($org->{name} // '');
1239 2 50       6 if ($org->{units}) {
1240 2         2 for my $unit (@{$org->{units}}) {
  2         4  
1241 2         3 push @values, $unit->{name};
1242             }
1243             }
1244              
1245 2 50       9 if (defined $org->{sortAs}) {
1246 0         0 my @sa = ($org->{sortAs});
1247 0 0       0 if ($org->{units}) {
1248 0         0 for my $unit (@{$org->{units}}) {
  0         0  
1249 0   0     0 push @sa, $unit->{sortAs} // '';
1250             }
1251             }
1252 0   0     0 pop @sa while @sa && $sa[-1] eq '';
1253 0 0       0 $params->{'sort-as'} = [join(',', @sa)] if @sa;
1254             }
1255              
1256 2         14 push @$out, { name => 'org', values => \@values, params => $params };
1257             }
1258             }
1259              
1260             sub _unconvert_titles {
1261 2     2   7 my ($out, $card) = @_;
1262 2   50     5 my $titles = $card->{titles} || return;
1263              
1264 2         11 for my $id (sort keys %$titles) {
1265 3         4 my $t = $titles->{$id};
1266 3 100 50     11 my $propname = ($t->{kind} // 'title') eq 'role' ? 'role' : 'title';
1267 3         29 my $params = { 'prop-id' => [$id] };
1268 3         10 push @$out, { name => $propname, value => $t->{name}, params => $params };
1269             }
1270             }
1271              
1272             sub _unconvert_anniversaries {
1273 2     2   3 my ($out, $card) = @_;
1274 2   50     5 my $anns = $card->{anniversaries} || return;
1275              
1276 2         7 my %kind_to_prop = (birth => 'bday', wedding => 'anniversary', death => 'deathdate');
1277              
1278 2         5 for my $id (sort keys %$anns) {
1279 4         4 my $ann = $anns->{$id};
1280 4   50     12 my $kind = $ann->{kind} // '';
1281 4   100     10 my $propname = $kind_to_prop{$kind} // 'anniversary';
1282 4         7 my $params = { 'prop-id' => [$id] };
1283              
1284 4   50     8 my $date = $ann->{date} // '';
1285             # Convert unknown-year dates to vCard 4 format
1286 4 50       10 $date = "--$1$2" if $date =~ /^0000-(\d{2})-(\d{2})/;
1287              
1288 4         19 push @$out, { name => $propname, value => $date, params => $params };
1289             }
1290             }
1291              
1292             sub _unconvert_notes {
1293 3     3   6 my ($out, $card) = @_;
1294 3   100     7 my $notes = $card->{notes} || return;
1295              
1296 2         4 for my $id (sort keys %$notes) {
1297 2         4 my $note = $notes->{$id};
1298 2         9 my $params = { 'prop-id' => [$id] };
1299              
1300 2 50       6 if ($note->{created}) {
1301 0         0 $params->{created} = [$note->{created}];
1302             }
1303 2 50       6 if ($note->{author}) {
1304 0 0       0 $params->{'author-name'} = [$note->{author}{name}] if $note->{author}{name};
1305 0 0       0 $params->{author} = [$note->{author}{uri}] if $note->{author}{uri};
1306             }
1307              
1308 2         7 push @$out, { name => 'note', value => $note->{note}, params => $params };
1309             }
1310             }
1311              
1312             sub _unconvert_online_services {
1313 2     2   4 my ($out, $card) = @_;
1314 2   100     12 my $services = $card->{onlineServices} || return;
1315              
1316 1         4 for my $id (sort keys %$services) {
1317 5         5 my $svc = $services->{$id};
1318 5         5 my $params = { 'prop-id' => [$id] };
1319 5         9 _add_context_params($params, $svc);
1320 5         13 _add_pref_params($params, $svc->{pref});
1321              
1322 5 50       10 $params->{'service-type'} = [$svc->{service}] if $svc->{service};
1323 5 100       9 $params->{username} = [$svc->{user}] if $svc->{user};
1324              
1325 5   66     9 my $uri = $svc->{uri} // $svc->{user} // '';
      50        
1326 5         10 push @$out, { name => 'impp', value => $uri, params => $params };
1327             }
1328             }
1329              
1330             sub _unconvert_media {
1331 2     2   4 my ($out, $card) = @_;
1332 2   100     5 my $media = $card->{media} || return;
1333              
1334 1         4 my %kind_to_prop = (photo => 'photo', logo => 'logo', sound => 'sound');
1335              
1336 1         2 for my $id (sort keys %$media) {
1337 1         1 my $m = $media->{$id};
1338 1   50     3 my $propname = $kind_to_prop{$m->{kind} // 'photo'} // 'photo';
      50        
1339 1         7 my $params = { 'prop-id' => [$id] };
1340 1         8 _add_pref_params($params, $m->{pref});
1341 1 50       4 $params->{mediatype} = [$m->{mediaType}] if $m->{mediaType};
1342 1         3 push @$out, { name => $propname, value => $m->{uri}, params => $params };
1343             }
1344             }
1345              
1346             sub _unconvert_links {
1347 2     2   4 my ($out, $card) = @_;
1348 2   50     4 my $links = $card->{links} || return;
1349              
1350 2         10 for my $id (sort keys %$links) {
1351 2         3 my $link = $links->{$id};
1352 2   50     8 my $kind = $link->{kind} // '';
1353 2 50       8 my $propname = $kind eq 'freeBusy' ? 'fburl'
    50          
1354             : $kind eq 'contact' ? 'contact-uri'
1355             : 'url';
1356 2         4 my $params = { 'prop-id' => [$id] };
1357 2         4 _add_context_params($params, $link);
1358 2         4 _add_pref_params($params, $link->{pref});
1359 2 50       5 $params->{mediatype} = [$link->{mediaType}] if $link->{mediaType};
1360 2         11 push @$out, { name => $propname, value => $link->{uri}, params => $params };
1361             }
1362             }
1363              
1364             sub _unconvert_resource {
1365 6     6   11 my ($out, $card, $key, $propname) = @_;
1366 6   100     12 my $resources = $card->{$key} || return;
1367              
1368 1         2 for my $id (sort keys %$resources) {
1369 1         1 my $r = $resources->{$id};
1370 1         3 my $params = { 'prop-id' => [$id] };
1371 1         2 _add_context_params($params, $r);
1372 1         2 _add_pref_params($params, $r->{pref});
1373 1 50       3 $params->{mediatype} = [$r->{mediaType}] if $r->{mediaType};
1374 1         2 push @$out, { name => $propname, value => $r->{uri}, params => $params };
1375             }
1376             }
1377              
1378             sub _unconvert_calendars {
1379 2     2   44 _unconvert_resource($_[0], $_[1], 'calendars', 'caluri');
1380             }
1381              
1382             sub _unconvert_scheduling_addresses {
1383 2     2   9 _unconvert_resource($_[0], $_[1], 'schedulingAddresses', 'caladruri');
1384             }
1385              
1386             sub _unconvert_crypto_keys {
1387 2     2   6 _unconvert_resource($_[0], $_[1], 'cryptoKeys', 'key');
1388             }
1389              
1390             sub _unconvert_directories {
1391 2     2   3 my ($out, $card) = @_;
1392 2   50     4 my $dirs = $card->{directories} || return;
1393              
1394 0         0 for my $id (sort keys %$dirs) {
1395 0         0 my $dir = $dirs->{$id};
1396 0   0     0 my $kind = $dir->{kind} // '';
1397 0 0       0 my $propname = $kind eq 'entry' ? 'source' : 'org-directory';
1398 0         0 my $params = { 'prop-id' => [$id] };
1399 0         0 _add_context_params($params, $dir);
1400 0         0 _add_pref_params($params, $dir->{pref});
1401 0 0       0 $params->{mediatype} = [$dir->{mediaType}] if $dir->{mediaType};
1402 0         0 push @$out, { name => $propname, value => $dir->{uri}, params => $params };
1403             }
1404             }
1405              
1406             sub _unconvert_preferred_languages {
1407 2     2   4 my ($out, $card) = @_;
1408 2   50     4 my $langs = $card->{preferredLanguages} || return;
1409              
1410 0         0 for my $id (sort keys %$langs) {
1411 0         0 my $lp = $langs->{$id};
1412 0         0 my $params = { 'prop-id' => [$id] };
1413 0         0 _add_context_params($params, $lp);
1414 0         0 _add_pref_params($params, $lp->{pref});
1415 0         0 push @$out, { name => 'lang', value => $lp->{language}, params => $params };
1416             }
1417             }
1418              
1419             sub _unconvert_personal_info {
1420 2     2   4 my ($out, $card) = @_;
1421 2   50     4 my $info = $card->{personalInfo} || return;
1422              
1423 0         0 my %level_map = (low => 'beginner', medium => 'average', high => 'expert');
1424              
1425 0         0 for my $id (sort keys %$info) {
1426 0         0 my $pi = $info->{$id};
1427 0   0     0 my $propname = $pi->{kind} // 'expertise';
1428 0         0 my $params = { 'prop-id' => [$id] };
1429 0 0       0 if ($pi->{level}) {
1430 0   0     0 $params->{level} = [$level_map{$pi->{level}} // $pi->{level}];
1431             }
1432 0 0       0 if (defined $pi->{listAs}) {
1433 0         0 $params->{index} = [$pi->{listAs}];
1434             }
1435 0         0 push @$out, { name => $propname, value => $pi->{value}, params => $params };
1436             }
1437             }
1438              
1439             sub _unconvert_keywords {
1440 2     2   2 my ($out, $card) = @_;
1441 2   100     5 my $keywords = $card->{keywords} || return;
1442 1 50 33     9 return unless ref $keywords eq 'HASH' && %$keywords;
1443 1         6 push @$out, { name => 'categories', value => join(',', sort keys %$keywords), params => {} };
1444             }
1445              
1446             sub _unconvert_members {
1447 2     2   4 my ($out, $card) = @_;
1448 2   50     5 my $members = $card->{members} || return;
1449 0         0 for my $uid (sort keys %$members) {
1450 0         0 push @$out, { name => 'member', value => $uid, params => {} };
1451             }
1452             }
1453              
1454             sub _unconvert_members_apple {
1455 1     1   2 my ($out, $card) = @_;
1456 1   50     2 my $members = $card->{members} || return;
1457 1         4 for my $uid (sort keys %$members) {
1458             # Apple uses urn:uuid: prefix for member UIDs
1459 3         4 my $val = $uid;
1460 3 50       5 $val = "urn:uuid:$val" unless $val =~ /^urn:uuid:/;
1461 3         7 push @$out, { name => 'x-addressbookserver-member', value => $val, params => {} };
1462             }
1463             }
1464              
1465             sub _unconvert_related {
1466 2     2   4 my ($out, $card) = @_;
1467 2   100     4 my $related = $card->{relatedTo} || return;
1468              
1469 1         3 for my $uid (sort keys %$related) {
1470 3         4 my $rel = $related->{$uid};
1471 3         1 my $params = {};
1472 3 50 33     15 if ($rel->{relation} && ref $rel->{relation} eq 'HASH') {
1473 3         3 my @types = sort keys %{$rel->{relation}};
  3         6  
1474 3 50       5 $params->{type} = \@types if @types;
1475             }
1476 3         7 push @$out, { name => 'related', value => $uid, params => $params };
1477             }
1478             }
1479              
1480             sub _unconvert_speak_to_as {
1481 2     2   5 my ($out, $card) = @_;
1482 2   50     58 my $sta = $card->{speakToAs} || return;
1483              
1484 0 0       0 if ($sta->{grammaticalGender}) {
1485 0         0 push @$out, { name => 'gramgender', value => $sta->{grammaticalGender}, params => {} };
1486             }
1487              
1488 0 0       0 if ($sta->{pronouns}) {
1489 0         0 for my $id (sort keys %{$sta->{pronouns}}) {
  0         0  
1490 0         0 my $p = $sta->{pronouns}{$id};
1491 0         0 my $params = { 'prop-id' => [$id] };
1492 0         0 _add_context_params($params, $p);
1493 0         0 _add_pref_params($params, $p->{pref});
1494 0         0 push @$out, { name => 'pronouns', value => $p->{pronouns}, params => $params };
1495             }
1496             }
1497             }
1498              
1499             ###############################################################################
1500             # PATCH: Apply JSContact changes to an existing vCard with minimal disruption
1501             ###############################################################################
1502              
1503             sub patch_vcard {
1504 6     6 0 5918 my ($original_vcard, $old_card, $new_card) = @_;
1505              
1506             # Determine which top-level JSContact properties changed
1507 6         15 my %changed = _diff_cards($old_card, $new_card);
1508              
1509             # If nothing changed, return original
1510 6 100       33 return $original_vcard unless %changed;
1511              
1512             # Parse the original vCard into its raw structure
1513 5         4 my $parsed = eval { vcard2hash($original_vcard, multival => [qw(n adr org)]) };
  5         18  
1514 5 50       294 return jscontact_to_vcard($new_card) unless $parsed;
1515              
1516 5         10 my $vcard = $parsed->{objects}[0];
1517 5 50 33     16 return jscontact_to_vcard($new_card) unless $vcard && $vcard->{type} eq 'vcard';
1518              
1519 5         5 my $props = $vcard->{properties};
1520              
1521             # Detect which property names the original vCard actually uses, so we
1522             # generate matching ones (e.g. X-ADDRESSBOOKSERVER-MEMBER vs MEMBER)
1523 5         5 my %original_has;
1524 5         28 for my $name (keys %$props) {
1525 66 50 33     72 $original_has{lc $name} = 1 if $props->{$name} && @{$props->{$name}};
  66         119  
1526             }
1527              
1528             # For each changed property, regenerate just that part
1529             # Map JSContact property names to the vCard properties they affect
1530 5         117 my %jscontact_to_vcard_props = (
1531             name => [qw(fn n x-phonetic-first-name x-phonetic-middle-name x-phonetic-last-name)],
1532             nicknames => [qw(nickname)],
1533             emails => [qw(email)],
1534             phones => [qw(tel)],
1535             addresses => [qw(adr x-abadr)],
1536             organizations => [qw(org)],
1537             titles => [qw(title role)],
1538             anniversaries => [qw(bday anniversary deathdate x-abdate)],
1539             notes => [qw(note)],
1540             onlineServices => [qw(impp socialprofile x-socialprofile x-aim x-icq x-msn x-yahoo x-jabber x-skype x-twitter x-google-talk x-skype-username)],
1541             media => [qw(photo logo sound)],
1542             links => [qw(url fburl contact-uri)],
1543             calendars => [qw(caluri)],
1544             schedulingAddresses => [qw(caladruri)],
1545             cryptoKeys => [qw(key)],
1546             directories => [qw(source org-directory)],
1547             keywords => [qw(categories)],
1548             members => [qw(member x-addressbookserver-member)],
1549             relatedTo => [qw(related x-abrelatednames)],
1550             speakToAs => [qw(gramgender pronouns)],
1551             preferredLanguages => [qw(lang)],
1552             personalInfo => [qw(expertise hobby interest)],
1553             # Simple scalar properties
1554             uid => [qw(uid)],
1555             kind => [qw(kind x-addressbookserver-kind)],
1556             prodId => [qw(prodid)],
1557             updated => [qw(rev)],
1558             created => [qw(created)],
1559             language => [qw(language)],
1560             );
1561              
1562 5         12 for my $js_prop (keys %changed) {
1563 5         6 my $vcard_names = $jscontact_to_vcard_props{$js_prop};
1564 5 50       12 next unless $vcard_names;
1565              
1566             # Remove old vCard properties for this JSContact property
1567             # Also remove any associated X-ABLabel grouped properties
1568 5         5 my %groups_to_remove;
1569 5         6 for my $vname (@$vcard_names) {
1570 10   100     8 for my $item (@{$props->{$vname} // []}) {
  10         22  
1571 8 100       15 $groups_to_remove{$item->{group}} = 1 if $item->{group};
1572             }
1573 10         19 delete $props->{$vname};
1574             }
1575              
1576             # Remove orphaned X-ABLabel entries for removed groups
1577 5 50 66     11 if (%groups_to_remove && $props->{'x-ablabel'}) {
1578             $props->{'x-ablabel'} = [
1579 2   50     5 grep { !$groups_to_remove{$_->{group} // ''} }
1580 1         1 @{$props->{'x-ablabel'}}
  1         3  
1581             ];
1582 1 50       2 delete $props->{'x-ablabel'} unless @{$props->{'x-ablabel'}};
  1         3  
1583             }
1584             }
1585              
1586             # Generate the new properties from the new card
1587 5         11 _reset_groups();
1588 5         4 my @new_props;
1589              
1590             # Generate only the changed properties
1591 5 100       7 if ($changed{name}) { _unconvert_name(\@new_props, $new_card) }
  1         3  
1592 5 50       6 if ($changed{nicknames}) { _unconvert_nicknames(\@new_props, $new_card) }
  0         0  
1593 5 100       8 if ($changed{emails}) { _unconvert_emails(\@new_props, $new_card) }
  1         4  
1594 5 100       7 if ($changed{phones}) { _unconvert_phones(\@new_props, $new_card) }
  1         4  
1595 5 50       7 if ($changed{addresses}) { _unconvert_addresses(\@new_props, $new_card) }
  0         0  
1596 5 50       7 if ($changed{organizations}) { _unconvert_organizations(\@new_props, $new_card) }
  0         0  
1597 5 50       12 if ($changed{titles}) { _unconvert_titles(\@new_props, $new_card) }
  0         0  
1598 5 50       5 if ($changed{anniversaries}) { _unconvert_anniversaries(\@new_props, $new_card) }
  0         0  
1599 5 100       8 if ($changed{notes}) { _unconvert_notes(\@new_props, $new_card) }
  1         3  
1600 5 50       8 if ($changed{onlineServices}){ _unconvert_online_services(\@new_props, $new_card) }
  0         0  
1601 5 50       7 if ($changed{media}) { _unconvert_media(\@new_props, $new_card) }
  0         0  
1602 5 50       29 if ($changed{links}) { _unconvert_links(\@new_props, $new_card) }
  0         0  
1603 5 50       7 if ($changed{calendars}) { _unconvert_calendars(\@new_props, $new_card) }
  0         0  
1604 5 50       7 if ($changed{schedulingAddresses}) { _unconvert_scheduling_addresses(\@new_props, $new_card) }
  0         0  
1605 5 50       7 if ($changed{cryptoKeys}) { _unconvert_crypto_keys(\@new_props, $new_card) }
  0         0  
1606 5 50       12 if ($changed{directories}) { _unconvert_directories(\@new_props, $new_card) }
  0         0  
1607 5 50       7 if ($changed{keywords}) { _unconvert_keywords(\@new_props, $new_card) }
  0         0  
1608 5 100       10 if ($changed{members}) {
1609 1 50       2 if ($original_has{'x-addressbookserver-member'}) {
1610 1         4 _unconvert_members_apple(\@new_props, $new_card);
1611             } else {
1612 0         0 _unconvert_members(\@new_props, $new_card);
1613             }
1614             }
1615 5 50       6 if ($changed{relatedTo}) { _unconvert_related(\@new_props, $new_card) }
  0         0  
1616 5 50       21 if ($changed{speakToAs}) { _unconvert_speak_to_as(\@new_props, $new_card) }
  0         0  
1617 5 50       6 if ($changed{preferredLanguages}) { _unconvert_preferred_languages(\@new_props, $new_card) }
  0         0  
1618 5 50       8 if ($changed{personalInfo}) { _unconvert_personal_info(\@new_props, $new_card) }
  0         0  
1619              
1620             # Simple scalar properties — use Apple property names when original has them
1621 5         28 for my $spec (['uid', 'uid'], ['prodId', 'prodid'],
1622             ['updated', 'rev'], ['created', 'created'], ['language', 'language']) {
1623 25         27 my ($js, $vc) = @$spec;
1624 25 50       32 if ($changed{$js}) {
1625 0 0 0     0 if (defined $new_card->{$js} && $new_card->{$js} ne '') {
1626 0         0 push @new_props, { name => $vc, value => $new_card->{$js}, params => {} };
1627             }
1628             }
1629             }
1630 5 50       10 if ($changed{kind}) {
1631 0 0 0     0 if (defined $new_card->{kind} && $new_card->{kind} ne '') {
1632 0 0       0 my $propname = $original_has{'x-addressbookserver-kind'} ? 'x-addressbookserver-kind' : 'kind';
1633 0         0 push @new_props, { name => $propname, value => $new_card->{kind}, params => {} };
1634             }
1635             }
1636              
1637             # Merge new properties into the existing vCard
1638 5         5 for my $prop (@new_props) {
1639 10   50     15 $prop->{params} //= {};
1640 10         8 push @{$props->{$prop->{name}}}, $prop;
  10         14  
1641             }
1642              
1643 5         17 return hash2vcard({ objects => [$vcard] });
1644             }
1645              
1646             # Compare two JSContact cards and return hash of changed property names
1647             sub _diff_cards {
1648 6     6   9 my ($old, $new) = @_;
1649 6         50 my $json = JSON->new->canonical->utf8;
1650              
1651 6         8 my %changed;
1652              
1653             # Check all keys in both old and new
1654             my %all_keys;
1655 6         47 $all_keys{$_} = 1 for keys %$old, keys %$new;
1656              
1657             # Skip internal/metadata keys
1658 6         24 delete $all_keys{$_} for qw(@type version CPath href _raw meta);
1659              
1660 6         11 for my $key (keys %all_keys) {
1661 54         49 my $old_val = $old->{$key};
1662 54         38 my $new_val = $new->{$key};
1663              
1664             # Both undef/missing -> no change
1665 54 0 33     59 next if !defined $old_val && !defined $new_val;
1666              
1667             # One exists, other doesn't -> changed
1668 54 100 66     94 if (!defined $old_val || !defined $new_val) {
1669 1         2 $changed{$key} = 1;
1670 1         2 next;
1671             }
1672              
1673             # Compare via canonical JSON encoding
1674 53         191 my $old_json = $json->encode([$old_val]);
1675 53         140 my $new_json = $json->encode([$new_val]);
1676 53 100       75 $changed{$key} = 1 if $old_json ne $new_json;
1677             }
1678              
1679 6         31 return %changed;
1680             }
1681              
1682             1;