File Coverage

blib/lib/DateTime/Event/Recurrence.pm
Criterion Covered Total %
statement 295 302 97.6
branch 105 128 82.0
condition 31 44 70.4
subroutine 29 29 100.0
pod n/a
total 460 503 91.4


line stmt bran cond sub pod time code
1 15     15   5618804 use strict;
  15         23  
  15         670  
2              
3             package DateTime::Set::ICal;
4              
5 15     15   91 use vars qw(@ISA);
  15         18  
  15         6568  
6             # use Carp;
7              
8             # a "dt::set" with a symbolic string representation
9             @ISA = qw( DateTime::Set );
10              
11             sub set_ical { # include list, exclude list
12 107     107   121 my $self = shift;
13             # carp "set_ical $_[0] => @{$_[1]}" if @_;
14 107         300 $self->{as_ical} = [ @_ ];
15 107         180 $self;
16             }
17              
18             sub get_ical {
19 59     59   69 my $self = shift;
20 59 100       174 return unless $self->{as_ical};
21 38         78 return @{ $self->{as_ical} };
  38         168  
22             }
23              
24             sub clone {
25 31     31   8794 my $self = shift;
26 31         149 my $new = $self->SUPER::clone( @_ );
27 31         1451 $new->set_ical( $self->get_ical );
28 31         67 $new;
29             }
30              
31             sub union {
32 14     14   246 my $self = shift;
33 14         53 my $new = $self->SUPER::union( @_ );
34              
35             # RFC2445 - op1, op2 must have no 'exclude'
36 14         1628 my (%op1, %op2);
37 14 50       75 %op1 = ( $self->get_ical ) if ( UNIVERSAL::can( $self, 'get_ical' ) );
38 14 100       66 %op2 = ( $_[0]->get_ical ) if ( UNIVERSAL::can( $_[0], 'get_ical' ) );
39             return $new if ( ( exists $op1{exclude} ) ||
40 14 50 33     59 ( exists $op2{exclude} ) );
41              
42 14         19 bless $new, 'DateTime::Set::ICal';
43             # warn " -- 1 isa @{[%op1]} -- 2 isa @{[%op2]} -- ";
44 14         15 my @ical;
45             @ical = exists $op1{include} ?
46 14 100       59 @{$op1{include}} :
  7         11  
47             $self;
48              
49             # push @ical, @{$op2{include}}, @_;
50 14 50       23 if ( exists $op2{include} )
51             {
52 0         0 push @ical, @{$op2{include}};
  0         0  
53             }
54             else
55             {
56 14         45 push @ical, @_; # whatever...
57             }
58             # warn "union: @ical";
59 14         48 $new->set_ical( include => [ @ical ] );
60 14         53 $new;
61             }
62              
63             sub complement {
64 1     1   3657 my $self = shift;
65 1         10 my $new = $self->SUPER::complement( @_ );
66 1 50       546 return $new unless @_;
67              
68             # RFC2445 - op2 must have no 'exclude'
69 1         1 my (%op1, %op2);
70 1 50       8 %op1 = ( $self->get_ical ) if ( UNIVERSAL::can( $self, 'get_ical' ) );
71 1 50       4 %op2 = ( $_[0]->get_ical ) if ( UNIVERSAL::can( $_[0], 'get_ical' ) );
72 1 50       4 return $new if ( exists $op2{exclude} );
73              
74 1         2 bless $new, 'DateTime::Set::ICal';
75             # warn " -- 1 isa @{[%op1]} -- 2 isa @{[%op2]} -- ";
76 1         2 my ( @include, @exclude );
77             @include = exists $op1{include} ?
78 1 50       3 @{$op1{include}} :
  1         3  
79             $self;
80              
81             @exclude = exists $op1{exclude} ?
82 1 50       3 @{$op1{exclude}} :
  0         0  
83             ();
84              
85 1 50       3 if ( exists $op2{include} )
86             {
87 0         0 push @exclude, @{$op2{include}};
  0         0  
88             }
89             else
90             {
91 1         1 push @exclude, @_; # whatever...
92             }
93              
94             # warn "complement: include @include exclude @exclude";
95 1         6 $new->set_ical( include => [ @include ], exclude => [ @exclude ] );
96 1         3 $new;
97             }
98              
99             package DateTime::Event::Recurrence;
100              
101 15     15   85 use strict;
  15         19  
  15         313  
102 15     15   1455 use DateTime;
  15         683155  
  15         340  
103 15     15   6898 use DateTime::Set;
  15         467872  
  15         366  
104 15     15   106 use DateTime::Span;
  15         21  
  15         315  
105 15     15   67 use Params::Validate qw(:all);
  15         20  
  15         2937  
106 15     15   68 use vars qw( $VERSION );
  15         21  
  15         769  
107             $VERSION = '0.19';
108              
109 15     15   60 use constant INFINITY => 100 ** 100 ** 100 ;
  15         19  
  15         889  
110 15     15   199 use constant NEG_INFINITY => -1 * (100 ** 100 ** 100);
  15         19  
  15         792  
111              
112             # -------- BASE OPERATIONS
113              
114 15         2803 use vars qw(
115             %as_number
116              
117             %truncate
118             %next_unit
119             %previous_unit
120            
121             %truncate_interval
122             %next_unit_interval
123             %previous_unit_interval
124              
125             %weekdays
126             %weekdays_1
127             %weekdays_any
128            
129             %memoized_duration
130            
131             %ical_name
132             %ical_days
133             %limits
134             @units
135 15     15   77 );
  15         25  
136              
137             BEGIN {
138 15     15   114 %weekdays = qw( mo 1 tu 2 we 3 th 4 fr 5 sa 6 su 7 );
139 15         130 %weekdays_1 = qw( 1mo 1 1tu 2 1we 3 1th 4 1fr 5 1sa 6 1su 7 );
140 15         130 %weekdays_any = ( %weekdays, %weekdays_1 );
141            
142 15         79 %ical_name = qw(
143             months BYMONTH
144             weeks BYWEEKNO
145             days BYMONTHDAY
146             hours BYHOUR
147             minutes BYMINUTE
148             seconds BYSECOND
149             );
150            
151 15         143 %ical_days = qw(
152             1 MO 2 TU 3 WE 4 TH 5 FR 6 SA 7 SU
153             -7 MO -6 TU -5 WE -4 TH -3 FR -2 SA -1 SU
154             );
155            
156 15         31 @units = qw( years months weeks days hours minutes seconds nanoseconds );
157            
158 15         2055 %limits = qw(
159             nanoseconds 1000000000
160             seconds 61
161             minutes 60
162             hours 24
163             months 12
164             weeks 53
165             days 366
166             );
167              
168             } # BEGIN
169              
170              
171             # memoization reduces 'duration' creation from >10000 to about 30 per run,
172             # in DT::E::ICal
173              
174             sub _add {
175             # datetime, unit, value
176 17779     17779   34765 my $dur = \$memoized_duration{$_[1]}{$_[2]};
177 17779 100       32083 $$dur = new DateTime::Duration( $_[1] => $_[2] )
178             unless defined $$dur;
179 17779         56440 $_[0]->add_duration( $$dur );
180             }
181              
182             # TODO: %as_number should use the "subtract" routines from DateTime
183              
184             %as_number = (
185             years => sub {
186             $_[0]->year
187             },
188             months => sub {
189             12 * $_[0]->year + $_[0]->month - 1
190             },
191             days => sub {
192             ( $_[0]->local_rd_values() )[0]
193             },
194             weeks => sub {
195             # $_[1] is the "week start day", such as "1mo"
196 15     15   70 use integer;
  15         19  
  15         100  
197             return ( $as_number{days}->( $_[0] ) - $weekdays_any{ $_[1] } ) / 7;
198             },
199             hours => sub {
200             $as_number{days}->($_[0]) * 24 + $_[0]->hour
201             },
202             minutes => sub {
203             $as_number{hours}->($_[0]) * 60 + $_[0]->minute
204             },
205             seconds => sub {
206             $_[0]->local_rd_as_seconds
207             },
208             years_weekly => sub {
209             # get the internal year number, in 'week' mode
210             # $_[1] is the "week start day", such as "1mo"
211             my $base = $_[0]->clone;
212             $base = $truncate{years_weekly}->( $base, $_[1] )
213             if $base->month > 11 || $base->month < 2;
214             _add( $base, weeks => 1 );
215             return $as_number{years}->( $base );
216             },
217             months_weekly => sub {
218             # get the internal month number, in 'week' mode
219             # $_[1] is the "week start day", such as "1mo"
220             my $base = $_[0]->clone;
221             $base = $truncate{months_weekly}->( $base, $_[1] )
222             if $base->day > 20 || $base->day < 7;
223             _add( $base, weeks => 1 );
224             return $as_number{months}->( $base );
225             },
226             );
227              
228              
229             %truncate = (
230             # @_ = ( $datetime, $week_start_day )
231              
232             (
233             map {
234             my $name = $_;
235             $name =~ s/s$//;
236             $_ => sub {
237             my $tmp = $_[0]->clone;
238             $tmp->truncate( to => $name )
239             }
240             } qw( years months days hours minutes seconds )
241             ),
242              
243             weeks => sub {
244             my $base = $_[0]->clone->truncate( to => 'day' );
245             _add( $base, days => - $_[0]->day_of_week
246             + $weekdays_any{ $_[1] } );
247             while(1) {
248             return $base if $base <= $_[0];
249             _add( $base, weeks => -1 );
250             }
251             },
252              
253             months_weekly => sub {
254             my $tmp;
255             my $base = $_[0]->clone;
256             _add( $base, days => 7 );
257             $base->truncate( to => 'month' );
258             my $val;
259             my $diff;
260             while(1) {
261             $tmp = $base->clone;
262             $val = $weekdays_1{ $_[1] };
263             if ( $val )
264             {
265             $diff = $val - $base->day_of_week;
266             $diff += 7 if $diff < 0;
267             }
268             else
269             {
270             $diff = ( $weekdays{ $_[1] } -
271             $base->day_of_week ) % 7;
272             $diff -= 7 if $diff > 3;
273             }
274             _add( $tmp, days => $diff );
275             return $tmp if $tmp <= $_[0];
276             _add( $base, months => -1 );
277             }
278             },
279              
280             years_weekly => sub {
281             my $tmp;
282             my $base = $_[0]->clone;
283             _add( $base, months => 1 );
284             $base->truncate( to => 'year' );
285             my $val;
286             my $diff;
287             # warn "wsd $_[1]\n";
288             while(1) {
289             $tmp = $base->clone;
290             $val = $weekdays_1{ $_[1] };
291             if ( $val )
292             {
293             $diff = $val - $base->day_of_week;
294             $diff += 7 if $diff < 0;
295             }
296             else
297             {
298             $diff = ( $weekdays{ $_[1] } -
299             $base->day_of_week ) % 7;
300             $diff -= 7 if $diff > 3;
301             }
302             _add( $tmp, days => $diff );
303             return $tmp if $tmp <= $_[0];
304             _add( $base, years => -1 );
305             }
306             },
307             );
308              
309             %next_unit = (
310             # @_ = ( $datetime, $week_start_day )
311              
312             (
313             map {
314             my $names = $_;
315             $_ => sub {
316             _add( $_[0], $names => 1 )
317             }
318             } qw( years months weeks days hours minutes seconds )
319             ),
320              
321             months_weekly => sub {
322             my $base = $_[0]->clone;
323             my $return;
324             while(1) {
325             _add( $base, days => 21 );
326             $return = $truncate{months_weekly}->( $base, $_[1] );
327             return $_[0] = $return if $return > $_[0];
328             }
329             },
330              
331             years_weekly => sub {
332             my $base = $_[0]->clone;
333             my $return;
334             while(1) {
335             _add( $base, months => 11 );
336             $return = $truncate{years_weekly}->( $base, $_[1] );
337             return $_[0] = $return if $return > $_[0];
338             }
339             },
340             );
341              
342             %previous_unit = (
343             # @_ = ( $datetime, $week_start_day )
344              
345             months_weekly => sub {
346             my $base = $_[0]->clone;
347             my $return;
348             while(1) {
349             $return = $truncate{months_weekly}->( $base, $_[1] );
350             return $_[0] = $return if $return < $_[0];
351             _add( $base, days => -21 );
352             }
353             },
354              
355             years_weekly => sub {
356             my $base = $_[0]->clone;
357             my $return;
358             while(1) {
359             $return = $truncate{years_weekly}->( $base, $_[1] );
360             return $_[0] = $return if $return < $_[0];
361             _add( $base, months => -11 );
362             }
363             },
364             );
365              
366             # -------- "INTERVAL" OPERATIONS
367              
368             %truncate_interval = (
369             # @_ = ( $datetime, $args )
370              
371             (
372             map {
373             my $names = $_;
374             my $name = $_;
375             $name =~ s/s$//;
376             $_ => sub {
377             my $tmp = $_[0]->clone;
378             $tmp->truncate( to => $name );
379             _add( $tmp, $names =>
380             $_[1]{offset} -
381             ( $as_number{$names}->($_[0]) %
382             $_[1]{interval}
383             )
384             );
385             }
386             } qw( years months days hours minutes seconds )
387             ),
388              
389             weeks => sub {
390             my $tmp = $truncate{weeks}->( $_[0], $_[1]{week_start_day} );
391             while ( $_[1]{offset} !=
392             ( $as_number{weeks}->(
393             $tmp, $_[1]{week_start_day} ) %
394             $_[1]{interval}
395             )
396             )
397             {
398             _add( $tmp, weeks => -1 );
399             }
400             return $tmp;
401             },
402              
403             months_weekly => sub {
404             my $tmp = $truncate{months_weekly}->( $_[0], $_[1]{week_start_day} );
405             while ( $_[1]{offset} !=
406             ( $as_number{months_weekly}->(
407             $tmp, $_[1]{week_start_day} ) %
408             $_[1]{interval}
409             )
410             )
411             {
412             $previous_unit{months_weekly}->( $tmp, $_[1]{week_start_day} );
413             }
414             return $tmp;
415             },
416              
417             years_weekly => sub {
418             my $tmp = $truncate{years_weekly}->( $_[0], $_[1]{week_start_day} );
419             while ( $_[1]{offset} !=
420             ( $as_number{years_weekly}->( $tmp, $_[1]{week_start_day} ) %
421             $_[1]{interval}
422             )
423             )
424             {
425             $previous_unit{years_weekly}->( $tmp, $_[1]{week_start_day} );
426             }
427             return $tmp;
428             },
429             );
430              
431             %next_unit_interval = (
432             (
433             map {
434             my $names = $_;
435             $_ => sub {
436             _add( $_[0], $names => $_[1]{interval} )
437             }
438             } qw( years months weeks days hours minutes seconds )
439             ),
440              
441             months_weekly => sub {
442             for ( 1 .. $_[1]{interval} )
443             {
444             $next_unit{months_weekly}->( $_[0], $_[1]{week_start_day} );
445             }
446             },
447              
448             years_weekly => sub {
449             for ( 1 .. $_[1]{interval} )
450             {
451             $next_unit{years_weekly}->( $_[0], $_[1]{week_start_day} );
452             }
453             },
454             );
455              
456             %previous_unit_interval = (
457             (
458             map {
459             my $names = $_;
460             $_ => sub {
461             _add( $_[0], $names => - $_[1]{interval} )
462             }
463             } qw( years months weeks days hours minutes seconds )
464             ),
465              
466             months_weekly => sub {
467             for ( 1 .. $_[1]{interval} )
468             {
469             $previous_unit{months_weekly}->( $_[0], $_[1]{week_start_day} );
470             }
471             },
472              
473             years_weekly => sub {
474             for ( 1 .. $_[1]{interval} )
475             {
476             $previous_unit{years_weekly}->( $_[0], $_[1]{week_start_day} );
477             }
478             },
479             );
480              
481             # -------- CONSTRUCTORS
482              
483             BEGIN {
484             # setup all constructors: daily, ...
485              
486 15     15   61 for ( @units[ 0 .. $#units-1 ] )
487             {
488 105         93 my $name = $_;
489 105         75 my $namely = $_;
490 105         122 $namely =~ s/ys$/ily/;
491 105         197 $namely =~ s/s$/ly/;
492            
493 15     15   20608 no strict 'refs';
  15         28  
  15         744  
494 105         24807 *{__PACKAGE__ . "::$namely"} =
495             sub {
496 15     15   57 use strict 'refs';
  15         18  
  15         857  
497 63     63   69246 my $class = shift;
498 63         238 return _create_recurrence( base => $name, @_ );
499 105         255 };
500             }
501             } # BEGIN
502              
503              
504             sub _create_recurrence {
505 63     63   244 my %args = @_;
506              
507             # print "ARGS: ";
508             # for(@_){ print (( ref($_) eq "ARRAY" ) ? "[ @$_ ] " : "$_ ") }
509             # print " \n";
510            
511             # --- FREQUENCY
512            
513 63         142 my $base = delete $args{base};
514 63         144 my $namely = $base;
515 63         173 $namely =~ s/ys$/ily/;
516 63         240 $namely =~ s/s$/ly/;
517 63         201 my $ical_string = uc( "RRULE:FREQ=$namely" );
518 63         99 my $base_unit = $base;
519             $base_unit = 'years_weekly'
520             if $base_unit eq 'years' &&
521 63 100 66     298 exists $args{weeks} ;
522             $base_unit = 'months_weekly'
523             if $base_unit eq 'months' &&
524 63 100 66     232 exists $args{weeks} ;
525              
526             # --- WEEK-START-DAY
527            
528 63         125 my $week_start_day = delete $args{week_start_day};
529 63 100       178 $ical_string .= ";WKST=". uc($week_start_day)
530             if $week_start_day;
531 63 100       245 $week_start_day = ( $base eq 'years' ) ? 'mo' : '1mo'
    100          
532             unless defined $week_start_day;
533             die "$base: invalid week start day ($week_start_day)"
534 63 50       202 unless $weekdays_any{ $week_start_day };
535            
536             # --- INTERVAL, START, and OFFSET
537            
538 63   50     364 my $interval = delete $args{interval} || 1;
539 63 50       163 die "invalid 'interval' specification ($interval)"
540             if $interval < 1;
541 63 50       158 $ical_string .= ";INTERVAL=$interval"
542             if $interval > 1;
543              
544 63         91 my $start = delete $args{start};
545 63 50 33     203 undef $start
546             if defined $start && $start->is_infinite;
547            
548 63         79 my $offset = 0;
549 63 50 33     202 $offset = $as_number{$base_unit}->( $start, $week_start_day ) % $interval
550             if $start && $interval > 1;
551              
552             # --- DURATION LIST
553            
554             # check for invalid "units" arguments, such as "daily( years=> )"
555 63         71 my @valid_units;
556 63         190 for ( 0 .. $#units )
557             {
558 136 100       310 if ( $base eq $units[$_] )
559             {
560 63         304 @valid_units = @units[ $_+1 .. $#units ];
561 63         109 last;
562             }
563             }
564             die "can't have both 'months' and 'weeks' arguments"
565             if exists $args{weeks} &&
566 63 50 66     250 exists $args{months};
567            
568 63         85 my $level = 1;
569 63         120 my @duration = ( [] );
570 63         122 my @level_unit = ( $base_unit );
571 63         118 for my $unit ( @valid_units )
572             {
573 364 100       595 next unless exists $args{$unit};
574              
575 100 100       243 if ( ref( $args{$unit} ) eq 'ARRAY' )
576             {
577 61         55 $args{$unit} = [ @{ $args{$unit} } ]
  61         137  
578             }
579             else
580             {
581 39         80 $args{$unit} = [ $args{$unit} ]
582             }
583            
584             # TODO: sort _after_ normalization
585              
586 100 100       204 if ( $unit eq 'days' )
587             {
588             # map rfc2445 weekdays to numbers
589 27         83 @{$args{$unit}} =
590             map {
591 149 100       290 $_ =~ /[a-z]/ ? $weekdays{$_} : $_
592 27         40 } @{$args{$unit}};
  27         56  
593             }
594              
595             # sort positive values first
596 100         183 @{$args{$unit}} =
597             sort {
598 381 50       474 ( $a < 0 ) <=> ( $b < 0 ) || $a <=> $b
599 100         106 } @{$args{$unit}};
  100         236  
600              
601              
602             # make the "ical" string
603 100 100 66     386 if ( $unit eq 'nanoseconds' )
    100          
604             {
605             # there are no nanoseconds in ICal
606             }
607             elsif ( $base eq 'weeks' &&
608             $unit eq 'days' )
609             {
610             # weekdays have names
611             $ical_string .= uc( ';' . 'BYDAY' . '=' .
612             join(",",
613             map {
614 6 50       42 exists( $ical_days{$_} ) ? $ical_days{$_} : $_
615 6         12 } @{$args{$unit}} )
  6         10  
616             )
617             }
618             else
619             {
620             $ical_string .= uc( ';' . $ical_name{$unit} . '=' .
621 93         217 join(",", @{$args{$unit}} ) )
  93         289  
622             }
623            
624 100 100 100     572 if ( $unit eq 'months' ||
      100        
625             $unit eq 'weeks' ||
626             $unit eq 'days' )
627             {
628             # these units start in '1'
629 58         62 for ( @{$args{$unit}} )
  58         119  
630             {
631 211 50       277 die $unit . ' cannot be zero'
632             unless $_;
633 211 100       330 $_-- if $_ > 0;
634             }
635             }
636            
637 100         212 @{$args{$unit}} =
638             grep {
639             $_ < $limits{ $unit } &&
640 431 100       1186 $_ >= - $limits{ $unit }
641 100         100 } @{$args{$unit}};
  100         147  
642            
643 100 100 100     315 if ( $unit eq 'days' &&
      66        
644             ( $base_unit eq 'months' ||
645             $level_unit[-1] eq 'months' ) )
646             { # month day
647 16         43 @{$args{$unit}} =
648             grep {
649 138 100       299 $_ < 31 && $_ >= -31
650 16         21 } @{$args{$unit}};
  16         26  
651             }
652              
653 100 100 100     302 if ( $unit eq 'days' &&
      66        
654             ( $base_unit eq 'weeks' ||
655             $level_unit[-1] eq 'weeks' ) )
656             { # week day
657            
658 10         19 @{$args{$unit}} =
659             grep {
660 10 50       61 $_ < 7 && $_ >= -7
661 10         18 } @{$args{$unit}};
  10         19  
662              
663 10         14 for ( @{$args{$unit}} )
  10         26  
664             {
665 10         21 $_ = $_ - $weekdays_any{ $week_start_day } + 1;
666 10         30 $_ += 7 while $_ < 0;
667             }
668              
669 10         16 @{$args{$unit}} = sort @{$args{$unit}};
  10         16  
  10         22  
670             }
671              
672             return DateTime::Set::ICal->empty_set
673 100 100       88 unless @{$args{$unit}}; # there are no args left
  100         220  
674              
675 99         123 push @duration, $args{$unit};
676 99         107 push @level_unit, $unit;
677              
678 99         122 delete $args{$unit};
679              
680 99         103 $level++;
681             }
682              
683             # TODO: use $span for selecting elements (using intersection)
684             # note - this may change the documented behaviour - check the pod first
685             # $span = delete $args{span};
686             # $span = DateTime::Span->new( %args ) if %args;
687              
688 62 100       159 die "invalid argument '@{[ keys %args ]}'"
  1         13  
689             if keys %args;
690            
691             # --- SPLIT NEGATIVE/POSITIVE DURATIONS
692              
693 61         67 my @args;
694 61         116 push @args, \@duration;
695            
696 61         193 for ( my $i = 0; $i < @args; $i++ )
697             {
698 74         113 my $dur1 = $args[$i];
699 74         84 for ( 1 .. $#{$dur1} )
  74         153  
700             {
701 121         113 my @negatives = grep { $_ < 0 } @{$dur1->[$_]};
  460         469  
  121         140  
702 121         118 my @positives = grep { $_ >= 0 } @{$dur1->[$_]};
  460         466  
  121         141  
703 121 100 100     553 if ( @positives && @negatives )
704             {
705             # split
706             # TODO: check if it really needs splitting
707 13         10 my $dur2 = [ @{$args[$i]} ];
  13         23  
708 13         20 $dur2->[$_] = \@negatives;
709 13         16 $dur1->[$_] = \@positives;
710 13         38 push @args, $dur2;
711             }
712             }
713             }
714              
715             # --- CREATE THE SET
716            
717 61         77 my $set;
718 61         104 for ( @args )
719             {
720 74         137 my @duration = @$_;
721 74         79 my $total_durations = 1;
722 74         71 my @total_level;
723 74         182 for ( my $i = $#duration; $i > 0; $i-- )
724             {
725 121 100       167 if ( $i == $#duration )
726             {
727 68         88 $total_level[$i] = 1;
728             }
729             else
730             {
731             $total_level[$i] = $total_level[$i + 1] *
732 53         52 ( 1 + $#{ $duration[$i + 1] } );
  53         67  
733             }
734 121         112 $total_durations *= 1 + $#{ $duration[$i] };
  121         246  
735             }
736              
737             my $args = {
738             truncate_interval => $truncate_interval{ $base_unit },
739             previous_unit_interval => $previous_unit_interval{ $base_unit },
740 74         679 next_unit_interval => $next_unit_interval{ $base_unit },
741            
742             duration => \@duration,
743             total_durations => $total_durations,
744             level_unit => \@level_unit,
745             total_level => \@total_level,
746            
747             interval => $interval,
748             offset => $offset,
749             week_start_day => $week_start_day,
750             };
751            
752             my $tmp = DateTime::Set::ICal->from_recurrence(
753             next => sub {
754 1084     1084   452791 _get_next( $_[0], $args );
755             },
756             previous => sub {
757 525     525   97681 _get_previous( $_[0], $args );
758             },
759 74         706 );
760            
761 74 100       9024 $set = defined $set ? $set->union( $tmp ) : $tmp;
762             }
763 61         254 $set->set_ical( include => [ $ical_string ] );
764             # warn "Creating set: ". $ical_string ." \n";
765            
766 61         225 return $set;
767            
768             } # _create_recurrence
769              
770              
771             sub _get_occurrence_by_index {
772 3063     3063   3944 my ( $base, $occurrence, $args ) = @_;
773             # TODO: memoize "occurrences" within an "INTERVAL" ???
774 3063         4310 RETRY_OVERFLOW: for ( 0 .. 5 )
775             {
776             return undef
777 3171 100       5365 if $occurrence < 0;
778 3137         6181 my $next = $base->clone;
779 3137         23907 my $previous = $base;
780 3137         4617 my @values = ( -1 );
781 3137         2749 for my $j ( 1 .. $#{$args->{duration}} )
  3137         6071  
782             {
783             # decode the occurrence-number into a parameter-index
784 7559         48739 my $i = int( $occurrence / $args->{total_level}[$j] );
785 7559         8721 $occurrence -= $i * $args->{total_level}[$j];
786 7559         7574 push @values, $i;
787            
788 7559 100       14196 if ( $args->{duration}[$j][$i] < 0 )
789             {
790             # warn "negative unit\n";
791             $next_unit{ $args->{level_unit}[$j - 1] }->(
792 437         1219 $next, $args->{week_start_day} );
793             }
794 7559         187700 _add( $next, $args->{level_unit}[$j], $args->{duration}[$j][$i] );
795            
796             # overflow check
797 7559 100       3468026 if ( $as_number{ $args->{level_unit}[$j - 1] }->(
798             $next, $args->{week_start_day} ) !=
799             $as_number{ $args->{level_unit}[$j - 1] }->(
800             $previous, $args->{week_start_day} )
801             )
802             {
803             # calculate the "previous" occurrence-number
804 108         635 $occurrence = -1;
805 108         270 for ( 1 .. $j )
806             {
807 203         335 $occurrence += $values[$_] * $args->{total_level}[$_];
808             }
809 108         596 next RETRY_OVERFLOW;
810             }
811 7451         43298 $previous = $next->clone;
812             }
813 3029         32849 return $next;
814             }
815 0         0 return undef;
816             }
817              
818              
819             sub _get_previous {
820 525     525   849 my ( $self, $args ) = @_;
821              
822 525 100       1313 return $self if $self->is_infinite;
823 318         1439 $self->set_time_zone( 'floating' );
824              
825 318         16019 my $base = $args->{truncate_interval}->( $self, $args );
826 318         9613 my ( $next, $i, $start, $end );
827 318         439 my $init = 0;
828 318         401 my $retry = 30;
829              
830 318         406 INTERVAL: while(1) {
831 498 100       1365 $args->{previous_unit_interval}->( $base, $args ) if $init;
832 498         72321 $init = 1;
833              
834             # binary search
835 498         495 $start = 0;
836 498         714 $end = $args->{total_durations} - 1;
837 498         1086 while ( $retry-- ) {
838 844 100       1725 if ( $end - $start < 3 )
839             {
840 498         1168 for ( $i = $end; $i >= $start; $i-- )
841             {
842 635         7448 $next = _get_occurrence_by_index ( $base, $i, $args );
843 635 50       2123 next INTERVAL unless defined $next;
844 635 100       1662 return $next if $next < $self;
845             }
846 180         8516 next INTERVAL;
847             }
848              
849 346         657 $i = int( $start + ( $end - $start ) / 2 );
850 346         518 $next = _get_occurrence_by_index ( $base, $i, $args );
851 346 50       1211 next INTERVAL unless defined $next;
852              
853 346 100       942 if ( $next < $self )
854             {
855 189         8756 $start = $i;
856             }
857             else
858             {
859 157         7531 $end = $i - 1;
860             }
861             }
862 0         0 return undef;
863             }
864             }
865              
866              
867             sub _get_next {
868 1084     1084   1490 my ( $self, $args ) = @_;
869              
870 1084 100       2449 return $self if $self->is_infinite;
871 858         4108 $self->set_time_zone( 'floating' );
872              
873 858         56665 my $base = $args->{truncate_interval}->( $self, $args );
874 858         18973 my ( $next, $i, $start, $end );
875 858         1455 my $init = 0;
876 858         994 my $retry = 30;
877            
878 858         901 INTERVAL: while(1) {
879 1468 100       3886 $args->{next_unit_interval}->( $base, $args ) if $init;
880 1468         185428 $init = 1;
881              
882             # binary search
883 1468         1633 $start = 0;
884 1468         2166 $end = $args->{total_durations} - 1;
885 1468         3265 while ( $retry-- ) {
886 1893 100       4060 if ( $end - $start < 3 )
887             {
888 1467         2466 for $i ( $start .. $end )
889             {
890 1656         11454 $next = _get_occurrence_by_index ( $base, $i, $args );
891 1656 100       5658 next INTERVAL unless defined $next;
892 1622 100       4487 return $next if $next > $self;
893             }
894 576         26802 next INTERVAL;
895             }
896              
897 426         802 $i = int( $start + ( $end - $start ) / 2 );
898 426         687 $next = _get_occurrence_by_index ( $base, $i, $args );
899 426 50       1495 next INTERVAL unless defined $next;
900              
901 426 100       1135 if ( $next > $self )
902             {
903 212         10421 $end = $i;
904             }
905             else
906             {
907 214         10090 $start = $i + 1;
908             }
909             }
910 1         10 return undef;
911             }
912             }
913              
914             1;
915              
916             __END__
917              
918             =head1 NAME
919              
920             DateTime::Event::Recurrence - DateTime::Set extension for create basic recurrence sets
921              
922             =head1 SYNOPSIS
923              
924             use DateTime;
925             use DateTime::Event::Recurrence;
926            
927             my $dt = DateTime->new( year => 2000,
928             month => 6,
929             day => 20,
930             );
931              
932             my $daily_set = DateTime::Event::Recurrence->daily;
933              
934             my $dt_next = $daily_set->next( $dt );
935              
936             my $dt_previous = $daily_set->previous( $dt );
937              
938             my $bool = $daily_set->contains( $dt );
939              
940             my @days = $daily_set->as_list( start => $dt1, end => $dt2 );
941              
942             my $iter = $daily_set->iterator;
943              
944             while ( my $dt = $iter->next ) {
945             print ' ', $dt->datetime;
946             }
947              
948             =head1 DESCRIPTION
949              
950             This module provides convenience methods that let you easily create
951             C<DateTime::Set> objects for various recurrences, such as "once a
952             month" or "every day". You can also create more complicated
953             recurrences, such as "every Monday, Wednesday and Thursday at 10:00 AM
954             and 2:00 PM".
955              
956             =head1 USAGE
957              
958             =over 4
959              
960             =item * yearly monthly weekly daily hourly minutely secondly
961              
962             These methods all return a new C<DateTime::Set> object representing
963             the given recurrence.
964              
965             my $daily_set = DateTime::Event::Recurrence->daily;
966              
967             If no parameters are given, then the set members each occur at the
968             I<beginning> of the specified recurrence.
969              
970             For example, by default, the C<monthly()> method returns a set
971             containing the first day of each month.
972              
973             Without parameters, the C<weekly()> method returns a set containing
974             I<Mondays>.
975              
976             However, you can pass in parameters to alter where these datetimes
977             fall. The parameters are the same as those given to the
978             C<DateTime::Duration> constructor for specifying the length of a
979             duration. For example, to create a set representing a daily
980             recurrence at 10:30 each day, we write this:
981              
982             my $daily_at_10_30_set =
983             DateTime::Event::Recurrence->daily( hours => 10, minutes => 30 );
984              
985             To represent every I<Tuesday> (second day of the week):
986              
987             my $weekly_on_tuesday_set =
988             DateTime::Event::Recurrence->weekly( days => 2 );
989              
990             A negative duration counts backwards from the end of the period. This
991             is done in the same manner as is specified in RFC 2445 (iCal).
992              
993             Negative durations are useful for creating recurrences such as the
994             I<last day of each month>:
995              
996             my $last_day_of_month_set =
997             DateTime::Event::Recurrence->monthly( days => -1 );
998              
999             You can also provide multiple sets of duration arguments, such as
1000             this:
1001              
1002             my $set = DateTime::Event::Recurrence->daily
1003             ( hours => [ 10, 14, -1 ],
1004             minutes => [ 15, 30, -15 ],
1005             );
1006              
1007             This specifies a recurrence occurring every day at these 9 different
1008             times:
1009              
1010             10:15, 10:30, 10:45, # +10h ( +15min / +30min / last 15min (-15) )
1011             14:15, 14:30, 14:45, # +14h ( +15min / +30min / last 15min (-15) )
1012             23:15, 23:30, 23:45, # last 1h (-1) ( +15min / +30min / last 15min (-15) )
1013              
1014             To create a set of recurrences occurring every thirty seconds, we could do this:
1015              
1016             my $every_30_seconds_set =
1017             DateTime::Event::Recurrence->minutely( seconds => [ 0, 30 ] );
1018              
1019             The following is also valid. See the section on the "interval" parameter:
1020            
1021             my $every_30_seconds_set =
1022             DateTime::Event::Recurrence->secondly( interval => 30 );
1023            
1024             =back
1025              
1026             =head2 Invalid DateTimes
1027              
1028             Invalid values are skipped at run time.
1029              
1030             For example, when days are added to a month, the result is checked for
1031             a nonexisting day (such as 31 or 30), and the invalid datetimes are skipped.
1032              
1033             Another example of this would be creating a set via the
1034             C<daily()> method and specifying C<< hours => 25 >>.
1035              
1036             =head2 The "days" Parameter
1037              
1038             The "days" parameter can be combined with yearly, monthly, and weekly
1039             recurrences, resulting in six possible meanings:
1040              
1041             # tuesday of every week
1042             my $set = DateTime::Event::Recurrence->weekly( days => 2 );
1043              
1044             # 10th day of every month
1045             my $set = DateTime::Event::Recurrence->monthly( days => 10 );
1046              
1047             # second full week of every month, on tuesday
1048             my $set = DateTime::Event::Recurrence->monthly( weeks => 2, days => 2 );
1049              
1050             # 10th day of every year
1051             my $set = DateTime::Event::Recurrence->yearly( days => 10 );
1052              
1053             # 10th day of every december
1054             my $set = DateTime::Event::Recurrence->yearly( months => 12, days => 10 );
1055              
1056             # second week of every year, on tuesday
1057             my $set = DateTime::Event::Recurrence->yearly( weeks => 2, days => 2 );
1058              
1059             Week days can also be called by name, as is specified in RFC 2445 (iCal):
1060              
1061             my $weekly_on_tuesday_set =
1062             DateTime::Event::Recurrence->weekly( days => 'tu' );
1063            
1064             The "days" parameter defaults to "the first day".
1065             See also the section on the "week_start_day" parameter.
1066            
1067             # second full week of every month, on monday
1068             my $set = DateTime::Event::Recurrence->monthly( weeks => 2 );
1069              
1070             # second tuesday of every month
1071             my $set = DateTime::Event::Recurrence->monthly( weeks => 2, days => "tu", week_start_day => "1tu" );
1072              
1073              
1074             =head2 The "interval" and "start" Parameters
1075              
1076             The "interval" parameter represents how often the recurrence rule repeats.
1077              
1078             The optional "start" parameter specifies where to start counting:
1079              
1080             my $dt_start = DateTime->new( year => 2003, month => 6, day => 15 );
1081              
1082             my $set = DateTime::Event::Recurrence->daily
1083             ( interval => 11,
1084             hours => 10,
1085             minutes => 30,
1086             start => $dt_start,
1087             );
1088              
1089             This specifies a recurrence that happens at 10:30 on the day specified
1090             by C<< start => $dt >>, and then every 11 days I<before and after>
1091             C<$dt>. So we get a set like this:
1092              
1093             ...
1094             2003-06-04T10:30:00,
1095             2003-06-15T10:30:00,
1096             2003-06-26T10:30:00,
1097             ...
1098              
1099             In this case, the method is used to specify the unit, so C<daily()>
1100             means that our unit is a day, and C<< interval => 11 >> specifies the
1101             quantity of our unit.
1102              
1103             The "start" parameter should have no time zone.
1104              
1105             =head2 The "week_start_day" Parameter
1106              
1107             The C<week_start_day> represents how the 'first week' of a period is
1108             calculated:
1109              
1110             "mo", "tu", "we", "th", "fr", "sa", "su" - The first week is one that starts
1111             on this week-day, and has I<the most days> in this period. Works for
1112             C<weekly> and C<yearly> recurrences.
1113              
1114             "1mo", "1tu", "1we", "1th", "1fr", "1sa", "1su" - The first week is one that
1115             starts on this week-day, and has I<all days> in this period. This
1116             works for C<weekly()>, C<monthly()> and C<yearly()> recurrences.
1117              
1118             The C<week_start_day> defaults to "1mo",
1119             except in yearly (C<yearly()>) recurrences which default to "mo".
1120              
1121             =head2 Time Zones
1122              
1123             Recurrences are created in the 'floating' time zone, as specified in
1124             the C<DateTime> module.
1125              
1126             If you want to specify a time zone for a recurrence, you can do this
1127             by calling C<set_time_zone()> on the returned set:
1128              
1129             my $daily = DateTime::Event::Recurrence->daily;
1130             $daily->set_time_zone( 'Europe/Berlin' );
1131              
1132             You can also pass a C<DateTime.pm> object with a time zone to
1133             the set's C<next()> and C<previous()> methods:
1134              
1135             my $dt = DateTime->today( time_zone => 'Europe/Berlin' );
1136             my $next = $daily->next($dt);
1137              
1138             A recurrence can be affected DST changes, so it would be possible to
1139             specify a recurrence that creates nonexistent datetimes. Because
1140             C<DateTime.pm> throws an exception if asked to create a non-existent
1141             datetime, please be careful when setting a time zone for your
1142             recurrence.
1143              
1144             It might be preferable to always use "UTC" for your sets, and then
1145             convert the returned object to the desired time zone.
1146              
1147             =head2 Leap Seconds
1148              
1149             There are no leap seconds, because the recurrences are created in the
1150             'floating' time zone.
1151              
1152             The value C<60> for seconds (the leap second) is ignored. If you
1153             I<really> want the leap second, then specify the second as C<-1>.
1154              
1155             =head1 AUTHOR
1156              
1157             Flavio Soibelmann Glock
1158             fglock@gmail.com
1159              
1160             =head1 CREDITS
1161              
1162             The API was developed with help from the people in the
1163             datetime@perl.org list.
1164              
1165             Special thanks to Dave Rolsky,
1166             Ron Hill and Matt Sisk for being around with ideas.
1167              
1168             If you can understand what this module does by reading the docs, you
1169             should thank Dave Rolsky. If you can't understand it, yell at him.
1170             He also helped removing weird idioms from the code.
1171              
1172             Jerrad Pierce came with the idea to move "interval" from
1173             DateTime::Event::ICal to here.
1174              
1175             =head1 COPYRIGHT
1176              
1177             Copyright (c) 2003 Flavio Soibelmann Glock.
1178             All rights reserved. This program
1179             is free software; you can redistribute it and/or modify it under the
1180             same terms as Perl itself.
1181              
1182             The full text of the license can be found in the LICENSE file included
1183             with this module.
1184              
1185             =head1 SEE ALSO
1186              
1187             datetime@perl.org mailing list
1188              
1189             DateTime Web page at http://datetime.perl.org/
1190              
1191             DateTime - date and time :)
1192              
1193             DateTime::Set - for recurrence-set accessors docs.
1194             You can use DateTime::Set to specify recurrences using callback subroutines.
1195              
1196             DateTime::Event::ICal - if you need more complex recurrences.
1197              
1198             DateTime::SpanSet - sets of intervals, including recurring sets of intervals.
1199              
1200             =cut
1201