File Coverage

blib/lib/Image/ExifTool/VCard.pm
Criterion Covered Total %
statement 134 137 97.8
branch 82 96 85.4
condition 20 28 71.4
subroutine 6 6 100.0
pod 0 3 0.0
total 242 270 89.6


line stmt bran cond sub pod time code
1             #------------------------------------------------------------------------------
2             # File: VCard.pm
3             #
4             # Description: Read vCard and iCalendar meta information
5             #
6             # Revisions: 2015/04/05 - P. Harvey Created
7             # 2015/05/02 - PH Added iCalendar support
8             #
9             # References: 1) http://en.m.wikipedia.org/wiki/VCard
10             # 2) http://tools.ietf.org/html/rfc6350
11             # 3) http://tools.ietf.org/html/rfc5545
12             #------------------------------------------------------------------------------
13              
14             package Image::ExifTool::VCard;
15              
16 1     1   4437 use strict;
  1         2  
  1         33  
17 1     1   5 use vars qw($VERSION);
  1         2  
  1         38  
18 1     1   5 use Image::ExifTool qw(:DataAccess :Utils);
  1         2  
  1         2516  
19              
20             $VERSION = '1.07';
21              
22             my %unescapeVCard = ( '\\'=>'\\', ','=>',', 'n'=>"\n", 'N'=>"\n" );
23              
24             # lookup for iCalendar components (used to generate family 1 group names if top level)
25             my %isComponent = ( Event=>1, Todo=>1, Journal=>1, Freebusy=>1, Timezone=>1, Alarm=>1 );
26              
27             my %timeInfo = (
28             # convert common date/time formats to EXIF style
29             ValueConv => q{
30             $val =~ s/(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z?)/$1:$2:$3 $4:$5:$6$7/g;
31             $val =~ s/(\d{4})(\d{2})(\d{2})/$1:$2:$3/g;
32             $val =~ s/(\d{4})-(\d{2})-(\d{2})/$1:$2:$3/g;
33             return $val;
34             },
35             PrintConv => '$self->ConvertDateTime($val)',
36             );
37              
38             # vCard tags (ref 1/2/PH)
39             # Note: The case of all tag ID's is normalized to lowercase with uppercase first letter
40             %Image::ExifTool::VCard::Main = (
41             GROUPS => { 2 => 'Document' },
42             VARS => { NO_LOOKUP => 1 }, # omit tags from lookup
43             NOTES => q{
44             This table lists common vCard tags, but ExifTool will also extract any other
45             vCard tags found. Tag names may have "Pref" added to indicate the preferred
46             instance of a vCard property, and other "TYPE" parameters may also added to
47             the tag name. VCF files may contain multiple vCard entries which are
48             distinguished by the ExifTool family 3 group name (document number). See
49             L for the vCard 4.0 specification.
50             },
51             Version => { Name => 'VCardVersion', Description => 'VCard Version' },
52             Fn => { Name => 'FormattedName', Groups => { 2 => 'Author' } },
53             N => { Name => 'Name', Groups => { 2 => 'Author' } },
54             Bday => { Name => 'Birthday', Groups => { 2 => 'Time' }, %timeInfo },
55             Tz => { Name => 'TimeZone', Groups => { 2 => 'Time' } },
56             Adr => { Name => 'Address', Groups => { 2 => 'Location' } },
57             Geo => {
58             Name => 'Geolocation',
59             Groups => { 2 => 'Location' },
60             # when used as a parameter, VCard 4.0 adds a "geo:" prefix that we need to remove
61             ValueConv => '$val =~ s/^geo://; $val',
62             },
63             Anniversary => { },
64             Email => { },
65             Gender => { },
66             Impp => 'IMPP',
67             Lang => 'Language',
68             Logo => { },
69             Nickname => { },
70             Note => { },
71             Org => 'Organization',
72             Photo => { Groups => { 2 => 'Preview' } },
73             Prodid => 'Software',
74             Rev => 'Revision',
75             Sound => { },
76             Tel => 'Telephone',
77             Title => 'JobTitle',
78             Uid => 'UID',
79             Url => 'URL',
80             'X-ablabel' => { Name => 'ABLabel', PrintConv => '$val =~ s/^_\$!<(.*)>!\$_$/$1/; $val' },
81             'X-abdate' => { Name => 'ABDate', Groups => { 2 => 'Time' }, %timeInfo },
82             'X-aim' => 'AIM',
83             'X-icq' => 'ICQ',
84             'X-abuid' => 'AB_UID',
85             'X-abrelatednames' => 'ABRelatedNames',
86             'X-socialprofile' => 'SocialProfile',
87             );
88              
89             %Image::ExifTool::VCard::VCalendar = (
90             GROUPS => { 1 => 'VCalendar', 2 => 'Document' },
91             VARS => {
92             NO_LOOKUP => 1, # omit tags from lookup
93             LONG_TAGS => 6, # some X-microsoft tags have unavoidably long ID's
94             },
95             NOTES => q{
96             The VCard module is also used to process iCalendar ICS files since they use
97             a format similar to vCard. The following table lists standard iCalendar
98             tags, but any existing tags will be extracted. Top-level iCalendar
99             components (eg. Event, Todo, Timezone, etc.) are used for the family 1 group
100             names, and embedded components (eg. Alarm) are added as a prefix to the tag
101             name. See L for the official iCalendar
102             2.0 specification.
103             },
104             Version => { Name => 'VCalendarVersion', Description => 'VCalendar Version' },
105             Calscale => 'CalendarScale',
106             Method => { },
107             Prodid => 'Software',
108             Attach => 'Attachment',
109             Categories => { },
110             Class => 'Classification',
111             Comment => { },
112             Description => { },
113             Geo => {
114             Name => 'Geolocation',
115             Groups => { 2 => 'Location' },
116             ValueConv => '$val =~ s/^geo://; $val',
117             },
118             Location => { Name => 'Location', Groups => { 2 => 'Location' } },
119             'Percent-complete' => 'PercentComplete',
120             Priority => { },
121             Resources => { },
122             Status => { },
123             Summary => { },
124             Completed => { Name => 'DateTimeCompleted', Groups => { 2 => 'Time' }, %timeInfo },
125             Dtend => { Name => 'DateTimeEnd', Groups => { 2 => 'Time' }, %timeInfo },
126             Due => { Name => 'DateTimeDue', Groups => { 2 => 'Time' }, %timeInfo },
127             Dtstart => { Name => 'DateTimeStart', Groups => { 2 => 'Time' }, %timeInfo },
128             Duration => { },
129             Freebusy => 'FreeBusyTime',
130             Transp => 'TimeTransparency',
131             Tzid => { Name => 'TimezoneID', Groups => { 2 => 'Time' } },
132             Tzname => { Name => 'TimezoneName', Groups => { 2 => 'Time' } },
133             Tzoffsetfrom=> { Name => 'TimezoneOffsetFrom', Groups => { 2 => 'Time' } },
134             Tzoffsetto => { Name => 'TimezoneOffsetTo', Groups => { 2 => 'Time' } },
135             Tzurl => { Name => 'TimeZoneURL', Groups => { 2 => 'Time' } },
136             Attendee => { },
137             Contact => { },
138             Organizer => { },
139             'Recurrence-id' => 'RecurrenceID',
140             'Related-to' => 'RelatedTo',
141             Url => 'URL',
142             Uid => 'UID',
143             Exdate => { Name => 'ExceptionDateTimes', Groups => { 2 => 'Time' }, %timeInfo },
144             Rdate => { Name => 'RecurrenceDateTimes', Groups => { 2 => 'Time' }, %timeInfo },
145             Rrule => { Name => 'RecurrenceRule', Groups => { 2 => 'Time' } },
146             Action => { },
147             Repeat => { },
148             Trigger => { },
149             Created => { Name => 'DateCreated', Groups => { 2 => 'Time' }, %timeInfo },
150             Dtstamp => { Name => 'DateTimeStamp', Groups => { 2 => 'Time' }, %timeInfo },
151             'Last-modified' => { Name => 'ModifyDate', Groups => { 2 => 'Time' }, %timeInfo },
152             Sequence => 'SequenceNumber',
153             'Request-status' => 'RequestStatus',
154             Acknowledged=> { Name => 'Acknowledged', Groups => { 2 => 'Time' }, %timeInfo },
155             #
156             # Observed X-tags (not a comprehensive list):
157             #
158             'X-apple-calendar-color'=> 'CalendarColor',
159             'X-apple-default-alarm' => 'DefaultAlarm',
160             'X-apple-local-default-alarm' => 'LocalDefaultAlarm',
161             'X-microsoft-cdo-appt-sequence' => 'AppointmentSequence',
162             'X-microsoft-cdo-ownerapptid' => 'OwnerAppointmentID',
163             'X-microsoft-cdo-busystatus' => 'BusyStatus',
164             'X-microsoft-cdo-intendedstatus' => 'IntendedBusyStatus',
165             'X-microsoft-cdo-alldayevent' => 'AllDayEvent',
166             'X-microsoft-cdo-importance' => {
167             Name => 'Importance',
168             PrintConv => {
169             0 => 'Low',
170             1 => 'Normal',
171             2 => 'High',
172             },
173             },
174             'X-microsoft-cdo-insttype' => {
175             Name => 'InstanceType',
176             PrintConv => {
177             0 => 'Non-recurring Appointment',
178             1 => 'Recurring Appointment',
179             2 => 'Single Instance of Recurring Appointment',
180             3 => 'Exception to Recurring Appointment',
181             },
182             },
183             'X-microsoft-donotforwardmeeting' => 'DoNotForwardMeeting',
184             'X-microsoft-disallow-counter' => 'DisallowCounterProposal',
185             'X-microsoft-locations' => { Name => 'MeetingLocations', Groups => { 2 => 'Location' } },
186             'X-wr-caldesc' => 'CalendarDescription',
187             'X-wr-calname' => 'CalendarName',
188             'X-wr-relcalid' => 'CalendarID',
189             'X-wr-timezone' => { Name => 'TimeZone2', Groups => { 2 => 'Time' } },
190             'X-wr-alarmuid' => 'AlarmUID',
191             );
192              
193             %Image::ExifTool::VCard::VNote = (
194             GROUPS => { 1 => 'VNote', 2 => 'Document' },
195             NOTES => 'Tags extracted from V-Note VNT files.',
196             Version => { },
197             Body => { },
198             Dcreated => { Name => 'CreateDate', Groups => { 2 => 'Time' }, %timeInfo },
199             'Last-modified' => { Name => 'ModifyDate', Groups => { 2 => 'Time' }, %timeInfo },
200             );
201              
202             #------------------------------------------------------------------------------
203             # Get vCard tag, creating if necessary
204             # Inputs: 0) ExifTool ref, 1) tag table ref, 2) tag ID, 3) tag Name,
205             # 4) source tagInfo ref, 5) lang code
206             # Returns: tagInfo ref
207             sub GetVCardTag($$$$;$$)
208             {
209 120     120 0 324 my ($et, $tagTablePtr, $tag, $name, $srcInfo, $langCode) = @_;
210 120         218 my $tagInfo = $$tagTablePtr{$tag};
211 120 100       239 unless ($tagInfo) {
212 52 100       487 if ($srcInfo) {
213 50         235 $tagInfo = { %$srcInfo };
214             } else {
215 2         7 $tagInfo = { };
216 2         18 $et->VPrint(0, $$et{INDENT}, "[adding $tag]\n");
217             }
218 52         119 $$tagInfo{Name} = $name;
219 52         85 delete $$tagInfo{Description}; # create new description
220 52         133 AddTagToTable($tagTablePtr, $tag, $tagInfo);
221             }
222             # handle alternate languages (the "language" parameter)
223 120 100       235 $tagInfo = Image::ExifTool::GetLangInfo($tagInfo, $langCode) if $langCode;
224 120         217 return $tagInfo;
225             }
226              
227             #------------------------------------------------------------------------------
228             # Decode vCard text
229             # Inputs: 0) ExifTool ref, 1) vCard text, 2) encoding
230             # Returns: decoded text (or array ref for a list of values)
231             sub DecodeVCardText($$;$)
232             {
233 120     120 0 348 my ($et, $val, $enc) = @_;
234 120 100       261 $enc = defined($enc) ? lc $enc : '';
235 120 100 100     1333 if ($enc eq 'b' or $enc eq 'base64') {
236 2         1444 require Image::ExifTool::XMP;
237 2         11 $val = Image::ExifTool::XMP::DecodeBase64($val);
238             } else {
239 118 100       227 if ($enc eq 'quoted-printable') {
240             # convert "=HH" hex codes to characters
241 1         8 $val =~ s/=([0-9a-f]{2})/chr(hex($1))/ige;
  4         28  
242             }
243 118         332 $val = $et->Decode($val, 'UTF8'); # convert from UTF-8
244             # convert unescaped commas to nulls to separate list items
245 118 100       539 $val =~ s/(\\.)|(,)/$1 || "\0"/sge;
  6         36  
246             # unescape necessary characters in value
247 118 50       200 $val =~ s/\\(.)/$unescapeVCard{$1}||$1/sge;
  5         21  
248             # split into list if necessary
249 118         306 my @vals = split /\0/, $val;
250 118 100       297 $val = \@vals if @vals > 1;
251             }
252 120         239 return $val;
253             }
254              
255             #------------------------------------------------------------------------------
256             # Read information in a vCard file
257             # Inputs: 0) ExifTool ref, 1) dirInfo ref
258             # Returns: 1 on success, 0 if this wasn't a valid vCard file
259             sub ProcessVCard($$)
260             {
261 2     2 0 4 local $_;
262 2         8 my ($et, $dirInfo) = @_;
263 2         5 my $raf = $$dirInfo{RAF};
264 2         6 my ($buff, $val, $ok, $component, %compNum, @count);
265              
266 2 50 33     7 return 0 unless $raf->Read($buff, 24) and $raf->Seek(0,0) and $buff=~/^BEGIN:(VCARD|VCALENDAR|VNOTE)\r\n/i;
      33        
267 2         28 my %info = (
268             VCARD => [ qw(VCard vCard Main VCF) ],
269             VCALENDAR => [ qw(ICS iCalendar VCalendar ICS) ],
270             VNOTE => [ qw(VNote vNote VNote VNT text/v-note) ],
271             );
272 2         5 my ($type, $lbl, $tbl, $ext, $mime) = @{$info{uc($1)}};
  2         11  
273 2         16 $et->SetFileType($type, $mime, $ext);
274 2 50 33     9 return 1 if $$et{OPTIONS}{FastScan} and $$et{OPTIONS}{FastScan} == 3;
275 2         12 local $/ = "\r\n";
276 2         8 my $tagTablePtr = GetTagTable("Image::ExifTool::VCard::$tbl");
277 2         10 my $more = $raf->ReadLine($buff); # read first line
278 2 50       8 chomp $buff if $more;
279 2         6 while ($more) {
280             # retrieve previous line from $buff
281 139 50       331 $val = $buff if defined $buff;
282             # read ahead to next line to see if is a continuation
283 139         395 $more = $raf->ReadLine($buff);
284 139 100       289 if ($more) {
285 137         197 chomp $buff;
286             # add continuation line if necessary
287 137 50       440 $buff =~ s/^[ \t]// and $val .= $buff, undef($buff), next;
288             }
289 139 100       490 if ($val =~ /^(BEGIN|END):(V?)(\w+)$/i) {
    100          
290 28 100       138 my ($begin, $v, $what) = ((lc($1) eq 'begin' ? 1 : 0), $2, ucfirst lc $3);
291 28 100 100     136 if ($what eq 'Card' or $what eq 'Calendar' or $what eq 'Note') {
      66        
292 6 100       14 if ($begin) {
293 3         8 @count = ( { } ); # reset group counters
294             } else {
295 3         10 $ok = 1; # ok if we read at least on full VCARD or VCALENDAR
296             }
297 6         19 next;
298             }
299             # absorb top-level component into family 1 group name
300 22 100       55 if ($isComponent{$what}) {
301 14 100 66     47 if ($begin) {
    100          
302 7 100       18 unless ($component) {
303             # begin a new top-level component
304 4         11 @count = ( { } );
305 4         8 $component = $what;
306 4   100     19 $compNum{$component} = ($compNum{$component} || 0) + 1;
307 4         11 next;
308             }
309             } elsif ($component and $component eq $what) {
310             # this top-level component has ended
311 4         7 undef $component;
312 4         8 next;
313             }
314             }
315             # keep count of each component at this level
316 14 100       55 if ($begin) {
    50          
317 7 100 100     24 $count[-1]{$what} = ($count[-1]{$what} || 0) + 1 if $v;
318 7         24 push @count, { obj => $what };
319             } elsif (@count > 1) {
320 7         13 pop @count;
321             }
322 14         40 next;
323             } elsif ($ok) {
324 1         2 $ok = 0;
325 1         7 $$et{DOC_NUM} = ++$$et{DOC_COUNT}; # read next card as a new document
326             }
327 111 50       538 unless ($val =~ s/^([-A-Za-z0-9.]+)//) {
328 0         0 $et->WarnOnce("Unrecognized line in $lbl file");
329 0         0 next;
330             }
331 111         283 my $tag = $1;
332             # set group if it exists
333 111 100       287 if ($tag =~ s/^([-A-Za-z0-9]+)\.//) {
    100          
334 8         27 $$et{SET_GROUP1} = ucfirst lc $1;
335             } elsif ($component) {
336 64         195 $$et{SET_GROUP1} = $component . $compNum{$component};
337             } else {
338 39         71 delete $$et{SET_GROUP1};
339             }
340 111         159 my ($name, %param, $p);
341             # vCard tag ID's are case-insensitive, so normalize to lowercase with
342             # an uppercase first letter for use as a tag name
343 111 100       288 $name = ucfirst $tag if $tag =~ /[a-z]/; # preserve mixed case in name if it exists
344 111         206 $tag = ucfirst lc $tag;
345             # get source tagInfo reference
346 111         304 my $srcInfo = $et->GetTagInfo($tagTablePtr, $tag);
347 111 100       236 if ($srcInfo) {
348 110         204 $name = $$srcInfo{Name}; # use our name
349             } else {
350 1 50       5 $name or $name = $tag;
351             # remove leading "X-" from name if it exists
352 1 50       4 $name =~ s/^X-// and $name = ucfirst $name;
353             }
354             # add object name(s) to tag if necessary
355 111 100       238 if (@count > 1) {
356 40         53 my $i;
357 40         104 for ($i=$#count-1; $i>=0; --$i) {
358 40         76 my $pre = $count[$i-1]{obj}; # use containing object name as tag prefix
359 40         75 my $c = $count[$i]{$pre}; # add index for object number
360 40 100       88 $c = '' unless defined $c;
361 40         92 $tag = $pre . $c . $tag;
362 40         119 $name = $pre . $c . $name;
363             }
364             }
365             # parse parameters
366 111         375 while ($val =~ s/^;([-A-Za-z0-9]*)(=?)//) {
367 56         130 $p = ucfirst lc $1;
368             # convert old vCard 2.x parameters to the new "TYPE=" format
369 56 100       128 $2 or $val = $1 . $val, $p = 'Type';
370             # read parameter value
371 56         77 for (;;) {
372 112 100 100     546 last unless $val =~ s/^"([^"]*)",?// or $val =~ s/^([^";:,]+,?)//;
373 56 100       193 my $v = $p eq 'Type' ? ucfirst lc $1 : $1;
374 56 100       165 $param{$p} = defined($param{$p}) ? $param{$p} . $v : $v;
375             }
376 56 50       116 if (defined $param{$p}) {
377 56 50       180 $param{$p} =~ s/\\(.)/$unescapeVCard{$1}||$1/sge;
  1         9  
378             } else {
379 0         0 $param{$p} = '';
380             }
381             }
382 111 50       388 $val =~ s/^:// or $et->WarnOnce("Invalid line in $lbl file"), next;
383             # add 'Type' parameter to id and name if it exists
384 111 100       273 $param{Type} and $tag .= $param{Type}, $name .= $param{Type};
385             # convert base64-encoded data
386 111 100       238 if ($val =~ s{^data:(\w+)/(\w+);base64,}{}) {
387 1         5 my $xtra = ucfirst(lc $1) . ucfirst(lc $2);
388 1         3 $tag .= $xtra;
389 1         2 $name .= $xtra;
390 1         4 $param{Encoding} = 'base64';
391             }
392 111         352 $val = DecodeVCardText($et, $val, $param{Encoding});
393 111         347 my $tagInfo = GetVCardTag($et, $tagTablePtr, $tag, $name, $srcInfo, $param{Language});
394 111         380 $et->HandleTag($tagTablePtr, $tag, $val, TagInfo => $tagInfo);
395             # handle some other parameters that we care about (ignore the rest for now)
396 111         195 foreach $p (qw(Geo Label Tzid)) {
397 333 100       840 next unless defined $param{$p};
398             # use tag attributes from our table if it exists
399 9         32 my $srcTag2 = $et->GetTagInfo($tagTablePtr, $p);
400 9 100       48 my $pn = $srcTag2 ? $$srcTag2{Name} : $p;
401 9         24 $val = DecodeVCardText($et, $param{$p});
402             # add parameter to tag ID and name
403 9         38 my ($tg, $nm) = ($tag . $p, $name . $pn);
404 9         33 $tagInfo = GetVCardTag($et, $tagTablePtr, $tg, $nm, $srcTag2, $param{Language});
405 9         33 $et->HandleTag($tagTablePtr, $tg, $val, TagInfo => $tagInfo);
406             }
407             }
408 2         7 delete $$et{SET_GROUP1};
409 2         6 delete $$et{DOC_NUM};
410 2 50       6 $ok or $et->Warn("Missing $lbl end");
411 2         25 return 1;
412             }
413              
414             1; # end
415              
416             __END__