File Coverage

blib/lib/Net/CalDAVTalk.pm
Criterion Covered Total %
statement 88 1269 6.9
branch 1 800 0.1
condition 0 246 0.0
subroutine 25 79 31.6
pod 22 22 100.0
total 136 2416 5.6


line stmt bran cond sub pod time code
1             package Net::CalDAVTalk;
2              
3 2     2   182681 use 5.006;
  2         5  
4 2     2   7 use strict;
  2         2  
  2         42  
5 2     2   5 use warnings FATAL => 'all';
  2         7  
  2         78  
6              
7 2     2   1009 use Net::DAVTalk;
  2         858852  
  2         61  
8 2     2   14 use base qw(Net::DAVTalk);
  2         3  
  2         185  
9              
10 2     2   9 use Carp;
  2         3  
  2         98  
11 2     2   873 use Data::ICal;
  2         32724  
  2         18  
12 2     2   850 use Data::ICal::Entry::Event;
  2         492  
  2         15  
13 2     2   805 use Data::ICal::TimeZone;
  2         4976  
  2         17  
14 2     2   886 use Data::ICal::Entry::Alarm::Email;
  2         1478  
  2         18  
15 2     2   796 use Data::ICal::Entry::Alarm::Display;
  2         335  
  2         16  
16 2     2   1168 use DateTime::Format::ICal;
  2         69579  
  2         24  
17 2     2   70 use DateTime::TimeZone;
  2         5  
  2         18  
18 2     2   51 use JSON::XS qw(encode_json);
  2         2  
  2         123  
19 2     2   4854 use Net::CalDAVTalk::TimeZones;
  2         7  
  2         34  
20 2     2   1255 use Text::VCardFast qw(vcard2hash);
  2         4408  
  2         106  
21 2     2   11 use XML::Spice;
  2         2  
  2         15  
22 2     2   57 use MIME::Base64 qw(encode_base64);
  2         3  
  2         64  
23 2     2   781 use MIME::Types;
  2         7092  
  2         97  
24 2     2   901 use Digest::SHA qw(sha1_hex);
  2         3752  
  2         159  
25 2     2   10 use URI::Escape qw(uri_unescape);
  2         4  
  2         164  
26              
27             # monkey patch like a bandit
28             BEGIN {
29 2     2   9 my @properties = Data::ICal::Entry::Alarm::optional_unique_properties();
30 2         10 foreach my $want (qw(uid acknowledged)) {
31 4 50       22 push @properties, $want unless grep { $_ eq $want } @properties;
  10         21  
32             }
33 2     2   7 no warnings 'redefine';
  2         2  
  2         105  
34 2     0   1082 *Data::ICal::Entry::Alarm::optional_unique_properties = sub { @properties };
  0         0  
35             }
36              
37             our (
38             $DefaultCalendarColour,
39             $DefaultDisplayName,
40             );
41              
42             our $UTC = DateTime::TimeZone::UTC->new();
43             our $FLOATING = DateTime::TimeZone::Floating->new();
44             our $LOCALE = DateTime::Locale->load('en_US');
45              
46             # Beginning and End of time as used for "all event" date ranges
47             # Reducing this range may result in events disappearing from FastMail
48             # calendars, as we think they have been deleted from the other end,
49             # so best to avoid this.
50             # However, from my tests, the events should be resurrected once this date
51             # window includes them again.
52              
53             my $BoT = '1970-01-01T00:00:00';
54             my $EoT = '2038-01-19T00:00:00';
55              
56             my (
57             %WeekDayNames,
58             %WeekDayNamesReverse,
59             %DaysByName,
60             %DaysByIndex,
61             %ColourNames,
62             @Frequencies,
63             %RecurrenceProperties,
64             %UTCLinks,
65             %MustBeTopLevel,
66             %EventKeys,
67             );
68              
69             BEGIN {
70 2     2   13 %WeekDayNames = (
71             su => 'sunday',
72             mo => 'monday',
73             tu => 'tuesday',
74             we => 'wednesday',
75             th => 'thursday',
76             fr => 'friday',
77             sa => 'saturday',
78             );
79 2         12 %WeekDayNamesReverse = reverse %WeekDayNames;
80              
81 2         6 %DaysByName = (
82             su => 0,
83             mo => 1,
84             tu => 2,
85             we => 3,
86             th => 4,
87             fr => 5,
88             sa => 6,
89             );
90              
91 2         12 %DaysByIndex = reverse %DaysByName;
92 2         3 $DefaultCalendarColour = '#0252D4';
93 2         2 $DefaultDisplayName = 'Untitled Calendar';
94 2         4 @Frequencies = qw{yearly monthly weekly daily hourly minutely secondly};
95              
96 2         132 %EventKeys = (
97             '' => {
98             uid => [0, 'string', 1, undef],
99             relatedTo => [0, 'string', 0, undef],
100             prodId => [0, 'string', 0, undef],
101             created => [0, 'utcdate', 0, undef],
102             updated => [0, 'utcdate', 1, undef],
103             sequence => [0, 'number', 0, undef],
104             title => [0, 'string', 0, ''],
105             description => [0, 'string', 0, ''],
106             links => [0, 'object', 0, undef],
107             locale => [0, 'string', 0, undef],
108             localizations => [0, 'patch', 0, undef],
109             locations => [0, 'object', 0, undef],
110             isAllDay => [0, 'bool', 0, $JSON::false],
111             start => [0, 'localdate', 1, undef],
112             timeZone => [0, 'timezone', 0, undef],
113             duration => [0, 'duration', 0, undef],
114             recurrenceRule => [0, 'object', 0, undef],
115             recurrenceOverrides => [0, 'patch', 0, undef],
116             status => [0, 'string', 0, undef],
117             showAsFree => [0, 'bool', 0, undef],
118             replyTo => [0, 'object', 0, undef],
119             participants => [0, 'object', 0, undef],
120             useDefaultAlerts => [0, 'bool', 0, $JSON::false],
121             alerts => [0, 'object', 0, undef],
122             },
123             replyTo => {
124             imip => [0, 'mailto', 0, undef],
125             web => [0, 'href', 0, undef],
126             },
127             links => {
128             href => [0, 'string', 1, undef],
129             type => [0, 'string', 0, undef],
130             size => [0, 'number', 0, undef],
131             rel => [0, 'string', 1, undef],
132             title => [0, 'string', 1, undef],
133             properties => [0, 'string', 1, undef],
134             },
135             locations => {
136             name => [0, 'string', 0, undef],
137             accessInstructions => [0, 'string', 0, undef],
138             rel => [0, 'string', 0, 'unknown'],
139             timeZone => [0, 'timezone', 0, undef],
140             address => [0, 'object', 0, undef],
141             coordinates => [0, 'string', 0, undef],
142             uri => [0, 'string', 0, undef],
143             },
144             recurrenceRule => {
145             frequency => [0, 'string', 1, undef],
146             interval => [0, 'number', 0, undef],
147             rscale => [0, 'string', 0, 'gregorian'],
148             skip => [0, 'string', 0, 'omit'],
149             firstDayOfWeek => [0, 'string', 0, 'monday'],
150             byDay => [1, 'object', 0, undef],
151             byDate => [1, 'number', 0, undef],
152             byMonth => [1, 'string', 0, undef],
153             byYearDay => [1, 'number', 0, undef],
154             byWeekNo => [1, 'number', 0, undef],
155             byHour => [1, 'number', 0, undef],
156             byMinute => [1, 'number', 0, undef],
157             bySecond => [1, 'number', 0, undef],
158             bySetPosition => [1, 'number', 0, undef],
159             count => [0, 'number', 0, undef],
160             until => [0, 'localdate', 0, undef],
161             },
162             byDay => {
163             day => [0, 'string', 1, undef],
164             nthOfPeriod => [0, 'number', 0, undef],
165             },
166             participants => {
167             name => [0, 'string', 1, undef],
168             email => [0, 'string', 1, undef],
169             kind => [0, 'string', 0, 'unknown'],
170             roles => [1, 'string', 1, undef],
171             locationId => [0, 'string', 0, undef],
172             scheduleStatus => [0, 'string', 0, 'needs-action'],
173             schedulePriority => [0, 'string', 0, 'required'],
174             scheduleRSVP => [0, 'bool', 0, $JSON::false],
175             scheduleUpdated => [0, 'utcdate', 0, undef],
176             memberOf => [1, 'string', 0, undef],
177             },
178             alerts => {
179             relativeTo => [0, 'string', 0, 'before-start'],
180             offset => [0, 'duration', 1, undef],
181             action => [0, 'object', 1, undef],
182             },
183             action => {
184             type => [0, 'string', 1, undef],
185             },
186             );
187              
188 2         22 %RecurrenceProperties = (
189             bymonthday => {
190             name => 'byDate',
191             max => 31,
192             signed => 1,
193             },
194             byyearday => {
195             name => 'byYearDay',
196             max => 366,
197             signed => 1,
198             },
199             byweekno => {
200             name => 'byWeekNo',
201             max => 53,
202             signed => 1,
203             },
204             byhour => {
205             name => 'byHour',
206             max => 23,
207             },
208             byminute => {
209             name => 'byMinute',
210             max => 59,
211             },
212             bysecond => {
213             name => 'bySecond',
214             max => 60,
215             },
216             bysetpos => {
217             name => 'bySetPosition',
218             max => 366,
219             signed => 1,
220             },
221             );
222              
223 2         2 %MustBeTopLevel = map { $_ => 1 } qw{
  16         19  
224             uid
225             relatedTo
226             prodId
227             isAllDay
228             recurrenceRule
229             recurrenceOverrides
230             replyTo
231             participantId
232             };
233             # not in tc-api / JMAP, but necessary for iMIP
234 2         5 $MustBeTopLevel{method} = 1;
235              
236             # Colour names defined in CSS Color Module Level 3
237             # http://www.w3.org/TR/css3-color/
238              
239             %ColourNames
240 2         6 = map { $_ => 1 }
  294         328  
241             qw{
242             aliceblue
243             antiquewhite
244             aqua
245             aquamarine
246             azure
247             beige
248             bisque
249             black
250             blanchedalmond
251             blue
252             blueviolet
253             brown
254             burlywood
255             cadetblue
256             chartreuse
257             chocolate
258             coral
259             cornflowerblue
260             cornsilk
261             crimson
262             cyan
263             darkblue
264             darkcyan
265             darkgoldenrod
266             darkgray
267             darkgreen
268             darkgrey
269             darkkhaki
270             darkmagenta
271             darkolivegreen
272             darkorange
273             darkorchid
274             darkred
275             darksalmon
276             darkseagreen
277             darkslateblue
278             darkslategray
279             darkslategrey
280             darkturquoise
281             darkviolet
282             deeppink
283             deepskyblue
284             dimgray
285             dimgrey
286             dodgerblue
287             firebrick
288             floralwhite
289             forestgreen
290             fuchsia
291             gainsboro
292             ghostwhite
293             gold
294             goldenrod
295             gray
296             green
297             greenyellow
298             grey
299             honeydew
300             hotpink
301             indianred
302             indigo
303             ivory
304             khaki
305             lavender
306             lavenderblush
307             lawngreen
308             lemonchiffon
309             lightblue
310             lightcoral
311             lightcyan
312             lightgoldenrodyellow
313             lightgray
314             lightgreen
315             lightgrey
316             lightpink
317             lightsalmon
318             lightseagreen
319             lightskyblue
320             lightslategray
321             lightslategrey
322             lightsteelblue
323             lightyellow
324             lime
325             limegreen
326             linen
327             magenta
328             maroon
329             mediumaquamarine
330             mediumblue
331             mediumorchid
332             mediumpurple
333             mediumseagreen
334             mediumslateblue
335             mediumspringgreen
336             mediumturquoise
337             mediumvioletred
338             midnightblue
339             mintcream
340             mistyrose
341             moccasin
342             navajowhite
343             navy
344             oldlace
345             olive
346             olivedrab
347             orange
348             orangered
349             orchid
350             palegoldenrod
351             palegreen
352             paleturquoise
353             palevioletred
354             papayawhip
355             peachpuff
356             peru
357             pink
358             plum
359             powderblue
360             purple
361             red
362             rosybrown
363             royalblue
364             saddlebrown
365             salmon
366             sandybrown
367             seagreen
368             seashell
369             sienna
370             silver
371             skyblue
372             slateblue
373             slategray
374             slategrey
375             snow
376             springgreen
377             steelblue
378             tan
379             teal
380             thistle
381             tomato
382             turquoise
383             violet
384             wheat
385             white
386             whitesmoke
387             yellow
388             yellowgreen
389             };
390              
391 2         9696 %UTCLinks = (
392             'Etc/GMT-0' => 1,
393             'Etc/GMT+0' => 1,
394             'Etc/GMT0' => 1,
395             'Etc/GMT' => 1,
396             'Etc/Greenwich' => 1,
397             'Etc/UCT' => 1,
398             'Etc/Universal' => 1,
399             'Etc/UTC' => 1,
400             'Etc/Zulu' => 1,
401             'GMT' => 1,
402             'UCT' => 1,
403             'UTC' => 1,
404             );
405             }
406              
407              
408             =head1 NAME
409              
410             Net::CalDAVTalk - Module to talk CalDAV and give a JSON interface to the data
411              
412             =head1 VERSION
413              
414             Version 0.10
415              
416             =cut
417              
418             our $VERSION = '0.10';
419              
420              
421             =head1 SYNOPSIS
422              
423             This module is the initial release of the code used at FastMail for talking
424             to CalDAV servers. It's quite specific to an early version of our API, so
425             while it might be useful to others, it's being pushed to CPAN more because
426             the Cassandane test suite needs it.
427              
428             See Net::DAVTalk for details on how to specify hosts and paths.
429              
430             my $CalDAV = Net::CalDAVTalk->new(
431             user => $service->user(),
432             password => $service->pass(),
433             host => $service->host(),
434             port => $service->port(),
435             scheme => 'http',
436             url => '/',
437             expandurl => 1,
438             );
439              
440             or using DNS:
441              
442             my $domain = $user;
443             $domain =~ s/.*\@//;
444              
445             my $url;
446             my ($reply) = $Resolver->search("_caldavs._tcp.$domain", "srv");
447             if ($reply) {
448             my @d = $reply->answer;
449             if (@d) {
450             my $host = $d[0]->target();
451             my $port = $d[0]->port();
452             $url = "https://$host";
453             $url .= ":$port" unless $port eq 443;
454             }
455             }
456              
457             This will use the '/.well-known/caldav' address to find the actual current user
458             principal, and from there the calendar-home-set for further operations.
459              
460             my $foo = Net::CalDAVTalk->new(
461             user => $user,
462             password => $password,
463             url => $url,
464             expandurl => 1,
465             );
466              
467              
468             =head1 SUBROUTINES/METHODS
469              
470             =head2 new(%args)
471              
472             Takes the same arguments as Net::DAVTalk and adds the caldav namespaces
473             and some Cyrus specific namespaces for all XML requests.
474              
475             A => 'http://apple.com/ns/ical/'
476             C => 'urn:ietf:params:xml:ns:caldav'
477             CY => 'http://cyrusimap.org/ns/'
478             UF => 'http://cyrusimap.org/ns/userflag/'
479             SF => 'http://cyrusimap.org/ns/sysflag/'
480              
481             =cut
482              
483             sub new {
484 0     0 1   my ($Class, %Params) = @_;
485              
486 0           $Params{homesetns} = 'C';
487 0           $Params{homeset} = 'calendar-home-set';
488 0           $Params{wellknown} = 'caldav';
489              
490 0           my $Self = $Class->SUPER::new(%Params);
491              
492 0           $Self->ns(A => 'http://apple.com/ns/ical/');
493 0           $Self->ns(C => 'urn:ietf:params:xml:ns:caldav');
494 0           $Self->ns(CY => 'http://cyrusimap.org/ns/');
495 0           $Self->ns(UF => 'http://cyrusimap.org/ns/userflag/');
496 0           $Self->ns(SF => 'http://cyrusimap.org/ns/sysflag/');
497              
498 0           return $Self;
499             }
500              
501             =head2 $self->tz($name)
502              
503             Returns a DateTime::TimeZone object for the given name, but caches
504             the result for speed.
505              
506             =cut
507              
508             sub tz {
509 0     0 1   my $Self = shift;
510 0           my $tzName = shift;
511 0 0         return $FLOATING unless defined $tzName;
512 0 0         return $UTC if $UTCLinks{$tzName};
513 0 0         unless (exists $Self->{_tz}{$tzName}) {
514 0           $Self->{_tz}{$tzName} = DateTime::TimeZone->new(name => $tzName);
515             }
516 0           return $Self->{_tz}{$tzName};
517             }
518              
519             =head2 $self->logger(sub { })
520              
521             Sets a function to receive all log messages. Gets called with the first
522             argument being a level name, and then a list of items to log:
523              
524             e.g.
525              
526             $CalDAV->logger(sub {
527             my $level = shift;
528             return if ($level eq 'debug' and not $ENV{DEBUG_CALDAV});
529             warn "LOG $level: $_\n" for @_;
530             });
531              
532             =cut
533              
534             sub logger {
535 0     0 1   my $Self = shift;
536              
537 0 0         if ($@) {
538 0           $Self->{logger} = shift;
539             }
540              
541 0           return $Self->{logger};
542             }
543              
544             =head2 $self->DeleteCalendar($calendarId)
545              
546             Delete the named calendar from the server (shorturl - see Net::DAVTalk)
547              
548             =cut
549              
550             =head2 $Cal->DeleteCalendar($calendarId)
551              
552             Delete the calendar with collection name $calendarId (full or relative path)
553              
554             e.g.
555              
556             $Cal->DeleteCalendar('Default');
557              
558             =cut
559              
560             sub DeleteCalendar {
561 0     0 1   my ($Self, $calendarId) = @_;
562              
563 0 0         unless ($calendarId) {
564 0           confess 'Calendar not specified';
565             }
566              
567             $Self->Request(
568 0           'DELETE',
569             "$calendarId/",
570             );
571              
572 0           return 1;
573             }
574              
575             sub _fixColour {
576 0   0 0     my $color = lc(shift || '');
577              
578 0 0         return $color if $ColourNames{$color};
579 0 0         return $DefaultCalendarColour unless $color =~ m/^\s*(\#[a-f0-9]{3,8})\s*$/;
580 0           $color = $1;
581 0 0         return uc($color) if length($color) == 7;
582              
583             # Optional digit is for transparency (RGBA)
584 0 0         if ( $color =~ m/^#(.)(.)(.).?$/ ) {
585 0           return uc "#$1$1$2$2$3$3";
586             }
587              
588             # Last two digits are for transparency (RGBA)
589 0 0         if ( length($color) == 9 ) {
590 0           return uc(substr($color,0,7));
591             }
592              
593 0           return $DefaultCalendarColour;
594             }
595              
596              
597             =head2 $self->GetCalendar($calendarId)
598              
599             Get a single calendar from the server by calendarId
600             (currently implemented very inefficiently as a get
601             of all calendars. Returns undef if the calendar
602             doesn't exist.
603              
604             e.g
605             my $Calendar = $CalDAV->GetCalendar('Default');
606              
607             =cut
608              
609             sub GetCalendar {
610 0     0 1   my ($Self, $CalendarId) = @_;
611 0           my $Calendars = $Self->GetCalendars();
612 0 0 0       die "No calendars" unless ($Calendars and @$Calendars);
613 0           my ($Calendar) = grep { $_->{id} eq $CalendarId } @$Calendars;
  0            
614 0           return $Calendar;
615             }
616              
617             =head2 $self->GetCalendars(Properties => [])
618              
619             Fetch all the calendars on the server. You can request additional
620             properties, but they aren't parsed well yet.
621              
622             e.g
623              
624             my $Calendars = $CalDAV->GetCalendars();
625             foreach my $Cal (@$Calendars) {
626             # do stuff
627             }
628              
629             =cut
630              
631             sub GetCalendars {
632 0     0 1   my ($Self, %Args) = @_;
633              
634             # XXX To generalise for CPAN:
635             # XXX - the PROPFIND should be D:allprop unless $Args{Properties} is set
636             # XXX - return all properties as object attributes without renaming
637             # XXX - translate property names to our own liking within ME::CalDAV
638              
639 0           my %Properties = map { $_ => 1 } (
640             'D:displayname',
641             'D:resourcetype',
642             'A:calendar-color',
643             'D:current-user-privilege-set',
644             'D:acl',
645             'A:calendar-order',
646             'C:calendar-timezone',
647             'D:sync-token',
648             'D:supported-report-set',
649             'C:supported-calendar-data',
650 0 0         @{$Args{Properties} || []},
  0            
651             );
652              
653             my $Response = $Self->Request(
654             'PROPFIND',
655             '',
656             x('D:propfind', $Self->NS(),
657             x('D:prop',
658 0           map { x($_) } keys %Properties,
  0            
659             ),
660             ),
661             Depth => 1,
662             );
663              
664 0           my @Calendars;
665              
666 0           my $NS_A = $Self->ns('A');
667 0           my $NS_C = $Self->ns('C');
668 0           my $NS_CY = $Self->ns('CY');
669 0           my $NS_D = $Self->ns('D');
670 0 0         foreach my $Response (@{$Response->{"{$NS_D}response"} || []}) {
  0            
671 0 0         next unless $Response->{"{$NS_D}href"}{content};
672 0           my $href = uri_unescape($Response->{"{$NS_D}href"}{content});
673              
674             # grab the short version of the path
675 0           my $calendarId = $Self->shortpath($href);
676             # and remove trailing slash always
677 0           $calendarId =~ s{/$}{};
678              
679 0 0         foreach my $Propstat (@{$Response->{"{$NS_D}propstat"} || []}) {
  0            
680 0 0         next unless $Propstat->{"{$NS_D}prop"}{"{$NS_D}resourcetype"}{"{$NS_C}calendar"};
681              
682             # XXX - this should be moved into ME::CalDAV::GetCalendars()
683 0           my $visData = $Propstat->{"{$NS_D}prop"}{"{$NS_C}X-FM-isVisible"}{content};
684 0 0 0       my $isVisible = (not defined($visData) or $visData) ? $JSON::true : $JSON::false;
685              
686 0           my %Privileges = (
687             mayAdmin => $JSON::false,
688             mayWrite => $JSON::false,
689             mayRead => $JSON::false,
690             mayReadFreeBusy => $JSON::false,
691             );
692              
693 0           my $Priv = $Propstat->{"{$NS_D}prop"}{"{$NS_D}current-user-privilege-set"}{"{$NS_D}privilege"};
694 0 0 0       $Priv = [] unless ($Priv and ref($Priv) eq 'ARRAY');
695 0           foreach my $item (@$Priv) {
696 0 0         $Privileges{'mayAdmin'} = $JSON::true if $item->{"{$NS_CY}admin"};
697 0 0         $Privileges{'mayWrite'} = $JSON::true if $item->{"{$NS_D}write-content"};
698 0 0         $Privileges{'mayRead'} = $JSON::true if $item->{"{$NS_D}read"};
699 0 0         $Privileges{'mayReadFreeBusy'} = $JSON::true if $item->{"{$NS_C}read-free-busy"};
700             }
701              
702 0           my $CanEvent;
703 0           my $Type = $Propstat->{"{$NS_D}prop"}{"{$NS_C}supported-calendar-data"}{"{$NS_C}calendar-data"};
704 0 0 0       $Type = [] unless ($Type and ref($Type) eq 'ARRAY');
705 0           foreach my $item (@$Type) {
706 0 0         next unless $item->{"\@content-type"};
707 0 0         $CanEvent = 1 if $item->{"\@content-type"}{content} eq "application/event+json";
708             }
709              
710             # XXX - temporary compat
711 0 0         $Privileges{isReadOnly} = $Privileges{mayWrite} ? $JSON::false : $JSON::true;
712              
713 0           my @ShareWith;
714 0           my $ace = $Propstat->{"{$NS_D}prop"}{"{$NS_D}acl"}{"{$NS_D}ace"};
715 0 0 0       $ace = [] unless ($ace and ref($ace) eq 'ARRAY');
716 0           foreach my $Acl (@$ace) {
717 0 0         next if $Acl->{"{$NS_D}protected"}; # ignore admin ACLs
718 0 0         next unless $Acl->{"{$NS_D}grant"};
719 0 0         next unless $Acl->{"{$NS_D}grant"}{"{$NS_D}privilege"};
720 0 0         next unless ref($Acl->{"{$NS_D}grant"}{"{$NS_D}privilege"}) eq 'ARRAY';
721             # XXX - freeBusyPublic here? Or should we do it via the web server?
722 0   0       my $user = uri_unescape($Acl->{"{$NS_D}principal"}{"{$NS_D}href"}{content} // '');
723 0 0         next unless $user =~ m{^/dav/principals/user/([^/]+)};
724 0           my $email = $1;
725 0 0         next if $email eq 'admin';
726 0           my %ShareObject = (
727             email => $email,
728             mayAdmin => $JSON::false,
729             mayWrite => $JSON::false,
730             mayRead => $JSON::false,
731             mayReadFreeBusy => $JSON::false,
732             );
733 0           foreach my $item (@{$Acl->{"{$NS_D}grant"}{"{$NS_D}privilege"}}) {
  0            
734 0 0         $ShareObject{'mayAdmin'} = $JSON::true if $item->{"{$NS_CY}admin"};
735 0 0         $ShareObject{'mayWrite'} = $JSON::true if $item->{"{$NS_D}write-content"};
736 0 0         $ShareObject{'mayRead'} = $JSON::true if $item->{"{$NS_D}read"};
737 0 0         $ShareObject{'mayReadFreeBusy'} = $JSON::true if $item->{"{$NS_C}read-free-busy"};
738             }
739              
740 0           push @ShareWith, \%ShareObject;
741             }
742              
743             my %Cal = (
744             id => $calendarId,
745             name => ($Propstat->{"{$NS_D}prop"}{"{$NS_D}displayname"}{content} || $DefaultDisplayName),
746             href => $href,
747             color => _fixColour($Propstat->{"{$NS_D}prop"}{"{$NS_A}calendar-color"}{content}),
748             timeZone => $Propstat->{"{$NS_D}prop"}{"{$NS_C}calendar-timezone"}{content},
749             isVisible => $isVisible,
750             precedence => int($Propstat->{"{$NS_D}prop"}{"{$NS_A}calendar-order"}{content} || 1),
751 0 0 0       syncToken => ($Propstat->{"{$NS_D}prop"}{"{$NS_D}sync-token"}{content} || ''),
    0 0        
      0        
752             shareWith => (@ShareWith ? \@ShareWith : $JSON::false),
753             _can_event => ($CanEvent ? $JSON::true : $JSON::false),
754             %Privileges,
755             );
756              
757              
758 0           push @Calendars, \%Cal;
759             }
760             }
761              
762 0           return \@Calendars;
763             }
764              
765             =head2 $self->NewCalendar($Args)
766              
767             Create a new calendar. The Args are the as the things returned by GetCalendars,
768             except that if you don't provide 'id' (same as shorturl), then a UUID will be
769             generated for you. It's recommended to not provide 'id' unless you need to
770             create a specific path for compatibility with other things, and to use 'name'
771             to identify the calendar for users. 'name' is stored as DAV:displayname.
772              
773             e.g.
774              
775             my $Id = $CalDAV->NewCalendar({name => 'My Calendar', color => 'aqua'});
776              
777             (Color names will be translated based on the CSS name list)
778              
779             =cut
780              
781             sub NewCalendar {
782 0     0 1   my ($Self, $Args) = @_;
783              
784 0 0         unless (ref($Args) eq 'HASH') {
785 0           confess 'Invalid calendar';
786             }
787              
788             # The URL should be "/$calendarId/" but this isn't true with Zimbra (Yahoo!
789             # Calendar). It will accept a MKCALENDAR at "/$calendarId/" but will rewrite
790             # the calendar's URL to be "/$HTMLEscapedDisplayName/". I'm sure MKCALENDAR
791             # should follow WebDAV's MKCOL method here, but it's not specified in CalDAV.
792              
793             # default values
794 0   0       $Args->{id} //= $Self->genuuid();
795 0   0       $Args->{name} //= $DefaultDisplayName;
796              
797 0           my $calendarId = $Args->{id};
798              
799 0           my @Properties;
800              
801 0           push @Properties, x('D:displayname', $Args->{name});
802              
803 0 0         if (exists $Args->{isVisible}) {
804 0 0         push @Properties, x('C:X-FM-isVisible', ($Args->{isVisible} ? 1 : 0));
805             }
806              
807 0 0         if (exists $Args->{color}) {
808 0           push @Properties, x('A:calendar-color', _fixColour($Args->{color}));
809             }
810              
811 0 0         if (exists $Args->{timeZone}) {
812 0           push @Properties, x('C:calendar-timezone', $Args->{timeZone});
813             }
814              
815 0 0         if (exists $Args->{precedence}) {
816 0 0 0       unless (($Args->{precedence} // '') =~ /^\d+$/) {
817 0           confess "Invalid precedence ($Args->{precedence}) (expected int >= 0)";
818             }
819              
820 0           push @Properties, x('A:calendar-order', $Args->{precedence});
821             }
822              
823             $Self->Request(
824 0           'MKCALENDAR',
825             "$calendarId/",
826             x('C:mkcalendar', $Self->NS(),
827             x('D:set',
828             x('D:prop', @Properties),
829             ),
830             ),
831             );
832              
833 0           return $calendarId;
834             }
835              
836             =head2 $self->UpdateCalendar($Args)
837              
838             Like 'NewCalendar', but updates an existing calendar, and 'id' is required.
839             Returns the id, just like NewCalendar.
840              
841             =cut
842              
843             sub UpdateCalendar {
844 0     0 1   my ($Self, $Args, $Prev) = @_;
845              
846 0 0         unless (ref($Args) eq 'HASH') {
847 0           confess 'Invalid calendar';
848             }
849              
850 0           my %Calendar = %{$Args};
  0            
851 0           my $calendarId = $Calendar{id};
852              
853 0 0         unless ($calendarId) {
854 0           confess 'Calendar not specified';
855             }
856              
857 0           my @Params;
858              
859 0 0         if (defined $Calendar{name}) {
860 0           push @Params, x('D:displayname', $Calendar{name});
861             }
862              
863 0 0         if (defined $Calendar{color}) {
864 0           push @Params, x('A:calendar-color', _fixColour($Calendar{color}));
865             }
866              
867 0 0         if (exists $Args->{timeZone}) {
868 0           push @Params, x('C:calendar-timezone', $Args->{timeZone});
869             }
870              
871 0 0         if (exists $Calendar{isVisible}) {
872 0 0         push @Params, x('C:X-FM-isVisible', $Calendar{isVisible} ? 1 : 0);
873             }
874              
875 0 0         if (exists $Calendar{precedence}) {
876 0 0 0       unless (($Calendar{precedence} ||'') =~ /^\d+$/) {
877 0           confess "Invalid precedence ($Calendar{precedence})";
878             }
879              
880 0           push @Params, x('A:calendar-order', $Calendar{precedence});
881             }
882              
883 0 0         return $calendarId unless @Params;
884              
885 0           $Self->Request(
886             'PROPPATCH',
887             "$calendarId/",
888             x('D:propertyupdate', $Self->NS(),
889             x('D:set',
890             x('D:prop',
891             @Params,
892             ),
893             ),
894             ),
895             );
896              
897 0           return $calendarId;
898             }
899              
900             # Event methods
901              
902             =head2 $self->DeleteEvent($Event|$href)
903              
904             Given a single event or the href to the event, delete that event,
905             delete it from the server.
906              
907             Returns true.
908              
909             =cut
910              
911             sub DeleteEvent {
912 0     0 1   my ($Self) = shift;
913 0           my ($Event) = @_;
914              
915 0 0         confess "Need an event" unless $Event;
916              
917 0 0         $Event = { href => $Event, summary => $Event } unless ref($Event) eq 'HASH';
918              
919             $Self->Request(
920             'DELETE',
921             $Event->{href},
922 0           );
923              
924 0           return 1;
925             }
926              
927             =head2 $self->GetEvents($calendarId, %Args)
928              
929             Fetches some or all of the events in a calendar.
930              
931             Supported args:
932              
933             href => [] - perform a multi-get on just these fullpath urls.
934             after+before => ISO8601 - date range to query
935              
936             In scalar context returns an arrayref of events. In list context
937             returns both an arrayref of events and an arrayref of errors:
938              
939             e.g.
940              
941             my ($Events, $Errors) = $CalDAV->GetEvents('Default');
942              
943             =cut
944              
945             sub GetEvents {
946 0     0 1   my ($Self, $calendarId, %Args) = @_;
947              
948             # validate parameters {{{
949              
950 0 0         confess "Need a calendarId" unless $calendarId;
951              
952 0           my @Query;
953 0           my $TopLevel = 'C:calendar-query';
954 0           my $Filter;
955 0 0 0       if ($Args{href}) {
    0 0        
956 0           $TopLevel = 'C:calendar-multiget';
957 0           $Filter = x('D:href', $Args{href});
958             }
959             elsif ($Args{AlwaysRange} || $Args{after} || $Args{before}) {
960 0   0       my $Start = _wireDate($Args{after} || $BoT);
961 0   0       my $End = _wireDate($Args{before} || $EoT);
962              
963 0           $Filter = x('C:filter',
964             x('C:comp-filter', { name => 'VCALENDAR' },
965             x('C:comp-filter', { name => 'VEVENT' },
966             x('C:time-range', {
967             start => $Start->strftime('%Y%m%dT000000Z'),
968             end => $End->strftime('%Y%m%dT000000Z'),
969             }),
970             ),
971             ),
972             );
973             }
974             else {
975 0           $Filter = x('C:filter',
976             x('C:comp-filter', { name => 'VCALENDAR' },
977             x('C:comp-filter', { name => 'VEVENT' },
978             ),
979             ),
980             );
981             }
982 0           my @Annotations;
983 0   0       my $AnnotNames = $Args{Annotations} || [];
984 0           foreach my $key (@$AnnotNames) {
985 0 0         my $name = ($key =~ m/:/ ? $key : "C:$key");
986 0           push @Annotations, x($name);
987             }
988              
989             # }}}
990              
991 0           my %CalProps;
992 0 0         if ($Args{ContentType}) {
993 0           $CalProps{'content-type'} = $Args{ContentType};
994             }
995 0 0         if ($Args{Version}) {
996 0           $CalProps{'version'} = $Args{Version};
997             }
998              
999 0           my $Response = $Self->Request(
1000             'REPORT',
1001             "$calendarId/",
1002             x($TopLevel, $Self->NS(),
1003             x('D:prop',
1004             x('C:calendar-data', \%CalProps),
1005             @Annotations,
1006             ),
1007             $Filter,
1008             ),
1009             Depth => 1,
1010             );
1011              
1012 0 0         return $Response if $Args{Raw};
1013              
1014 0           my (@Events, @Errors);
1015              
1016 0           my $NS_A = $Self->ns('A');
1017 0           my $NS_C = $Self->ns('C');
1018 0           my $NS_D = $Self->ns('D');
1019 0 0         foreach my $Response (@{$Response->{"{$NS_D}response"} || []}) {
  0            
1020 0   0       my $href = uri_unescape($Response->{"{$NS_D}href"}{content} // '');
1021 0 0         next unless $href;
1022 0 0         foreach my $Propstat (@{$Response->{"{$NS_D}propstat"} || []}) {
  0            
1023 0           my $Prop = $Propstat->{"{$NS_D}prop"}{"{$NS_C}calendar-data"};
1024 0           my $Data = $Prop->{content};
1025 0 0         next unless $Data;
1026              
1027 0           my $Event;
1028              
1029 0 0 0       if ($Prop->{'-content-type'} and $Prop->{'-content-type'} =~ m{application/event\+json}) {
1030             # JSON event is in API format already
1031 0           $Event = eval { decode_json($Data) };
  0            
1032             }
1033             else {
1034             # returns an array, but there should only be one UID per file
1035 0           ($Event) = eval { $Self->vcalendarToEvents($Data) };
  0            
1036             }
1037              
1038 0 0         if ($@) {
1039 0           push @Errors, $@;
1040 0           next;
1041             }
1042 0 0         next unless $Event;
1043              
1044 0 0         if ($Args{Full}) {
1045 0           $Event->{_raw} = $Data;
1046             }
1047              
1048 0           $Event->{href} = $href;
1049 0           $Event->{id} = $Self->shortpath($href);
1050              
1051 0           foreach my $key (@$AnnotNames) {
1052 0           my $propns = $NS_C;
1053 0           my $name = $key;
1054 0 0         if ($key =~ m/(.*):(.*)/) {
1055 0           $name = $2;
1056 0           $propns = $Self->ns($1);
1057             }
1058 0           my $AData = $Propstat->{"{$NS_D}prop"}{"{$propns}$name"}{content};
1059 0 0         next unless $AData;
1060 0           $Event->{annotation}{$name} = $AData;
1061             }
1062              
1063 0           push @Events, $Event;
1064             }
1065             }
1066              
1067 0 0         return wantarray ? (\@Events, \@Errors) : \@Events;
1068             }
1069              
1070             =head2 $self->GetEvent($href)
1071              
1072             Just get a single event (calls GetEvents with that href)
1073              
1074             =cut
1075              
1076             sub GetEvent {
1077 0     0 1   my ($Self, $href, %Args) = @_;
1078              
1079             # XXX - API
1080 0           my $calendarId = $href;
1081 0           $calendarId =~ s{/[^/]*$}{};
1082              
1083 0           my ($Events, $Errors) = $Self->GetEvents($calendarId, %Args, href => $Self->fullpath($href));
1084              
1085 0 0         die "Errors @$Errors" if @$Errors;
1086 0 0         die "Multiple items returned for $href" if @$Events > 1;
1087              
1088 0           return $Events->[0];
1089             }
1090              
1091             =head2 $self->GetFreeBusy($calendarId, %Args)
1092              
1093             Like 'GetEvents' but uses a free-busy-query and then generates
1094             synthetic events out of the result.
1095              
1096             Doesn't have a 'href' parameter, just the before/after range.
1097              
1098             =cut
1099              
1100             sub GetFreeBusy {
1101 0     0 1   my ($Self, $calendarId, %Args) = @_;
1102              
1103             # validate parameters {{{
1104              
1105 0 0         confess "Need a calendarId" unless $calendarId;
1106              
1107 0           my @Query;
1108 0 0 0       if ($Args{AlwaysRange} || $Args{after} || $Args{before}) {
      0        
1109 0   0       my $Start = _wireDate($Args{after} || $BoT);
1110 0   0       my $End = _wireDate($Args{before} || $EoT);
1111              
1112 0           push @Query,
1113             x('C:time-range', {
1114             start => $Start->strftime('%Y%m%dT000000Z'),
1115             end => $End->strftime('%Y%m%dT000000Z'),
1116             });
1117             }
1118              
1119             # }}}
1120              
1121 0           my $Response = $Self->Request(
1122             'REPORT',
1123             "$calendarId/",
1124             x('C:free-busy-query', $Self->NS(),
1125             @Query,
1126             ),
1127             Depth => 1,
1128             );
1129              
1130 0 0         my $Data = eval { vcard2hash($Response->{content}, multival => ['rrule'], only_one => 1) }
  0            
1131             or confess "Error parsing VFreeBusy data: $@";
1132              
1133 0           my @result;
1134             my @errors;
1135 0           my $now = DateTime->now();
1136 0           foreach my $item (@{$Data->{objects}[0]{objects}}) {
  0            
1137 0 0         next unless $item->{type} eq 'vfreebusy';
1138 0           foreach my $line (@{$item->{properties}{freebusy}}) {
  0            
1139 0           my ($Start, $End) = split '/', $line->{value};
1140 0           my ($StartTime, $IsAllDay) = $Self->_makeDateObj($Start, 'UTC', 'UTC');
1141 0           my $EndTime;
1142 0 0         if ($End =~ m/^[+-]?P/i) {
1143 0   0       my $Duration = eval { DateTime::Format::ICal->parse_duration(uc $End) }
1144             || next;
1145 0           $EndTime = $StartTime->clone()->add($Duration);
1146             } else {
1147 0           ($EndTime) = $Self->_makeDateObj($End, 'UTC', 'UTC');
1148             }
1149 0           my $duration = $Self->_make_duration($EndTime->subtract_datetime($StartTime));
1150             my $NewEvent = {
1151             timeZone => 'Etc/UTC',
1152             start => $StartTime->iso8601(),
1153             duration => $duration,
1154 0 0 0       title => ($Args{name} // ''),
1155             isAllDay => ($IsAllDay ? $JSON::true : $JSON::false),
1156             updated => $now->iso8601(),
1157             };
1158              
1159             # Generate a uid that should remain the same for this freebusy entry
1160 0           $NewEvent->{uid} = _hexkey($NewEvent) . '-freebusyauto';
1161             $NewEvent->{isAllDay} =
1162 0 0         $NewEvent->{isAllDay} ? $JSON::true : $JSON::false;
1163 0           push @result, $NewEvent;
1164             }
1165             }
1166              
1167 0           warn Dumper(\@result);
1168 0           return (\@result, \@errors);
1169             }
1170              
1171             =head2 $self->SyncEvents($calendarId, %Args)
1172              
1173             Like GetEvents, but if you pass a syncToken argument, then it will
1174             fetch changes since that token (obtained from an earlier GetCalendars
1175             call).
1176              
1177             In scalar context still just returns new events, in list context returns
1178             Events, Removed and Errors.
1179              
1180             e.g.
1181              
1182             my ($Events, $Removed, $Errors) = $CalDAV->SyncEvents('Default', syncToken => '...');
1183              
1184             =cut
1185              
1186             sub SyncEvents {
1187 0     0 1   my ($Self, $calendarId, %Args) = @_;
1188              
1189 0 0         confess "Need a calendarId" unless $calendarId;
1190              
1191 0           my @Annotations;
1192 0   0       my $AnnotNames = $Args{Annotations} || [];
1193 0           foreach my $key (@$AnnotNames) {
1194 0 0         my $name = ($key =~ m/:/ ? $key : "C:$key");
1195 0           push @Annotations, x($name);
1196             }
1197              
1198 0           my %CalProps;
1199 0 0         if ($Args{ContentType}) {
1200 0           $CalProps{'content-type'} = $Args{ContentType};
1201             }
1202 0 0         if ($Args{Version}) {
1203 0           $CalProps{'version'} = $Args{Version};
1204             }
1205              
1206             my $Response = $Self->Request(
1207             'REPORT',
1208             "$calendarId/",
1209             x('D:sync-collection', $Self->NS(),
1210 0 0         x('D:sync-token', ($Args{syncToken} ? ($Args{syncToken}) : ())),
1211             x('D:sync-level', 1),
1212             x('D:prop',
1213             x('D:getetag'),
1214             x('C:calendar-data', \%CalProps),
1215             @Annotations,
1216             ),
1217             ),
1218             );
1219              
1220 0           my (@Events, @Removed, @Errors);
1221              
1222 0           my $NS_A = $Self->ns('A');
1223 0           my $NS_C = $Self->ns('C');
1224 0           my $NS_D = $Self->ns('D');
1225 0 0         foreach my $Response (@{$Response->{"{$NS_D}response"} || []}) {
  0            
1226 0   0       my $href = uri_unescape($Response->{"{$NS_D}href"}{content} // '');
1227 0 0         next unless $href;
1228              
1229 0 0         unless ($Response->{"{$NS_D}propstat"}) {
1230 0           push @Removed, $Self->shortpath($href);
1231 0           next;
1232             }
1233              
1234 0 0         foreach my $Propstat (@{$Response->{"{$NS_D}propstat"} || []}) {
  0            
1235 0           my $status = $Propstat->{"{$NS_D}status"}{content};
1236 0 0         if ($status =~ m/ 200 /) {
1237 0           my $Prop = $Propstat->{"{$NS_D}prop"}{"{$NS_C}calendar-data"};
1238 0           my $Data = $Prop->{content};
1239 0 0         next unless $Data;
1240              
1241 0           my $Event;
1242              
1243 0 0 0       if ($Prop->{'-content-type'} and $Prop->{'-content-type'} =~ m{application/event\+json}) {
1244             # JSON event is in API format already
1245 0           $Event = eval { decode_json($Data) };
  0            
1246             }
1247             else {
1248             # returns an array, but there should only be one UID per file
1249 0           ($Event) = eval { $Self->vcalendarToEvents($Data) };
  0            
1250             }
1251              
1252 0 0         if ($@) {
1253 0           push @Errors, $@;
1254 0           next;
1255             }
1256 0 0         next unless $Event;
1257              
1258 0 0         if ($Args{Full}) {
1259 0           $Event->{_raw} = $Data;
1260             }
1261              
1262 0           $Event->{href} = $href;
1263 0           $Event->{id} = $Self->shortpath($href);
1264              
1265 0           foreach my $key (@$AnnotNames) {
1266 0           my $propns = $NS_C;
1267 0           my $name = $key;
1268 0 0         if ($key =~ m/(.*):(.*)/) {
1269 0           $name = $2;
1270 0           $propns = $Self->ns($1);
1271             }
1272 0           my $AData = $Propstat->{"{$NS_D}prop"}{"{$propns}$name"}{content};
1273 0 0         next unless $AData;
1274 0           $Event->{annotation}{$name} = $AData;
1275             }
1276              
1277 0           push @Events, $Event;
1278             }
1279             else {
1280 0           push @Errors, "Odd status $status";
1281             }
1282             }
1283             }
1284              
1285 0 0         return wantarray ? (\@Events, \@Removed, \@Errors) : \@Events;
1286             }
1287              
1288             =head2 $self->NewEvent($calendarId, $Args)
1289              
1290             Create a new event in the named calendar. If you don't specify 'uid' then
1291             a UUID will be created. You should only specify the UID if you need to for
1292             syncing purposes - it's better to auto-generate otherwise.
1293              
1294             Returns the href, but also updates 'uid' in $Args.
1295              
1296             Also updates 'sequence'.
1297              
1298             e.g.
1299              
1300             my $href = $CalDAV->NewEvent('Default', $Args);
1301             my $newuid = $Args->{uid};
1302              
1303             =cut
1304              
1305             sub NewEvent {
1306 0     0 1   my ($Self, $calendarId, $Args) = @_;
1307              
1308 0 0         confess "Need a calendarId" unless $calendarId;
1309              
1310 0 0         confess "invalid event" unless ref($Args) eq 'HASH';
1311              
1312 0           my $UseEvent = delete $Args->{_put_event_json};
1313              
1314             # calculate updated sequence numbers
1315 0 0         unless (exists $Args->{sequence}) {
1316 0           $Args->{sequence} = 1;
1317             }
1318              
1319 0 0         if ($Args->{exceptions}) {
1320 0           foreach my $recurrenceId (sort keys %{$Args->{exceptions}}) {
  0            
1321 0           my $val = $Args->{exceptions}{$recurrenceId};
1322 0 0         next unless $val;
1323 0 0         next if exists $val->{sequence};
1324              
1325 0           $val->{sequence} = $Args->{sequence};
1326             }
1327             }
1328              
1329 0   0       $Args->{uid} //= $Self->genuuid();
1330 0           my $uid = $Args->{uid};
1331 0           my $path = $uid;
1332 0           $path =~ tr/[a-zA-Z0-9\@\.\_\-]//cd;
1333 0           my $href = "$calendarId/$path.ics";
1334              
1335 0 0         if ($UseEvent) {
1336 0           $Self->Request(
1337             'PUT',
1338             $href,
1339             encode_json($Args),
1340             'Content-Type' => 'application/event+json',
1341             );
1342             }
1343             else {
1344 0           my $VCalendar = $Self->_argsToVCalendar($Args);
1345 0           $Self->Request(
1346             'PUT',
1347             $href,
1348             $VCalendar->as_string(),
1349             'Content-Type' => 'text/calendar',
1350             );
1351             }
1352              
1353 0           return $href;
1354             }
1355              
1356             =head2 $self->UpdateEvent($href, $Args)
1357              
1358             Like NewEvent, but you only need to specify keys that you want to change,
1359             and it takes the full href to the card instead of the containing calendar.
1360              
1361             =cut
1362              
1363             sub UpdateEvent {
1364 0     0 1   my ($Self, $href, $Args) = @_;
1365              
1366 0           my $UseEvent = delete $Args->{_put_event_json};
1367              
1368 0           my ($OldEvent, $NewEvent) = $Self->_updateEvent($href, $Args);
1369              
1370 0 0         if ($UseEvent) {
1371 0           $Self->Request(
1372             'PUT',
1373             $href,
1374             encode_json($NewEvent),
1375             'Content-Type' => 'application/event+json',
1376             );
1377             }
1378             else {
1379 0           my $VCalendar = $Self->_argsToVCalendar($NewEvent);
1380 0           $Self->Request(
1381             'PUT',
1382             $href,
1383             $VCalendar->as_string(),
1384             'Content-Type' => 'text/calendar',
1385             );
1386             }
1387              
1388 0           return 1;
1389             }
1390              
1391             sub _updateEvent {
1392 0     0     my ($Self, $href, $Args) = @_;
1393              
1394 0           my $OldEvent = $Self->GetEvent($href);
1395              
1396 0 0         confess "Error getting old event for $href"
1397             unless $OldEvent;
1398              
1399 0           my %NewEvent;
1400              
1401 0           foreach my $Property (keys %EventKeys) {
1402 0 0         if (exists $Args->{$Property}) {
    0          
1403 0 0         if (defined $Args->{$Property}) {
1404 0           $NewEvent{$Property} = $Args->{$Property};
1405             }
1406             }
1407             elsif (exists $OldEvent->{$Property}) {
1408 0           $NewEvent{$Property} = $OldEvent->{$Property};
1409             }
1410             }
1411              
1412             # calculate updated sequence numbers
1413 0 0         unless (exists $Args->{sequence}) {
1414 0   0       $NewEvent{sequence} = ($OldEvent->{sequence} || 0) + 1;
1415             }
1416              
1417 0 0         if ($NewEvent{exceptions}) {
1418 0           foreach my $recurrenceId (sort keys %{$NewEvent{exceptions}}) {
  0            
1419 0           my $val = $NewEvent{exceptions}{$recurrenceId};
1420 0 0         next unless $val;
1421 0 0         next if exists $val->{sequence};
1422              
1423 0           my $old = $OldEvent->{exceptions}{$recurrenceId};
1424 0           my $sequence = $NewEvent{sequence};
1425 0 0 0       if ($old && exists $old->{sequence}) {
1426 0 0         $sequence = $old->{sequence} + 1 unless $sequence > $old->{sequence};
1427             }
1428 0           $val->{sequence} = $sequence;
1429             }
1430             }
1431              
1432 0           $NewEvent{href} = $href;
1433              
1434 0           return ($OldEvent, \%NewEvent);
1435             }
1436              
1437             =head2 $self->AnnotateEvent($href, $Args)
1438              
1439             Instead of actually changing an event itself, use proppatch to
1440             add or remove properties on the event.
1441              
1442             =cut
1443              
1444             sub AnnotateEvent {
1445 0     0 1   my ($Self, $href, $Args) = @_;
1446              
1447 0           my $OldEvent = $Self->GetEvent($href);
1448              
1449 0 0         confess "Error getting old event for $href"
1450             unless $OldEvent;
1451              
1452 0           my @Set;
1453             my @Remove;
1454 0           foreach my $key (sort keys %$Args) {
1455 0 0         my $name = ($key =~ m/:/ ? $key : "C:$key");
1456 0 0         if (defined $Args->{$key}) {
1457 0           push @Set, x($name, $Args->{$key});
1458             }
1459             else {
1460 0           push @Remove, x($name);
1461             }
1462             }
1463              
1464 0           my @Params;
1465 0 0         push @Params, x('D:set', x('D:prop', @Set)) if @Set;
1466 0 0         push @Params, x('D:remove', x('D:prop', @Remove)) if @Remove;
1467 0 0         return undef unless @Params;
1468              
1469 0           $Self->Request(
1470             'PROPPATCH',
1471             $href,
1472             x('D:propertyupdate', $Self->NS(), @Params),
1473             );
1474              
1475 0           return 1;
1476             }
1477              
1478             =head2 $self->MoveEvent($href, $newCalendarId)
1479              
1480             Move an event into a new calendar. Returns the new href.
1481              
1482             =cut
1483              
1484             sub MoveEvent {
1485 0     0 1   my ($Self, $href, $newCalendarId) = @_;
1486              
1487 0           my $OldEvent = $Self->GetEvent($href);
1488              
1489 0 0         return unless $OldEvent;
1490              
1491 0           my $dest = $href;
1492 0           $dest =~ s{.*/}{$newCalendarId/};
1493 0 0         return if $href eq $dest;
1494              
1495 0           $Self->Request(
1496             'MOVE',
1497             $href,
1498             undef,
1499             'Destination' => $Self->fullpath($dest),
1500             );
1501              
1502 0           warn "CAL: MoveEvent $Self->{user} ($href => $dest)\n";
1503              
1504 0           return $dest;
1505             }
1506              
1507             sub _BYDAY2byDay {
1508 0     0     my ($BYDAY) = @_;
1509              
1510 0           my ($Count, $Day) = $BYDAY =~ /^([-+]?\d+)?(\w\w)$/;
1511              
1512 0 0         unless ($Day) {
1513 0           confess 'Recurrence BYDAY-weekday not specified';
1514             }
1515              
1516 0 0         unless ($WeekDayNames{$Day}) {
1517 0           confess 'Invalid recurrence BYDAY-weekday';
1518             }
1519              
1520 0 0         if ($Count) {
1521 0 0 0       unless (($Count >= -53) and ($Count <= 53)) {
1522 0           confess 'Recurrence BYDAY-ordwk is out of range';
1523             }
1524             }
1525              
1526             return {
1527 0 0         day => $WeekDayNames{$Day},
1528             $Count ? (nthOfPeriod => int($Count)) : (),
1529             };
1530             }
1531              
1532             sub _byDay2BYDAY {
1533 0     0     my ($byDay) = @_;
1534              
1535 0 0         unless (defined $byDay) {
1536 0           confess 'Invalid recurrence byDay';
1537             }
1538              
1539 0 0         unless (ref $byDay eq 'HASH') {
1540 0           confess 'Recurrence byDay is not an object';
1541             }
1542              
1543 0           my $Day = $WeekDayNamesReverse{$byDay->{day}};
1544 0 0         unless ($Day) {
1545 0           confess 'Recurrence byDay is not a known day';
1546             }
1547 0           my $Prefix = '';
1548 0 0         $Prefix = int($byDay->{nthOfPeriod}) if $byDay->{nthOfPeriod};
1549              
1550 0           return $Prefix . uc($Day);
1551             }
1552              
1553             sub _makeDateObj {
1554 0     0     my $Self = shift;
1555 0           my $DateStr = shift;
1556 0           my $TZStr = shift;
1557 0           my $TargetTz = shift;
1558              
1559 0           my ($Date, $HasTime) = _vDate($DateStr);
1560              
1561             # if it's all day, return it immediately
1562 0 0         return ($Date, 1) unless $HasTime;
1563              
1564             # Do the timezone manipulation as required
1565 0 0         $Date->set_time_zone($Self->tz($TZStr)) if $TZStr;
1566 0 0         $Date->set_time_zone($Self->tz($TargetTz)) if $TargetTz;
1567              
1568 0           return ($Date, 0);
1569             }
1570              
1571             sub _getDateObj {
1572 0     0     my $Self = shift;
1573 0           my $Calendar = shift;
1574 0           my $VItem = shift;
1575 0           my $TargetTz = shift;
1576              
1577 0           my $TimeZone = $Self->_getTimeZone($Calendar, $VItem);
1578 0           my ($Date, $IsAllDay) = $Self->_makeDateObj($VItem->{value}, $TimeZone, $TargetTz);
1579              
1580 0 0         return (wantarray ? ($Date, $TimeZone, $IsAllDay) : $Date);
1581             }
1582              
1583             sub _getDateObjMulti {
1584 0     0     my $Self = shift;
1585 0           my $Calendar = shift;
1586 0           my $VItem = shift;
1587 0           my $TargetTz = shift;
1588              
1589 0           my @Dates;
1590              
1591 0           my $TimeZone = $Self->_getTimeZone($Calendar, $VItem);
1592 0           foreach my $Value (split /,/, $VItem->{value}) {
1593             # XXX - handle $V2 sanely
1594 0 0 0       if (lc($VItem->{params}{value}[0] || '') eq 'period') {
1595 0           ($Value, my $V2) = split /\//, $Value;
1596             }
1597 0           my ($Date, $IsAllDay) = $Self->_makeDateObj($Value, $TimeZone, $TargetTz);
1598 0           push @Dates, $Date;
1599             }
1600              
1601 0           return @Dates;
1602             }
1603              
1604             # Exclude DTSTAMP from auto uid generation
1605             sub _hexkey {
1606 0     0     my $VEvent = shift;
1607 0           my $updated = delete $VEvent->{properties}->{updated};
1608 0           my $d = Data::Dumper->new([$VEvent]);
1609 0           $d->Indent(0);
1610 0           $d->Sortkeys(1);
1611 0           my $Key = sha1_hex($d->Dump());
1612 0 0         $VEvent->{properties}->{updated} = $updated if defined $updated;
1613 0           return $Key;
1614             }
1615              
1616             sub _saneuid {
1617 0     0     my $uid = shift;
1618 0 0         return unless $uid;
1619 0 0         return if $uid =~ m/\s/;
1620 0 0         return if $uid =~ m/[\x7f-\xff]/;
1621             # any other sanity checks?
1622 0           return 1;
1623             }
1624              
1625             sub _makeParticipant {
1626 0     0     my ($Self, $Calendar, $Participants, $VAttendee, $role) = @_;
1627              
1628 0           my $id = $VAttendee->{value};
1629 0 0         return unless $id;
1630 0           $id =~ s/^mailto://i;
1631 0 0         return if $id eq '';
1632              
1633 0   0       $Participants->{$id} ||= {};
1634              
1635             # XXX - if present on one but not the other, take the "best" version
1636 0   0       $Participants->{$id}{name} = $VAttendee->{params}{"cn"}[0] // "";
1637 0           $Participants->{$id}{email} = $id;
1638             $Participants->{$id}{kind} = lc $VAttendee->{params}{"cutype"}[0]
1639 0 0         if $VAttendee->{params}{"cutype"};
1640 0           push @{$Participants->{$id}{roles}}, $role;
  0            
1641             # we don't support locationId yet
1642 0 0         if ($VAttendee->{params}{"partstat"}) {
1643 0   0       $Participants->{$id}{scheduleStatus} = lc($VAttendee->{params}{"partstat"}[0] // "needs-action");
1644             }
1645 0 0         if ($VAttendee->{params}{"role"}) {
1646 0           push @{$Participants->{$id}{roles}}, 'chair'
1647 0 0         if uc $VAttendee->{params}{"role"}[0] eq 'CHAIR';
1648             $Participants->{$id}{schedulePriority} = 'optional'
1649 0 0         if uc $VAttendee->{params}{"role"}[0] eq 'OPT-PARTICIPANT';
1650             $Participants->{$id}{schedulePriority} = 'non-participant'
1651 0 0         if uc $VAttendee->{params}{"role"}[0] eq 'NON-PARTICIPANT';
1652             }
1653 0 0         if ($VAttendee->{params}{"rsvp"}) {
1654 0 0 0       $Participants->{$id}{scheduleRSVP} = lc($VAttendee->{params}{"rsvp"}[0] // "") eq 'yes' ? $JSON::true : $JSON::false;
1655             }
1656 0 0         if (exists $VAttendee->{params}{"x-dtstamp"}) {
1657 0           my ($Date) = eval { $Self->_makeDateObj($VAttendee->{params}{"x-dtstamp"}[0], 'UTC', 'UTC') };
  0            
1658 0 0         $Participants->{$id}{"scheduleUpdated"} = $Date->iso8601() . 'Z' if $Date;
1659             }
1660             # memberOf is not supported
1661              
1662 0 0         if (exists $VAttendee->{params}{"x-sequence"}) {
1663 0   0       $Participants->{$id}{"x-sequence"} = $VAttendee->{params}{"x-sequence"}[0] // "";
1664             }
1665             }
1666              
1667             sub _make_duration {
1668 0     0     my ($Self, $dtdur, $IsAllDay) = @_;
1669              
1670 0           my ($w, $d, $H, $M, $S) = (
1671             $dtdur->weeks,
1672             $dtdur->days,
1673             $dtdur->hours,
1674             $dtdur->minutes,
1675             $dtdur->seconds,
1676             );
1677              
1678 0 0 0       return 'PT0S' unless ($w || $d || $H || $M || $S);
      0        
      0        
      0        
1679              
1680 0           my @bits = ('P');
1681 0 0         push @bits, ($w, 'W') if $w;
1682 0 0         push @bits, ($d, 'D') if $d;
1683 0 0 0       if (not $IsAllDay and ($H || $M || $S)) {
      0        
1684 0           push @bits, 'T';
1685 0 0         push @bits, ($H, 'H') if $H;
1686 0 0         push @bits, ($M, 'M') if $M;
1687 0 0         push @bits, ($S, 'S') if $S;
1688             }
1689              
1690 0           return join ('', @bits);
1691             }
1692              
1693             =head2 $NewEvent = Net::CalDAVTalk->NormaliseEvent($Event);
1694              
1695             Doesn't change the original event, but removes any keys which are the same as their default value
1696              
1697             =cut
1698              
1699             sub NormaliseEvent {
1700 0     0 1   my ($class, $Event, $Root) = @_;
1701              
1702 0   0       $Root ||= '';
1703              
1704 0           my %Copy = %$Event;
1705              
1706             # XXX: patches need to be normalised as well...
1707 0           my $Spec = $EventKeys{$Root};
1708 0           foreach my $key (keys %$Event) {
1709 0 0         delete $Copy{$key} unless $Spec->{$key};
1710             }
1711 0           foreach my $key (sort keys %$Spec) {
1712             # remove if it's the default
1713 0 0         if ($Spec->{$key}[1] eq 'object') {
    0          
    0          
1714 0           my $Item = delete $Copy{$key};
1715 0 0         next unless $Item; # no object
1716 0 0         if ($Spec->{$key}[0]) {
1717 0           $Copy{$key} = [map { $class->NormaliseEvent($_, $key) } @$Item];
  0            
1718             }
1719             else {
1720 0           $Copy{$key} = $class->NormaliseEvent($Item, $key);
1721             }
1722             }
1723             elsif ($Spec->{$key}[1] eq 'bool') {
1724 0 0         delete $Copy{$key} if !!$Spec->{$key}[3] == !!$Copy{$key};
1725             }
1726             elsif ($Spec->{$key}[1] eq 'mailto') {
1727 0 0         $Copy{$key} = lc $Copy{$key} if $Copy{$key};
1728             }
1729             else {
1730 0 0         delete $Copy{$key} if _safeeq($Spec->{$key}[3], $Copy{$key});
1731             }
1732             }
1733              
1734 0           return \%Copy;
1735             }
1736              
1737             =head2 Net::CalDAVTalk->CompareEvents($Event1, $Event2);
1738              
1739             Returns true if the events are identical
1740              
1741             =cut
1742              
1743             sub CompareEvents {
1744 0     0 1   my ($class, $Event1, $Event2) = @_;
1745              
1746 0           my $E1 = $class->NormaliseEvent($Event1);
1747 0           my $E2 = $class->NormaliseEvent($Event2);
1748              
1749 0           return _safeeq($E1, $E2);
1750             }
1751              
1752              
1753             sub _getEventsFromVCalendar {
1754 0     0     my ($Self, $VCalendar) = @_;
1755              
1756 0 0         my $CalendarData = eval { vcard2hash($VCalendar, multival => ['rrule'], only_one => 1) }
  0            
1757             or confess "Error parsing VCalendar data: $@\n\n$VCalendar";
1758              
1759 0           my @Events;
1760              
1761 0 0         foreach my $Calendar (@{$CalendarData->{objects} || []}) {
  0            
1762 0 0         next unless lc $Calendar->{type} eq 'vcalendar';
1763              
1764 0           my $method = $Calendar->{properties}{method}[0]{value};
1765 0           my $prodid = $Calendar->{properties}{prodid}[0]{value};
1766              
1767 0 0         foreach my $VEvent (@{$Calendar->{objects} || []}) {
  0            
1768 0 0         next unless lc $VEvent->{type} eq 'vevent';
1769              
1770             # parse simple component properties {{{
1771              
1772             my %Properties
1773 0           = map { $_ => $VEvent->{properties}{$_}[0] }
1774 0           keys %{$VEvent->{properties}};
  0            
1775              
1776 0           my $uid = $Properties{uid}{value};
1777             # Case: UID is badly broken or missing -
1778             # let's just calculate a UID based on the incoming data. This
1779             # is the 'ICS sync url with no UIDs in it' case from BTS-3205,
1780             # http://mozorg.cdn.mozilla.net/media/caldata/DutchHolidays.ics
1781 0 0         $uid = _hexkey($VEvent) . '-syncauto' unless _saneuid($uid);
1782              
1783 0   0       my $ShowAsFree = (lc($Properties{transp}{value} || '')) eq 'transparent';
1784              
1785             # clean up whitespace on text fields
1786 0           foreach my $Property (qw{description location summary}) {
1787 0 0         next unless defined $Properties{$Property}{value};
1788 0           $Properties{$Property}{value} =~ s/^\s+//gs;
1789 0           $Properties{$Property}{value} =~ s/\s+$//gs;
1790             }
1791              
1792 0           my @description;
1793             push @description, $Properties{description}{value}
1794 0 0         if defined $Properties{description}{value};
1795              
1796             # }}}
1797              
1798             # parse time component properties {{{
1799              
1800 0           my ($IsAllDay, $Start, $StartTimeZone, $End, $EndTimeZone) = ('') x 5;
1801              
1802 0 0         confess "$uid: DTSTART not specified" unless defined $Properties{dtstart}{value};
1803              
1804 0           ($Start, $StartTimeZone, $IsAllDay) = $Self->_getDateObj($Calendar, $Properties{dtstart});
1805              
1806 0 0         if (defined $Properties{dtend}{value}) {
    0          
1807 0 0         if (defined $Properties{duration}{value}) {
1808 0           warn "$uid: DTEND and DURATION cannot both be set";
1809             }
1810              
1811 0           ($End, $EndTimeZone) = $Self->_getDateObj($Calendar, $Properties{dtend});
1812             }
1813             elsif (defined $Properties{duration}{value}) {
1814 0           my $Duration = DateTime::Format::ICal->parse_duration(uc $Properties{duration}{value});
1815 0           $End = $Start->clone()->add($Duration);
1816 0           $EndTimeZone = $StartTimeZone;
1817             }
1818             else {
1819 0           $End = $Start->clone();
1820 0           $EndTimeZone = $StartTimeZone;
1821             }
1822              
1823 0 0         if (DateTime->compare($Start, $End) > 0) {
1824             # swap em!
1825 0           ($Start, $End) = ($End, $Start);
1826 0           ($StartTimeZone, $EndTimeZone) = ($EndTimeZone, $StartTimeZone);
1827             }
1828              
1829 0 0 0       if ($IsAllDay and $StartTimeZone) {
1830 0           warn "$uid: AllDay event with timezone $StartTimeZone specified";
1831             }
1832              
1833             # if one is set, make sure they are both set
1834 0   0       $StartTimeZone ||= $EndTimeZone;
1835 0   0       $EndTimeZone ||= $StartTimeZone;
1836              
1837             # }}}
1838              
1839 0           my %Recurrence;
1840              
1841 0 0         if (exists $Properties{rrule}) {
1842 0           my %RRULE;
1843              
1844 0           foreach my $RRULE (@{$Properties{rrule}{values}}) {
  0            
1845 0           my ($Key,$Value) = split '=', $RRULE;
1846 0 0         next unless defined $Value;
1847              
1848 0           $RRULE{lc $Key} = $Value;
1849             }
1850              
1851             # parse simple recurrence properties {{{
1852              
1853 0 0         if (exists $RRULE{freq}) {
1854 0           my $freq = lc $RRULE{freq};
1855 0 0         unless (grep { $_ eq $freq } @Frequencies) {
  0            
1856 0           confess "$uid: Invalid recurrence FREQ ($RRULE{freq})";
1857             }
1858              
1859 0           $Recurrence{frequency} = $freq;
1860             }
1861             else {
1862 2     2   12 use Data::Dumper;
  2         3  
  2         10813  
1863 0           confess "$uid: Recurrence FREQ not specified";
1864             }
1865              
1866 0 0         if (exists $RRULE{interval}) {
1867 0 0         unless ($RRULE{interval} =~ /^\d+$/) {
1868 0           confess "$uid: Invalid recurrence INTERVAL ($RRULE{interval})";
1869             }
1870 0           my $interval = int $RRULE{interval};
1871              
1872 0 0         if ($interval == 0) {
1873 0           confess "$uid: Recurrence INTERVAL is out of range ($RRULE{interval})";
1874             }
1875              
1876             # default == 1, so don't set a key for it
1877 0 0         if ($interval > 1) {
1878 0           $Recurrence{interval} = $interval;
1879             }
1880             }
1881              
1882 0 0         if (exists $RRULE{rscale}) {
1883 0           $Recurrence{rscale} = lc $RRULE{rscale};
1884 0 0         $Recurrence{skip} = lc $RRULE{skip} if $RRULE{skip};
1885             }
1886              
1887 0 0         if (exists $RRULE{wkst}) {
1888 0           my $wkst = lc $RRULE{wkst};
1889 0 0         unless ($WeekDayNames{$wkst}) {
1890 0           confess "$uid: Invalid recurrence WKST ($RRULE{wkst})";
1891             }
1892              
1893             # default is Monday, so don't set a key for it
1894 0 0         if ($wkst ne 'mo') {
1895 0           $Recurrence{firstDayOfWeek} = $WeekDayNames{$wkst};
1896             }
1897             }
1898              
1899 0 0         if (exists $RRULE{byday}) {
1900 0           my @byDays;
1901              
1902 0           foreach my $BYDAY (split ',', $RRULE{byday}) {
1903 0           push @byDays, _BYDAY2byDay(lc $BYDAY);
1904             }
1905              
1906 0 0         $Recurrence{byDay} = \@byDays if @byDays;
1907             }
1908              
1909 0 0         if (exists $RRULE{bymonth}) {
1910 0           foreach my $BYMONTH (split ',', $RRULE{bymonth}) {
1911 0 0         unless ($BYMONTH =~ /^\d+L?$/) {
1912 0           confess "$uid: Invalid recurrence BYMONTH ($BYMONTH, $RRULE{bymonth})";
1913             }
1914              
1915 0           push @{$Recurrence{byMonth}}, "$BYMONTH";
  0            
1916             }
1917             }
1918              
1919 0 0         if (exists $RRULE{count}) {
1920 0 0         if (exists $RRULE{until}) {
1921             #confess "$uid: Recurrence COUNT and UNTIL cannot both be set";
1922             # seen in the wild: PRODID:-//dmfs.org//mimedir.icalendar//EN
1923 0           delete $RRULE{until};
1924             }
1925              
1926 0 0         unless ($RRULE{count} =~ /^\d+$/) {
1927 0           confess "$uid: Invalid recurrence COUNT ($RRULE{count})";
1928             }
1929              
1930 0           $Recurrence{count} = int $RRULE{count};
1931             }
1932              
1933 0 0         if (exists $RRULE{until}) {
1934             # rfc5545 3.3.10 - UNTIL must be in DTSTART timezone, but both
1935             # google and iCloud store it in Z, so we will too as per rfc2445.
1936 0           my ($Until, $IsAllDay) = $Self->_makeDateObj($RRULE{until}, $StartTimeZone, $StartTimeZone);
1937 0           $Recurrence{until} = $Until->iso8601();
1938             }
1939              
1940             # }}}
1941              
1942             # parse generic recurrence properties {{{
1943              
1944 0           foreach my $Property (keys %RecurrenceProperties) {
1945 0 0         if (defined $RRULE{$Property}) {
1946 0           foreach my $Value (split ',', $RRULE{$Property}) {
1947             my ($Valid, $Min) = $RecurrenceProperties{$Property}{signed}
1948 0 0         ? ('[-+]?[1-9]\d*', ($RecurrenceProperties{$Property}{max} * -1))
1949             : ('\d+', 0);
1950              
1951 0 0         unless ($Value =~ /^$Valid$/) {
1952 0           confess "$uid: Invalid recurrence $Property ($Value)";
1953             }
1954              
1955 0 0 0       unless (($Value >= $Min) and ($Value <= $RecurrenceProperties{$Property}{max})) {
1956 0           confess "$uid: Recurrence $Property is out of range ($Value)";
1957             }
1958              
1959 0           push @{$Recurrence{$RecurrenceProperties{$Property}{name}}}, int $Value;
  0            
1960             }
1961             }
1962             }
1963              
1964             # }}}
1965             }
1966              
1967 0           my %Overrides;
1968 0 0         if (exists $VEvent->{properties}{exdate}) {
1969 0           foreach my $Item (@{$VEvent->{properties}{exdate}}) {
  0            
1970 0           foreach my $Date ($Self->_getDateObjMulti($Calendar, $Item, $StartTimeZone)) {
1971 0           $Overrides{$Date->iso8601()} = $JSON::null;
1972             }
1973             }
1974             }
1975              
1976 0 0         if ($VEvent->{properties}{rdate}) {
1977             # rdate = "RDATE" rdtparam ":" rdtval *("," rdtval) CRLF
1978 0           foreach my $Item (@{$VEvent->{properties}{rdate}}) {
  0            
1979 0           foreach my $Date ($Self->_getDateObjMulti($Calendar, $Item, $StartTimeZone)) {
1980 0           $Overrides{$Date->iso8601()} = {};
1981             }
1982             }
1983             }
1984              
1985             # parse alarms {{{
1986              
1987 0           my %Alerts;
1988 0 0         foreach my $VAlarm (@{$VEvent->{objects} || []}) {
  0            
1989 0 0         next unless lc $VAlarm->{type} eq 'valarm';
1990              
1991             my %AlarmProperties
1992 0           = map { $_ => $VAlarm->{properties}{$_}[0] }
1993 0           keys %{$VAlarm->{properties}};
  0            
1994              
1995 0   0       my $alarmuid = $AlarmProperties{uid}{value} || _hexkey($VAlarm) . '-alarmauto';
1996              
1997 0           my %Alert;
1998              
1999 0           my $AlarmAction = lc $AlarmProperties{action}{value};
2000 0 0         next unless $AlarmAction;
2001              
2002 0           my %Action;
2003              
2004 0 0         if ($AlarmAction eq 'display') {
    0          
    0          
    0          
    0          
2005 0           $Action{type} = 'display';
2006             }
2007             elsif ($AlarmAction eq 'email') {
2008 0           $Action{type} = 'email';
2009              
2010             $Action{to} = [
2011 0           map { my ($x) = $_->{value} =~ m/^(?:mailto:)?(.*)/i; { email => $x } }
  0            
2012 0   0       @{$VAlarm->{properties}{attendee} // []}
  0            
2013             ];
2014             }
2015             elsif ($AlarmAction eq 'uri') {
2016 0           $Action{type} = 'uri';
2017 0   0       $Action{uri} = $VAlarm->{properties}{uri} // [];
2018             }
2019             elsif ($AlarmAction eq 'audio') {
2020             # audio alerts aren't the same as popups, but for now...
2021 0           $Action{type} = 'display';
2022             }
2023             elsif ($AlarmAction eq 'none') {
2024 0           next;
2025             }
2026             else {
2027 0           warn "$uid: UNKNOWN VALARM ACTION $AlarmAction";
2028 0           next;
2029             }
2030              
2031 0 0         if ($AlarmProperties{acknowledged}) {
2032 0           my $date = $Self->_getDateObj($Calendar, $AlarmProperties{acknowledged}, 'UTC');
2033 0           $Action{acknowledged} = $date->iso8601() . 'Z';
2034             }
2035              
2036             my $Trigger = $AlarmProperties{trigger}{value}
2037 0   0       || next;
2038              
2039 0 0 0       my $Related = (lc ($AlarmProperties{trigger}{params}{related}[0] || '') eq 'end')
2040             ? 'end'
2041             : 'start';
2042              
2043 0           my $Duration;
2044 0 0         if ($Trigger =~ m/^[+-]?P/i) {
2045 0   0       $Duration = eval { DateTime::Format::ICal->parse_duration(uc $Trigger) }
2046             || next;
2047              
2048             } else {
2049 0           my $AlertDate = $Self->_getDateObj($Calendar, $AlarmProperties{trigger}, $StartTimeZone);
2050 0 0         $Duration = $AlertDate->subtract_datetime($Related eq 'end' ? $End : $Start);
2051             }
2052              
2053 0 0         if ($Duration->is_negative()) {
2054 0           $Duration = $Duration->inverse();
2055 0           $Alert{relativeTo} = "before-$Related";
2056             }
2057             else {
2058 0           $Alert{relativeTo} = "after-$Related";
2059             }
2060              
2061 0           $Alert{action} = \%Action;
2062 0           $Alert{offset} = $Self->_make_duration($Duration);
2063              
2064 0           $Alerts{$alarmuid} = \%Alert;
2065             }
2066              
2067             # }}}
2068              
2069             # parse attendees {{{
2070              
2071 0           my %Participants;
2072 0 0         for my $VOrganizer (@{$VEvent->{properties}{organizer} || []}) {
  0            
2073 0           $Self->_makeParticipant($Calendar, \%Participants, $VOrganizer, 'owner');
2074             }
2075 0 0         for my $VAttendee (@{$VEvent->{properties}{attendee} || []}) {
  0            
2076 0           $Self->_makeParticipant($Calendar, \%Participants, $VAttendee, 'attendee');
2077             }
2078              
2079             # }}}
2080              
2081             # parse attachments {{{
2082              
2083 0           my %Links;
2084 0 0         foreach my $Attach (@{$VEvent->{properties}{attach} || []}) {
  0            
2085 0 0         next unless $Attach->{value};
2086 0 0         next unless grep { $Attach->{value} =~ m{^$_://} } qw{http https ftp};
  0            
2087              
2088 0           my $uri = $Attach->{value};
2089 0           my $filename = $Attach->{params}{filename}[0];
2090             # XXX - mime guessing?
2091 0           my $mime = $Attach->{params}{fmttype}[0];
2092 0 0         if (not defined $mime) {
2093 0   0       $::MimeTypes ||= MIME::Types->new;
2094 0           my $MimeTypeObj = $::MimeTypes->mimeTypeOf($filename);
2095 0 0         $mime = $MimeTypeObj->type() if $MimeTypeObj;
2096             }
2097              
2098 0           my $size = $Attach->{params}{size}[0];
2099              
2100 0 0         $Links{$uri} = {
    0          
    0          
2101             href => $uri,
2102             rel => 'enclosure',
2103             defined $filename ? (title => $filename) : (),
2104             defined $mime ? (type => $mime) : (),
2105             defined $size ? (size => 0+$size) : (),
2106             };
2107             }
2108 0 0         foreach my $URL (@{$VEvent->{properties}{url} || []}) {
  0            
2109 0           my $uri = $URL->{value};
2110 0 0         next unless $uri;
2111 0           $Links{$uri} = { href => $uri };
2112             }
2113              
2114             # }}}
2115              
2116             # ============= Metadata
2117 0           my %Event = (uid => $uid);
2118             # no support for relatedTo yet
2119 0           $Event{prodId} = $prodid;
2120 0 0         if ($Properties{created}{value}) {
2121             # UTC item
2122 0           my $Date = eval { $Self->_getDateObj($Calendar, $Properties{created}, 'UTC') };
  0            
2123 0 0         $Event{created} = $Date->iso8601() . 'Z' if $Date;
2124             }
2125 0 0         if ($Properties{dtstamp}{value}) {
2126             # UTC item
2127 0           my $Date = eval { $Self->_getDateObj($Calendar, $Properties{dtstamp}, 'UTC') };
  0            
2128 0 0         $Event{updated} = $Date->iso8601() . 'Z' if $Date;
2129             }
2130 0   0       $Event{updated} ||= DateTime->now->iso8601();
2131 0 0         $Event{sequence} = int($Properties{sequence}{value}) if $Properties{sequence};
2132 0 0         $Event{method} = $method if $method;
2133              
2134             # ============= What
2135 0 0         $Event{title} = $Properties{summary}{value} if $Properties{summary};
2136 0 0         $Event{description} = join("\n", @description) if @description;
2137             # htmlDescription is not supported
2138 0 0         $Event{links} = \%Links if %Links;
2139 0           my $language;
2140 0 0 0       if ($Properties{description} and $Properties{description}{params}{language}) {
2141 0           $language = $Properties{description}{params}{language}[0];
2142             }
2143 0 0 0       if ($Properties{summary} and $Properties{summary}{params}{language}) {
2144 0           $language = $Properties{summary}{params}{language}[0];
2145             }
2146 0 0         $Event{locale} = $language if $language;
2147             # translations is not supported
2148              
2149             # ============= Where
2150             # XXX - support more structured representations from VEVENTs
2151 0 0         if ($Properties{location}{value}) {
2152 0           $Event{locations}{location} = { name => $Properties{location}{value} };
2153             }
2154 0 0 0       if (not $IsAllDay and $StartTimeZone and $StartTimeZone ne $EndTimeZone) {
      0        
2155 0           $Event{locations}{end} = { rel => 'end', timeZone => $EndTimeZone };
2156             }
2157              
2158             # ============= When
2159 0 0         $Event{isAllDay} = $IsAllDay ? $JSON::true : $JSON::false;
2160 0 0         $Event{start} = $Start->iso8601() if ref($Start);
2161 0 0         $Event{timeZone} = $StartTimeZone if not $IsAllDay;
2162 0           my $duration = $Self->_make_duration($End->subtract_datetime($Start), $IsAllDay);
2163 0 0         $Event{duration} = $duration if $duration;
2164              
2165 0 0         $Event{recurrenceRule} = \%Recurrence if %Recurrence;
2166 0 0         $Event{recurrenceOverrides} = \%Overrides if %Overrides;
2167              
2168             # ============= Scheduling
2169 0 0         if ($Properties{status}{value}) {
2170 0 0         $Event{status} = lc($Properties{status}{value}) if lc($Properties{status}{value}) ne 'confirmed';
2171             }
2172 0 0         if ($Properties{transp}{value}) {
2173 0 0         $Event{showAsFree} = $JSON::true if lc($Properties{transp}{value}) eq 'transparent';
2174             }
2175 0           foreach my $email (sort keys %Participants) { # later wins
2176 0 0         $Event{replyTo} = { imip => "mailto:$email" } if grep { $_ eq 'owner' } @{$Participants{$email}{roles}};
  0            
  0            
2177             }
2178 0 0         $Event{participants} = \%Participants if %Participants;
2179              
2180             # ============= Alerts
2181             # useDefaultAlerts is not supported
2182 0 0         $Event{alerts} = \%Alerts if %Alerts;
2183              
2184 0 0         if ($Properties{lastmodified}{value}) {
2185             # UTC item
2186 0           my $Date = eval { $Self->_getDateObj($Calendar, $Properties{lastmodified}, 'UTC') };
  0            
2187 0           $Event{lastModified} = $Date->iso8601() . 'Z';
2188             }
2189 0 0         if ($Properties{'recurrence-id'}{value}) {
2190             # in our system it's always in the timezone of the event, but iCloud
2191             # returns it in UTC despite the event having a timezone. Super weird.
2192             # Anyway, we need to format it to the StartTimeZone of the parent
2193             # event if there is one, and we don't have that yet!
2194 0           $Event{_recurrenceObj} = $Self->_getDateObj($Calendar, $Properties{'recurrence-id'});
2195             }
2196 0           push @Events, \%Event;
2197             }
2198             }
2199              
2200 0           return \@Events;
2201             }
2202              
2203             sub _getTimeZone {
2204 0     0     my $Self = shift;
2205 0           my ($Calendar, $Element) = @_;
2206              
2207 0 0         if ($Element->{value} =~ m/Z$/) {
2208 0           return 'Etc/UTC';
2209             }
2210              
2211 0           my $TZID = $Element->{params}{tzid}[0];
2212              
2213 0 0         return undef unless $TZID;
2214              
2215 0 0         return $Self->{_tznamemap}{$TZID} if exists $Self->{_tznamemap}{$TZID};
2216              
2217 0           my %TzOffsets;
2218              
2219 0 0         foreach my $VTimeZone (@{$Calendar->{objects} || []}) {
  0            
2220 0 0         next unless lc $VTimeZone->{type} eq 'vtimezone';
2221 0 0 0       next unless ($VTimeZone->{properties}{tzid}[0]{value} || '') eq $TZID;
2222              
2223 0 0         foreach my $Observance (@{$VTimeZone->{objects} || []}) {
  0            
2224 0 0 0       next unless grep { (lc $Observance->{type} || '') eq $_ } qw{standard daylight};
  0            
2225 0 0         next unless defined $Observance->{properties}{tzoffsetto}[0]{value};
2226              
2227             $TzOffsets{lc $Observance->{type}}
2228 0           = $Observance->{properties}{tzoffsetto}[0]{value};
2229             }
2230             }
2231              
2232 0 0         return undef unless exists $TzOffsets{standard};
2233              
2234             my $TimeZone = Net::CalDAVTalk::TimeZones->GetTimeZone(
2235             TZID => $TZID,
2236             Time => $Element->{value},
2237             StandardTzOffsetTo => $TzOffsets{standard},
2238             ($TzOffsets{daylight}
2239             ? (DaylightTzOffsetTo => $TzOffsets{daylight})
2240 0   0       : ()),
2241             ) || undef;
2242              
2243 0           $Self->{_tznamemap}{$TZID} = $TimeZone;
2244 0           return $TimeZone;
2245             }
2246              
2247             sub _wireDate {
2248             # format: YYYY-MM-DDTHH:MM:SS Z?
2249 0     0     my $isoDate = shift;
2250 0   0       my $timeZone = shift || $FLOATING;
2251 0 0         confess "Invalid value '$isoDate' was not ISO8601" unless $isoDate =~ m/^(\d{4,})-(\d\d)-(\d\d)T(\d\d):(\d\d):(\d\d)(Z?)$/i;
2252 0 0         $timeZone = 'Etc/UTC' if $7;
2253              
2254 0 0         my $Date = DateTime->_new(
2255             year => $1,
2256             month => $2,
2257             day => $3,
2258             hour => $4,
2259             minute => $5,
2260             second => $6,
2261             time_zone => $timeZone,
2262             locale => $LOCALE,
2263             ) or confess "Invalid value '$isoDate'";
2264              
2265 0           return $Date;
2266             }
2267              
2268             sub _vDate {
2269             # format: :YYYYMMDDTHHMMSS (floating)
2270             # format: :YYYYMMDDTHHMMSSZ (UTC)
2271             # format: ;TZID=X/Y:YYMMDDTHHMMSS (zoned)
2272             # format: ;TYPE=DATE:YYYYMMDD (but we don't know about that)
2273 0     0     my $vDate = shift;
2274              
2275 0 0         if ($vDate =~ m/^(\d\d\d\d)(\d\d)(\d\d)T(\d\d)(\d\d)(\d\d)(\.\d+)?(Z?)$/i) {
2276 0 0         my $Date = DateTime->_new(
    0          
2277             year => $1,
2278             month => $2,
2279             day => $3,
2280             hour => $4,
2281             minute => $5,
2282             second => $6,
2283             # ignore milliseconds in $7
2284             time_zone => ($8 eq 'Z' ? $UTC : $FLOATING),
2285             locale => $LOCALE,
2286             ) or confess "Invalid value '$vDate' for DATETIME";
2287              
2288 0           return ($Date, 1);
2289             }
2290              
2291 0 0         if ($vDate =~ m/^(\d\d\d\d)(\d\d)(\d\d)$/) {
2292             # all day
2293 0 0         my $Date = DateTime->_new(
2294             year => $1,
2295             month => $2,
2296             day => $3,
2297             time_zone => $FLOATING,
2298             locale => $LOCALE,
2299             ) or confess "Invalid value '$vDate' for DATE";
2300              
2301 0           return ($Date, 0);
2302             }
2303              
2304             # we only support those two patterns
2305 0           confess "Date '$vDate' was neither a DATE or DATETIME value";
2306             }
2307              
2308             sub _makeVTime {
2309 0     0     my $Self = shift;
2310 0           my ($TimeZones, $wire, $tz, $IsAllDay) = @_;
2311              
2312 0           my $date = _wireDate($wire, $tz);
2313              
2314 0           return $Self->_makeVTimeObj($TimeZones, $date, $tz, $IsAllDay);
2315             }
2316              
2317             sub _makeVTimeObj {
2318 0     0     my $Self = shift;
2319 0           my ($TimeZones, $date, $tz, $IsAllDay) = @_;
2320              
2321             # all day?
2322 0 0         if ($IsAllDay) {
2323 0           return [$date->strftime('%Y%m%d'), { VALUE => 'DATE' }];
2324             }
2325              
2326             # floating?
2327 0 0         unless ($tz) {
2328 0           return [$date->strftime('%Y%m%dT%H%M%S')];
2329             }
2330              
2331             # UTC?
2332 0 0         if ($UTCLinks{$tz}) {
2333 0           return [$date->strftime('%Y%m%dT%H%M%SZ')];
2334             }
2335              
2336 0           my $zone = $Self->tz($tz);
2337              
2338 0           $TimeZones->{$zone->name()} = 1;
2339              
2340 0           return [$date->strftime('%Y%m%dT%H%M%S'), { TZID => $zone->name() }];
2341             }
2342              
2343             sub _makeZTime {
2344 0     0     my ($Self, $date) = @_;
2345 0           return $Self->_makeVTime({}, $date, 'UTC');
2346             }
2347              
2348             sub _makeLTime {
2349 0     0     my $Self = shift;
2350 0           my ($TimeZones, $ltime, $tz, $IsAllDay) = @_;
2351              
2352 0           my $date = _wireDate($ltime, $Self->tz($tz));
2353              
2354 0 0         return [$date->strftime('%Y%m%d'), { VALUE => 'DATE' }] if $IsAllDay;
2355              
2356 0 0         unless ($tz) {
2357             # floating
2358 0           return [$date->strftime('%Y%m%dT%H%M%S')];
2359             }
2360              
2361 0 0         if ($tz =~ m/UTC/i) {
2362 0           return [$date->strftime('%Y%m%dT%H%M%SZ')];
2363             }
2364              
2365             # XXX - factor this crap out
2366 0           $TimeZones->{$tz} = 1;
2367              
2368             # XXX - use our cache
2369 0           my $zone = $Self->tz($tz);
2370              
2371 0           return [$date->strftime('%Y%m%dT%H%M%S'), { TZID => $zone->name() }];
2372             }
2373              
2374             sub _argsToVEvents {
2375 0     0     my $Self = shift;
2376 0           my ($TimeZones, $Args, $recurrenceData) = @_;
2377 0           my @VEvents;
2378              
2379 0           my $VEvent = Data::ICal::Entry::Event->new();
2380              
2381             # required properties
2382             $VEvent->add_properties(
2383             uid => $Args->{uid},
2384             sequence => ($Args->{sequence} || 0),
2385 0 0 0       transp => ($Args->{showAsFree} ? 'TRANSPARENT' : 'OPAQUE'),
2386             );
2387              
2388 0 0         if ($recurrenceData) {
2389 0           my ($recurrenceId, $TopLevel) = @$recurrenceData;
2390 0           $VEvent->add_property('recurrence-id' => $Self->_makeLTime($TimeZones, $recurrenceId, $TopLevel->{timeZone}, $TopLevel->{isAllDay}));
2391             }
2392              
2393             # direct copy if properties exist
2394 0           foreach my $Property (qw{description title}) {
2395 0   0       my $Prop = $Args->{$Property} // '';
2396 0 0         next if $Prop eq '';
2397 0           my %lang;
2398 0 0         $lang{language} = $Args->{locale} if exists $Args->{locale};
2399 0           my $key = $Property;
2400 0 0         $key = 'summary' if $Property eq 'title';
2401 0           $VEvent->add_property($key => [$Prop, \%lang]);
2402             }
2403              
2404             # dates in UTC - stored in UTC
2405 0 0         $VEvent->add_property(created => $Self->_makeZTime($Args->{created})) if $Args->{created};
2406 0   0       $VEvent->add_property(dtstamp => $Self->_makeZTime($Args->{updated} || DateTime->now->iso8601()));
2407              
2408             # dates in localtime - zones based on location
2409 0           my $EndTimeZone;
2410 0   0       my $locations = $Args->{locations} || {};
2411 0           foreach my $id (sort keys %$locations) {
2412 0 0 0       if ($locations->{$id}{rel} and $locations->{id}{rel} eq 'end') {
2413 0           $EndTimeZone = $locations->{end}{timeZone};
2414             }
2415 0 0         if ($locations->{$id}{name}) {
2416 0           $VEvent->add_property(location => $locations->{$id}{name});
2417             }
2418             }
2419              
2420 0           my $StartTimeZone = $Args->{timeZone};
2421 0           my $Start = _wireDate($Args->{start}, $StartTimeZone);
2422 0           $VEvent->add_property(dtstart => $Self->_makeVTimeObj($TimeZones, $Start, $StartTimeZone, $Args->{isAllDay}));
2423 0 0         if ($Args->{duration}) {
2424 0   0       $EndTimeZone //= $StartTimeZone;
2425 0           my $Duration = eval { DateTime::Format::ICal->parse_duration($Args->{duration}) };
  0            
2426 0 0         my $End = $Start->clone()->add($Duration) if $Duration;
2427 0           $VEvent->add_property(dtend => $Self->_makeVTimeObj($TimeZones, $End, $EndTimeZone, $Args->{isAllDay}));
2428             }
2429              
2430 0 0         if ($Args->{recurrenceRule}) {
2431 0           my %Recurrence = $Self->_makeRecurrence($Args->{recurrenceRule}, $Args->{isAllDay}, $StartTimeZone);
2432              
2433             # RFC 2445 4.3.10 - FREQ is the first part of the RECUR value type.
2434             # RFC 5545 3.3.10 - FREQ should be first to ensure backward compatibility.
2435             my $rule = join(';',
2436             ('FREQ=' . delete($Recurrence{FREQ})),
2437 0           (map { "$_=$Recurrence{$_}" } keys %Recurrence),
  0            
2438             );
2439 0           $VEvent->add_property(rrule => $rule);
2440             }
2441              
2442 0 0         if ($Args->{recurrenceOverrides}) {
2443 0           foreach my $recurrenceId (sort keys %{$Args->{recurrenceOverrides}}) {
  0            
2444 0           my $val = $Args->{recurrenceOverrides}{$recurrenceId};
2445 0 0         if ($val) {
2446 0 0         if (keys %$val) {
2447 0           my $SubEvent = $Self->_maximise($Args, $val, $recurrenceId);
2448 0           push @VEvents, $Self->_argsToVEvents($TimeZones, $SubEvent, [$recurrenceId, $Args]);
2449             }
2450             else {
2451 0           $VEvent->add_property(rdate => $Self->_makeLTime($TimeZones, $recurrenceId, $StartTimeZone, $Args->{isAllDay}));
2452             }
2453             }
2454             else {
2455 0           $VEvent->add_property(exdate => $Self->_makeLTime($TimeZones, $recurrenceId, $StartTimeZone, $Args->{isAllDay}));
2456             }
2457             }
2458             }
2459              
2460 0 0         if ($Args->{alerts}) {
2461 0           for my $id (sort keys %{$Args->{alerts}}) {
  0            
2462 0           my $Alert = $Args->{alerts}{$id};
2463              
2464 0   0       my $Type = $Alert->{action}{type} // '';
2465 0   0       my $Recipients = $Alert->{action}{recipients} // [];
2466 0   0       my $Uri = $Alert->{action}{uri} // '';
2467 0           my $Offset = $Alert->{offset};
2468 0 0         my $Sign = $Alert->{relativeTo} =~ m/before/ ? '-' : '';
2469 0 0         my $Loc1 = $Alert->{relativeTo} =~ m/end/ ? "ends" : "starts";
2470 0 0         my $Loc2 = $Alert->{relativeTo} =~ m/end/ ? "ended" : "started";
2471 0           my $Minutes = DateTime::Format::ICal->parse_duration(uc $Offset)->in_units('minutes');
2472              
2473 0           my $VAlarm;
2474              
2475 0 0 0       if ($Type eq 'display') {
    0          
2476 0           $VAlarm = Data::ICal::Entry::Alarm::Display->new();
2477 0 0         $VAlarm->add_properties(
2478             description => (($Sign eq '-')
2479             ? "'$Args->{title}' $Loc1 in $Minutes minutes"
2480             : "'$Args->{title}' $Loc2 $Minutes minutes ago"),
2481             );
2482             }
2483             elsif ($Type eq 'email' || $Type eq 'uri') {
2484 0           my ($Summary, $Description);
2485              
2486 0 0         if ($Sign eq '-') {
2487 0           $Summary = "Event alert: '$Args->{title}' $Loc1 in $Minutes minutes";
2488 0           $Description = "Your event '$Args->{title}' $Loc1 in $Minutes minutes";
2489             }
2490             else {
2491 0           $Summary = "Event alert: '$Args->{title}' $Loc2 $Minutes minutes ago";
2492 0           $Description = "Your event '$Args->{title}' $Loc2 $Minutes minutes ago";
2493             }
2494              
2495 0           $VAlarm = Data::ICal::Entry::Alarm::Email->new();
2496             $VAlarm->add_properties(
2497             summary => $Summary,
2498             description => join("\n",
2499             $Description,
2500             "",
2501             "Description:",
2502             $Args->{description},
2503             # XXX more
2504             ),
2505 0           (map { ( attendee => "MAILTO:$_" ) } @$Recipients), # XXX naive?
  0            
2506             );
2507              
2508 0 0         if ($Type eq 'uri') {
2509 0           $VAlarm->add_property("X-URI", $Uri);
2510             }
2511             }
2512             else {
2513 0           confess "Unknown alarm type $Type";
2514             }
2515              
2516 0           $VAlarm->add_property(uid => $id);
2517 0           $VAlarm->add_property(trigger => "${Sign}$Offset");
2518 0 0         $VAlarm->add_property(related => 'end') if $Alert->{relativeTo} =~ m/end/;
2519              
2520 0 0         if ($Alert->{action}{acknowledged}) {
2521 0           $VAlarm->add_property(acknowledged => $Self->_makeZTime($Alert->{action}{acknowledged}));
2522             }
2523              
2524 0           $VEvent->add_entry($VAlarm);
2525             }
2526             }
2527              
2528 0           my %namemap;
2529 0 0         if ($Args->{participants}) {
2530 0           foreach my $Address (sort keys %{$Args->{participants}}) {
  0            
2531 0           my $Attendee = $Args->{participants}{$Address};
2532 0   0       my $Email = $Attendee->{email} || $Address;
2533 0           my $Rsvp = $Attendee->{rsvp};
2534              
2535 0           my %AttendeeProps;
2536 0 0         if ($Attendee->{"name"}) {
2537 0           $AttendeeProps{"CN"} = $Attendee->{"name"};
2538 0           $namemap{lc "mailto:$Email"}= $Attendee->{"name"};
2539             }
2540              
2541 0 0         next unless grep { $_ eq 'attendee' } @{$Attendee->{roles}};
  0            
  0            
2542              
2543 0 0         $AttendeeProps{"CUTYPE"} = uc $Attendee->{"kind"} if defined $Attendee->{"kind"};
2544 0 0         $AttendeeProps{"RSVP"} = uc $Attendee->{"scheduleRSVP"} if defined $Attendee->{"scheduleRSVP"};
2545 0 0         $AttendeeProps{"X-SEQUENCE"} = $Attendee->{"x-sequence"} if defined $Attendee->{"x-sequence"};
2546 0 0         $AttendeeProps{"X-DTSTAMP"} = $Self->_makeZTime($Attendee->{"scheduleUpdated"}) if defined $Attendee->{"scheduleUpdated"};
2547 0           foreach my $prop (keys %AttendeeProps) {
2548 0 0         delete $AttendeeProps{$prop} if $AttendeeProps{$prop} eq '';
2549             }
2550 0 0 0       if (grep { $_ eq 'chair' } @{$Attendee->{roles}}) {
  0 0 0        
  0 0          
2551 0           $Attendee->{ROLE} = 'CHAIR';
2552             }
2553             elsif ($Attendee->{schedulePriority} and $Attendee->{schedulePriority} eq 'optional') {
2554 0           $Attendee->{ROLE} = 'OPT-PARTICIPANT';
2555             }
2556             elsif ($Attendee->{schedulePriority} and $Attendee->{schedulePriority} eq 'non-participant') {
2557 0           $Attendee->{ROLE} = 'NON-PARTICIPANT';
2558             }
2559             # default is REQ-PARTICIPANT
2560              
2561 0 0         $AttendeeProps{PARTSTAT} = uc $Attendee->{"scheduleStatus"} if $Attendee->{"scheduleStatus"};
2562              
2563 0           $VEvent->add_property(attendee => [ "MAILTO:$Email", \%AttendeeProps ]);
2564             }
2565             }
2566 0 0         if ($Args->{replyTo}) {
2567 0 0         if ($Args->{replyTo}{imip}) {
2568 0           my $CN = $namemap{lc $Args->{replyTo}{imip}};
2569 0 0         $VEvent->add_property(organizer => [ $Args->{replyTo}{imip}, $CN ? {CN => $CN} : () ]);
2570             }
2571             }
2572              
2573 0 0         if ($Args->{links}) {
2574 0           foreach my $uri (sort keys %{$Args->{links}}) {
  0            
2575 0           my $Attach = $Args->{links}{$uri};
2576 0   0       my $Url = $Attach->{href} || $uri;
2577 0 0 0       if ($Attach->{rel} && $Attach->{rel} eq 'enclosure') {
2578 0           my $FileName = $Attach->{title};
2579 0           my $Mime = $Attach->{type};
2580 0           my $Size = $Attach->{size};
2581              
2582 0           my %AttachProps;
2583 0 0         $AttachProps{FMTTYPE} = $Mime if defined $Mime;
2584 0 0         $AttachProps{SIZE} = $Size if defined $Size;
2585 0 0         $AttachProps{FILENAME} = $FileName if defined $FileName;
2586 0           $VEvent->add_property(attach => [ $Url, \%AttachProps ]);
2587             }
2588             # otherwise it's just a URL
2589             else {
2590 0           $VEvent->add_property(url => [ $Url ]);
2591             }
2592             }
2593             }
2594              
2595             # detect if this is a dummy top-level event and skip it
2596 0 0 0       unshift @VEvents, $VEvent unless ($Args->{replyTo} and not $Args->{participants});
2597              
2598 0           return @VEvents;
2599             }
2600              
2601             sub _argsToVCalendar {
2602 0     0     my $Self = shift;
2603 0           my $Item = shift;
2604 0           my %ExtraProp = @_;
2605              
2606 0           my $VCalendar = Data::ICal->new();
2607 0           my $havepid = 0;
2608              
2609 0           foreach my $extra (keys %ExtraProp) {
2610 0           $VCalendar->add_properties($extra => $ExtraProp{$extra});
2611             }
2612 0           $VCalendar->add_properties(calscale => 'GREGORIAN');
2613              
2614 0           my @VEvents;
2615             my %TimeZones;
2616 0 0         foreach my $Args (ref $Item eq 'ARRAY' ? @$Item : $Item) {
2617 0 0 0       if (not $havepid and $Args->{prodId}) {
2618 0           $VCalendar->add_properties('prodid' => $Args->{prodId});
2619 0           $havepid = 1;
2620             }
2621             # initialise timestamp if not given one
2622 0   0       $Args->{dtstamp} //= DateTime->now()->strftime('%Y-%m-%dT%H:%M:%S');
2623 0           push @VEvents, $Self->_argsToVEvents(\%TimeZones, $Args);
2624             }
2625              
2626             # add timezone parts first
2627 0           foreach my $Zone (sort keys %TimeZones) {
2628 0           my $VTimeZone = Net::CalDAVTalk::TimeZones->GetVTimeZone($Zone);
2629 0 0         next unless $VTimeZone;
2630 0           $VCalendar->add_entry($VTimeZone);
2631             }
2632              
2633             # then the events
2634 0           foreach my $VEvent (@VEvents) {
2635 0           $VCalendar->add_entry($VEvent);
2636             }
2637              
2638 0           return $VCalendar;
2639             }
2640              
2641             sub _makeRecurrence {
2642 0     0     my $Self = shift;
2643 0           my ($Args, $IsAllDay, $TZ) = @_;
2644              
2645 0           my %Recurrence;
2646              
2647             # validate simple recurrence properties {{{
2648              
2649 0 0         unless (ref($Args) eq 'HASH') {
2650 0           confess 'Invalid recurrence';
2651             }
2652              
2653 0 0         if ($Args->{frequency}) {
2654 0 0         unless (grep { $_ eq $Args->{frequency} } @Frequencies) {
  0            
2655 0           confess "Invalid recurrence frequency ($Args->{frequency})";
2656             }
2657              
2658 0           $Recurrence{FREQ} = uc($Args->{frequency});
2659             }
2660             else {
2661 0           confess 'Recurrence frequency not specified';
2662             }
2663              
2664 0 0         if (defined $Args->{interval}) {
2665 0 0         unless ($Args->{interval} =~ /^\d+$/) {
2666 0           confess "Invalid recurrence interval ($Args->{interval})";
2667             }
2668              
2669 0 0         if ($Args->{interval} == 0) {
2670 0           confess "Recurrence interval is out of range ($Args->{interval})";
2671             }
2672              
2673 0 0         if ($Args->{interval} > 1) {
2674 0           $Recurrence{INTERVAL} = $Args->{interval};
2675             }
2676             }
2677              
2678 0 0         if (defined $Args->{firstDayOfWeek}) {
2679 0 0         unless (exists $DaysByIndex{$Args->{firstDayOfWeek}}) {
2680 0           confess "Invalid recurrence firstDayOfWeek ($Args->{firstDayOfWeek})";
2681             }
2682              
2683 0 0         unless ($Args->{firstDayOfWeek} == 1){
2684 0           $Recurrence{WKST} = uc $DaysByIndex{$Args->{firstDayOfWeek}};
2685             }
2686             }
2687              
2688 0 0         if ($Args->{byDay}) {
2689 0 0         unless (ref($Args->{byDay}) eq 'ARRAY') {
2690 0           confess 'Invalid recurrence byDay';
2691             }
2692              
2693 0 0         unless (@{$Args->{byDay}}) {
  0            
2694 0           confess 'Recurrence byDay is empty';
2695             }
2696              
2697 0           $Recurrence{BYDAY} = join(',', map{ _byDay2BYDAY($_) } @{$Args->{byDay}});
  0            
  0            
2698             }
2699              
2700 0 0         if ($Args->{byMonth}) {
2701 0 0         unless (ref($Args->{byMonth}) eq 'ARRAY') {
2702 0           confess 'Invalid recurrence byMonth';
2703             }
2704              
2705 0 0         unless (@{$Args->{byMonth}}) {
  0            
2706 0           confess 'Recurrence byMonth is empty';
2707             }
2708              
2709 0           my @BYMONTHS;
2710              
2711 0           foreach my $byMonth (@{$Args->{byMonth}}) {
  0            
2712 0 0         unless ($byMonth =~ /^(\d+)L?$/i) {
2713 0           confess "Recurrence byMonth is not a number with optional L ($byMonth)";
2714             }
2715 0           my $monthNum = $1;
2716 0 0 0       unless ($monthNum >= 1 and $monthNum <= 13) {
2717             # not sure if 13 is OK
2718 0           confess "Recurrence byMonth is too high ($monthNum)";
2719             }
2720              
2721 0           push @BYMONTHS, $byMonth;
2722             }
2723              
2724 0           $Recurrence{BYMONTH} = join ',', @BYMONTHS;
2725             }
2726              
2727 0 0         if (defined $Args->{count}) {
2728 0 0         if (defined $Args->{until}) {
2729 0           confess 'Recurrence count and until cannot both be set';
2730             }
2731              
2732 0 0         unless ($Args->{count} =~ /^\d+$/) {
2733 0           confess "Invalid recurrence count ($Args->{count})";
2734             }
2735              
2736 0           $Recurrence{COUNT} = $Args->{count};
2737             }
2738              
2739 0 0         if ($Args->{until}) {
2740 0           my $Until = _wireDate($Args->{until}, $Self->tz($TZ));
2741              
2742 0 0         if ($IsAllDay) {
2743 0           $Recurrence{UNTIL} = $Until->strftime('%Y%m%d');
2744             }
2745             else {
2746             # API is in Localtime, but both iCloud and Google use 'Z' times as per
2747             # rfc2445, so we'll copy them for compatibility.
2748 0           $Until->set_time_zone($UTC);
2749 0           $Recurrence{UNTIL} = $Until->strftime('%Y%m%dT%H%M%SZ');
2750             }
2751             }
2752              
2753             # }}}
2754              
2755             # validate generic recurrence properties {{{
2756              
2757 0           foreach my $Property (keys %RecurrenceProperties) {
2758 0           my $Name = $RecurrenceProperties{$Property}{name};
2759              
2760 0 0         if ($Args->{$Name}) {
2761 0 0         unless (ref($Args->{$Name}) eq 'ARRAY') {
2762 0           confess "Invalid recurrence $Name";
2763             }
2764              
2765 0 0         unless (@{$Args->{$Name}}) {
  0            
2766 0           confess "Recurrence $Name is empty";
2767             }
2768              
2769 0           my @Values;
2770              
2771 0           foreach my $Value (@{$Args->{$Name}}) {
  0            
2772             my ($Valid, $Min) = $RecurrenceProperties{$Property}{signed}
2773 0 0         ? ('[-+]?[1-9]\d*', ($RecurrenceProperties{$Property}{max} * -1))
2774             : ('\d+', 0);
2775              
2776 0 0         unless ($Value =~ /^$Valid$/) {
2777 0           confess "Invalid recurrence $Name ($Value)";
2778             }
2779              
2780 0 0 0       unless (($Min <= $Value) and ($Value <= $RecurrenceProperties{$Property}{max})) {
2781 0           confess "Recurrence $Name is out of range ($Value)";
2782             }
2783              
2784 0           push @Values, $Value;
2785             }
2786              
2787 0           $Recurrence{uc $Property} = join ',', @Values;
2788             }
2789             }
2790              
2791             # }}}
2792              
2793 0           return %Recurrence;
2794             }
2795              
2796             =head2 $self->vcalendarToEvents($Data)
2797              
2798             Convert a text vcalendar (either a single event or an entire ical file) into an array of events.
2799              
2800             Returns an array (not arrayref) of Events in UID order.
2801              
2802             e.g.
2803              
2804             foreach my $Event ($CalDAV->vcalendarToEvents($Data)) {
2805             # ...
2806             }
2807              
2808             =cut
2809              
2810             sub _insert_override {
2811 0     0     my $Event = shift;
2812 0           my $recurrenceId = shift;
2813 0           my $Recurrence = shift;
2814              
2815 0           my %override;
2816 0           my %oldkeys = map { $_ => 1 } keys %$Event;
  0            
2817 0           foreach my $Key (sort keys %$Recurrence) {
2818 0           delete $oldkeys{$Key};
2819 0 0         next if $MustBeTopLevel{$Key}; # XXX - check safeeq and die?
2820 0 0         if ($Key eq 'start') {
2821             # special case, it's the recurrence-id
2822 0 0         next if _safeeq($Recurrence->{start}, $recurrenceId);
2823 0           $override{start} = $Recurrence->{start};
2824 0           next;
2825             }
2826 0 0         next if _safeeq($Recurrence->{$Key}, $Event->{$Key});
2827 0           _add_override(\%override, _quotekey($Key), $Recurrence->{$Key}, $Event->{$Key});
2828             }
2829              
2830 0           foreach my $Key (sort keys %oldkeys) {
2831 0 0         next if $MustBeTopLevel{$Key};
2832 0           $override{$Key} = $JSON::null;
2833             }
2834              
2835             # in theory should never happen, but you could edit something back to be identical
2836 0 0         return unless %override;
2837 0           $Event->{recurrenceOverrides}{$recurrenceId} = \%override;
2838             }
2839              
2840             sub vcalendarToEvents {
2841 0     0 1   my $Self = shift;
2842 0           my $Data = shift;
2843              
2844             # Internal caches need to be invalidated on each item read! A bit evil really...
2845 0           delete $Self->{_tznamemap};
2846              
2847 0           my %map;
2848             my %exceptions;
2849 0           my $Events = $Self->_getEventsFromVCalendar($Data);
2850              
2851 0           foreach my $Event (@$Events) {
2852 0           my $uid = $Event->{uid};
2853 0 0         if ($Event->{_recurrenceObj}) {
    0          
2854 0           push @{$exceptions{$uid}}, $Event;
  0            
2855             }
2856             elsif ($map{$uid}) {
2857             # it looks like sometimes Google doesn't remember to put the Recurrence ID
2858             # on additional recurrences after the first one, which is going to screw up
2859             # pretty badly because if the date has changed, then we can't even notice
2860             # which recurrent it was SUPPOSED to be. *sigh*.
2861 0           warn "DUPLICATE EVENT FOR $uid\n" . Dumper($map{$uid}, $Event);
2862 0           push @{$exceptions{$uid}}, $Event;
  0            
2863 0           $map{$uid}{_dirty} = 1;
2864             }
2865             else {
2866 0           $map{$uid} = $Event;
2867             }
2868             }
2869              
2870 0           foreach my $uid (keys %exceptions) {
2871 0 0         unless ($map{$uid}) {
2872             # create a synthetic top-level
2873 0           my $First = $exceptions{$uid}[0];
2874             $map{$uid} = {
2875             uid => $uid,
2876             # these two are required at top level, but may be different
2877             # in recurrences so aren't in MustBeTopLevel
2878             start => $First->{start},
2879             updated => $First->{updated},
2880 0           };
2881 0 0         $map{$uid}{timeZone} = $First->{timeZone} unless $First->{isAllDay};
2882 0           foreach my $key (keys %MustBeTopLevel) {
2883 0 0         $map{$uid}{$key} = $First->{$key} if exists $First->{$key};
2884             }
2885             }
2886 0           foreach my $SubEvent (@{$exceptions{$uid}}) {
  0            
2887 0           my $recurrenceId = $SubEvent->{start};
2888 0 0         if ($SubEvent->{_recurrenceObj}) {
2889 0           my $Date = delete $SubEvent->{_recurrenceObj};
2890 0 0         $Date->set_time_zone($map{$uid}{timeZone}) if $map{$uid}{timeZone};
2891 0           $recurrenceId = $Date->iso8601();
2892             }
2893 0           _insert_override($map{$uid}, $recurrenceId, $SubEvent);
2894             }
2895             }
2896              
2897 0           return map { $map{$_} } sort keys %map;
  0            
2898             }
2899              
2900             =head2 $self->UpdateAddressSet($DisplayName, $EmailAddress)
2901              
2902             Set the address set and display name for the calendar user (if supported)
2903              
2904             =cut
2905              
2906             sub UpdateAddressSet {
2907 0     0 1   my ($Self, $NewDisplayName, $NewAddressSet) = @_;
2908              
2909 0           my ($DisplayName, $AddressSet) = $Self->GetProps(\$Self->{principal}, 'D:displayname', [ 'C:calendar-user-address-set', 'D:href' ]);
2910              
2911 0 0 0       if (!$AddressSet || $AddressSet ne "mailto:" . $NewAddressSet ||
      0        
      0        
2912             !$DisplayName || $DisplayName ne $NewDisplayName) {
2913 0           $Self->Request(
2914             'PROPPATCH',
2915             "",
2916             x('D:propertyupdate', $Self->NS(),
2917             x('D:set',
2918             x('D:prop',
2919             x('D:displayname', $NewDisplayName),
2920             x('C:calendar-user-address-set', "mailto:" . $NewAddressSet),
2921             )
2922             )
2923             )
2924             );
2925 0           return 1;
2926             }
2927              
2928 0           return 0;
2929             }
2930              
2931             =head2 $self->GetICal($calendarId, $isFreeBusy)
2932              
2933             Given a calender, fetch all the events and generate an ical format file
2934             suitable for import into a client.
2935              
2936             =cut
2937              
2938             sub GetICal {
2939 0     0 1   my $Self = shift;
2940 0           my $calendarId = shift;
2941 0           my $isFreeBusy = shift;
2942              
2943 0 0         confess "Need a calendarId" unless $calendarId;
2944              
2945 0           my $Calendars = $Self->GetCalendars();
2946 0           foreach my $Cal (@$Calendars) {
2947 0 0         next unless $calendarId eq $Cal->{id};
2948 0 0         my ($Events, $Errors) = $isFreeBusy ?
2949             $Self->GetFreeBusy($calendarId) :
2950             $Self->GetEvents($calendarId);
2951 0 0         return undef if @$Errors;
2952 0           $Self->_stripNonICal($_) for @$Events;
2953             my $VCalendar = $Self->_argsToVCalendar($Events,
2954             method => 'PUBLISH',
2955             'x-wr-calname' => $Cal->{name},
2956             'x-wr-timezone' => $Cal->{timeZone},
2957             'x-apple-calendar-color' => $Cal->{color},
2958             # XXX - do we want to add our sync-token here or something?
2959 0           );
2960 0           return ($VCalendar->as_string(), $Cal);
2961             }
2962 0           return undef; # 404
2963             }
2964              
2965             sub _quotekey {
2966 0     0     my $key = shift;
2967 0           $key =~ s/\~/~0/gs;
2968 0           $key =~ s/\//~1/gs;
2969 0           return $key;
2970             }
2971              
2972             sub _unquotekey {
2973 0     0     my $key = shift;
2974 0           $key =~ s/\~1/\//gs;
2975 0           $key =~ s/\~0/~/gs;
2976 0           return $key;
2977             }
2978              
2979             sub _add_override {
2980 0     0     my ($override, $prefix, $New, $Old) = @_;
2981              
2982             # basic case - it's not an object, so we just override
2983 0 0 0       if ($ENV{JMAP_ALWAYS_FULL} or ref($New) ne 'HASH' or ref($Old) or 'HASH') {
      0        
      0        
2984 0           $override->{$prefix} = $New;
2985 0           return;
2986             }
2987              
2988             # XXX - if too many, we could just abort...
2989 0           my %subover;
2990 0           my %oldkeys = map { $_ => 1 } keys %$Old;
  0            
2991 0           foreach my $Key (sort keys %$New) {
2992 0           delete $oldkeys{$Key};
2993 0 0         next if _safeeq($New->{$Key}, $Old->{$Key});
2994 0           _add_override(\%subover, "$prefix/" . _quotekey($Key), $New->{$Key}, $Old->{$Key});
2995             }
2996 0           foreach my $Key (sort keys %oldkeys) {
2997 0           $subover{"$prefix/" . _quotekey($Key)} = $JSON::null;
2998             }
2999              
3000             # which one is better?
3001 0 0         if (length(encode_json($New)) < length(encode_json(\%subover))) {
3002 0           $override->{$prefix} = $New; # cheaper to just encode the whole object
3003             }
3004             else {
3005 0           $override->{$_} = $subover{$_} for keys %subover;
3006             }
3007             }
3008              
3009             sub _apply_patch {
3010 0     0     my $path = shift;
3011 0           my $hash = shift;
3012 0           my $value = shift;
3013              
3014 0 0         return unless $path =~ s{^([^/]+)(/?)}{};
3015 0 0         return unless ref($hash) eq 'HASH';
3016 0           my $qkey = $1;
3017 0           my $slash = $2;
3018 0           my $key = _unquotekey($qkey);
3019 0 0         if ($slash) {
    0          
3020 0           _apply_patch($path, $hash->{$key}, $value);
3021             }
3022             elsif(defined $value) {
3023 0           $hash->{$key} = $value;
3024             }
3025             else {
3026 0           delete $hash->{$key};
3027             }
3028             }
3029              
3030             sub _maximise {
3031 0     0     my $Self = shift;
3032 0           my $Event = shift;
3033 0           my $Recurrence = shift;
3034 0           my $recurrenceId = shift;
3035              
3036             #warn "MAXIMIZING EVENT INTO RECURRENCE: " . Dumper($Event, $Recurrence);
3037              
3038 0           my $new = _deepcopy($Event);
3039 0           $new->{start} = $recurrenceId;
3040 0           delete $new->{recurrenceRule};
3041 0           delete $new->{recurrenceOverrides};
3042              
3043 0           foreach my $path (sort keys %$Recurrence) {
3044 0           my $value = $Recurrence->{$path};
3045 0           _apply_patch($path, $new, $value);
3046             }
3047              
3048 0           return $new;
3049             }
3050              
3051             sub _stripNonICal {
3052 0     0     my $Self = shift;
3053 0           my $Event = shift;
3054              
3055 0           delete $Event->{alerts};
3056 0           delete $Event->{attendees};
3057 0           delete $Event->{organizer};
3058              
3059 0           foreach my $exception (values %{$Event->{exceptions}}) {
  0            
3060 0 0         next unless $exception;
3061 0           $Self->_stripNonICal($exception);
3062             }
3063             }
3064              
3065             sub _safeeq {
3066 0     0     my ($a, $b) = @_;
3067 0           my $json = JSON::XS->new->canonical;
3068 0           return $json->encode([$a]) eq $json->encode([$b]);
3069             }
3070              
3071             sub _deepcopy {
3072 0     0     my $data = shift;
3073 0           my $json = JSON::XS->new->canonical;
3074 0           my $enc = $json->encode([$data]);
3075 0           my $copy = $json->decode($enc);
3076 0           return $copy->[0];
3077             }
3078              
3079              
3080             =head1 AUTHOR
3081              
3082             Bron Gondwana, C<< <brong at cpan.org> >>
3083              
3084             =head1 BUGS
3085              
3086             Please report any bugs or feature requests to C<bug-net-caldavtalk at rt.cpan.org>, or through
3087             the web interface at L<http://rt.cpan.org/NoAuth/ReportBug.html?Queue=Net-CalDAVTalk>. I will be notified, and then you'll
3088             automatically be notified of progress on your bug as I make changes.
3089              
3090              
3091              
3092              
3093             =head1 SUPPORT
3094              
3095             You can find documentation for this module with the perldoc command.
3096              
3097             perldoc Net::CalDAVTalk
3098              
3099              
3100             You can also look for information at:
3101              
3102             =over 4
3103              
3104             =item * RT: CPAN's request tracker (report bugs here)
3105              
3106             L<http://rt.cpan.org/NoAuth/Bugs.html?Dist=Net-CalDAVTalk>
3107              
3108             =item * AnnoCPAN: Annotated CPAN documentation
3109              
3110             L<http://annocpan.org/dist/Net-CalDAVTalk>
3111              
3112             =item * CPAN Ratings
3113              
3114             L<http://cpanratings.perl.org/d/Net-CalDAVTalk>
3115              
3116             =item * Search CPAN
3117              
3118             L<http://search.cpan.org/dist/Net-CalDAVTalk/>
3119              
3120             =back
3121              
3122              
3123             =head1 ACKNOWLEDGEMENTS
3124              
3125              
3126             =head1 LICENSE AND COPYRIGHT
3127              
3128             Copyright 2015 FastMail Pty Ltd.
3129              
3130             This program is free software; you can redistribute it and/or modify it
3131             under the terms of the the Artistic License (2.0). You may obtain a
3132             copy of the full license at:
3133              
3134             L<http://www.perlfoundation.org/artistic_license_2_0>
3135              
3136             Any use, modification, and distribution of the Standard or Modified
3137             Versions is governed by this Artistic License. By using, modifying or
3138             distributing the Package, you accept this license. Do not use, modify,
3139             or distribute the Package, if you do not accept this license.
3140              
3141             If your Modified Version has been derived from a Modified Version made
3142             by someone other than you, you are nevertheless required to ensure that
3143             your Modified Version complies with the requirements of this license.
3144              
3145             This license does not grant you the right to use any trademark, service
3146             mark, tradename, or logo of the Copyright Holder.
3147              
3148             This license includes the non-exclusive, worldwide, free-of-charge
3149             patent license to make, have made, use, offer to sell, sell, import and
3150             otherwise transfer the Package with respect to any patent claims
3151             licensable by the Copyright Holder that are necessarily infringed by the
3152             Package. If you institute patent litigation (including a cross-claim or
3153             counterclaim) against any party alleging that the Package constitutes
3154             direct or contributory patent infringement, then this Artistic License
3155             to you shall terminate on the date that such litigation is filed.
3156              
3157             Disclaimer of Warranty: THE PACKAGE IS PROVIDED BY THE COPYRIGHT HOLDER
3158             AND CONTRIBUTORS "AS IS' AND WITHOUT ANY EXPRESS OR IMPLIED WARRANTIES.
3159             THE IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR
3160             PURPOSE, OR NON-INFRINGEMENT ARE DISCLAIMED TO THE EXTENT PERMITTED BY
3161             YOUR LOCAL LAW. UNLESS REQUIRED BY LAW, NO COPYRIGHT HOLDER OR
3162             CONTRIBUTOR WILL BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, OR
3163             CONSEQUENTIAL DAMAGES ARISING IN ANY WAY OUT OF THE USE OF THE PACKAGE,
3164             EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
3165              
3166              
3167             =cut
3168              
3169             1; # End of Net::CalDAVTalk