File Coverage

lib/Schedule/Cron/Events.pm
Criterion Covered Total %
statement 267 280 95.3
branch 98 108 90.7
condition 68 119 57.1
subroutine 26 27 96.3
pod 7 20 35.0
total 466 554 84.1


line stmt bran cond sub pod time code
1             package Schedule::Cron::Events;
2              
3 4     4   59625 use strict;
  4         9  
  4         116  
4 4     4   21 use Carp 'confess';
  4         8  
  4         224  
5 4     4   2723 use Set::Crontab;
  4         3487  
  4         115  
6 4     4   3071 use Time::Local;
  4         7191936  
  4         391  
7 4     4   36 use vars qw($VERSION @monthlens);
  4         8  
  4         14497  
8              
9             ($VERSION) = ('$Revision: 1.94 $' =~ /([\d\.]+)/ );
10             @monthlens = ( 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 );
11              
12             ## PUBLIC INTERFACE
13              
14             sub new {
15 32     32 1 15920 my $class = shift;
16 32   33     96 my $cronline = shift || confess "You must supply a line from a crontab";
17 32 100       107 if ($cronline =~ /^\s*#/) { return undef; }
  2         8  
18 30 100       82 if ($cronline =~ /^\w+=\S+/) { return undef; }
  1         4  
19 29 100       139 if ($cronline !~ /^\s*\S+\s+\S+\s+\S+\s+\S+\s+\S+/) { return undef; }
  2         7  
20              
21             # https://rt.cpan.org/Ticket/Display.html?id=53899
22 27         61 $cronline =~ s/^\s+//g;
23            
24 27         66 my %opts = @_;
25              
26             # 0 1 2 3 4 5 6 7 8
27             # ($sec,$min,$hour,$mday,$mon,$year,$wday,$yday,$isdst) = localtime(time);
28             #
29             # $mday is the day of the month
30             #
31             # $mon the month in the range 0..11, with 0 indicating
32             # January and 11 indicating December.
33             #
34             # $year contains the number of years since 1900.
35             #
36 27         37 my @date;
37 27 50       96 if (exists $opts{'Seconds'}) {
    100          
38 0         0 @date = ( localtime($opts{'Seconds'}) )[0..5];
39             } elsif (exists $opts{'Date'}) {
40 26         36 @date = @{ $opts{'Date'} };
  26         71  
41              
42             # input validation
43             # https://rt.cpan.org/Ticket/Display.html?id=68393
44 26 50 33     140 if ($date[0] < 0 || $date[0] > 59) {
45 0         0 confess("Invalid value for seconds [" . $date[0] . "]");
46             }
47 26 50 33     117 if ($date[1] < 0 || $date[1] > 59) {
48 0         0 confess("Invalid value for minutes [" . $date[1] . "]");
49             }
50 26 50 33     136 if ($date[2] < 0 || $date[2] > 23) {
51 0         0 confess("Invalid value for hours [" . $date[2] . "]");
52             }
53 26 50 33     118 if ($date[3] < 1 || $date[2] > 31) {
54 0         0 confess("Invalid value for day of month [" . $date[3] . "]");
55             }
56 26 50 33     109 if ($date[4] < 0 || $date[4] > 11) {
57 0         0 confess("Month must be between 0 (January) and 11 (December), got [" . $date[4] . "]");
58             }
59 26 100 66     138 if (time() < 2**31 && $date[5] > 137) {
60 1         277 confess("Year must be less than 137 (for example, if you need 2013 year, pass 113)");
61             }
62             } else {
63 1         6 @date = ( localtime(time()) )[0..5];
64             }
65              
66 26         47 chomp($cronline);
67 26         65 my %ranges = cronLineToRanges($cronline);
68              
69 18         158 my $self = {
70             'ranges' => \%ranges,
71             'e' => [], # the set of possible months
72             'f' => [], # the set of possible days of the month, which varies (e.g. to omit day 31 in June, or to specify days by weekday)
73             'g' => [], # the set of possible hours
74             'h' => [], # the set of possible minutes
75             'pa' => 0, # pointer to which month we are 'currently' on, as far as the counter is concerned
76             'pb' => 0, # pointer to the day (actually the index of the current element)
77             'pc' => 0, # pointer to the hour
78             'pd' => 0, # pointer to the minute
79             'pyear' => 0, # the 'current' year
80             'initdate' => \@date,
81             'initline' => $cronline,
82             'set_e_checked_years' => [],
83             };
84            
85             # now fill the static sets with the sets from Set::Crontab
86 18         27 @{ $self->{'e'} } = @{ $ranges{'months'} };
  18         50  
  18         37  
87 18         26 @{ $self->{'g'} } = @{ $ranges{'hours'} };
  18         38  
  18         34  
88 18         26 @{ $self->{'h'} } = @{ $ranges{'minutes'} };
  18         52  
  18         31  
89 18         34 bless $self, $class;
90            
91 18         41 $self->resetCounter;
92 18         69 return $self;
93             }
94              
95             sub resetCounter {
96 32     32 1 941 my $self = shift;
97 32         36 $self->setCounterToDate( @{$self->{'initdate'}} );
  32         99  
98             }
99              
100             sub nextEvent {
101 70   33 70 1 3036 my $self = shift || confess "Must be called as a method";
102 70         140 my @rv = $self->getdate();
103 70         158 $self->inc_h();
104 70         312 return @rv;
105             }
106              
107             sub previousEvent {
108 42   33 42 1 1959 my $self = shift || confess "Must be called as a method";
109 42         80 $self->dec_h();
110 42         85 return $self->getdate();
111             }
112              
113             sub setCounterToNow {
114 1   33 1 1 102 my $self = shift || confess "Must be called as a method";
115 1         5 $self->setCounterToDate( (localtime(time()))[0..5] );
116             }
117              
118             sub setCounterToDate {
119 34   33 34 1 152 my $self = shift || confess "Must be called as a method";
120 34 50       72 unless (@_ == 6) { confess "Must supply a 6-element date list"; }
  0         0  
121            
122             # add a fudge factor of 60 seconds because cron events happen at the beginning of every minute, at the start of exactly seconds 00
123 34         38 my ($theMon, $theMday, $theHour, $theMin);
124 34         108 (undef, $theMin, $theHour, $theMday, $theMon, $self->{'pyear'}) = (localtime(timelocal(@_) + 60))[0..5];
125 34         1482 $theMon++;
126 34         61 $self->{'pyear'} += 1900;
127              
128             # nested ifs... to set the next occurrence time
129 34         50 my ($exact, $pos) = contains($theMon, @{ $self->{'e'} });
  34         93  
130 34         63 $self->{'pa'} = $pos;
131 34 100       68 if ($exact) {
132 30         68 $self->set_f();
133 30         42 ($exact, $pos) = contains($theMday, @{ $self->{'f'} });
  30         75  
134 30         55 $self->{'pb'} = $pos;
135 30 100       59 if ($exact) {
136 14         19 ($exact, $pos) = contains($theHour, @{ $self->{'g'} });
  14         38  
137 14         27 $self->{'pc'} = $pos;
138 14 100       29 if ($exact) {
139 11         15 ($exact, $pos) = contains($theMin, @{ $self->{'h'} });
  11         28  
140 11         19 $self->{'pd'} = $pos;
141 11 100       34 if ($pos == -1) { # search wrapped around
142 6         18 $self->inc_h();
143             }
144             } else {
145 3         6 $self->{'pd'} = 0;
146 3 50       13 if ($pos == -1) { # search wrapped around
147 0         0 $self->inc_g();
148             }
149             }
150             } else {
151 16         21 $self->{'pc'} = 0;
152 16         20 $self->{'pd'} = 0;
153 16 100       48 if ($pos == -1) { # search wrapped around, maybe no valid days this month
154 6         15 $self->inc_f();
155             }
156             }
157             } else {
158 4         6 $self->{'pb'} = 0;
159 4         7 $self->{'pc'} = 0;
160 4         6 $self->{'pd'} = 0;
161 4         9 my $rv = $self->set_f();
162 4 100 66     21 if ((! $rv) || ($pos == -1)) { # search wrapped around or no valid days this month, clock to the next month
163 2         6 $self->inc_e();
164             }
165             }
166              
167             }
168              
169             sub commandLine {
170 0   0 0 1 0 my $self = shift || confess "Must be called as a method";
171 0         0 return $self->{'ranges'}->{'execute'};
172             }
173              
174             ## Internals only beyond this point
175              
176             sub cronLineToRanges {
177 26   33 26 0 63 my $line = shift || confess "Must supply a crontab line";
178              
179 26         31 my %ranges;
180 26         156 my @crondate = split(/\s+/, $line, 6);
181 26 50       66 if (@crondate < 5) { confess "Could not split the crontab line into enough fields"; }
  0         0  
182            
183 26         212 my $s = new Set::Crontab( $crondate[0], [0..59] );
184 26         2145 $ranges{'minutes'} = [ $s->list() ];
185            
186 26         299 $s = new Set::Crontab( $crondate[1], [0..23] );
187 26         1548 $ranges{'hours'} = [ $s->list() ];
188            
189 26         281 $s = new Set::Crontab( $crondate[2], [0..31] );
190 26         1997 $ranges{'daynums'} = [ $s->list() ];
191 26 100 100     211 if (@{ $ranges{'daynums'} } && ($ranges{'daynums'}->[0] == 0)) {
  26         165  
192 18         21 shift @{ $ranges{'daynums'} };
  18         38  
193             }
194            
195 26         108 $s = new Set::Crontab( $crondate[3], [0..12] );
196 26         1665 $ranges{'months'} = [ $s->list() ];
197 26 100 100     214 if (@{ $ranges{'months'} } && ($ranges{'months'}->[0] == 0)) {
  26         148  
198 22         24 shift @{ $ranges{'months'} };
  22         73  
199             }
200            
201 26         105 $s = new Set::Crontab( $crondate[4], [0..7] );
202 26         1296 $ranges{'weekdays'} = [ $s->list() ];
203 26 100 100     211 if (@{$ranges{'weekdays'}} && $ranges{'weekdays'}->[-1] == 7) {
  26         172  
204 19         22 pop @{ $ranges{'weekdays'} };
  19         31  
205 19 100 66     26 if ((! @{ $ranges{'weekdays'} }) || $ranges{'weekdays'}->[0] != 0) {
  19         100  
206 1         2 unshift @{ $ranges{'weekdays'} }, 0;
  1         3  
207             }
208             }
209            
210             # emulate cron's logic in determining which days to use
211 26 100 100     223 if ($crondate[2] ne '*' && $crondate[4] ne '*') {
    100 100        
    100 66        
212             # leave weekday and daynumber ranges alone, and superimpose
213             } elsif ($crondate[2] eq '*' && $crondate[4] ne '*') {
214 5         13 $ranges{'daynums'} = []; # restricted by weekday, not daynumber, so only use weekday range
215             } elsif ($crondate[4] eq '*' && $crondate[2] ne '*') {
216 7         12 $ranges{'weekdays'} = []; # restricted by daynumber, so only use daynumber range
217             } else {
218 11         21 $ranges{'weekdays'} = []; # both are '*' so simply use every daynumber
219             }
220            
221             # check that ranges contain things
222 26         68 foreach (qw(minutes hours months)) {
223 76 100       87 unless (@{ $ranges{$_} }) { confess "The $_ range must contain at least one valid value" }
  76         235  
  2         490  
224             }
225 24 100 100     31 unless (@{ $ranges{'weekdays'} } || @{ $ranges{'daynums'} }) { confess "The ranges of days (weekdays and monthdays) must contain at least one valid value" }
  24         72  
  17         61  
  1         186  
226            
227             # sanity checking of ranges here, ensuring they only contain acceptable numbers
228 23 100       62 if ($ranges{'minutes'}[-1] > 59) { confess 'minutes only go up to 59'; }
  1         192  
229 22 100       51 if ($ranges{'hours'}[-1] > 23) { confess 'hours only go up to 23'; }
  1         186  
230 21 100       55 if ($ranges{'months'}[-1] > 12) { confess 'months only go up to 12'; }
  1         185  
231 20 100 100     62 if (@{$ranges{'daynums'}} && $ranges{'daynums'}[-1] > 31) { confess 'daynumber must be 31 or less'; }
  20         97  
  1         180  
232 19 100 100     25 if (@{$ranges{'weekdays'}} && $ranges{'weekdays'}[-1] > 6) { confess 'weekday too large - use 0 to 7'; }
  19         87  
  1         205  
233              
234 18   100     61 $ranges{'execute'} = $crondate[5] || '#nothing';
235 18         162 return %ranges;
236             }
237              
238             sub contains {
239 89     89 0 233 my ($val, @set) = @_;
240 89         106 my $flag = 0;
241 89         205 while ($flag <= $#set) {
242 918 100       2260 if ($set[$flag] == $val) {
    100          
243 60         206 TRACE("contains: 1: $set[$flag] == $val");
244 60         187 return 1, $flag;
245             } elsif ($set[$flag] > $val) {
246 15         47 TRACE("contains: 0: $set[$flag] > $val");
247 15         38 return 0, $flag;
248             }
249 843         1473 $flag++;
250             }
251 14         60 TRACE("contains: $val not found in <" . join(':', @set) . '>');
252 14         30 return 0, -1;
253             }
254              
255             # return current date on the counter as seconds since epoch
256             sub getdate {
257 112   33 112 0 235 my $self = shift || confess "Must be called as a method";
258 112         554 return (0, $self->{'h'}[$self->{'pd'}], $self->{'g'}[$self->{'pc'}], $self->{'f'}[$self->{'pb'}], $self->{'e'}[$self->{'pa'}]-1, $self->{'pyear'}-1900);
259             }
260              
261             # returns a list of days during which next event is possible
262             sub set_f {
263 102   33 102 0 210 my $self = shift || confess "Must be called as a method";
264              
265 102         206 my $flag = _isLeapYear($self->{'pyear'});
266 102         170 my $monthnum = $self->{'e'}[$self->{'pa'}];
267 102         143 my $maxday = $monthlens[$monthnum];
268 102 100       195 if ($monthnum == 2) { $maxday += $flag; }
  39         57  
269            
270 102         113 my %days = map { $_ => 1 } @{ $self->{'ranges'}{'daynums'} };
  496         1031  
  102         260  
271 102         252 foreach (29, 30, 31) {
272 306 100       614 if ($_ > $maxday) { delete $days{$_}; }
  139         259  
273             }
274            
275             # get which weekday is the first of the month
276 102         207 my $startday = _DayOfWeek($monthnum, 1, $self->{'pyear'});
277             # add in, if needed, the selected weekdays
278 102         128 foreach my $daynum (@{ $self->{'ranges'}{'weekdays'} }) {
  102         276  
279 27         36 my $offset = $daynum - $startday; # 0 - 6 = -6; start on Saturday, want a Sunday
280 27         37 for my $week (1, 8, 15, 22, 29, 36) {
281 162         205 my $monthday = $week + $offset;
282 162 100       297 next if $monthday < 1;
283 150 100       311 next if $monthday > $maxday;
284 118         216 $days{$monthday} = 1;
285             }
286             }
287              
288 102         292 @{ $self->{'f'} } = sort { $a <=> $b } keys %days;
  102         299  
  1732         1897  
289 102         171 return scalar @{ $self->{'f'} };
  102         292  
290             }
291              
292             # routines to increment the counter
293             sub inc_h {
294 76   33 76 0 164 my $self = shift || confess "Must be called as a method";
295 76         100 $self->{'pd'}++;
296 76 100 100     208 if ($self->{'pd'} == 0 || $self->{'pd'} > $#{$self->{'h'}}) {
  70         282  
297 58         78 $self->{'pd'} = 0;
298 58         145 $self->inc_g();
299             }
300             }
301              
302             sub inc_g {
303 58   33 58 0 133 my $self = shift || confess "Must be called as a method";
304 58         91 $self->{'pc'}++;
305 58 100 66     156 if ($self->{'pc'} == 0 || $self->{'pc'} > $#{$self->{'g'}}) {
  58         294  
306 46         64 $self->{'pc'} = 0;
307 46         95 $self->inc_f();
308             }
309             }
310              
311             sub inc_f {
312 52   33 52 0 104 my $self = shift || confess "Must be called as a method";
313 52         73 $self->{'pb'}++;
314 52 100 100     144 if ($self->{'pb'} == 0 || $self->{'pb'} > $#{$self->{'f'}}) {
  46         189  
315 24         36 $self->{'pb'} = 0;
316 24         47 $self->inc_e();
317             }
318             }
319              
320             # increments currents month, skips to the next year
321             # when no months left during the current year
322             #
323             # if there're no possible months during 5 years in a row, bails out:
324             # https://rt.cpan.org/Public/Bug/Display.html?id=109246
325             #
326             sub inc_e {
327 37   33 37 0 76 my $self = shift || confess "Must be called as a method";
328 37         49 $self->{'pa'}++;
329 37 100 100     139 if ($self->{'pa'} == 0 || $self->{'pa'} > $#{$self->{'e'}}) {
  35         122  
330 15         23 $self->{'pa'} = 0;
331 15         21 $self->{'pyear'}++;
332              
333             # https://rt.cpan.org/Public/Bug/Display.html?id=109246
334 15         18 push (@{$self->{'set_e_checked_years'}}, $self->{'pyear'});
  15         30  
335 15 50       18 if (scalar(@{$self->{'set_e_checked_years'}} > 5)) {
  15         37  
336             confess("Cron line [" . $self->{'initline'} . "] does not define any valid point in time, checked years: [" .
337 0         0 join(",", @{$self->{'set_e_checked_years'}}) .
  0         0  
338             "] (ex, 31th of February) ");
339             }
340             }
341 37         79 my $rv = $self->set_f();
342 37 100       83 unless ($rv) { ###
343 11         30 $self->inc_e;
344             }
345              
346 37         100 $self->{'set_e_checked_years'} = [];
347             }
348              
349              
350             # and to decrement it
351             sub dec_h {
352 42   33 42 0 80 my $self = shift || confess "Must be called as a method";
353 42         118 $self->{'pd'}--;
354 42 100       103 if ($self->{'pd'} == -1) {
355 38         39 $self->{'pd'} = $#{$self->{'h'}};
  38         83  
356 38         75 $self->dec_g();
357             }
358             }
359              
360             sub dec_g {
361 38   33 38 0 79 my $self = shift || confess "Must be called as a method";
362 38         45 $self->{'pc'}--;
363 38 100       84 if ($self->{'pc'} == -1) {
364 30         33 $self->{'pc'} = $#{$self->{'g'}};
  30         55  
365 30         62 $self->dec_f();
366             }
367             }
368              
369             sub dec_f {
370 30   33 30 0 58 my $self = shift || confess "Must be called as a method";
371 30         35 $self->{'pb'}--;
372 30 100       75 if ($self->{'pb'} == -1) {
373 19         20 $self->{'pb'} = $#{$self->{'f'}};
  19         32  
374 19         36 $self->dec_e();
375             }
376             }
377              
378             sub dec_e {
379 31   33 31 0 61 my $self = shift || confess "Must be called as a method";
380 31         40 $self->{'pa'}--;
381 31 100       64 if ($self->{'pa'} == -1) {
382 16         18 $self->{'pa'} = $#{$self->{'e'}};
  16         28  
383 16         32 $self->{'pyear'}--;
384             }
385 31         56 my $rv = $self->set_f();
386 31 100       69 unless ($rv) { ###
387 12         29 $self->dec_e;
388             }
389 31         35 $self->{'pb'} = $#{$self->{'f'}};
  31         74  
390             }
391              
392             # These two routines courtesy of B Paulsen
393             sub _isLeapYear {
394 204     204   295 my $year = shift;
395 204 100 66     904 return !($year % 400) || ( !( $year % 4 ) && ( $year % 100 ) ) ? 1 : 0;
396             }
397              
398             sub _DayOfWeek {
399 102     102   147 my ( $month, $day, $year ) = @_;
400            
401 102         158 my $flag = _isLeapYear( $year );
402 102 100       368 my @months = (
    100          
403             $flag ? 0 : 1,
404             $flag ? 3 : 4,
405             4, 0, 2, 5, 0, 3, 6, 1, 4, 6 );
406 102         163 my @century = ( 4, 2, 0, 6 );
407            
408 102         125 my $dow = $year % 100;
409 102         181 $dow += int( $dow / 4 );
410 102         139 $dow += $day + $months[$month-1];
411 102         180 $dow += $century[ ( int($year/100) - 1 ) % 4 ];
412            
413 102         239 return ($dow-1) % 7;
414             }
415              
416       89 0   sub TRACE {}
417              
418             1;
419              
420             __END__