File Coverage

blib/lib/Net/CalDAVTalk.pm
Criterion Covered Total %
statement 85 1262 6.7
branch 1 794 0.1
condition 0 246 0.0
subroutine 24 81 29.6
pod 25 25 100.0
total 135 2408 5.6


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