File Coverage

blib/lib/Org/To/VCF.pm
Criterion Covered Total %
statement 130 154 84.4
branch 45 74 60.8
condition 11 21 52.3
subroutine 20 20 100.0
pod 2 4 50.0
total 208 273 76.1


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