File Coverage

blib/lib/Org/To/VCF.pm
Criterion Covered Total %
statement 131 155 84.5
branch 45 74 60.8
condition 11 21 52.3
subroutine 20 20 100.0
pod 2 4 50.0
total 209 274 76.2


line stmt bran cond sub pod time code
1             package Org::To::VCF;
2              
3 1     1   6000 use 5.010001;
  1         4  
  1         37  
4 1     1   1193 use Log::Any '$log';
  1         2157  
  1         4  
5              
6 1     1   66 use vars qw($VERSION);
  1         1  
  1         51  
7              
8 1     1   5 use File::Slurp::Tiny qw(read_file write_file);
  1         2  
  1         68  
9 1     1   1129 use Org::Document;
  1         77915  
  1         53  
10 1     1   963 use Org::Dump qw();
  1         8512  
  1         31  
11 1     1   10 use Scalar::Util qw(blessed);
  1         3  
  1         54  
12 1     1   1057 use Text::vCard::Addressbook;
  1         949479  
  1         16  
13              
14 1     1   44 use Moo;
  1         3  
  1         12  
15 1     1   376 use experimental 'smartmatch';
  1         2  
  1         9  
16             extends 'Org::To::Base';
17              
18             has default_country => (is => 'rw');
19             has export_notes => (is => 'rw');
20             has _vcf => (is => 'rw'); # vcf object
21             has _cccode => (is => 'rw'); # country calling code
22              
23             our $VERSION = '0.06'; # VERSION
24             our $DATE = '2014-08-16'; # DATE
25              
26             require Exporter;
27             our @ISA;
28             push @ISA, qw(Exporter);
29             our @EXPORT_OK = qw(org_to_vcf);
30              
31             our %SPEC;
32             $SPEC{org_to_vcf} = {
33             v => 1.1,
34             summary => 'Export contacts in Org document to VCF (vCard addressbook)',
35             args => {
36             source_file => {
37             summary => 'Source Org file to export',
38             schema => ['str' => {
39             }],
40             },
41             source_str => {
42             summary => 'Alternatively you can specify Org string directly',
43             schema => ['str' => {
44             }],
45             },
46             target_file => {
47             summary => 'VCF file to write to',
48             schema => ['str' => {}],
49             description => <<'_',
50              
51             If not specified, VCF output string will be returned instead.
52              
53             _
54             },
55             include_tags => {
56             summary => 'Include trees that carry one of these tags',
57             schema => ['array' => {
58             of => 'str*',
59             }],
60             description => <<'_',
61              
62             Works like Org's 'org-export-select-tags' variable. If the whole document
63             doesn't have any of these tags, then the whole document will be exported.
64             Otherwise, trees that do not carry one of these tags will be excluded. If a
65             selected tree is a subtree, the heading hierarchy above it will also be selected
66             for export, but not the text below those headings.
67              
68             _
69             },
70             exclude_tags => {
71             summary => 'Exclude trees that carry one of these tags',
72             schema => ['array' => {
73             of => 'str*',
74             }],
75             description => <<'_',
76              
77             If the whole document doesn't have any of these tags, then the whole document
78             will be exported. Otherwise, trees that do not carry one of these tags will be
79             excluded. If a selected tree is a subtree, the heading hierarchy above it will
80             also be selected for export, but not the text below those headings.
81              
82             exclude_tags is evaluated after include_tags.
83              
84             _
85             },
86             default_country => {
87             summary => 'Specify default country code',
88             schema => ['str*'],
89             description => <<'_',
90              
91             Free-form phone numbers on phone fields are formatted by this function, e.g.
92             `081 123 4567` becomes `0811234567`. If default country is specified (e.g.
93             "ID"), the number will be formatted as `+62811234567`. Setting this option is
94             recommended so the phone numbers are nicely formatted as international number.
95              
96             _
97             },
98             export_notes => {
99             summary => 'Whether to export note fields',
100             schema => ['bool*', default=>1],
101             },
102             }
103             };
104             sub org_to_vcf {
105 1     1 1 21900 my %args = @_;
106              
107             # XXX schema
108 1   50     11 $args{export_notes} //= 1;
109              
110 1         2 my $doc;
111 1 50       6 if ($args{source_file}) {
    0          
112 1         5 $doc = Org::Document->new(from_string =>
113             scalar read_file($args{source_file}));
114             } elsif (defined($args{source_str})) {
115 0         0 $doc = Org::Document->new(from_string => $args{source_str});
116             } else {
117 0         0 return [400, "Please specify source_file/source_str"];
118             }
119              
120 1         385980 my $obj = __PACKAGE__->new(
121             include_tags => $args{include_tags},
122             exclude_tags => $args{exclude_tags},
123             default_country => $args{default_country},
124             export_notes => $args{export_notes},
125             );
126              
127 1         73 my $vcf = Text::vCard::Addressbook->new;
128 1         33 $obj->{_vcf} = $vcf;
129              
130 1         12 $obj->export($doc);
131             #$log->tracef("vcf = %s", $vcf);
132 1 50       5 if ($args{target_file}) {
133 0         0 write_file($args{target_file}, $vcf->export);
134 0         0 return [200, "OK"];
135             } else {
136 1         6 return [200, "OK", $vcf->export];
137             }
138             }
139              
140             sub BUILD {
141 1     1 0 1853 my ($self, $args) = @_;
142              
143 1 50       6 if ($args->{default_country}) {
144 1         1395 require Number::Phone::CountryCode;
145 1         2420 my $pc = Number::Phone::CountryCode->new($args->{default_country});
146 1 50       73 die "Can't find country calling code for country ".
147             "'$args->{default_country}'" unless $pc;
148 1         6 $self->{_cccode} = $pc->country_code;
149             }
150             }
151              
152             sub _clean_field {
153 13     13   80 my ($self, $str) = @_;
154 13         31 $str =~ s/\s*#.+//g; # strip comments
155 13         17 $str =~ s/\[\d+-\d+-\d+.*?\]//g; # strip timestamps
156 13         25 $str =~ s/\A\s+//s; $str =~ s/\s+\z//s; # trim
  13         28  
157 13         91 $str;
158             }
159              
160             # XXX don't lose extension information, e.g. +62 22 1234567 ext 10
161             sub _format_phone {
162 2     2   8 my ($self, $str) = @_;
163 2 50       5 if ($str =~ /^\+/) {
164 0         0 $str =~ s/[^0-9]//g;
165 0         0 return "+$str";
166             } else {
167 2         9 $str =~ s/[^0-9]//g;
168 2 50       6 if ($self->{_cccode}) {
169 2         7 $str =~ s/^0//;
170 2         74 return "+$self->{_cccode}$str";
171             } else {
172 0         0 return $str;
173             }
174             }
175             }
176              
177             sub _parse_field {
178 6     6   93 my ($self, $fields, $key, $textval, $vals) = @_;
179 6 100       19 $vals = [$vals] unless ref($vals) eq 'ARRAY';
180 6 50       17 if ($log->is_trace) {
181 0 0 0     0 $log->tracef("parsing field: key=%s, textval=%s, vals=%s",
182             $key, $textval,
183 0         0 [map {blessed($_) && $_->isa('Org::Element') ?
184             Org::Dump::dump_element($_) : $_} @$vals]);
185             }
186 6         26 $key = $self->_clean_field($key);
187 6         121 $textval = $self->_clean_field($textval);
188 6 100       57 if ($key =~ /^((?:full\s?)?name |
    100          
    100          
    50          
189             nama(?:\slengkap)?)$/ix) {
190 1         3 $fields->{FN} = $textval;
191 1         5 $log->tracef("found FN field: %s", $textval);
192             } elsif ($key =~ /^(birthday |
193             ultah|ulang\stahun|(?:tanggal\s|tgg?l\s)?lahir)$/ix) {
194             # find the first timestamp field
195 1         2 my @ts;
196 1         3 for (@$vals) {
197             $_->walk(sub {
198 3 100   3   28 push @ts, $_
199             if $_->isa('Org::Element::Timestamp');
200 3         52 });
201             }
202 1 50       11 if (@ts) {
203 1         34 $fields->{BDAY} = $ts[0]->datetime->ymd;
204 1         68 $log->tracef("found BDAY field: %s", $fields->{BDAY});
205 1         7 $fields->{_has_contact} = 1;
206             } else {
207             # or from a regex match
208 0 0       0 if ($textval =~ /(\d{4}-\d{2}-\d{2})/) {
209 0         0 $fields->{BDAY} = $1;
210 0         0 $log->tracef("found BDAY field: %s", $fields->{BDAY});
211 0         0 $fields->{_has_contact} = 1;
212             }
213             }
214             } elsif ($key =~ /(?:phone|cell|portable|mobile|mob|\bph\b|\bf\b|fax) |
215             (?:te?l[pf](on)|selul[ae]r|\bfaks|\bhp\b|\bhape\b)
216             /ix) {
217 2   100     9 $fields->{TEL} //= {};
218 2         3 my $type;
219 2 50       14 if ($key =~ /fax |
    50          
    0          
    0          
220             faks/ix) {
221 0         0 $type = "fax";
222             } elsif ($key =~ /(?:cell|hand|portable|mob) |
223             (?:sel|hp|hape)
224             /ix) {
225 2         4 $type = "mobile";
226             } elsif ($key =~ /(?:wo?rk|office|ofc) |
227             (?:kerja|krj|kantor|ktr)
228             /ix) {
229 0         0 $type = "work";
230             } elsif ($key =~ /(?:home) |
231             (?:rumah|rmh)
232             /ix) {
233 0         0 $type = "home";
234             } else {
235             # XXX use Number::Phone to parse phone number (is_mobile() etc)
236 0         0 $type = "mobile";
237             }
238 2         6 $fields->{TEL}{$type} = $self->_format_phone($textval);
239 2         10 $log->tracef("found TEL ($type) field: %s", $fields->{TEL}{$type});
240 2         11 $fields->{_has_contact} = 1;
241             } elsif ($key =~ /^((?:e[-]?mail|mail) |
242             (?:i[ -]?mel|surel))$/ix) {
243 0         0 $fields->{EMAIL} = $textval;
244 0         0 $log->tracef("found EMAIL field: %s", $fields->{EMAIL});
245 0         0 $fields->{_has_contact} = 1;
246             } else {
247             # note is from note fields or everything that does not have field names
248             # or any field that is not parsed (but limit it to 3 for now)
249 2   100     9 $fields->{_num_notes} //= 0;
250 2 50 33     16 if ($self->export_notes && $fields->{_num_notes}++ < 3) {
251 2 100       11 $fields->{NOTE} .= ( $fields->{NOTE} ? "\n" : "" ) .
    50          
252             ($key ? "$key: " : "") . $textval;
253 2 100       11 $log->tracef("%s NOTE field: %s",
254             $fields->{_num_notes} == 1 ? "found" : "add",
255             $fields->{NOTE});
256             }
257             }
258             # complex (but depreciated): N (name: family, given, middle, prefixes,
259             # suffixes)
260              
261             # complex: ADR/addresses (po_box, extended, street, city, region, post_code,
262             # country, lat, long)
263              
264             # complex: ORG (name, unit)
265              
266             # TITLE, ROLE, URL,NICKNAME
267             # LABELS, PHOTO, TZ, MAILER?, PRODID?, REV?, SORT-STRING?, UID?, CLASS?
268             }
269              
270             sub _add_vcard {
271 1     1   2225 no strict 'refs';
  1         3  
  1         873  
272              
273 1     1   2 my ($self, $fields) = @_;
274              
275             #$log->tracef("adding vcard");
276 1         6 my $vc = $self->{_vcf}->add_vcard;
277 1         42 for my $k (keys %$fields) {
278 6 100       528 next if $k =~ /^_/;
279 4         5 my $v = $fields->{$k};
280 4 100       10 if (!ref($v)) {
281             #$log->tracef(" adding simple vcard node: %s => %s", $k, $v);
282 3         13 $vc->$k($v);
283             } else {
284 1         4 my @tt = keys %$v;
285 1         4 for my $t (@tt) {
286             #$log->tracef(" adding complex vcard node: %s, types=%s", $k, $t);
287 1         7 my $node = $vc->add_node({
288             node_type=>$k,
289             types => $t, # doesn't work? must use add_types()
290             });
291 1         68 $node->add_types($t);
292 1         18 $node->value($v->{$t});
293             }
294             }
295             }
296             }
297              
298             sub export_headline {
299 1     1 0 2 my ($self, $elem) = @_;
300              
301 1 50       7 if ($log->is_trace) {
302 0         0 require String::Escape;
303 0         0 $log->tracef("exporting headline %s (%s) ...", ref($elem),
304             String::Escape::elide(
305             String::Escape::printable($elem->as_string), 30));
306             }
307              
308 1         6 my $vcf = $self->{_vcf};
309 4 50       106 my @subhl = grep {
310 1         10 $_->isa('Org::Element::Headline') && !$_->is_todo }
311             $self->_included_children($elem);
312              
313 1         3 my $fields = {}; # fields
314 1         10 $fields->{FN} = $self->_clean_field($elem->title->as_string);
315              
316 1   50     3 for my $c (@{ $elem->children // [] }) {
  1         7  
317 4 50 33     54 if ($c->isa('Org::Element::Drawer') && $c->name eq 'PROPERTIES') {
    50          
318             # search fields in properties drawer
319 0         0 my $props = $c->properties;
320 0         0 $self->_parse_field($fields, $_, $props->{$_}) for keys %$props;
321             } elsif ($c->isa('Org::Element::List')) {
322             # search fields in list items
323 4   50     6 for my $c2 (grep {$_->isa('Org::Element::ListItem')}
  6         22  
  4         17  
324             @{ $c->children // [] }) {
325 6 100       20 if ($c2->desc_term) {
326 4         13 $self->_parse_field($fields,
327             $c2->desc_term->as_string, # key
328             $c2->children->[0]->as_string, # textval
329             $c2->children); # val
330             } else {
331 2         9 my $val = $c2->as_string;
332 2 100       78 my $key = $1 if $val =~ s/\A\s*[+-]\s+(\S+?):(.+)/$2/;
333 2 100       4 if ($key) {
334 1         4 $self->_parse_field($fields,
335             $key,
336             $val,
337             $c2);
338             } else {
339 1         16 $self->_parse_field($fields, "note", $val, $c2);
340             }
341             }
342             }
343             }
344             }
345              
346 1         11 $log->tracef("fields: %s", $fields);
347 1 50       10 $self->_add_vcard($fields) if $fields->{_has_contact};
348              
349 1         10 $self->export_headline($_) for @subhl;
350             }
351              
352             sub export_elements {
353 2     2 1 16 my ($self, @elems) = @_;
354              
355 2   100     12 $self->{_vcards} //= [];
356              
357             ELEM:
358 2         4 for my $elem (@elems) {
359 2 100       27 if ($elem->isa('Org::Element::Headline')) {
    50          
360 1         4 $self->export_headline($elem);
361             } elsif ($elem->isa('Org::Document')) {
362 1         2 $self->export_elements(@{ $elem->children });
  1         10  
363             } else {
364             # ignore other elements
365             }
366             }
367             }
368              
369             1;
370             # ABSTRACT: Export contacts in Org document to VCF (vCard addressbook)
371              
372             __END__
373              
374             =pod
375              
376             =encoding UTF-8
377              
378             =head1 NAME
379              
380             Org::To::VCF - Export contacts in Org document to VCF (vCard addressbook)
381              
382             =head1 VERSION
383              
384             This document describes version 0.06 of Org::To::VCF (from Perl distribution Org-To-VCF), released on 2014-08-16.
385              
386             =head1 SYNOPSIS
387              
388             use Org::To::VCF qw(org_to_vcf);
389              
390             my $res = org_to_vcf(
391             source_file => 'addressbook.org', # or source_str
392             #target_file => 'addressbook.vcf', # defaults return the VCF in $res->[2]
393             #include_tags => [...], # default exports all tags
394             #exclude_tags => [...], # behavior mimics emacs's include/exclude rule
395             );
396             die "Failed" unless $res->[0] == 200;
397              
398             =head1 DESCRIPTION
399              
400             Export contacts in Org document to VCF (vCard addressbook).
401              
402             My use case: I maintain my addressbook in an Org document C<addressbook.org>
403             which I regularly export to VCF and then import to Android phones.
404              
405             How contacts are found in an Org document: each contact is written in an Org
406             headline (of whatever level) in a rather free-form format, e.g.:
407              
408             ** dad # [2014-01-25 Sat] :remind_anniv:
409             - fullname :: frasier crane
410             - birthday :: [1900-01-02 ]
411             - cell :: 0811 000 0001
412             - some note
413             *** TODO get dad's jakarta office number
414              
415             Todo items (headline with todo labels) are currently excluded.
416              
417             Contact fields are searched in list items. Currently Indonesian and English
418             phrases are supported. If name field is not found, the title of the headline is
419             used. I use timestamps a lot, so currently timestamps are stripped from headline
420             titles.
421              
422             Perl-style comments (with C<#> to the end of the line) are allowed.
423              
424             Org-contacts format is also supported, where fields are stored in a properties
425             drawer:
426              
427             * Friends
428             ** Dave Null
429             :PROPERTIES:
430             :EMAIL: dave@null.com
431             :END:
432             This is one of my friend.
433             *** TODO Call him for the party
434              
435             =head1 FUNCTIONS
436              
437              
438             =head2 org_to_vcf(%args) -> [status, msg, result, meta]
439              
440             Export contacts in Org document to VCF (vCard addressbook).
441              
442             Arguments ('*' denotes required arguments):
443              
444             =over 4
445              
446             =item * B<default_country> => I<str>
447              
448             Specify default country code.
449              
450             Free-form phone numbers on phone fields are formatted by this function, e.g.
451             C<081 123 4567> becomes C<0811234567>. If default country is specified (e.g.
452             "ID"), the number will be formatted as C<+62811234567>. Setting this option is
453             recommended so the phone numbers are nicely formatted as international number.
454              
455             =item * B<exclude_tags> => I<array>
456              
457             Exclude trees that carry one of these tags.
458              
459             If the whole document doesn't have any of these tags, then the whole document
460             will be exported. Otherwise, trees that do not carry one of these tags will be
461             excluded. If a selected tree is a subtree, the heading hierarchy above it will
462             also be selected for export, but not the text below those headings.
463              
464             exclude_tags is evaluated after include_tags.
465              
466             =item * B<export_notes> => I<bool> (default: 1)
467              
468             Whether to export note fields.
469              
470             =item * B<include_tags> => I<array>
471              
472             Include trees that carry one of these tags.
473              
474             Works like Org's 'org-export-select-tags' variable. If the whole document
475             doesn't have any of these tags, then the whole document will be exported.
476             Otherwise, trees that do not carry one of these tags will be excluded. If a
477             selected tree is a subtree, the heading hierarchy above it will also be selected
478             for export, but not the text below those headings.
479              
480             =item * B<source_file> => I<str>
481              
482             Source Org file to export.
483              
484             =item * B<source_str> => I<str>
485              
486             Alternatively you can specify Org string directly.
487              
488             =item * B<target_file> => I<str>
489              
490             VCF file to write to.
491              
492             If not specified, VCF output string will be returned instead.
493              
494             =back
495              
496             Return value:
497              
498             Returns an enveloped result (an array).
499              
500             First element (status) is an integer containing HTTP status code
501             (200 means OK, 4xx caller error, 5xx function error). Second element
502             (msg) is a string containing error message, or 'OK' if status is
503             200. Third element (result) is optional, the actual result. Fourth
504             element (meta) is called result metadata and is optional, a hash
505             that contains extra information.
506              
507             (any)
508              
509             =for Pod::Coverage ^(default_country|export|export_.+)$
510              
511             =head1 TODO
512              
513             This is an early release. An important node, addresses (ADR), not parsed yet
514             because my use case is currently for phone's phonebook.
515              
516             Other unimplemented nodes are listed in the source code.
517              
518             =head1 SEE ALSO
519              
520             For more information about Org document format, visit http://orgmode.org/
521              
522             L<Org::Parser>
523              
524             L<Text::vCard>
525              
526             Org-contacts: http://julien.danjou.info/projects/emacs-packages#org-contacts
527              
528             =head1 HOMEPAGE
529              
530             Please visit the project's homepage at L<https://metacpan.org/release/Org-To-VCF>.
531              
532             =head1 SOURCE
533              
534             Source repository is at L<https://github.com/sharyanto/perl-Org-To-VCF>.
535              
536             =head1 BUGS
537              
538             Please report any bugs or feature requests on the bugtracker website L<https://rt.cpan.org/Public/Dist/Display.html?Name=Org-To-VCF>
539              
540             When submitting a bug or request, please include a test-file or a
541             patch to an existing test-file that illustrates the bug or desired
542             feature.
543              
544             =head1 AUTHOR
545              
546             Steven Haryanto <stevenharyanto@gmail.com>
547              
548             =head1 COPYRIGHT AND LICENSE
549              
550             This software is copyright (c) 2014 by Steven Haryanto.
551              
552             This is free software; you can redistribute it and/or modify it under
553             the same terms as the Perl 5 programming language system itself.
554              
555             =cut