From 5b1f1f289cc5bad83dec16212c5d078b4cd4ca5e Mon Sep 17 00:00:00 2001 From: Packit Date: Sep 24 2020 10:33:12 +0000 Subject: perl-Net-CardDAVTalk-0.09 base --- diff --git a/Changes b/Changes new file mode 100644 index 0000000..2c782e5 --- /dev/null +++ b/Changes @@ -0,0 +1,32 @@ +Revision history for Net-CardDAVTalk + +0.09 Fri Mar 2 13:09 2018 + - add VCard updates for 'n' setting on groups and VPhoto + +0.08 Wed Nov 15 16:04 2017 + - always request syncToken and fetch canSync + +0.07 Sun Nov 12 18:07 2017 + - add BATCHSIZE support to GetContactsMulti + - set $Net::CardDAVTalk::BATCHSIZE = 100 (default, overridable) + +0.06 Sun Nov 12 17:45 2017 + - add GetContactLinks + - add SyncContactLinks + - add GetContactsMulti + +0.05 Wed Feb 15 16:51 2017 + - depend on List::MoreUtils, it's not always an included battery + +0.04 Wed Feb 15 10:31 2017 + - added ACL support from FastMail + +0.03 Tue May 3 09:38 2016 + - added dependencies to satisfy tests + +0.02 + - added POD + +0.01 Date/time + First version, released on an unsuspecting world. + diff --git a/MANIFEST b/MANIFEST new file mode 100644 index 0000000..ea5f37a --- /dev/null +++ b/MANIFEST @@ -0,0 +1,12 @@ +Changes +lib/Net/CardDAVTalk.pm +lib/Net/CardDAVTalk/VCard.pm +Makefile.PL +MANIFEST This list of files +README +t/00-load.t +t/manifest.t +t/pod-coverage.t +t/pod.t +META.yml Module YAML meta-data (added by MakeMaker) +META.json Module JSON meta-data (added by MakeMaker) diff --git a/META.json b/META.json new file mode 100644 index 0000000..9480b43 --- /dev/null +++ b/META.json @@ -0,0 +1,49 @@ +{ + "abstract" : "A library for talking to CardDAV servers", + "author" : [ + "Bron Gondwana " + ], + "dynamic_config" : 1, + "generated_by" : "ExtUtils::MakeMaker version 7.0401, CPAN::Meta::Converter version 2.150005", + "license" : [ + "unknown" + ], + "meta-spec" : { + "url" : "http://search.cpan.org/perldoc?CPAN::Meta::Spec", + "version" : "2" + }, + "name" : "Net-CardDAVTalk", + "no_index" : { + "directory" : [ + "t", + "inc" + ] + }, + "prereqs" : { + "build" : { + "requires" : { + "Test::More" : "0" + } + }, + "configure" : { + "requires" : { + "ExtUtils::MakeMaker" : "0" + } + }, + "runtime" : { + "requires" : { + "Date::Format" : "2.24", + "File::MMagic" : "1.30", + "List::MoreUtils" : "0.01", + "List::Pairwise" : "1.00", + "Net::DAVTalk" : "0.08", + "Text::VCardFast" : "0.07", + "XML::Spice" : "0.04", + "perl" : "5.006" + } + } + }, + "release_status" : "stable", + "version" : "0.09", + "x_serialization_backend" : "JSON::PP version 2.27300" +} diff --git a/META.yml b/META.yml new file mode 100644 index 0000000..3825d90 --- /dev/null +++ b/META.yml @@ -0,0 +1,30 @@ +--- +abstract: 'A library for talking to CardDAV servers' +author: + - 'Bron Gondwana ' +build_requires: + Test::More: '0' +configure_requires: + ExtUtils::MakeMaker: '0' +dynamic_config: 1 +generated_by: 'ExtUtils::MakeMaker version 7.0401, CPAN::Meta::Converter version 2.150005' +license: unknown +meta-spec: + url: http://module-build.sourceforge.net/META-spec-v1.4.html + version: '1.4' +name: Net-CardDAVTalk +no_index: + directory: + - t + - inc +requires: + Date::Format: '2.24' + File::MMagic: '1.30' + List::MoreUtils: '0.01' + List::Pairwise: '1.00' + Net::DAVTalk: '0.08' + Text::VCardFast: '0.07' + XML::Spice: '0.04' + perl: '5.006' +version: '0.09' +x_serialization_backend: 'CPAN::Meta::YAML version 0.018' diff --git a/Makefile.PL b/Makefile.PL new file mode 100644 index 0000000..f3d1ec6 --- /dev/null +++ b/Makefile.PL @@ -0,0 +1,31 @@ +use 5.006; +use strict; +use warnings FATAL => 'all'; +use ExtUtils::MakeMaker; + +WriteMakefile( + NAME => 'Net::CardDAVTalk', + AUTHOR => q{Bron Gondwana }, + VERSION_FROM => 'lib/Net/CardDAVTalk.pm', + ABSTRACT_FROM => 'lib/Net/CardDAVTalk.pm', + LICENSE => 'Artistic_2_0', + PL_FILES => {}, + MIN_PERL_VERSION => 5.006, + CONFIGURE_REQUIRES => { + 'ExtUtils::MakeMaker' => 0, + }, + BUILD_REQUIRES => { + 'Test::More' => 0, + }, + PREREQ_PM => { + 'List::Pairwise' => '1.00', + 'List::MoreUtils' => '0.01', + 'XML::Spice' => '0.04', + 'Net::DAVTalk' => '0.08', + 'Text::VCardFast' => '0.07', + 'File::MMagic' => '1.30', + 'Date::Format' => '2.24', + }, + dist => { COMPRESS => 'gzip -9f', SUFFIX => 'gz', }, + clean => { FILES => 'Net-CardDAVTalk-*' }, +); diff --git a/README b/README new file mode 100644 index 0000000..0e7b904 --- /dev/null +++ b/README @@ -0,0 +1,85 @@ +Net-CardDAVTalk + +The README is used to introduce the module and provide instructions on +how to install the module, any machine dependencies it may have (for +example C compilers and installed libraries) and any other information +that should be provided before the module is installed. + +A README file is required for CPAN modules since CPAN extracts the README +file from a module distribution so that people browsing the archive +can use it to get an idea of the module's uses. It is usually a good idea +to provide version information here so that people can decide whether +fixes for the module are worth downloading. + + +INSTALLATION + +To install this module, run the following commands: + + perl Makefile.PL + make + make test + make install + +SUPPORT AND DOCUMENTATION + +After installing, you can find documentation for this module with the +perldoc command. + + perldoc Net::CardDAVTalk + +You can also look for information at: + + RT, CPAN's request tracker (report bugs here) + http://rt.cpan.org/NoAuth/Bugs.html?Dist=Net-CardDAVTalk + + AnnoCPAN, Annotated CPAN documentation + http://annocpan.org/dist/Net-CardDAVTalk + + CPAN Ratings + http://cpanratings.perl.org/d/Net-CardDAVTalk + + Search CPAN + http://search.cpan.org/dist/Net-CardDAVTalk/ + + +LICENSE AND COPYRIGHT + +Copyright (C) 2015 Bron Gondwana + +This program is free software; you can redistribute it and/or modify it +under the terms of the the Artistic License (2.0). You may obtain a +copy of the full license at: + +L + +Any use, modification, and distribution of the Standard or Modified +Versions is governed by this Artistic License. By using, modifying or +distributing the Package, you accept this license. Do not use, modify, +or distribute the Package, if you do not accept this license. + +If your Modified Version has been derived from a Modified Version made +by someone other than you, you are nevertheless required to ensure that +your Modified Version complies with the requirements of this license. + +This license does not grant you the right to use any trademark, service +mark, tradename, or logo of the Copyright Holder. + +This license includes the non-exclusive, worldwide, free-of-charge +patent license to make, have made, use, offer to sell, sell, import and +otherwise transfer the Package with respect to any patent claims +licensable by the Copyright Holder that are necessarily infringed by the +Package. If you institute patent litigation (including a cross-claim or +counterclaim) against any party alleging that the Package constitutes +direct or contributory patent infringement, then this Artistic License +to you shall terminate on the date that such litigation is filed. + +Disclaimer of Warranty: THE PACKAGE IS PROVIDED BY THE COPYRIGHT HOLDER +AND CONTRIBUTORS "AS IS' AND WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES. +THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +PURPOSE, OR NON-INFRINGEMENT ARE DISCLAIMED TO THE EXTENT PERMITTED BY +YOUR LOCAL LAW. UNLESS REQUIRED BY LAW, NO COPYRIGHT HOLDER OR +CONTRIBUTOR WILL BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, OR +CONSEQUENTIAL DAMAGES ARISING IN ANY WAY OUT OF THE USE OF THE PACKAGE, +EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + diff --git a/lib/Net/CardDAVTalk.pm b/lib/Net/CardDAVTalk.pm new file mode 100644 index 0000000..84e6bd4 --- /dev/null +++ b/lib/Net/CardDAVTalk.pm @@ -0,0 +1,830 @@ +package Net::CardDAVTalk; + +use 5.006; +use strict; +use warnings FATAL => 'all'; + + +use Net::DAVTalk; +use base qw(Net::DAVTalk); + +use Carp; +use Text::VCardFast qw(vcard2hash); +use XML::Spice; +use URI::Escape qw(uri_unescape); +use Net::CardDAVTalk::VCard; +use Data::Dumper; + + +=head1 NAME + +Net::CardDAVTalk - A library for talking to CardDAV servers + +=head1 VERSION + +Version 0.09 + +=cut + +our $VERSION = '0.09'; + +our $BATCHSIZE = 100; + + +=head1 SYNOPSIS + +This module maps from CardDAV to an old version of the FastMail API. +It's mostly useful as an example of how to talk CardDAV and for the +Cyrus IMAP test suite Cassandane. + + use Net::CardDAVTalk; + + my $foo = Net::CardDAVTalk->new(); + ... + +=head1 SUBROUTINES/METHODS + +=head2 $class->new() + +Takes the same arguments as Net::DAVTalk and adds the single +namespace: + + C => 'urn:ietf:params:xml:ns:carddav' + +=cut + +sub new { + my ($Class, %Params) = @_; + + $Params{homesetns} = 'C'; + $Params{homeset} = 'addressbook-home-set'; + $Params{wellknown} = 'carddav'; + + my $Self = $Class->SUPER::new(%Params); + + $Self->ns(C => 'urn:ietf:params:xml:ns:carddav'); + $Self->ns(CY => 'http://cyrusimap.org/ns/'); + + return $Self; +} + +# Address book methods {{{ + +=head2 $self->NewAddressBook($Path, %Args) + +Creates a new addressbook collection. Requires the full +path (unlike Net::CalDAVTalk, which creates paths by UUID) +and takes a single argument, the name: + +e.g. + + $CardDAV->NewAddressBook("Default", name => "Addressbook"); + +=cut + +sub NewAddressBook { + my ($Self, $Path, %Args) = @_; + + $Path || confess 'New address book path not specified'; + + $Self->Request( + 'MKCOL', + "$Path/", + x('D:mkcol', $Self->NS(), + x('D:set', + x('D:prop', + x('D:resourcetype', + x('D:collection'), + x('C:addressbook'), + ), + x('D:displayname', $Args{name}), + ), + ), + ), + ); + + return $Path; +} + +=head2 $self->DeleteAddressBook($Path) + +Deletes the addressbook at the given path + +e.g. + + $CardDAV->DeleteAddressBook("Shared"); + +=cut + +sub DeleteAddressBook { + my ($Self, $Path) = @_; + + $Path || confess 'Delete address book path not specified'; + + $Self->Request( + 'DELETE', + "$Path/" + ); + + return 1; +} + +=head2 $self->UpdateAddressBook($Path, %Args) + +Like 'new', but for an existing addressbook. For now, can only change +the name. + +e.g. + + $CardDAV->UpdateAddressBook("Default", name => "My Happy Addressbook"); + +=cut + +sub UpdateAddressBook { + my ($Self, $Path, %Args) = @_; + + $Path || confess 'Update address book path not specified'; + + my @Params; + + if (defined $Args{name}) { + push @Params, x('D:displayname', $Args{name}); + } + + return undef unless @Params; + + $Self->Request( + 'PROPPATCH', + "$Path/", + x('D:propertyupdate', $Self->NS(), + x('D:set', + x('D:prop', + @Params, + ), + ), + ), + ); + + return 1; +} + +=head2 $self->GetAddressBook($Path, %Args) + +Calls 'GetAddressBooks' with the args, and greps for the one with the +matching path. + +e.g. + + my $AB = $CardDAV->GetAddressBook("Default"); + +=cut + +sub GetAddressBook { + my ($Self, $Id, %Args) = @_; + + my $Data = $Self->GetAddressBooks(%Args); + + die "Can't read data" unless $Data; + my ($AddressBook) = grep { $_->{path} eq $Id } @$Data; + + return $AddressBook; +} + +=head2 $self->GetAddressBooks(%Args) + +Get all the addressbooks on the server. + +Returns an arrayref of hashrefs + +e.g. + + my $ABs = $CardDAV->GetAddressBooks(Sync => 1); + foreach my $AB (@$ABs) { + say "$AB->{path}: $AB->{name}"; + } + +=cut + +sub GetAddressBooks { + my ($Self, %Args) = @_; + + my $props = $Args{Properties} || []; + + my $Response = $Self->Request( + 'PROPFIND', + '', + x('D:propfind', $Self->NS(), + x('D:prop', + x('D:displayname'), + x('D:resourcetype'), + x('D:current-user-privilege-set'), + x('D:acl'), + x('D:sync-token'), + x('D:supported-report-set'), + @$props, + ), + ), + Depth => 1, + ); + + my @AddressBooks; + + my $NS_C = $Self->ns('C'); + my $NS_D = $Self->ns('D'); + my $NS_CY = $Self->ns('CY'); + foreach my $Response (@{$Response->{"{$NS_D}response"} || []}) { + my $HRef = $Response->{"{$NS_D}href"}{content} + || next; + my $Path = $Self->_unrequest_url($HRef); + + foreach my $Propstat (@{$Response->{"{$NS_D}propstat"} || []}) { + next unless $Propstat->{"{$NS_D}prop"}{"{$NS_D}resourcetype"}{"{$NS_C}addressbook"}; + + # XXX - this is really quite specific and probably wrong-namespaced... + my $Perms = $Propstat->{"{$NS_D}prop"}{"{$NS_D}current-user-privilege-set"}{"{$NS_D}privilege"}; + + my $CanSync; + my $Report = $Propstat->{"{$NS_D}prop"}{"{$NS_D}supported-report-set"}{"{$NS_D}supported-report"}; + $Report = [] unless ($Report and ref($Report) eq 'ARRAY'); + foreach my $item (@$Report) { + # XXX - do we want to check the other things too? + $CanSync = 1 if $item->{"{$NS_D}report"}{"{$NS_D}sync-collection"}; + } + + my @ShareWith; + my $ace = $Propstat->{"{$NS_D}prop"}{"{$NS_D}acl"}{"{$NS_D}ace"}; + $ace = [] unless ($ace and ref($ace) eq 'ARRAY'); + foreach my $Acl (@$ace) { + next if $Acl->{"{$NS_D}protected"}; # ignore admin ACLs + my $user = uri_unescape($Acl->{"{$NS_D}principal"}{"{$NS_D}href"}{content} // ''); + next unless $user =~ m{^/dav/principals/user/([^/]+)}; + my $email = $1; + next if $email eq 'admin'; + my %ShareObject = ( + email => $email, + mayAdmin => $JSON::false, + mayWrite => $JSON::false, + mayRead => $JSON::false, + ); + foreach my $item (@{$Acl->{"{$NS_D}grant"}{"{$NS_D}privilege"}}) { + $ShareObject{'mayAdmin'} = $JSON::true if $item->{"{$NS_CY}admin"}; + $ShareObject{'mayWrite'} = $JSON::true if $item->{"{$NS_D}write-content"}; + $ShareObject{'mayRead'} = $JSON::true if $item->{"{$NS_D}read"}; + } + + push @ShareWith, \%ShareObject; + } + + my %AddressBook = ( + href => $HRef, + path => $Path, + name => ($Propstat->{"{$NS_D}prop"}{"{$NS_D}displayname"}{content} || ''), + isReadOnly => (grep { exists $_->{"{$NS_D}write-content"} } @{$Perms || []}) ? $JSON::false : $JSON::true, + mayRead => (grep { exists $_->{"{$NS_D}read"} } @{$Perms || []}) ? $JSON::true : $JSON::false, + mayWrite => (grep { exists $_->{"{$NS_D}write-content"} } @{$Perms || []}) ? $JSON::true : $JSON::false, + mayAdmin => (grep { exists $_->{"{$NS_CY}admin"} } @{$Perms || []}) ? $JSON::true : $JSON::false, + shareWith => (@ShareWith ? \@ShareWith : $JSON::false), + syncToken => $Propstat->{"{$NS_D}prop"}{"{$NS_D}sync-token"}{content} || '', + canSync => $CanSync ? $JSON::true : $JSON::false, + ); + + push @AddressBooks, \%AddressBook; + } + } + + return \@AddressBooks; +} + +# }}} + +# Contact methods {{{ + +=head2 $Self->NewContact($AddressBookPath, $VCard) + +Create a new contact from the Net::CardDAVTalk::VCard object, +either using its uid field or generating a new UUID and appending +.vcf for the filename. + +Returns the full path to the card. + +NOTE: can also be used for a kind: group v4 style group. + +=cut + +sub NewContact { + my ($Self, $Path, $VCard) = @_; + + $Path || confess "New contact path not specified"; + $VCard->isa("Net::CardDAVTalk::VCard") || confess "Invalid contact"; + + my $Uid = $VCard->uid() // $VCard->uid($Self->genuuid()); + + $Self->Request( + 'PUT', + "$Path/$Uid.vcf", + $VCard->as_string(), + 'Content-Type' => 'text/vcard', + 'If-None-Match' => '*', + ); + + return $VCard->{CPath} = "$Path/$Uid.vcf"; +} + +=head2 $self->DeleteContact($Path) + +Delete the contact at path $Path. + +=cut + +sub DeleteContact { + my ($Self, $CPath) = @_; + + $CPath || confess "Delete contact path not specified"; + + $Self->Request( + 'DELETE', + $CPath, + ); + + return $CPath; +} + +=head2 $Self->UpdateContact($Path, $VCard) + +Identical to NewContact, but will fail unless there is an +existing contact with that path. Also takes the full path +instead of just the addressbook path. + +NOTE: can also be used for a kind: group v4 style group. + +=cut + +sub UpdateContact { + my ($Self, $CPath, $VCard) = @_; + + $CPath || confess "Update contact path not specified"; + $VCard->isa("Net::CardDAVTalk::VCard") || confess "Invalid contact"; + + $Self->Request( + 'PUT', + $CPath, + $VCard->as_string(), + 'Content-Type' => 'text/vcard', + 'If-Match' => '*', + ); + + return $VCard->{CPath} = $CPath; +} + +=head2 $Self->GetContact($Path) + +Fetch a specific contact by path. Returns a +Net::CardDAVTalk::VCard object. + +=cut + +sub GetContact { + my ($Self, $CPath) = @_; + + $CPath || confess "Get contact path not specified"; + + my $Response = $Self->Request( + 'GET', + $CPath, + ); + + my $Data = $Response && $Response->{content} + // return undef; + + my $VCard = eval { Net::CardDAVTalk::VCard->new_fromstring($Data) } + // return undef; + + $VCard->{CPath} = $CPath; + + return $VCard; +} + +=head2 $Self->GetContactAndProps($Path, $Props) + +Use a multiget to fetch the properties in the arrayref as well +as the card content. + +Returns the card in scalar context - the card and an array of errors +in list context. + +=cut + +sub GetContactAndProps { + my ($Self, $CPath, $Props) = @_; + $Props //= []; + + $CPath || confess "Get contact path not specified"; + + my $Response = $Self->Request( + 'REPORT', + $CPath, + x('C:addressbook-multiget', $Self->NS(), + x('D:prop', + x('D:getetag'), + x('D:getcontenttype'), + x('C:address-data'), + map { x(join ":", @$_) } @$Props, + ), + x('D:href', $CPath), + ), + Depth => '0', + ); + + my ($Contact, @Errors); + + my $NS_C = $Self->ns('C'); + my $NS_D = $Self->ns('D'); + foreach my $Response (@{$Response->{"{$NS_D}response"} || []}) { + foreach my $Propstat (@{$Response->{"{$NS_D}propstat"} || []}) { + my $VCard = eval { $Self->_ParseReportData($Response, $Propstat, $Props) } || do { + push @Errors, $@ if $@; + next; + }; + + $Contact = $VCard; + } + } + + return wantarray ? ($Contact, \@Errors) : $Contact; +} + +=head2 $self->GetContacts($Path, $Props, %Args) + +Get multiple cards, possibly including props, using both a propfind +AND a multiget. + +Returns an arrayref of contact and an arrayref of errors (or just the +contacts in scalar context again) + +=cut + +sub GetContacts { + my ($Self, $Path, $Props) = @_; + + my $data = $Self->GetContactLinks($Path); + my @AllUrls = sort keys %$data; + + my ($Contacts, $Errors, $HRefs) = $Self->GetContactsMulti($Path, \@AllUrls, $Props); + + return wantarray ? ($Contacts, $Errors, $HRefs) : $Contacts; +} + +=head2 $self->GetContactLinks($Path) + +Returns a hash of href => etag for every contact URL (type: text/(x-)?vcard) +inside the collection at \$Path. + +=cut + +sub GetContactLinks { + my ($Self, $Path) = @_; + + my $Response = $Self->Request( + 'PROPFIND', + "$Path/", + x('D:propfind', $Self->NS(), + x('D:prop', + x('D:getcontenttype'), + x('D:getetag'), + ), + ), + Depth => '1', + ); + + my %response; + my $NS_C = $Self->ns('C'); + my $NS_D = $Self->ns('D'); + foreach my $Response (@{$Response->{"{$NS_D}response"} || []}) { + my $href = $Response->{"{$NS_D}href"}{content}; + next unless $href; + if ($Response->{"{$NS_D}prop"}{"{$NS_D}getcontenttype"}) { + my $type = $Response->{"{$NS_D}prop"}{"{$NS_D}getcontenttype"}{content} || ''; + next unless $type =~ m{text/(x-)?vcard}; + } + my $etag = $Response->{"{$NS_D}prop"}{"{$NS_D}getetag"}{content} || ''; + $response{$href} = $etag; + } + + return \%response; +} + +=head2 $self->GetContactsMulti($Path, $Urls, $Props) + +Does an addressbook-multiget on the \$Path for all the URLs in \$Urls +also fetching \$Props on top of the address-data and getetag. + +=cut + +sub GetContactsMulti { + my ($Self, $Path, $Urls, $Props) = @_; + $Props //= []; + my (@Contacts, @Errors, %Links); + + while (my @urls = splice(@$Urls, 0, $BATCHSIZE)) { + my $Response = $Self->Request( + 'REPORT', + "$Path/", + x('C:addressbook-multiget', $Self->NS(), + x('D:prop', + x('D:getetag'), + x('C:address-data'), + map { x(join ":", @$_) } @$Props, + ), + map { x('D:href', $_) } @urls, + ), + Depth => '0', + ); + + my $NS_C = $Self->ns('C'); + my $NS_D = $Self->ns('D'); + foreach my $Response (@{$Response->{"{$NS_D}response"} || []}) { + my $href = $Response->{"{$NS_D}href"}{content}; + next unless $href; + foreach my $Propstat (@{$Response->{"{$NS_D}propstat"} || []}) { + my $etag = $Propstat->{"{$NS_D}prop"}{"{$NS_D}getetag"}{content} || ''; + my $VCard = eval { $Self->_ParseReportData($Response, $Propstat, $Props) } || do { + push @Errors, $@ if $@; + next; + }; + + push @Contacts, $VCard; + + $Links{$href} = $etag; + } + } + } + + return wantarray ? (\@Contacts, \@Errors, \%Links) : \@Contacts; +} + +=head2 $self->SyncContacts($Path, $Props, %Args) + +uses the argument 'syncToken' to find newly added and removed +cards from the server. Returns just the added/changed contacts +in scalar context, or a list of array of contacts, array of +removed, array of errors and the new syncToken as 4 items in +list context. + +=cut + +sub SyncContacts { + my ($Self, $Path, $Props, %Args) = @_; + + my ($Added, $Removed, $Errors, $SyncToken) = $Self->SyncContactLinks($Path, %Args); + + my @AllUrls = sort keys %$Added; + + my ($Contacts, $ThisErrors, $Links) = $Self->GetContactsMulti($Path, \@AllUrls, $Props); + push @$Errors, @$ThisErrors; + + return wantarray ? ($Contacts, $Removed, $Errors, $SyncToken, $Links) : $Contacts; +} + +=head2 $self->SyncContactLinks($Path, %Args) + +uses the argument 'syncToken' to find newly added and removed +cards from the server. + +Returns a list of: + +* Hash of href to etag for added/changed cargs +* List of href of removed cards +* List of errors +* Scalar value of new syncToken + +=cut + +sub SyncContactLinks { + my ($Self, $Path, %Args) = @_; + + $Path || confess "Sync contacts path required"; + + # WebDAV Collection Synchronization (RFC6578) + my $Response = $Self->Request( + 'REPORT', + "$Path/", + x('D:sync-collection', $Self->NS(), + x('D:sync-token', ($Args{syncToken} ? ($Args{syncToken}) : ())), + x('D:sync-level', 1), + x('D:prop', + x('D:getetag'), + ), + ), + ); + + my (%Added, @Removed, @Errors); + + my $NS_C = $Self->ns('C'); + my $NS_D = $Self->ns('D'); + foreach my $Response (@{$Response->{"{$NS_D}response"} || []}) { + my $href = $Response->{"{$NS_D}href"}{content} + || next; + + # For members that have been removed, the DAV:response MUST + # contain one DAV:status with a value set to '404 Not Found' and + # MUST NOT contain any DAV:propstat element + if (!$Response->{"{$NS_D}propstat"}) { + my $Status = $Response->{"{$NS_D}status"}{content}; + if ($Status =~ m/ 404 /) { + push @Removed, $href; + } else { + warn "ODD STATUS"; + push @Errors, "Odd status in non-propstat response $href: $Status"; + } + next; + } + + # For members that have changed (i.e., are new or have had their + # mapped resource modified), the DAV:response MUST contain at + # least one DAV:propstat element and MUST NOT contain any + # DAV:status element. + foreach my $Propstat (@{$Response->{"{$NS_D}propstat"} || []}) { + my $Status = $Propstat->{"{$NS_D}status"}{content}; + + if ($Status =~ m/ 200 /) { + my $etag = $Propstat->{"{$NS_D}prop"}{"{$NS_D}getetag"}{content}; + $Added{$href} = $etag; + } + elsif ($Status =~ m/ 404 /) { + # Missing properties return 404 status response, ignore + + } + else { + warn "ODD STATUS"; + push @Errors, "Odd status in propstat response $href: $Status"; + } + } + } + + my $SyncToken = $Response->{"{$NS_D}sync-token"}{content}; + + return (\%Added, \@Removed, \@Errors, $SyncToken); +} + +=head2 $self->MoveContact($Path, $NewPath) + +Move a contact to a new path (usually in a new addressbook) - both +paths are card paths. + +=cut + +sub MoveContact { + my ($Self, $CPath, $NewPath) = @_; + + $CPath || confess "Move contact path not specified"; + $NewPath || confess "Move contact destination path not specified"; + + $Self->Request( + 'MOVE', + $CPath, + undef, + 'Destination' => $Self->request_url($NewPath), + ); + + return $NewPath; +} + +# }}} + +sub _ParseReportData { + my ($Self, $Response, $Propstat, $Props) = @_; + + my $NS_C = $Self->ns('C'); + my $NS_D = $Self->ns('D'); + + my $HRef = $Response->{"{$NS_D}href"}{content} + // return; + my $CPath = $Self->_unrequest_url($HRef); + + my $Data = $Propstat->{"{$NS_D}prop"}{"{$NS_C}address-data"}{content} + // return; + + my $VCard = Net::CardDAVTalk::VCard->new_fromstring($Data); + return unless $VCard; + + $VCard->{CPath} = $CPath; + $VCard->{href} = $HRef; + + my %Props; + for (@$Props) { + my ($NS, $PropName) = @$_; + my $NS_P = $Self->ns($NS); + my $PropValue = $Propstat->{"{$NS_D}prop"}{"{$NS_P}$PropName"}{content} + // next; + $Props{"${NS}:${PropName}"} = $PropValue; + } + + $VCard->{meta} = \%Props; + + return $VCard; +} + +sub _unrequest_url { + my $Self = shift; + my $Path = shift; + + if ($Path =~ m{^/}) { + $Path =~ s#^\Q$Self->{basepath}\E/?##; + } else { + $Path =~ s#^\Q$Self->{url}\E/?##; + } + $Path =~ s#/$##; + + return $Path; +} + +=head1 AUTHOR + +Bron Gondwana, C<< >> + +=head1 BUGS + +Please report any bugs or feature requests to C, or through +the web interface at L. I will be notified, and then you'll +automatically be notified of progress on your bug as I make changes. + + + + +=head1 SUPPORT + +You can find documentation for this module with the perldoc command. + + perldoc Net::CardDAVTalk + + +You can also look for information at: + +=over 4 + +=item * RT: CPAN's request tracker (report bugs here) + +L + +=item * AnnoCPAN: Annotated CPAN documentation + +L + +=item * CPAN Ratings + +L + +=item * Search CPAN + +L + +=back + + +=head1 ACKNOWLEDGEMENTS + + +=head1 LICENSE AND COPYRIGHT + +Copyright 2015 FastMail Pty. Ltd. + +This program is free software; you can redistribute it and/or modify it +under the terms of the the Artistic License (2.0). You may obtain a +copy of the full license at: + +L + +Any use, modification, and distribution of the Standard or Modified +Versions is governed by this Artistic License. By using, modifying or +distributing the Package, you accept this license. Do not use, modify, +or distribute the Package, if you do not accept this license. + +If your Modified Version has been derived from a Modified Version made +by someone other than you, you are nevertheless required to ensure that +your Modified Version complies with the requirements of this license. + +This license does not grant you the right to use any trademark, service +mark, tradename, or logo of the Copyright Holder. + +This license includes the non-exclusive, worldwide, free-of-charge +patent license to make, have made, use, offer to sell, sell, import and +otherwise transfer the Package with respect to any patent claims +licensable by the Copyright Holder that are necessarily infringed by the +Package. If you institute patent litigation (including a cross-claim or +counterclaim) against any party alleging that the Package constitutes +direct or contributory patent infringement, then this Artistic License +to you shall terminate on the date that such litigation is filed. + +Disclaimer of Warranty: THE PACKAGE IS PROVIDED BY THE COPYRIGHT HOLDER +AND CONTRIBUTORS "AS IS' AND WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES. +THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +PURPOSE, OR NON-INFRINGEMENT ARE DISCLAIMED TO THE EXTENT PERMITTED BY +YOUR LOCAL LAW. UNLESS REQUIRED BY LAW, NO COPYRIGHT HOLDER OR +CONTRIBUTOR WILL BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, OR +CONSEQUENTIAL DAMAGES ARISING IN ANY WAY OUT OF THE USE OF THE PACKAGE, +EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +=cut + +1; # End of Net::CardDAVTalk diff --git a/lib/Net/CardDAVTalk/VCard.pm b/lib/Net/CardDAVTalk/VCard.pm new file mode 100644 index 0000000..bf186a1 --- /dev/null +++ b/lib/Net/CardDAVTalk/VCard.pm @@ -0,0 +1,998 @@ +package Net::CardDAVTalk::VCard; + +use 5.014; +use strict; +use warnings; +use Text::VCardFast qw(vcard2hash hash2vcard); +use Encode qw(decode_utf8 encode_utf8); +use MIME::Base64 qw(decode_base64); +use List::Pairwise qw(mapp); +use List::MoreUtils qw(all pairwise); +use Date::Format qw(strftime); +use File::MMagic; +use Data::Dumper; + +=head1 NAME + +Net::CardDAVTalk::VCard - A wrapper for VCard files + +=head1 SUBROUTINES/METHODS + +# Core {{{ + +=head2 $class->new() + +Create a basic VCard object with no fields set + +=cut + +my $FileMagic = File::MMagic->new; + +sub new { + my $Proto = shift; + my $Class = ref($Proto) || $Proto; + + my $Self = { + type => 'VCARD', + properties => { + version => [ + { + name => "version", + value => "3.0" + }, + ], + } + }; + + return bless $Self, $Class; +} + +=head2 $class->new_fromstring($String) + +Create a new object and populate it by parsing the VCard file +who's contents are given in the string. + +=cut + +sub new_fromstring { + my $Proto = shift; + my $Class = ref($Proto) || $Proto; + my $Data = shift; + + my $Parsed = eval { vcard2hash($Data, multival => [ qw(n adr org) ]) }; + + my $Self = $Parsed->{objects}->[0]; + if ($Self->{type} ne 'vcard') { + warn "Found non-vcard '$Self->{type}' for in $_"; + return undef; + } + + bless $Self, $Class; + + $Self->Normalise(); + + $Self->{_raw} = $Data; + + return $Self; +} + +=head2 $class->new_fromfile($File) + +Given a filename or filehandle, read and parse a vcard from it. + +=cut + +sub new_fromfile { + my $Proto = shift; + my $Class = ref($Proto) || $Proto; + + my $FileR = shift; + + my $Fh; + if (ref $FileR) { + $Fh = $FileR; + } else { + open($Fh, $FileR) + || die "Could not read '$FileR': $!"; + } + + my $Input = do { local $/; <$Fh>; }; + + my $Self = $Class->new_fromstring($Input); + $Self->{file} = $FileR if !ref $FileR; + + return $Self; +} + +=head2 $self->as_string() + +Return a string representation of the VCard (inverse of +new_fromstring) + +=cut + +sub as_string { + my $Self = shift; + delete $Self->{_raw}; + $Self->{_raw} = eval { hash2vcard({ objects => [ $Self ] }) }; + return $Self->{_raw}; +} + +=head2 $self->uid() + +Get or set the uid field of the card. + +=cut + +sub uid { + my $Self = shift; + $Self->V('uid', 'value', @_); +} +sub rev { + my $Self = shift; + $Self->V('rev', 'value', @_); +} + +# }}} + +# ME VCard manipulation {{{ + +my @VParamTypes = qw(work home text voice fax cell cell video pager textphone internet); +push @VParamTypes, map { uc } @VParamTypes; + +my @VItemN = qw(surnames givennames additionalnames honorificprefixs honorificsuffixes); +my @VItemADR = qw(postofficebox extendedaddress streetaddress locality region postalcode countryname); +my @VItemORG = qw(company department); +my %VExpand = (n => \@VItemN, adr => \@VItemADR, org => \@VItemORG); + +my @ProtoPrefixes = ( + [ 'tel', qr/tel:/ ], + [ 'impp', qr/skype:/ ], + [ 'impp', qr/xmpp:/ ], + [ 'x-skype', qr/skype:/ ], + [ 'x-socialprofile', qr/twitter:/ ], +); + +my %ABLabelTypeMap = (Home => 'home', Mobile => 'cell', Twitter => 'twitter'); + +my %VCardEmailTypeMap = ( + home => 'personal', + work => 'work', +); +my %RevVCardEmailTypeMap = reverse %VCardEmailTypeMap; + +my %VCardAdrTypeMap = ( + home => 'home', + work => 'work', +); +my %RevVCardAdrTypeMap = reverse %VCardAdrTypeMap; + +my %VCardTelTypeMap = ( + home => 'home', + work => 'work', + cell => 'mobile', + fax => 'fax', + pager => 'pager', +); +my %RevVCardTelTypeMap = reverse %VCardTelTypeMap; + +my %VCardTypeMap = ( + email => [ \%VCardEmailTypeMap, \%RevVCardEmailTypeMap ], + adr => [ \%VCardAdrTypeMap, \%RevVCardAdrTypeMap ], + tel => [ \%VCardTelTypeMap, \%RevVCardTelTypeMap ], +); + +my %IMPPServiceTypeMap = qw( + skype skype +); + +my %IMPPProtoPrefixes = ( + 'skype' => ['skype'], + 'msn' => ['msn','msnim'], + 'googletalk' => ['xmpp'], + 'facebook' => ['xmpp'], + 'aim' => ['aim'], + 'yahoo' => ['ymsgr'], + 'icq' => ['icq','aim'], + 'jabber' => ['xmpp'], +); + +my %XSocialProfileTypeMap = qw( + twitter twitter +); + +my %XServiceTypeMap = qw( + twitter twitter + skype skype + skype-username skype + aim chat + icq chat + google-talk chat + jabber chat + msn chat + yahoo chat + ms-imaddress chat +); + +my %VCardNewOnlineMap = ( + 'web' => [ + [ 'url' ] + ], + 'chat' => sub { [ + [ 'impp', { 'x-service-type' => 'jabber', 'x-user' => $_[0] } ], + ] }, + 'twitter' => sub { [ + [ 'x-socialprofile', { 'type' => 'twitter', 'x-user' => $_[0] }, "http://twitter.com/$_[0]" ], + [ 'x-twitter' ], + ] }, + 'skype' => sub { [ + [ 'impp', { 'x-service-type' => 'skype', 'x-user' => $_[0] } ], + [ 'x-skype' ], + ] }, + 'other' => sub { [ + [ 'impp', { 'x-user' => $_[0] } ], + ] }, +); + +my $NoteParamName = 'x-menote'; + +sub Normalise { + my $Self = shift; + + $Self->{meta} = {}; + + my $Props = $Self->{properties}; + + # Expand/decode/normalise all values + for (values %$Props) { + + # All properties are array ref of items + for (@$_) { + + # Scalar or array ref (e.g. 'n', 'adr', etc compound fields) + my $Value = $_->{value} // $_->{values}; + + # If non-ascii value, it's utf-8 + for (ref($Value) ? @$Value : $Value) { + if (/[\x80-\xff]/) { + $_ = eval { decode_utf8($_) } // $_; + } + } + + # Expand out 'n' and 'adr' fields into components. + # Put scalars into expanded fields and scalar refs in values arrayref + if (my $VFields = $VExpand{$_->{name}}) { + @$_{@$VFields} = map { $_ // '' } @$Value[0 .. scalar(@$VFields)-1]; + $_->{values} = [ \@$_{@$VFields} ]; + delete $_->{value}; + } + + # Handle base64 encoded value + my $Encoding = $_->{params}->{encoding}; + if (ref($Encoding) && lc $Encoding->[0] eq 'b') { + $Value = decode_base64($Value); + $_->{binary} = 1; + } + + # Expand and lowercase comma separated type= parameters + if (my $Type = $_->{params}->{type}) { + $_->{params}->{type} = $Type = [ $Type ] if !ref $Type; + @$Type = map { split /,/, lc $_ } @$Type; + } + if (my $ServiceType = $_->{params}->{'x-service-type'}) { + $_->{params}->{'x-service-type'} = $ServiceType = [ $ServiceType ] if !ref $ServiceType; + } + + $_->{value} = $Value; + + # Create 'groups' item that tracks items in each group + push @{$Self->{groups}->{$_->{group}}}, $_ if $_->{group}; + } + } + + # Add any X-ABLabel group items as 'label' attribute + if (my $Labels = $Props->{'x-ablabel'}) { + my %LabelMap = map { $_->{group} ? ($_->{group} => $_) : () } @$Labels; + for (keys %$Props) { + next if $_ eq 'x-ablabel'; + for (@{$Props->{$_}}) { + if (my $Label = $LabelMap{$_->{group} // ''}) { + my $LabelV = $_->{label} = $Label->{value}; + $_->{labelref} = $Label; + + # Attach type= param if appropriate + $LabelV = $1 if $LabelV =~ m{^_\$\!<([^>]*)}; + if (my $TypeP = $ABLabelTypeMap{$LabelV}) { + my $TypeList = ($_->{params}->{type} //= []); + push @$TypeList, $TypeP if !grep { $_ eq $TypeP } @$TypeList; + } + } + } + } + } + + # Handle v4 value=uri telephone numbers + my $Version = $Props->{version}; + if ($Version && $Version->[0] >= 4.0) { + for (@ProtoPrefixes) { + my ($Prop, $ProtoRE) = @$_; + if (my $Items = $Props->{$Prop}) { + for (@$Items) { + if ($_->{value} =~ s/^($ProtoRE)//) { + $_->{proto_strip} = $1; + # If we found a uri prefix, better have value=uri param + if (!$_->{params}->{value} && $Prop eq 'tel') { + $_->{params}->{value} = [ 'uri' ]; + } + } + } + } + } + } + + # Create synthetic "online" list. Generate "online_type" and "online_value" + # based on all the different types for twitter and skype contact info + my $Online = $Props->{online} = []; + + # URL:foo.com + for (@{$Props->{url} // []}) { + $_->{online_type} = 'web'; + $_->{online_value} = $_->{value}; + + push @$Online, $_; + } + + # IMPP;X-SERVICE-TYPE=Skype;type=pref:skype:someskype + for (@{$Props->{impp} // []}) { + my $Type = lc(($_->{params}->{'x-service-type'} // [])->[0] // ''); + my $Value = $_->{value}; + my $ProtoPrefixes = $IMPPProtoPrefixes{$Type} // ['x-apple']; + $Value =~ s/^$_:// for @$ProtoPrefixes; + $_->{online_type} = $IMPPServiceTypeMap{$Type} // 'chat'; + $_->{online_value} = $Value; + + push @$Online, $_; + } + + # X-SOCIALPROFILE;type=twitter;x-user=sometwitter:http://twitter.com/sometwitter + for (@{$Props->{'x-socialprofile'} // []}) { + my $Type = lc(($_->{params}->{type} // [])->[0] // ''); + my $Value = $_->{params}->{'x-user'}->[0] // $_->{value}; + $_->{online_type} = $XSocialProfileTypeMap{$Type} // 'other'; + $_->{online_value} = $Value; + + push @$Online, $_; + } + + # X-YAHOO:someyahoo + for my $Type (keys %XServiceTypeMap) { + for (@{$Props->{"x-$Type"} // []}) { + $_->{online_type} = $XServiceTypeMap{$Type}; + $_->{online_value} = $_->{value}; + + push @$Online, $_; + } + } + + # Set contact_type to match API + for ([ 'email', \%VCardEmailTypeMap ], + [ 'tel', \%VCardTelTypeMap ], + [ 'adr', \%VCardAdrTypeMap ]) { + my ($Prop, $Map) = @$_; + + my $Props = $Props->{$Prop} || next; + for (@$Props) { + # Prefer calculated online_type, otherwise case on property name or type params + my ($ContactType) = + map { ($_ && $Map->{$_}) or () } + (($_->{online_type} or ()), $_->{name}, @{$_->{params}->{type} // []}); + + $_->{contact_type} = $ContactType // 'other'; + } + } + + # Check N, FN and VERSION fields are present + if (!$Props->{version}) { + $Self->V('version', 'value', '3.0'); + } + if (!$Props->{n}) { + $Self->V('n', 'surnames', ''); + } + if (!$Props->{fn}) { + $Self->VRebuildFN(); + } + +} + +sub DeleteUnusedLabels { + my ($Self) = @_; + my $Props = $Self->{properties}; + + for (@{$Props->{'x-ablabel'} // []}) { + my $Group = $Self->{groups}->{$_->{group}}; + my $NumItems = grep { !$_->{deleted} } @$Group; + $_->{deleted} = 1 if $NumItems <= 1; + } +} + +sub ReadOnly { + $_[0]->{ReadOnly} = $_[1] if @_ > 1; return $_[0]->{ReadOnly}; +} + +sub V { + my ($Self, $Prop, $Item) = splice @_, 0, 3; + $Item //= 'value'; + my $Props = $Self->{properties}; + + die "Tried to modify read-only contact, fetch directly, not from cache" + if @_ && $Self->{ReadOnly}; + + # Always get/set first item of given type + my $V = $Props->{$Prop} && $Props->{$Prop}->[0]; + + # If setting value, and no existing value, create new + if (!$V && @_) { + $V = $Props->{$Prop}->[0] = { name => $Prop, params => {} }; + + # Create parts if an multipart field + if (my $VFields = $VExpand{$Prop}) { + @$V{@$VFields} = ("") x scalar @$VFields; + $V->{values} = [ \@$V{@$VFields} ]; + } + } + + # Get value + if (!@_) { + return $V ? $V->{$Item} : undef; + + # Set value + } else { + $Self->{vchanged}->{$Prop} = 1; + + local $_ = shift; + + if (defined $_) { + # Trim whitespace and garbage from values + s/^\s+//; + s/\s+$//; + # Ugg, saw U+200B (ZERO WIDTH SPACE) in some data, http://www.perlmonks.org/?node_id=1020973 + s/\p{FORMAT}//g; + } + + # Delete item if not a compound value and setting to empty string or undef + if ((!defined $_ || $_ eq '') && !$V->{values}) { + my $E = shift @{$Props->{$Prop}}; + $E->{deleted} = 1; + } + + # Otherwise store the new value + else { + $V->{$Item} = $_ // ''; + + # Uggg, for compound value, delete if all values empty + if ($Prop ne 'n' && $V->{values} && all { $$_ eq '' } @{$V->{values}} ) { + my $E = shift @{$Props->{$Prop}}; + $E->{deleted} = 1; + } + } + + $Self->DeleteUnusedLabels; + + $Self->VRebuildFN if $Prop eq 'n' || $Prop eq 'org'; + return $_; + } +} + +sub VDate { + my $Self = shift; + local $_ = shift; + + # Convert VCard -> Our format + if (!@_) { + return undef if !$_; + + if (/^(\d{4})-(\d{2})-(\d{2})(?:T|$)/) { + my ($Y, $M, $D) = ($1, $2, $3); + $Y = '0000' if $Y eq '1604'; # iOS magic "no year" value + return "$Y-$M-$D"; + } + + # V4 format + if (/^(\d{4}|--)(\d{2})(\d{2})(?:T|$)/) { + my ($Y, $M, $D) = ($1, $2, $3); + $Y = '0000' if $Y eq '--'; + $Y = '0000' if $Y eq '1604'; # iOS magic "no year" value + return "$Y-$M-$D"; + } + + # Convert Our format -> VCard + } else { + # Delete value if special "empty" value + return undef if $_ eq '0000-00-00'; + + # Our format is V3 format + + # Convert to V4 format if V4 card + if ($Self->V('version') >= 4.0) { + my ($Y, $M, $D) = /^(\d{4})-(\d{2})-(\d{2})/; + $Y = '--' if $Y eq '0000'; + $_ = $Y . $M . $D; + } + + return $_; + } + + return undef; +} +sub VRebuildFN { + my $Self = shift; + + my $NewFN = join " ", map { + $Self->V('n', $_) or () + } qw(honorificprefixs givennames additionalnames surnames); + + my $Suffixes = $Self->V('n', 'honorificsuffixes'); + $NewFN .= ', ' . $Suffixes if $Suffixes; + + # FN is a required field, so we have to set it to something + unless ($NewFN) { + $NewFN = $Self->VCompany(); + } + unless ($NewFN) { + my ($Email) = $Self->VEmails(); + $NewFN = $Email->{value}; + } + unless ($NewFN) { + $NewFN = "No Name"; + } + + $Self->V('fn', 'value', $NewFN); +} + +sub VTitle { + my $Self = shift; + $Self->V('n', 'honorificprefixs', @_) // ''; +} +sub VFirstName { + my $Self = shift; + if (!@_) { + return join " ", map { $_ or () } $Self->V('n', 'givennames'), $Self->V('n', 'additionalnames'); + } else { + my ($GivenNames, $AdditionalNames) = split / +/, $_[0], 2; + $Self->V('n', 'givennames', $GivenNames); + $Self->V('n', 'additionalnames', $AdditionalNames); + } +} +sub VLastName { + my $Self = shift; + $Self->V('n', 'surnames', @_) // ''; +} + +sub VFN { + my $Self = shift; + $Self->V('fn', 'value', @_) // ''; +} + +sub VNickname { + shift->V('nickname', 'value', @_) // ''; +} +sub VBirthday { + my $Self = shift; + if (!@_) { + return $Self->VDate($Self->V('bday')) // '0000-00-00'; + } else { + $Self->V('bday', 'value', $Self->VDate($_[0], 1)); + } +} + +sub VCompany { + shift->V('org', 'company', @_) // ''; +} +sub VDepartment { + shift->V('org', 'department', @_) // ''; +} +sub VPosition { + shift->V('title', 'value', @_) // ''; +} + +sub VNotes { + shift->V('note', 'value', @_) // ''; +} + +my %VBasicTypeMap = (type => 'contact_type', value => 'value'); +my %VOnlineTypeMap = (type => 'online_type', value => 'online_value'); +my %VAdrTypeMap = (type => 'contact_type', street => 'streetaddress', city => 'locality', state => 'region', postcode => 'postalcode', country => 'countryname'); +my %RevVAdrTypeMap = reverse %VAdrTypeMap; + +sub VKN { + my $I = shift; + join "/", map { $I->{$_} } @_; +} + +sub VIsSame { + my ($Self, $Prop, $E, $N) = @_; + + if ($Prop eq 'email' || $Prop eq 'tel') { + # If type or value is same, consider it the same item + return 1 if $N->{contact_type} eq $E->{contact_type} + || $N->{value} eq $E->{value}; + + } elsif ($Prop eq 'adr') { + # If type or value is same, consider it the same item + return 1 if $N->{contact_type} eq $E->{contact_type} + || all { ($N->{$_} // '') eq $E->{$_} } @VItemADR; + + } elsif ($Prop eq 'online') { + # If synthetic online type AND value is same, consider it the same item + return 1 if $N->{contact_type} eq ($E->{online_type} // $E->{contact_type}) + && $N->{value} eq ($E->{online_value} // $E->{value}); + + } else { + die "Unknown prop: $Prop"; + } +} + +sub VUpdateExisting { + my ($Self, $Prop, $E, $N, $TypeMap) = @_; + + # Need to update vcard specific properties + if (my $Maps = $VCardTypeMap{$Prop}) { + if (my $ParamType = $Maps->[1]->{$N->{contact_type}}) { + # Make sure only the single right type is present in the vcard type param + my $Types = ($E->{params}->{type} //= []); + @$Types = grep { !$Maps->[0]->{$_} } @$Types; + push @$Types, $ParamType; + + # Lets try and be smart and update any label + $Self->VUpdateLabel($E, $N) if $Prop eq 'adr'; + } + elsif ($N->{contact_type} eq 'other') { + delete $E->{params}->{type}; + } + + } else { + die "Unknown prop: $Prop"; + } + + # Now copy over value(s) + $E->{$_} = $N->{$_} for values %$TypeMap; +} + +sub VUpdateLabel { + my ($Self, $E, $N) = @_; + + my @Labels; + # In v4, it's a parameter + push @Labels, map { \$_ } @{$E->{params}->{label}}; + + # In v3, it's a separate property. Either in same group... + if (my $Group = $E->{group}) { + for (@{$E->{groups}->{$Group}}) { + push @Labels, \$_->{value} if $_->{name} eq 'label'; + } + } + # ... or check for label with same type (e.g. 'work', 'home', etc) + if (!@Labels) { + my ($EType) = grep { $VCardAdrTypeMap{$_} } @{$E->{params}->{type} // []}; + my $Labels = $Self->{properties}->{label}; + if ($EType && $Labels) { + for (@$Labels) { + my ($Type) = grep { $VCardAdrTypeMap{$_} } @{$_->{params}->{type} // []}; + push @Labels, \$_->{value} if $Type && $Type eq $EType; + } + } + } + + my @EI = @$E{@VItemADR}; + my @NI = @$N{@VItemADR}; + + for my $Label (@Labels) { + pairwise { + $$Label =~ s/\b\Q$a\E\b/$b/ if length $a >= 3; + } @EI, @NI; + } +} + +sub _MakeItem { + my ($Name, $Type, $Value, $Params, @Extra) = @_; + +{ + name => $Name, + contact_type => $Type, + (ref $Value ? 'values' : 'value') => $Value, + params => $Params // {}, + @Extra, + }; +} + +sub VNewItem { + my ($Self, $Prop, $N) = @_; + my $Type = $N->{online_type} // $N->{contact_type}; + my $Value = $N->{online_value} // $N->{value}; + + my @New; + + if (my $Maps = $VCardTypeMap{$Prop}) { + my $Params = {}; + my %Extra; + + # Set vcard type parameter + if (my $ParamType = $Maps->[1]->{$Type}) { + $Params->{type} = [ $ParamType ]; + } + + # Expand address value into array ref components + if ($Prop eq 'adr') { + @Extra{@VItemADR} = @$N{@VItemADR}; + $Value = [ \@Extra{@VItemADR} ]; + } + + $Params->{$NoteParamName} = $N->{note} if $N->{note}; + if ($N->{pref}) { + $Params->{type} //= []; + push @{$Params->{type}}, 'pref'; + } + + push @New, _MakeItem($Prop, $Type, $Value, $Params, %Extra); + } + + elsif ($Prop eq 'online') { + + my $NewMap = $VCardNewOnlineMap{$Type} // $VCardNewOnlineMap{other}; + push @New, _MakeItem($_->[0], $Type, $Value, $_->[1]) + for @{ref $NewMap eq 'CODE' ? $NewMap->($N->{online_value}) : $NewMap}; + } + + else { + die "Unknown prop: $Prop"; + } + + if ($N->{note}) { + $_->{$NoteParamName} = $N->{note} for @New; + } + if ($N->{pref}) { + $_->{pref} = 1 for @New; + } + + return @New; +} + +sub VL { + my ($Self, $Prop, $TypeMap) = splice @_, 0, 3; + my $Props = $Self->{properties}; + + die "Tried to modify read-only contact, fetch directly, not from cache" + if @_ && $Self->{ReadOnly}; + + my @E = grep { !$_->{deleted} } @{$Props->{$Prop} // []}; + + # Easy part, return items + if (!@_) { + my %Seen; + return map { + my $I = $_; + # dedup. this might be wrong if the second has pref or note + my $VKN = VKN($I, values %$TypeMap); + if ($Seen{$VKN}) { + (); + } + else { + $Seen{$VKN} = 1; + my %Props = mapp { ($a => $I->{$b}) } %$TypeMap; + $Props{pref} = 1 if grep { $_ eq 'pref' } @{$_->{params}->{type} // []}; + $Props{note} = $_->{params}->{$NoteParamName}->[0] if $_->{params}->{$NoteParamName}; + \%Props; + } + } @E; + + # Harder part, set items. Try and preserve existing items + } else { + $Self->{vchanged}->{$Prop} = 1; + + # Find exact existing matches moved to different spot + my %EMap = map { VKN($_, values %$TypeMap) => $_ } @E; + + my $Pos = 0; + + my @R; + for my $New (@_) { + my $N = { mapp { $b => ($New->{$a} // '') } %$TypeMap }; + + my @NewItems; + + # Exact existing item exists (maybe different position) + if (my $E = delete $EMap{VKN($N, values %$TypeMap)}) { + push @NewItems, $E; + + } else { + my $E = $E[$Pos]; + + # Same item in same position, update value(s) + # Not for online though, we always replace those + if ($Prop ne 'online' && $E && $Self->VIsSame($Prop, $E, $N)) { + # Don't re-use this item + delete $EMap{VKN($E, values %$TypeMap)}; + + $Self->VUpdateExisting($Prop, $E, $N, $TypeMap); + + push @NewItems, $E; + } + + # Add new item! + else { + push @NewItems, $Self->VNewItem($Prop, $N); + + } + } + + if (my $Note = $New->{note}) { + $_->{params}->{$NoteParamName} = [ $Note ] for @NewItems; + } else { + delete $_->{params}->{$NoteParamName} for @NewItems; + } + + if ($New->{pref}) { + for (@NewItems) { + $_->{params}->{type} //= []; + push @{$_->{params}->{type}}, 'pref'; + } + } else { + for (@NewItems) { + $_->{params}->{type} //= []; + @{$_->{params}->{type}} = grep { $_ ne 'pref' } @{$_->{params}->{type}}; + } + } + + # Always add to result list + push @R, @NewItems; + $Pos += @NewItems; + } + + # For tel, email, adr, just replace list + if ($Prop eq 'email' || $Prop eq 'tel' || $Prop eq 'adr') { + @{$Props->{$Prop}} = @R; + + } elsif ($Prop eq 'online') { + # Maps to multiple props. Delete the old ones of types we're replacing + my %ReplaceTypes = map { $_->{contact_type} => 1 } @R; + $_->{deleted} = 1 for grep { $ReplaceTypes{$_->{online_type}} } @E; + + push @{$Props->{$Prop}}, @R; + + } else { + die "Unknown prop: $Prop"; + } + + $Self->DeleteUnusedLabels; + + $Self->VRebuildFN if $Prop eq 'email'; + } +} + +sub VEmails { + shift->VL('email', \%VBasicTypeMap, @_); +} +sub VPhones { + shift->VL('tel', \%VBasicTypeMap, @_); +} +sub VOnline { + shift->VL('online', \%VOnlineTypeMap, @_); +} +sub VAddresses { + shift->VL('adr', \%VAdrTypeMap, @_); +} + +sub VKind { + shift->V('x-addressbookserver-kind', 'value', @_) // 'contact'; +} + +sub VGroupContactUIDs { + my $Self = shift; + my $Props = $Self->{properties}; + + die "Tried to modify read-only contact, fetch directly, not from cache" + if @_ && $Self->{ReadOnly}; + + if (!@_) { + return + map { s/^urn://; s/^uuid://; $_ } + map { $_->{value} } + @{$Props->{'x-addressbookserver-member'} ||[]}; + + } else { + @{$Props->{'x-addressbookserver-member'}} = map { + { + name => 'x-addressbookserver-member', + params => {}, + value => 'urn:uuid:' . $_, + } + } @{$_[0]}; + + $Self->{vchanged}->{'x-addressbookserver-member'} = 1; + + return @{$_[0]}; + } + +} + +sub VGroupIds { + my $Self = shift; + !@_ || die "You can't set GroupIds on a contact, use ME::CalDAV::UpdateGroups"; + return sort keys %{$Self->{ABGroups} || {}}; +} + +sub VChanged { + my $Self = shift; + return keys %{$Self->{vchanged} // {}}; +} +sub VClearChanged { + my $Self = shift; + delete $Self->{vchanged}; +} + +sub MFlagged { + return shift->MMeta('SF:flagged', @_) || 0; +} +sub MImportance { + # Defaults to empty string, make it a number + return shift->MMeta('CY:importance', @_) || 0; +} +sub MMeta { + my ($Self, $Prop) = (shift, shift); + if (@_) { + $Self->{meta}->{$Prop} = shift; + $Self->{metachanged}->{$Prop} = 1; + } + return $Self->{meta}->{$Prop}; +} + +sub MChanged { + my $Self = shift; + return map { [ $_, $Self->{meta}->{$_} ] } keys %{$Self->{metachanged} // {}}; +} +sub MClearChanged { + my $Self = shift; + delete $Self->{metachanged}; +} + +sub VPhoto { + my $Self = shift; + !@_ || die "You can't set a photo on a contact (yet)"; + my $Prop = $Self->{properties}->{photo}->[0] // undef; + return unless $Prop; + + # XXX assuming binary (inline) + # v2.1: implied + # v3: param VALUE=BINARY + # v4: data: URI + + # XXX assuming v2.1/v3 TYPE= param + my $Type = exists $Prop->{params}->{type}->[0] ? "image/$Prop->{params}->{type}->[0]" : $FileMagic->checktype_contents($Prop->{value}); + + # XXX using REV for modified, probably safe + my $Modified = $Self->{properties}->{rev}->[0]->{value} // strftime("%Y%m%dT%H%M%SZ", @{[gmtime]}); + + # X-ABCROP-RECTANGLE=ABClipRect_1&0&248&640&640&RulkMf15QMtW5L8kpxRZBw== + my $Crop; + if (exists $Prop->{params}->{'x-abcrop-rectangle'}) { + my ($Label, $X, $Y, $W, $H, $Checksum) = split '&', $Prop->{params}->{'x-abcrop-rectangle'}->[0]; + $Crop = { + X => $X, + Y => $Y, + W => $W, + H => $H, + }; + } + + # bundle it all up + return { + Data => $Prop->{value}, + Type => $Type, + Size => length $Prop->{value}, + Modified => $Modified, + ($Crop ? (Crop => $Crop) : ()), + } +} + +# }}} + +1; diff --git a/t/00-load.t b/t/00-load.t new file mode 100644 index 0000000..8801487 --- /dev/null +++ b/t/00-load.t @@ -0,0 +1,13 @@ +#!perl -T +use 5.006; +use strict; +use warnings FATAL => 'all'; +use Test::More; + +plan tests => 1; + +BEGIN { + use_ok( 'Net::CardDAVTalk' ) || print "Bail out!\n"; +} + +diag( "Testing Net::CardDAVTalk $Net::CardDAVTalk::VERSION, Perl $], $^X" ); diff --git a/t/manifest.t b/t/manifest.t new file mode 100644 index 0000000..6ddfe36 --- /dev/null +++ b/t/manifest.t @@ -0,0 +1,15 @@ +#!perl -T +use 5.006; +use strict; +use warnings FATAL => 'all'; +use Test::More; + +unless ( $ENV{RELEASE_TESTING} ) { + plan( skip_all => "Author tests not required for installation" ); +} + +my $min_tcm = 0.9; +eval "use Test::CheckManifest $min_tcm"; +plan skip_all => "Test::CheckManifest $min_tcm required" if $@; + +ok_manifest(); diff --git a/t/pod-coverage.t b/t/pod-coverage.t new file mode 100644 index 0000000..f5cdae3 --- /dev/null +++ b/t/pod-coverage.t @@ -0,0 +1,24 @@ +#!perl -T +use 5.006; +use strict; +use warnings FATAL => 'all'; +use Test::More; + +# Ensure a recent version of Test::Pod::Coverage +my $min_tpc = 1.08; +eval "use Test::Pod::Coverage $min_tpc"; +plan skip_all => "Test::Pod::Coverage $min_tpc required for testing POD coverage" + if $@; + +# Test::Pod::Coverage doesn't require a minimum Pod::Coverage version, +# but older versions don't recognize some common documentation styles +my $min_pc = 0.18; +eval "use Pod::Coverage $min_pc"; +plan skip_all => "Pod::Coverage $min_pc required for testing POD coverage" + if $@; + +plan skip_all => "Author tests not required for installation" + unless $ENV{AUTHOR_TESTING} or $ENV{RELEASE_TESTING}; + +plan tests => 1; +pod_coverage_ok( "Net::CardDAVTalk", "main module is covered is covered" ); diff --git a/t/pod.t b/t/pod.t new file mode 100644 index 0000000..a0054e9 --- /dev/null +++ b/t/pod.t @@ -0,0 +1,12 @@ +#!perl -T +use 5.006; +use strict; +use warnings FATAL => 'all'; +use Test::More; + +# Ensure a recent version of Test::Pod +my $min_tp = 1.22; +eval "use Test::Pod $min_tp"; +plan skip_all => "Test::Pod $min_tp required for testing POD" if $@; + +all_pod_files_ok();