File Coverage

blib/lib/Net/ICal/Recurrence.pm
Criterion Covered Total %
statement 18 304 5.9
branch 0 126 0.0
condition 0 54 0.0
subroutine 6 24 25.0
pod 5 6 83.3
total 29 514 5.6


line stmt bran cond sub pod time code
1             #!/usr/bin/perl -w
2             # -*- Mode: perl -*-
3             #======================================================================
4             #
5             # This package is free software and is provided "as is" without
6             # express or implied warranty. It may be used, redistributed and/or
7             # modified under the same terms as perl itself. ( Either the Artistic
8             # License or the GPL. )
9             #
10             # $Id: Recurrence.pm,v 1.14 2001/08/04 04:59:36 srl Exp $
11             #
12             # (C) COPYRIGHT 2000-2001, Reefknot developers.
13             #
14             # See the AUTHORS file included in the distribution for a full list.
15             #======================================================================
16              
17             =head1 NAME
18              
19             Net::ICal::Recurrence -- Represent a single recurrence rule
20              
21             =cut
22              
23             package Net::ICal::Recurrence;
24 1     1   6 use strict;
  1         2  
  1         44  
25              
26 1     1   5 use base qw(Net::ICal::Property);
  1         1  
  1         64  
27              
28 1     1   5 use Carp;
  1         2  
  1         63  
29              
30             #TODO: remove Date::Calc usage from this module; it's not epoch-safe.
31 1     1   806 use Date::Calc qw(:all);
  1         26241  
  1         558  
32 1     1   12 use POSIX qw(strftime);
  1         2  
  1         11  
33 1     1   594 use Time::Local;
  1         3  
  1         4458  
34              
35             =head1 SYNOPSIS
36              
37             use Net::ICal::Recurrence;
38             $rrule = new Net::ICal::Recurrence([ OPTION_PAIRS ]) ;
39              
40             =head1 WARNING
41              
42             This implementation of Recurrences needs serious work and
43             refactoring for clarity. The API is not stable.
44             Patches and suggestions welcome.
45              
46             =head1 DESCRIPTION
47              
48             I holds a single recurrence property, ala section 4.3.10 of
49             RFC 2445.
50              
51             =cut
52              
53             my %freqorder = do {
54             my $i = 0;
55             map { $_ => $i++ } qw(SECONDLY MINUTELY HOURLY DAILY WEEKLY MONTHLY YEARLY);
56             };
57              
58             my @oDoW = qw[ SU MO TU WE TH FR SA SU MO TU WE TH FR SA SU MO TU WE TH FR SA ];
59             my %oDoW = map { $oDoW[$_] => $_ } (0..6);
60              
61             my $enum_freq = [ qw(SECONDLY MINUTELY HOURLY DAILY),
62             qw(WEEKLY MONTHLY YEARLY) ];
63             my $enum_wday = [ qw(MO TU WE TH FR SA SU) ];
64             my $is_weekdaynum = qr[^(?:(?:-|\+)?\d+)?(?:SU|MO|TU|WE|TH|FR|SA)$]i;
65              
66             # Simple ranges (sets with end value that doesn't change)
67             my $is_second = [0, 59];
68             my $is_minute = [0, 59];
69             my $is_hour = [0, 23];
70             my $is_monthnum = [1, 12];
71              
72             # Ranges with variable upper boundaries (negative offsets supported)
73             my $is_ordyrday = [1, 366];
74             my $is_ordmoday = [1, 31];
75             my $is_ordwk = [1, 53];
76              
77              
78             =head1 CONSTRUCTOR
79              
80             =head2 new([ OPTIONS_PAIRS ])
81              
82             Create a new recurrence rule. Values for any of the accessors (below) may
83             be specified at intialization time.
84              
85             =begin testing
86              
87             # TODO: write tests for this module, cleaning up the API as necessary.
88             TODO: {
89             local $TODO = 'write tests for N::I::Recurrence';
90             ok(0, 'write tests for Net::ICal::Recurrence');
91              
92             }
93             =end testing
94             =cut
95              
96             sub new {
97 0     0 1   my $class = shift;
98 0           my ($value, %args) = @_;
99 0           $args{content} = $value;
100              
101 0           return $class->_create(%args);
102             }
103              
104             =head2 new_from_ical($ical_string)
105              
106             Create a new recurrence rule from an iCalendar string.
107              
108             =cut
109              
110             sub new_from_ical {
111 0     0 1   my $class = shift;
112 0           my $ical = shift;
113              
114 0           my ($name, $delim, $rest) = $ical =~ /^(\w+)([:;])(.*)/;
115 0 0         return undef unless $name;
116 0 0         my $fdelim = $delim eq ';' ? ':' : ';';
117 0           my $self = $class->_create();
118 0           my @pairs = split(/[=$fdelim]/, $rest);
119 0           while (my ($k, $v) = splice(@pairs, 0, 2)) {
120 0           $self->set(lc($k), $v);
121             }
122 0           return $self;
123             }
124              
125              
126              
127              
128              
129             =head2 as_ical_value()
130              
131             Return an iCal format RRULE string
132              
133             =cut
134              
135             sub as_ical_value () {
136 0     0 1   my $self = shift;
137              
138 0           my @comps;
139              
140             # FREQ is always forced to to the front of list
141 0 0         foreach my $key (sort { $a eq 'freq' ? -1 : $b eq 'freq' ? 1 : ($a cmp $b) }
  0 0          
142             keys %$self)
143             {
144 0 0 0       next if $key eq 'name' || $key eq 'content';
145 0           my $val = $self->{$key};
146 0 0 0       if (exists($val->{value}) && defined($val->{value})) {
147 0           my $value = $val->{value};
148 0 0         if (!ref($value)) { # single value
    0          
    0          
149 0           push(@comps, uc($key).'='.uc($value));
150             } elsif (ref($value) eq 'ARRAY') { # list of values
151 0           push(@comps, uc($key).'='.uc(join(',', @$value)));
152             } elsif (ref($value) =~ /::/) { # Internal type
153 0           push(@comps, uc($key).'='.$value->as_ical_value);
154             } else {
155 0           croak "'$key' component of recurrence has an unexpected value ($value)";
156             }
157             }
158             }
159 0           return ':'.join(';', @comps);
160             }
161              
162 0     0 1   sub as_ical () { (shift)->as_ical_value() }
163              
164             =head2 by()
165              
166             Return a hash reference containing the BY* elements. Keys are DAY,
167             MONTH, etc., and the values are hashrefs with one key per element. E.g.,
168             a RRULE with BYMONTH=6,8;BYDAY=1MO,2WE,FR would be structured as follows:
169              
170             {
171             DAY => { MO => 1, WE => 2, FR => undef },
172             MONTH => { 6 => undef, 8 => undef }
173             }
174              
175             =cut
176              
177             sub by () {
178 0     0 1   my $self = shift;
179              
180 0           my %by;
181 0           foreach my $bfreq (keys %$self) {
182 0 0         next unless $bfreq =~ /^by(.*)/;
183 0           my $bywhat = uc($1);
184 0 0         next unless defined $self->{$bfreq}->{value};
185 0           foreach my $value (@{$self->{$bfreq}->{value}}) {
  0            
186 0 0         if ($bywhat eq 'DAY') {
187 0           my ($ord, $day) = $value =~ /^([-+]?\d+)?(MO|TU|WE|TH|FR|SA|SU)/;
188 0 0         if ($day) {
189 0           $by{$bywhat}->{$day} = $ord;
190             } else {
191 0           warn "BYDAY element unparseable: $value";
192             }
193             } else {
194 0           $by{$bywhat}->{$value} = undef;
195             }
196             }
197             }
198              
199 0           return \%by;
200             }
201              
202             sub occurrences ($) {
203 0     0 0   my $self = shift;
204 0           my $comp = shift;
205 0   0       my $reqperiod = shift || croak "Missing period parameter for occurrences()";
206              
207             # Define period start and end as simple int's
208 0           my ($pstart, $pend) = ($reqperiod->start->as_int, $reqperiod->end->as_int);
209            
210             # Get this event's dtstart, and bump up req
211 0           my $dtstart = $comp->dtstart; # TODO: What do we do if this isn't defined?
212 0 0         if (!defined($dtstart)) {
213 0           carp "Component has no DTSTART. Can't determine occurrences.";
214 0           return [ ];
215             }
216              
217             # When does each occurence of this event end? Is it specified with
218             # a duration, or a hard end time?
219 0           my $dtend = $comp->dtend;
220 0           my $duration = $comp->duration;
221              
222 0 0 0       if (!$duration && $dtend) {
223 0           $duration = Net::ICal::Duration->new($dtend->as_int - $dtstart->as_int);
224             }
225              
226 0           my @occurrences;
227              
228             # Here we go...
229              
230             # For now, we try to set this to the beginning of the event, not
231             # the beginning of the period. We'll get this working the brute force
232             # way (preferably bug-free) before we get more exotic with the math
233              
234              
235             # Fortunately, RFC2445 says that the DTSTART *must* be the first
236             # occurrence.
237              
238             # Does this recurrence have an end time specified?
239             my $until;
240 0 0         if (defined(my $runtil = $self->until)) {
241 0           $until = $runtil->as_int;
242             }
243              
244 0           my $ccount = 1; # Keep track of the occurence count
245 0           my $count = $self->count();
246              
247             # This is ignored for now
248 0           my %bysetpos;
249 0           my $bysetpos = $self->bysetpos();
250 0 0         if (defined($bysetpos)) {
251 0           %bysetpos = map { $_ => 1 } split(/\s*,\s*/, $bysetpos);
  0            
252             }
253              
254 0           my %bywhat = %{$self->by};
  0            
255              
256              
257             # The "candidate occurrence" queue
258 0           my @coqueue = ( );
259             # The event always specifies the first candidate occurrence
260             # FIXME: just make the this the first occurrence, not the first candidate
261 0           push(@coqueue, $dtstart);
262              
263 0           while (@coqueue) {
264 0           my $cstart = shift @coqueue;
265              
266             # Is event bounded by an UNTIL?
267 0 0 0       last if defined($until) && $cstart->as_int > $until;
268              
269             # Is event bounded by a recurrence limit?
270 0 0 0       last if defined($count) && $ccount > $count;
271              
272             # Have we reached the end of the viewing period?
273 0 0         last if $cstart->as_int > $pend;
274              
275             # Get the goods on this start time
276             # FIXME: Hardcoded local time zone!
277 0           my ($ss, $mm, $hh, $DD, $MM, $YY, $pDoW) = localtime($cstart->as_int);
278             #my $DoW = (qw(SU MO TU WE TH FR SA))[$pDoW];
279 0           my $DoW = $self->_tz_dow($cstart);
280 0           my $MoY = $MM+1;
281 0           my $YYYY = $YY+1900;
282              
283             # Check the BY* rules one-by-one -- these are *restrictions* only
284             # (i.e., they determine whether this occurrence is valid).
285             # These apply where BY* rule specifies a unit *less* than or the
286             # same as the frequency (e.g., if FREQ=DAILY, and BYMONTH=TU,TH,
287             # then this occurrence is invalidated if it falls outside those days,
288             # and the count is DECREMENTED.
289              
290             # FIXME
291             # For now, we IGNORE BY* where the * is an interval that is
292             # *less* than the recurrence frequency. This violates the spec.
293             # (is this even necessary if a DURATION hasn't been specified?)
294              
295             # BYDAY
296 0 0         if (defined(my $hr_byday = $bywhat{'DAY'})) {
297             # If this day doesn't match any of the keys, skip it
298 0 0         if (!exists($hr_byday->{$DoW})) {
299             #warn "This day($DoW) isn't in the BYDAY spec";
300 0           goto INCREMENT_CSTART;
301             }
302             }
303              
304             # We have a winner. We must increment the count even if it's not
305             # a candidate due to it occurring before the period
306 0           $ccount++;
307              
308             # Does this occurrence start before the viewing period?
309 0 0         goto INCREMENT_CSTART if $cstart->as_int < $pstart;
310              
311             # Push into the occurrence array
312 0 0         if ($duration) {
313 0           push(@occurrences, Net::ICal::Period->new($cstart, $duration));
314             } else {
315 0           push(@occurrences, $cstart);
316             }
317              
318 0 0         INCREMENT_CSTART:
319             # This is only done when the candidate queue is empty
320             $self->_supplement_queue(\@coqueue, \%bywhat, $dtstart, $cstart)
321             unless @coqueue;
322             }
323              
324 0           return \@occurrences;
325             }
326              
327             =head1 METHODS
328              
329             All of the methods that set multi-valued attributes (e.g., I)
330             accept either a single value or a reference to an array.
331              
332             =head2 freq (FREQ)
333              
334             Specify the frequency of the recurrence. Allowable values are:
335              
336             SECONDLY MINUTELY HOURLY DAILY WEEKLY MONTHLY YEARLY
337              
338             =cut
339              
340             =head2 count(N)
341              
342             Specify that the recurrence rule occurs for N recurrences, at most.
343             May not be used in conjunction with I.
344              
345             =cut
346              
347             =head2 until(ICAL_TIME)
348              
349             Specify that the recurrence rule occurs until ICAL_TIME at the latest.
350             ICAL_TIME is a Net::ICal::Time object. May not be used in conjunction
351             with I.
352              
353             =cut
354              
355             =head2 interval(N)
356              
357             Specify how often the recurrence rule repeats. Defaults to '1'.
358              
359             =cut
360              
361             =head2 bysecond([ SECOND , ... ])
362              
363             Specify the valid of seconds within a minute. SECONDs range from 0 to 59.
364             Use an arrayref to specify more than one value.
365              
366             =cut
367              
368             =head2 byminute([ MINUTE , ... ])
369              
370             Specify the valid of minutes within an hour. MINUTEs range from 0 to 59.
371             Use an arrayref to specify more than one value.
372              
373             =cut
374              
375             =head2 byhour([ HOUR , ... ])
376              
377             Specify the valid of hours within a day. HOURs range from 0 to 23.
378             Use an arrayref to specify more than one value.
379              
380             =cut
381              
382             =head2 byday([ WDAY , ... ])
383              
384             Specify the valid weekdays. Weekdays must be one of
385              
386             MO TU WE TH FR SA SU
387              
388             and may be preceded with an ordinal week number. If the recurrence
389             frequency is MONTHLY, the ordinal specifies the valid week within the
390             month. If the recurrence frequency is YEARLY, the ordinal specify the
391             valid week within the year. A negative ordinal specifys an offset from
392             the end of the month or year.
393              
394             =cut
395              
396             =head2 bymonthday([ MONTHDAY, ... ])
397              
398             Specify the valid days within the month. A negative number specifies an
399             offset from the end of the month.
400              
401             =cut
402              
403             =head2 byyearday([ YEARDAY, ... ])
404              
405             Specify the valid day(s) within the year (i.e., 1 is January 1st).
406             A negative number specifies an offset from the end of the year.
407              
408             =cut
409              
410             =head2 byweekno([ WEEKNO, ... ])
411              
412             Specify the valid week(s) within the year. A negative number specifies
413             an offset from the end of the year.
414              
415             =cut
416              
417             =head2 bymonth([ MONTH, ... ])
418              
419             Specify the valid months within the year.
420              
421             =cut
422              
423             =head2 bysetpos([ N, ... ])
424              
425             Specify the valid recurrences for the recurrence rule. Use this when
426             you need something more complex than INTERVAL. N may be negative, and
427             would specify an offset from the last occurrence specified by another
428             attribute (-1 is the last occurrence).
429              
430             =cut
431              
432             =head2 wkst(WEEKDAY)
433              
434             Specify the starting day of the week as applicable to week calculations.
435             The default is MO. The allowable options are the same weekday codes as
436             for I.
437              
438             =cut
439              
440              
441             =head1 INTERNAL-ONLY METHODS
442              
443             These still need to be documented and/or revamped to be more readable
444             by mere mortals.
445              
446             =head2 _create($classname, $arghashref)
447              
448             A background method to new() that creates the internal
449             data storage map for Class::MethodMapper.
450              
451             =cut
452             sub _create ($;%) {
453 0     0     my $class = shift;
454 0           my @args = @_;
455              
456 0           return $class->SUPER::new(
457             'RECUR',
458             {
459              
460             # for Property.pm
461             content => { type => 'volatile',
462             doc => 'Full value of property',
463             #domain => 'reclass',
464             #options => { default => 'Net::ICal::Recurrence' },
465             value => undef,
466             },
467              
468             # "FREQ"=freq
469             # freq = "SECONDLY" / "MINUTELY" / "HOURLY" / "DAILY"
470             # / "WEEKLY" / "MONTHLY" / "YEARLY"
471             freq => { type => 'volatile',
472             doc => 'Recurrence frequency',
473             domain => 'enum',
474             options => $enum_freq,
475             },
476              
477             # ( ";" "COUNT" "=" 1*DIGIT )
478             count => { type => 'volatile',
479             doc => 'End of recurrence range',
480             domain => 'positive_int',
481             },
482              
483             # ( ";" "UNTIL" "=" enddate )
484             until => { type => 'volatile',
485             doc => 'End of recurrence range',
486             domain => 'ref',
487             options => 'Net::ICal::Time',
488             },
489             # ( ";" "INTERVAL" "=" 1*DIGIT )
490             interval => { type => 'volatile',
491             doc => 'Event occurs every Nth instance',
492             domain => 'positive_int',
493             value => 1,
494             },
495              
496             # ( ";" "BYSECOND" "=" byseclist ) /
497             # byseclist = seconds / ( seconds *("," seconds) )
498             # seconds = 1DIGIT / 2DIGIT ;0 to 59
499             bysecond => { type => 'volatile',
500             doc => 'Valid seconds within each minute',
501             domain => 'multi_fixed_range',
502             options => $is_second,
503             },
504              
505             # ( ";" "BYMINUTE" "=" byminlist ) /
506             # byminlist = minutes / ( minutes *("," minutes) )
507             # minutes = 1DIGIT / 2DIGIT ;0 to 59
508             byminute => { type => 'volatile',
509             doc => 'Valid minutes within each hour',
510             domain => 'multi_fixed_range',
511             options => $is_minute,
512             },
513              
514             # ( ";" "BYHOUR" "=" byhrlist ) /
515             # byhrlist = hour / ( hour *("," hour) )
516             # hour = 1DIGIT / 2DIGIT ;0 to 23
517             byhour => { type => 'volatile',
518             doc => 'Valid hours within each day',
519             domain => 'multi_fixed_range',
520             options => $is_hour,
521             },
522              
523             # ( ";" "BYDAY" "=" bywdaylist ) /
524             # bywdaylist = weekdaynum / ( weekdaynum *("," weekdaynum) )
525             # weekdaynum = [([plus] ordwk / minus ordwk)] weekday
526             # plus = "+"
527             # minus = "-"
528             # ordwk = 1DIGIT / 2DIGIT ;1 to 53
529             byday => { type => 'volatile',
530             doc => 'Valid weekdsays within week',
531             domain => 'multi_match',
532             options => $is_weekdaynum,
533             },
534              
535             # ( ";" "BYMONTHDAY" "=" bymodaylist ) /
536             # bymodaylist = monthdaynum / ( monthdaynum *("," monthdaynum) )
537             # monthdaynum = ([plus] ordmoday) / (minus ordmoday)
538             # ordmoday = 1DIGIT / 2DIGIT ;1 to 31
539             bymonthday => { type => 'volatile',
540             doc => 'Valid days within week',
541             domain => 'multi_ordinal_range',
542             options => $is_ordmoday,
543             },
544              
545             # ( ";" "BYYEARDAY" "=" byyrdaylist ) /
546             # byyrdaylist = yeardaynum / ( yeardaynum *("," yeardaynum) )
547             # yeardaynum = ([plus] ordyrday) / (minus ordyrday)
548             # plus = "+"
549             # minus = "-"
550             # ordyrday = 1DIGIT / 2DIGIT / 3DIGIT ;1 to 366
551             byyearday => { type => 'volatile',
552             doc => 'Valid days within year',
553             domain => 'multi_ordinal_range',
554             options => $is_ordyrday,
555             },
556              
557             # ( ";" "BYWEEKNO" "=" bywknolist ) /
558             # bywknolist = weeknum / ( weeknum *("," weeknum) )
559             # weeknum = ([plus] ordwk) / (minus ordwk)
560             # plus = "+"
561             # minus = "-"
562             # ordwk = 1DIGIT / 2DIGIT ;1 to 53
563             byweekno => { type => 'volatile',
564             doc => 'Valid weeks within year',
565             domain => 'multi_ordinal_range',
566             options => $is_ordwk,
567             },
568              
569             # ( ";" "BYMONTH" "=" bymolist ) /
570             # bymolist = monthnum / ( monthnum *("," monthnum) )
571             # monthnum = 1DIGIT / 2DIGIT ;1 to 12
572             bymonth => { type => 'volatile',
573             doc => 'Valid months within year',
574             domain => 'multi_fixed_range',
575             options => $is_monthnum,
576             },
577              
578             # ( ";" "BYSETPOS" "=" bysplist ) /
579             # bysplist = setposday / ( setposday *("," setposday) )
580             # setposday = yeardaynum
581             # yeardaynum = ([plus] ordyrday) / (minus ordyrday)
582             # plus = "+"
583             # minus = "-"
584             # ordyrday = 1DIGIT / 2DIGIT / 3DIGIT ;1 to 366
585             bysetpos => { type => 'volatile',
586             doc => 'Valid occurrences of recurrence rule',
587             domain => 'multi_ordinal_range',
588             options => $is_ordyrday,
589             },
590              
591             # ( ";" "WKST" "=" weekday ) /
592             wkst => { type => 'volatile',
593             doc => 'First day of week',
594             domain => 'enum',
595             options => $enum_wday,
596             value => 'MO',
597             },
598             },
599             @args);
600             }
601              
602              
603             =head2 _positive_int_set
604              
605             Set a value only if it's a positive integer (ala 1*DIGIT)
606              
607             =cut
608              
609              
610             sub _positive_int_set ($$) {
611 0     0     my $self = shift;
612 0           my ($key, $val) = @_;
613              
614 0 0 0       if (!defined($val) || ref($val) || int($val) != $val || $val < 1) {
      0        
      0        
615 0           carp "'$val' is not a positive integer";
616 0           return undef;
617             }
618              
619 0           $self->{$key}->{value} = $val;
620             }
621              
622             =head2 _multi_fixed_range_set
623              
624             Set a value only if it falls within a range (inclusive)
625              
626             =cut
627              
628             sub _multi_fixed_range_set ($$) {
629 0     0     my $self = shift;
630 0           my ($key, $vals) = @_;
631              
632 0   0       my $ar_minmax = $self->{$key}->{options} ||
633             croak "Missing required 'options' for multi_fixed_range check on '$key'";
634              
635 0           my ($min, $max) = @$ar_minmax;
636              
637 0           my @vals;
638 0 0         if (ref($vals) eq 'ARRAY') {
    0          
639 0           @vals = @$vals;
640             } elsif (!ref($vals)) {
641 0           @vals = ($vals);
642             } else {
643 0           warn "value for $key is neither a scalar nor an array reference";
644 0           return undef;
645             }
646              
647 0           foreach my $val (@vals) {
648 0 0         if (!defined($val)) {
649 0           carp "undefined values can't be within a numeric range";
650 0           return undef;
651             }
652 0 0 0       if ($val < $min || $val > $max) {
653 0           carp "'$val' is outside of allowable range of $min to $max";
654 0           return undef;
655             }
656             }
657              
658 0           $self->{$key}->{value} = \@vals;
659             }
660              
661              
662             =head2 _multi_match_set
663              
664             Set a value if all of the elements match a regular expression
665              
666             =cut
667             sub _multi_match_set ($$) {
668 0     0     my $self = shift;
669 0           my ($key, $vals) = @_;
670              
671 0   0       my $regex = $self->{$key}->{options} ||
672             croak "Missing required 'options' for multi_match check on '$key'";
673              
674 0           my @vals;
675 0 0         if (ref($vals) eq 'ARRAY') {
    0          
676 0           @vals = @$vals;
677             } elsif (!ref($vals)) {
678 0 0         if ($vals =~ /,/) {
679 0           @vals = split(/,/, $vals);
680             } else {
681 0           @vals = ($vals);
682             }
683             } else {
684 0           warn "value for $key is neither a scalar nor an array reference";
685 0           return undef;
686             }
687              
688 0           foreach my $val (@vals) {
689 0 0         if (!defined($val)) {
690 0           carp "undefined values not permitted";
691 0           return undef;
692             }
693 0 0         if ($val !~ $regex) {
694 0           carp "'$val' is not an allowable value";
695 0           return undef;
696             }
697             }
698              
699 0           $self->{$key}->{value} = \@vals;
700             }
701              
702             =head2 _multi_ordinal_range_set
703              
704             Set a value if all of the elements are within a range, regardless of sign
705              
706             =cut
707              
708             sub _multi_ordinal_range_set ($$) {
709 0     0     my $self = shift;
710 0           my ($key, $vals) = @_;
711              
712 0   0       my $ar_minmax = $self->{$key}->{options} ||
713             croak "Missing required 'options' for multi_ordinal_range check on '$key'";
714              
715 0           my ($min, $max) = @$ar_minmax;
716              
717 0           my @vals;
718 0 0         if (ref($vals) eq 'ARRAY') {
    0          
719 0           @vals = @$vals;
720             } elsif (!ref($vals)) {
721 0           @vals = ($vals);
722             } else {
723 0           warn "value for $key is neither a scalar nor an array reference";
724 0           return undef;
725             }
726              
727 0           foreach my $val (@vals) {
728 0 0         if (!defined($val)) {
729 0           carp "undefined values can't be within a numeric range";
730 0           return undef;
731             }
732 0 0 0       if (abs($val) < $min || abs($val) > $max) {
733 0           carp "'$val' is outside of allowable range ".
734             "of -$max to -$min and $min to $max";
735 0           return undef;
736             }
737             }
738              
739 0           $self->{$key}->{value} = \@vals;
740             }
741              
742             =head2 _supplement_queue
743              
744             TODO: document this routine and refactor it.
745              
746             =cut
747              
748             sub _supplement_queue ($$) {
749 0     0     my $self = shift;
750              
751 0           my $ar_ocqueue = shift;
752 0           my %bywhat = %{shift()};
  0            
753 0           my $dtstart = shift;
754 0           my $cstart = shift;
755              
756 0   0       my $rfreq = $self->freq() || 'DAILY';
757 0           my $freqorder = $freqorder{$rfreq};
758 0   0       my $rinterval = int($self->interval) || 1;
759              
760              
761 0 0         if ($rfreq eq 'DAILY') {
    0          
    0          
762 0           my $toadd = Net::ICal::Duration->new(sprintf('P%dD', $rinterval));
763 0           push(@$ar_ocqueue, $cstart->add($toadd));
764             } elsif ($rfreq eq 'WEEKLY') {
765             # Handle the simplest case first -- no "BY*" components
766             # components
767             # TODO: add BY{WEEKNO,MONTH,MONTHDAY,YEARDAY}
768 0 0         if (keys(%bywhat) == 0) {
769 0           my $toadd = Net::ICal::Duration->new(sprintf('P%dW', $rinterval));
770 0           push(@$ar_ocqueue, $cstart->add($toadd));
771             } else {
772 0 0         if (my $hr_days = $bywhat{DAY}) {
773             # Are we still working on the first week? If so, populate any
774             # remaining BYDAY's that apply to this week
775 0           my @newdays;
776 0 0         if ($cstart->as_int == $dtstart->as_int) {
777 0           my ($this_dow) =
778             $self->_order_days_of_week($self->_tz_dow($cstart));
779 0           my @tDoW = $self->_order_days_of_week(keys %$hr_days);
780 0           my @offsets;
781 0           foreach my $tDoW (@tDoW) {
782 0 0         next if $tDoW <= $this_dow;
783 0           push(@offsets, $tDoW - $this_dow);
784             }
785 0 0         @newdays = $self->_compute_set_of_days($cstart, @offsets)
786             if @offsets;
787             }
788              
789 0 0         if (!@newdays) {
790             # Find out what the beginning of this week is, increment
791             # it by seven days, and push in the appropriate days from
792             # the BYDAY list. The day of week of this event must be
793             # determined within the time's preferred timezone. The Time
794             # object doesn't currently track this, hence the need to run
795             # it back through localtime.
796 0           my $nextweek = $self->_first_day_of_next_week($cstart,
797             $rinterval);
798              
799             # And get that next set of days
800 0           @newdays =
801             $self->_compute_set_of_days($nextweek,
802             $self->_order_days_of_week(keys %$hr_days));
803             }
804 0           push(@$ar_ocqueue, @newdays);
805             }
806             }
807             } elsif ($rfreq eq 'MONTHLY') {
808             # Handle the simplest case first -- no "BY*" components
809             # components
810 0 0         if (keys(%bywhat) == 0) {
811             # Start with the DTSTART rather than current time to preserve
812             # the day of month -- adjust down only if necessary
813 0           my $nexttime = $dtstart->clone;
814 0           my $nextMoY = $cstart->month + $rinterval;
815 0           my $nextYYYY = $cstart->year;
816 0           my $nextDD = $dtstart->day;
817 0           while ($nextMoY > 12) {
818 0           $nextMoY -= 12;
819 0           $nextYYYY++;
820             }
821             # Move back to last day if the corresponding day in the DTSTART
822             # is beyond the end of this month
823 0           my $DiM = Days_in_Month($nextYYYY, $nextMoY);
824 0 0         $nexttime->day($DiM) if $nextDD > $DiM;
825 0           $nexttime->year($nextYYYY);
826 0           $nexttime->month($nextMoY);
827 0           push(@$ar_ocqueue, $nexttime);
828             } else {
829 0 0         if (my $hr_mdays = $bywhat{MONTHDAY}) {
830 0           my ($nYYYY, $nMoY, $nDD) =
831             ($cstart->year, $cstart->month, $cstart->day);
832 0           my $last_day_of_month;
833             my @newdays;
834             # Are we still working on the first month? If so, populate any
835             # remaining BYMONTHDAY's that apply to this month
836 0 0         if ($cstart->as_int == $dtstart->as_int) {
837 0           my $DiM = Days_in_Month($nYYYY, $nMoY);
838 0           my @DDs;
839 0           foreach my $dayord (keys %$hr_mdays) {
840 0 0         if ($dayord > 1) {
    0          
841 0 0         push(@DDs, $dayord > $DiM ? $DiM : $dayord);
842             } elsif ($dayord < 0) {
843 0           my $newday = $DiM + $dayord + 1;
844 0 0         push(@DDs, $newday < 1 ? 1 : $newday);
845             }
846             }
847             # Now, prune out the events for days before the dtstart DD
848 0           my $sDD = $dtstart->day;
849 0           foreach my $thisDD (sort @DDs) {
850 0 0         next if $thisDD <= $sDD;
851 0           my $newtime = $dtstart->clone();
852 0           $newtime->day($thisDD);
853 0           push(@newdays, $newtime);
854             }
855             }
856              
857             # We're either in the next month, or there were no valid
858             # occurrence candidates left in the first month
859 0 0         if (!@newdays) {
860 0           $nMoY += $rinterval;
861 0           while ($nMoY > 12) {
862 0           $nMoY -= 12;
863 0           $nYYYY++;
864             }
865 0           my $firsttime = $dtstart->clone();
866 0           my $DiM = Days_in_Month($nYYYY, $nMoY);
867 0           my @DDs;
868 0           foreach my $dayord (keys %$hr_mdays) {
869 0 0         if ($dayord > 1) {
    0          
870 0 0         push(@DDs, $dayord > $DiM ? $DiM : $dayord);
871             } elsif ($dayord < 0) {
872 0           my $newday = $DiM + $dayord + 1;
873 0 0         push(@DDs, $newday < 1 ? 1 : $newday);
874             }
875             }
876 0           foreach my $thisDD (sort @DDs) {
877 0           my $newtime = $dtstart->clone();
878             # FIXME: This gets around the auto-normalize
879 0           $newtime->day(1);
880 0           $newtime->month($nMoY);
881 0           $newtime->year($nYYYY);
882             # FIXME: End of workaround
883 0           $newtime->day($thisDD);
884 0           push(@newdays, $newtime);
885             }
886             }
887 0           push(@$ar_ocqueue, @newdays);
888             }
889             }
890             } else {
891 0           croak "Can't handle frequency of $rfreq just yet...";
892             }
893              
894             }
895              
896             #########
897             # FIXME #
898             ##########################################################################
899             # Yes, folks the following code is truly bizarre and probably unnecessary
900             # but I'm leaving in for now until I can thow some of this WKST support
901             # into the ::Time module. Off hand, the only reason I can think of that
902             # WKST even matters is computing the week number within the year, but I
903             # must've been smoking some good crack when I wrote this. -SHUTTON
904             ##########################################################################
905              
906              
907             =head2 _order_days_of_week
908              
909             Order a set of weekdays according to the WKST setting in the rule
910             E.g., if MO is the first day of the week, and we're given TU, TH, FR, SU
911             then return 1, 3, 4, and 6 (the zero-index offsets from Monday)
912              
913             =cut
914             sub _order_days_of_week {
915 0     0     my $self = shift;
916 0           my @days = @_;
917              
918 0   0       my $wkst_day = $oDoW{$self->wkst() || 'SU'};
919            
920             # Prepare a map order to speed things up
921             # TODO: cache this or set it up when the module loads
922 0           my %order = map { $oDoW[($_+$wkst_day) % 7] => $_ } (0 .. 6);
  0            
923              
924             # Return the day indices based on this order
925 0           return sort @order{@days};
926             }
927              
928             =head2 _first_day_of_next_week
929              
930             TODO: document the parameters for this.
931              
932             =cut
933             sub _first_day_of_next_week {
934 0     0     my $self = shift;
935 0           my $time = shift;
936 0           my $interval = shift;
937              
938 0           local %ENV;
939 0   0       $ENV{TZ} = $time->timezone || '';
940 0           my $cDoW = (localtime($time->as_int))[6];
941              
942             # Compute the beginning of the week
943 0   0       my $wkst_day = $self->wkst() || 'SU';
944 0           my $week_began_days_ago = ($cDoW+7 - $oDoW{$wkst_day}) % 7;
945              
946             # Build the new day
947 0           my ($bYYYY, $bMoY, $bDD) = Add_Delta_Days($time->year,
948             $time->month,
949             $time->day,
950             -1*$week_began_days_ago+7*$interval);
951 0           my $bow = $time->clone();
952 0           $bow->year($bYYYY);
953 0           $bow->month($bMoY);
954 0           $bow->day($bDD);
955              
956 0           return $bow;
957             }
958              
959             =head2 _first_day_of_next_month
960              
961             TODO: document the parameters for this.
962              
963             =cut
964              
965             sub _first_day_of_next_month {
966 0     0     my $self = shift;
967 0           my $time = shift;
968              
969 0           my $newtime = $time->clone();
970 0           my ($YYYY, $MoY) = ($newtime->year, $newtime->month);
971 0 0         if (++$MoY > 12) {
972 0           $MoY = 1;
973 0           $YYYY++;
974             }
975 0           $newtime->year($YYYY);
976 0           $newtime->month($MoY);
977              
978 0           return $newtime;
979             }
980              
981             =head2 _tz_dow
982              
983             Return the day of the week that this time falls on, adjusted for time zone
984              
985             =cut
986             sub _tz_dow {
987 0     0     my $self = shift;
988 0           my $time = shift;
989              
990 0           local %ENV;
991 0   0       $ENV{TZ} = $time->timezone || '';
992 0           return $oDoW[(localtime($time->as_int))[6]];
993             }
994              
995             =head2 _days_till_next_week
996              
997             TODO: document the parameters for this.
998              
999             =cut
1000              
1001             sub _days_till_next_week {
1002 0     0     my $self = shift;
1003 0           my $time = shift;
1004              
1005 0           return 7 - $self->_tz_dow($time);
1006             }
1007              
1008             =head2 _compute_set_of_days
1009              
1010             TODO: document the parameters for this.
1011              
1012             =cut
1013              
1014             sub _compute_set_of_days {
1015 0     0     my $self = shift;
1016              
1017 0           my $start = shift;
1018 0           my @increments = @_;
1019              
1020 0           my ($sYYYY, $sMoY, $sDD) = ($start->year, $start->month, $start->day);
1021 0           my ($HH, $MM, $SS) = ($start->year, $start->month, $start->day);
1022 0           my @days;
1023 0           foreach my $inc (@increments) {
1024 0           my $newtime = $start->clone();
1025 0           my ($nYYYY, $nMoY, $nDD) = Add_Delta_Days($sYYYY, $sMoY, $sDD, $inc);
1026 0           $newtime->year($nYYYY);
1027 0           $newtime->month($nMoY);
1028 0           $newtime->day($nDD);
1029 0           push(@days, $newtime);
1030             }
1031 0           return @days;
1032             }
1033              
1034             =head1 SEE ALSO
1035              
1036             L
1037              
1038             =cut
1039              
1040             1;