File Coverage

lib/Schedule/Cron/Events.pm
Criterion Covered Total %
statement 266 277 96.0
branch 99 108 91.6
condition 69 119 57.9
subroutine 25 26 96.1
pod 7 20 35.0
total 466 550 84.7


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