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   34957 use strict;
  4         5  
  4         106  
4 4     4   13 use Carp 'confess';
  4         3  
  4         166  
5 4     4   1355 use Set::Crontab;
  4         2345  
  4         83  
6 4     4   1611 use Time::Local;
  4         1994307  
  4         277  
7 4     4   27 use vars qw($VERSION @monthlens);
  4         4  
  4         7974  
8              
9             ($VERSION) = ('$Revision: 1.95 $' =~ /([\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 9041 my $class = shift;
16 32   33     66 my $cronline = shift || confess "You must supply a line from a crontab";
17 32 100       85 if ($cronline =~ /^\s*#/) { return undef; }
  2         5  
18 30 100       50 if ($cronline =~ /^\w+=\S+/) { return undef; }
  1         2  
19 29 100       113 if ($cronline !~ /^\s*\S+\s+\S+\s+\S+\s+\S+\s+\S+/) { return undef; }
  2         3  
20              
21             # https://rt.cpan.org/Ticket/Display.html?id=53899
22 27         38 $cronline =~ s/^\s+//g;
23            
24 27         49 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         20 my @date;
37 27 50       61 if (exists $opts{'Seconds'}) {
    100          
38 0         0 @date = ( localtime($opts{'Seconds'}) )[0..5];
39             } elsif (exists $opts{'Date'}) {
40 26         15 @date = @{ $opts{'Date'} };
  26         50  
41              
42             # input validation
43             # https://rt.cpan.org/Ticket/Display.html?id=68393
44 26 50 33     100 if ($date[0] < 0 || $date[0] > 59) {
45 0         0 confess("Invalid value for seconds [" . $date[0] . "]");
46             }
47 26 50 33     82 if ($date[1] < 0 || $date[1] > 59) {
48 0         0 confess("Invalid value for minutes [" . $date[1] . "]");
49             }
50 26 50 33     76 if ($date[2] < 0 || $date[2] > 23) {
51 0         0 confess("Invalid value for hours [" . $date[2] . "]");
52             }
53 26 50 33     69 if ($date[3] < 1 || $date[2] > 31) {
54 0         0 confess("Invalid value for day of month [" . $date[3] . "]");
55             }
56 26 50 33     73 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     85 if (time() < 2**31 && $date[5] > 137) {
60 1         157 confess("Year must be less than 137 (for example, if you need 2013 year, pass 113)");
61             }
62             } else {
63 1         5 @date = ( localtime(time()) )[0..5];
64             }
65              
66 26         39 chomp($cronline);
67 26         37 my %ranges = cronLineToRanges($cronline);
68              
69 18         97 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         20 @{ $self->{'e'} } = @{ $ranges{'months'} };
  18         31  
  18         19  
87 18         13 @{ $self->{'g'} } = @{ $ranges{'hours'} };
  18         27  
  18         15  
88 18         11 @{ $self->{'h'} } = @{ $ranges{'minutes'} };
  18         22  
  18         17  
89 18         20 bless $self, $class;
90            
91 18         49 $self->resetCounter;
92 18         42 return $self;
93             }
94              
95             sub resetCounter {
96 32     32 1 636 my $self = shift;
97 32         25 $self->setCounterToDate( @{$self->{'initdate'}} );
  32         58  
98             }
99              
100             sub nextEvent {
101 70   33 70 1 2141 my $self = shift || confess "Must be called as a method";
102 70         80 my @rv = $self->getdate();
103 70         98 $self->inc_h();
104 70         177 return @rv;
105             }
106              
107             sub previousEvent {
108 42   33 42 1 1467 my $self = shift || confess "Must be called as a method";
109 42         47 $self->dec_h();
110 42         46 return $self->getdate();
111             }
112              
113             sub setCounterToNow {
114 1   33 1 1 85 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 116 my $self = shift || confess "Must be called as a method";
120 34 50       50 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         20 my ($theMon, $theMday, $theHour, $theMin);
124 34         71 (undef, $theMin, $theHour, $theMday, $theMon, $self->{'pyear'}) = (localtime(timelocal(@_) + 60))[0..5];
125 34         1067 $theMon++;
126 34         36 $self->{'pyear'} += 1900;
127              
128             # nested ifs... to set the next occurrence time
129 34         26 my ($exact, $pos) = contains($theMon, @{ $self->{'e'} });
  34         57  
130 34         37 $self->{'pa'} = $pos;
131 34 100       41 if ($exact) {
132 30         38 $self->set_f();
133 30         27 ($exact, $pos) = contains($theMday, @{ $self->{'f'} });
  30         46  
134 30         32 $self->{'pb'} = $pos;
135 30 100       35 if ($exact) {
136 14         9 ($exact, $pos) = contains($theHour, @{ $self->{'g'} });
  14         21  
137 14         15 $self->{'pc'} = $pos;
138 14 100       19 if ($exact) {
139 11         11 ($exact, $pos) = contains($theMin, @{ $self->{'h'} });
  11         20  
140 11         11 $self->{'pd'} = $pos;
141 11 100       25 if ($pos == -1) { # search wrapped around
142 6         10 $self->inc_h();
143             }
144             } else {
145 3         3 $self->{'pd'} = 0;
146 3 50       11 if ($pos == -1) { # search wrapped around
147 0         0 $self->inc_g();
148             }
149             }
150             } else {
151 16         12 $self->{'pc'} = 0;
152 16         15 $self->{'pd'} = 0;
153 16 100       32 if ($pos == -1) { # search wrapped around, maybe no valid days this month
154 6         9 $self->inc_f();
155             }
156             }
157             } else {
158 4         5 $self->{'pb'} = 0;
159 4         4 $self->{'pc'} = 0;
160 4         1 $self->{'pd'} = 0;
161 4         9 my $rv = $self->set_f();
162 4 100 66     15 if ((! $rv) || ($pos == -1)) { # search wrapped around or no valid days this month, clock to the next month
163 2         5 $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 39 my $line = shift || confess "Must supply a crontab line";
178              
179 26         21 my %ranges;
180 26         108 my @crondate = split(/\s+/, $line, 6);
181 26 50       44 if (@crondate < 5) { confess "Could not split the crontab line into enough fields"; }
  0         0  
182            
183 26         123 my $s = new Set::Crontab( $crondate[0], [0..59] );
184 26         1370 $ranges{'minutes'} = [ $s->list() ];
185            
186 26         179 $s = new Set::Crontab( $crondate[1], [0..23] );
187 26         931 $ranges{'hours'} = [ $s->list() ];
188            
189 26         161 $s = new Set::Crontab( $crondate[2], [0..31] );
190 26         1200 $ranges{'daynums'} = [ $s->list() ];
191 26 100 100     123 if (@{ $ranges{'daynums'} } && ($ranges{'daynums'}->[0] == 0)) {
  26         108  
192 18         16 shift @{ $ranges{'daynums'} };
  18         21  
193             }
194            
195 26         63 $s = new Set::Crontab( $crondate[3], [0..12] );
196 26         1008 $ranges{'months'} = [ $s->list() ];
197 26 100 100     109 if (@{ $ranges{'months'} } && ($ranges{'months'}->[0] == 0)) {
  26         97  
198 22         17 shift @{ $ranges{'months'} };
  22         28  
199             }
200            
201 26         54 $s = new Set::Crontab( $crondate[4], [0..7] );
202 26         788 $ranges{'weekdays'} = [ $s->list() ];
203 26 100 100     112 if (@{$ranges{'weekdays'}} && $ranges{'weekdays'}->[-1] == 7) {
  26         100  
204 19         13 pop @{ $ranges{'weekdays'} };
  19         22  
205 19 100 66     13 if ((! @{ $ranges{'weekdays'} }) || $ranges{'weekdays'}->[0] != 0) {
  19         65  
206 1         1 unshift @{ $ranges{'weekdays'} }, 0;
  1         2  
207             }
208             }
209            
210             # emulate cron's logic in determining which days to use
211 26 100 100     160 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         8 $ranges{'daynums'} = []; # restricted by weekday, not daynumber, so only use weekday range
215             } elsif ($crondate[4] eq '*' && $crondate[2] ne '*') {
216 7         8 $ranges{'weekdays'} = []; # restricted by daynumber, so only use daynumber range
217             } else {
218 11         16 $ranges{'weekdays'} = []; # both are '*' so simply use every daynumber
219             }
220            
221             # check that ranges contain things
222 26         38 foreach (qw(minutes hours months)) {
223 76 100       51 unless (@{ $ranges{$_} }) { confess "The $_ range must contain at least one valid value" }
  76         126  
  2         325  
224             }
225 24 100 100     16 unless (@{ $ranges{'weekdays'} } || @{ $ranges{'daynums'} }) { confess "The ranges of days (weekdays and monthdays) must contain at least one valid value" }
  24         41  
  17         40  
  1         128  
226            
227             # sanity checking of ranges here, ensuring they only contain acceptable numbers
228 23 100       42 if ($ranges{'minutes'}[-1] > 59) { confess 'minutes only go up to 59'; }
  1         170  
229 22 100       36 if ($ranges{'hours'}[-1] > 23) { confess 'hours only go up to 23'; }
  1         123  
230 21 100       30 if ($ranges{'months'}[-1] > 12) { confess 'months only go up to 12'; }
  1         122  
231 20 100 100     24 if (@{$ranges{'daynums'}} && $ranges{'daynums'}[-1] > 31) { confess 'daynumber must be 31 or less'; }
  20         60  
  1         129  
232 19 100 100     15 if (@{$ranges{'weekdays'}} && $ranges{'weekdays'}[-1] > 6) { confess 'weekday too large - use 0 to 7'; }
  19         52  
  1         120  
233              
234 18   100     36 $ranges{'execute'} = $crondate[5] || '#nothing';
235 18         106 return %ranges;
236             }
237              
238             sub contains {
239 89     89 0 134 my ($val, @set) = @_;
240 89         59 my $flag = 0;
241 89         145 while ($flag <= $#set) {
242 843 100       1173 if ($set[$flag] == $val) {
    100          
243 60         138 TRACE("contains: 1: $set[$flag] == $val");
244 60         116 return 1, $flag;
245             } elsif ($set[$flag] > $val) {
246 15         31 TRACE("contains: 0: $set[$flag] > $val");
247 15         22 return 0, $flag;
248             }
249 768         797 $flag++;
250             }
251 14         41 TRACE("contains: $val not found in <" . join(':', @set) . '>');
252 14         17 return 0, -1;
253             }
254              
255             # return current date on the counter as seconds since epoch
256             sub getdate {
257 112   33 112 0 157 my $self = shift || confess "Must be called as a method";
258 112         347 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 129 my $self = shift || confess "Must be called as a method";
264              
265 102         106 my $flag = _isLeapYear($self->{'pyear'});
266 102         91 my $monthnum = $self->{'e'}[$self->{'pa'}];
267 102         80 my $maxday = $monthlens[$monthnum];
268 102 100       115 if ($monthnum == 2) { $maxday += $flag; }
  39         27  
269            
270 102         73 my %days = map { $_ => 1 } @{ $self->{'ranges'}{'daynums'} };
  496         569  
  102         114  
271 102         157 foreach (29, 30, 31) {
272 306 100       351 if ($_ > $maxday) { delete $days{$_}; }
  139         136  
273             }
274            
275             # get which weekday is the first of the month
276 102         109 my $startday = _DayOfWeek($monthnum, 1, $self->{'pyear'});
277             # add in, if needed, the selected weekdays
278 102         63 foreach my $daynum (@{ $self->{'ranges'}{'weekdays'} }) {
  102         143  
279 27         18 my $offset = $daynum - $startday; # 0 - 6 = -6; start on Saturday, want a Sunday
280 27         34 for my $week (1, 8, 15, 22, 29, 36) {
281 162         93 my $monthday = $week + $offset;
282 162 100       165 next if $monthday < 1;
283 150 100       176 next if $monthday > $maxday;
284 118         107 $days{$monthday} = 1;
285             }
286             }
287              
288 102         189 @{ $self->{'f'} } = sort { $a <=> $b } keys %days;
  102         187  
  1715         1092  
289 102         89 return scalar @{ $self->{'f'} };
  102         156  
290             }
291              
292             # routines to increment the counter
293             sub inc_h {
294 76   33 76 0 103 my $self = shift || confess "Must be called as a method";
295 76         57 $self->{'pd'}++;
296 76 100 100     122 if ($self->{'pd'} == 0 || $self->{'pd'} > $#{$self->{'h'}}) {
  70         193  
297 58         46 $self->{'pd'} = 0;
298 58         70 $self->inc_g();
299             }
300             }
301              
302             sub inc_g {
303 58   33 58 0 78 my $self = shift || confess "Must be called as a method";
304 58         42 $self->{'pc'}++;
305 58 100 66     84 if ($self->{'pc'} == 0 || $self->{'pc'} > $#{$self->{'g'}}) {
  58         153  
306 46         33 $self->{'pc'} = 0;
307 46         49 $self->inc_f();
308             }
309             }
310              
311             sub inc_f {
312 52   33 52 0 68 my $self = shift || confess "Must be called as a method";
313 52         34 $self->{'pb'}++;
314 52 100 100     77 if ($self->{'pb'} == 0 || $self->{'pb'} > $#{$self->{'f'}}) {
  46         105  
315 24         18 $self->{'pb'} = 0;
316 24         30 $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 51 my $self = shift || confess "Must be called as a method";
328 37         26 $self->{'pa'}++;
329 37 100 100     73 if ($self->{'pa'} == 0 || $self->{'pa'} > $#{$self->{'e'}}) {
  35         82  
330 15         9 $self->{'pa'} = 0;
331 15         10 $self->{'pyear'}++;
332              
333             # https://rt.cpan.org/Public/Bug/Display.html?id=109246
334 15         10 push (@{$self->{'set_e_checked_years'}}, $self->{'pyear'});
  15         17  
335 15 50       8 if (scalar(@{$self->{'set_e_checked_years'}} > 5)) {
  15         23  
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         38 my $rv = $self->set_f();
342 37 100       53 unless ($rv) { ###
343 11         16 $self->inc_e;
344             }
345              
346 37         51 $self->{'set_e_checked_years'} = [];
347             }
348              
349              
350             # and to decrement it
351             sub dec_h {
352 42   33 42 0 57 my $self = shift || confess "Must be called as a method";
353 42         33 $self->{'pd'}--;
354 42 100       67 if ($self->{'pd'} == -1) {
355 38         23 $self->{'pd'} = $#{$self->{'h'}};
  38         46  
356 38         45 $self->dec_g();
357             }
358             }
359              
360             sub dec_g {
361 38   33 38 0 46 my $self = shift || confess "Must be called as a method";
362 38         31 $self->{'pc'}--;
363 38 100       49 if ($self->{'pc'} == -1) {
364 30         15 $self->{'pc'} = $#{$self->{'g'}};
  30         28  
365 30         33 $self->dec_f();
366             }
367             }
368              
369             sub dec_f {
370 30   33 30 0 41 my $self = shift || confess "Must be called as a method";
371 30         20 $self->{'pb'}--;
372 30 100       43 if ($self->{'pb'} == -1) {
373 19         13 $self->{'pb'} = $#{$self->{'f'}};
  19         16  
374 19         20 $self->dec_e();
375             }
376             }
377              
378             sub dec_e {
379 31   33 31 0 39 my $self = shift || confess "Must be called as a method";
380 31         19 $self->{'pa'}--;
381 31 100       39 if ($self->{'pa'} == -1) {
382 16         9 $self->{'pa'} = $#{$self->{'e'}};
  16         16  
383 16         15 $self->{'pyear'}--;
384             }
385 31         34 my $rv = $self->set_f();
386 31 100       40 unless ($rv) { ###
387 12         16 $self->dec_e;
388             }
389 31         15 $self->{'pb'} = $#{$self->{'f'}};
  31         44  
390             }
391              
392             # These two routines courtesy of B Paulsen
393             sub _isLeapYear {
394 204     204   121 my $year = shift;
395 204 100 66     573 return !($year % 400) || ( !( $year % 4 ) && ( $year % 100 ) ) ? 1 : 0;
396             }
397              
398             sub _DayOfWeek {
399 102     102   130 my ( $month, $day, $year ) = @_;
400            
401 102         84 my $flag = _isLeapYear( $year );
402 102 100       218 my @months = (
    100          
403             $flag ? 0 : 1,
404             $flag ? 3 : 4,
405             4, 0, 2, 5, 0, 3, 6, 1, 4, 6 );
406 102         102 my @century = ( 4, 2, 0, 6 );
407            
408 102         68 my $dow = $year % 100;
409 102         110 $dow += int( $dow / 4 );
410 102         87 $dow += $day + $months[$month-1];
411 102         92 $dow += $century[ ( int($year/100) - 1 ) % 4 ];
412            
413 102         122 return ($dow-1) % 7;
414             }
415              
416       89 0   sub TRACE {}
417              
418             1;
419              
420             __END__