File Coverage

blib/lib/App/JobLog/TimeGrammar.pm
Criterion Covered Total %
statement 359 411 87.3
branch 148 206 71.8
condition 40 81 49.3
subroutine 28 28 100.0
pod 2 19 10.5
total 577 745 77.4


line stmt bran cond sub pod time code
1             package App::JobLog::TimeGrammar;
2             $App::JobLog::TimeGrammar::VERSION = '1.041';
3             # ABSTRACT: parse natural (English) language time expressions
4              
5              
6 3     3   813 use Exporter 'import';
  3         8  
  3         142  
7             our @EXPORT = qw(
8             parse
9             daytime
10             );
11              
12 3     3   15 use Modern::Perl;
  3         6  
  3         23  
13 3     3   377 use DateTime;
  3         6  
  3         21  
14 3         22 use Class::Autouse qw(
15             App::JobLog::Log
16 3     3   81 );
  3         6  
17 3     3   266 use Carp 'croak';
  3         6  
  3         155  
18 3         29 use autouse 'App::JobLog::Config' => qw(
19             log
20             sunday_begins_week
21             pay_period_length
22             start_pay_period
23             DIRECTORY
24 3     3   20 );
  3         26  
25 3         12 use autouse 'App::JobLog::Time' => qw(
26             now
27             today
28             tz
29 3     3   458 );
  3         11  
30 3     3   1269 no if $] >= 5.018, warnings => "experimental::smartmatch";
  3         14  
  3         19  
31              
32             # some variables we need visible inside the date parsing regex
33             # %matches holds a complete parsing
34             # %buffer, as its name suggests, is a temporary buffer
35             # $d1 and $d2 are the starting and ending dates
36             our ( %matches, %buffer, $d1, $d2 );
37              
38             # buffers for numeric month, day, or year
39             our ( $b1, $b2 );
40              
41             # holds time of day information
42             our $time_buffer;
43              
44             # static maps for translating month and day names to numbers
45             my ( %month_abbr, %day_abbr );
46              
47             # the master date parsing regex
48             my $re = qr{
49             \A \s*+ (?: (?&ever) | (?&span) ) \s*+ \Z
50              
51             (?(DEFINE)
52              
53 0         0 (? (?: all | always | ever | (?:(?:the \s++)? (?: entire | whole ) \s++ )? log ) (?{ $matches{ever} = 1 }) )
54            
55             (?
56 911         1619 ((?&date)) (?{ $d1 = $^N; stow($d1) })
  911         1418  
57 213         389 (?: (?&span_divider) ((?&date)) (?{ $d2 = $^N; stow($d2) }) )?
  213         326  
58             )
59              
60             (? \s*+ (?: -++ | \b(?: through | thru | to | till?+ | until )\b ) \s*+)
61              
62             (? at | @ )
63              
64             (?
65 515         2787 (?{ $time_buffer = undef })
66             (?: (?: \s++ | \s*+ (?&at) \s*+ ) (?&time))?
67             )
68              
69             (? (?:(?&at) \s++)? (?&time) \s++ on \s++ )
70              
71             (?
72 487         1895 (?{ (%buffer, $b1, $b2, $time_buffer) = ()})
73             (?: (?&numeric) | (?&verbal) )
74 1124 50       4241 (?{ $buffer{time} = $time_buffer if $time_buffer })
75             )
76            
77             (?
78 1846         13257 (?{ $time_buffer = undef })
79             (
80             \d{1,2}
81             (?:
82             : \d{2}
83             (?:
84             : \d{2}
85             )?
86             )?
87             (?: \s*+ (?&time_suffix))?
88             )
89 1878         16194 (?{ $time_buffer = $^N })
90             )
91              
92             (? [ap] (?:m|\.m\.))
93              
94             (?
95             (?:
96             (?&year)
97             |
98             (?&ym)
99             |
100             (?&at_time_on) (?&numeric_no_time)
101             |
102             (?&numeric_no_time) (?&at_time))
103 676         2233 (?{ $buffer{type} = 'numeric' })
104             )
105            
106 1004         4815 (? (?{ %buffer = () }) (\d{4}) (?{ $buffer{year} = $^N }) )
  153         911  
107            
108 91         471 (? (?&year) (?÷r) (\d{1,2}) (?{ @buffer{qw(month unit)} = ($^N, 'months') }) )
109              
110 557         4887 (? (?{ %buffer = () }) (?&us) | (?&iso) | (?&md) | (?&dom) )
111              
112             (?
113 480         2730 (\d{1,2}) (?{ $b1 = $^N })
114             ((?÷r))
115 142         717 (\d{1,2}) (?{ $b2 = $^N })
116             \g{-2}
117             (\d{4})
118             (?{
119 80         223 $buffer{year} = $^N;
120 80         153 $buffer{month} = $b1;
121 80         411 $buffer{day} = $b2;
122             })
123             )
124              
125             (?
126 102         623 (\d{4}) (?{ $b1 = $^N })
127             ((?÷r))
128 102         399 (\d{1,2}) (?{ $b2 = $^N })
129             \g{-2}
130             (\d{1,2})
131             (?{
132 102         228 $buffer{year} = $b1;
133 102         198 $buffer{month} = $b2;
134 102         518 $buffer{day} = $^N;
135             })
136             )
137              
138             (?
139 196         1060 (\d{1,2}) (?{ $b1 = $^N })
140             (?÷r)
141             (\d{1,2})
142             (?{
143 62         144 $buffer{month} = $b1;
144 62         321 $buffer{day} = $^N;
145             })
146             )
147              
148             (?
149             (\d{1,2})
150 154         883 (?{ $buffer{day} = $^N })
151             )
152              
153             (?
154             (?: (?&my) | (?&named_period) | (?&relative_period) | (?&month_day) | (?&full) )
155 448         1732 (?{ $buffer{type} = 'verbal' })
156             )
157              
158             (? (?&modifiable_day) | (?&modifiable_month) | (?&modifiable_period) )
159              
160             (? (?&at_time_on) (?&modifiable_day_no_time) | (?&modifiable_day_no_time) (?&at_time))
161              
162             (?
163 310         5349 ((?:(?&modifier) \s++ )?) (?{ $b1 = $^N })
164             ((?&weekday))
165             (?{
166 83 100       299 $buffer{modifier} = $b1 if $b1;
167 83         549 $buffer{day} = $^N;
168             })
169             )
170              
171             (?
172 182         1896 ((?:(?&month_modifier) \s++ )?) (?{ $b1 = $^N })
173             ((?&month))
174             (?{
175 32 100       88 $buffer{modifier} = $b1 if $b1;
176 32         211 $buffer{month} = $^N;
177             })
178             )
179              
180             (?
181 172         3906 (?{ $b1 = undef })
182 6         41 (?:((?&period_modifier)) \s*+ (?{ $b1 = $^N }))?
183             ((?&period))
184             (?{
185 11 100       39 $buffer{modifier} = $b1 if $b1;
186 11         71 $buffer{period} = $^N;
187             })
188             )
189              
190             (? pay | pp | pay \s*+ period )
191              
192             (?
193             (?:(?&at) \s*+)? (?&time) \s++ (?&relative_period_no_time)
194             |
195             (?&relative_period_no_time) (?&at_time)
196             |
197             (?&now)
198             )
199            
200 6         58 (? now (?{ $buffer{day} = 'today' }))
201              
202 7         48 (? ( yesterday | today | tomorrow ) (?{ $buffer{day} = $^N }))
203              
204             (? (?&at_time_on) (?&month_day_no_time) | (?&month_day_no_time) (?&at_time))
205              
206             (? (?&month_first) | (?&day_first) )
207              
208             (?
209 43         187 ((?&month)) (?{ $b1 = $^N })
210             \s++
211             (\d{1,2})
212             (?{
213 43         96 $buffer{month} = $b1;
214 43         358 $buffer{day} = $^N;
215             })
216             )
217            
218 1         8 (? ((?&month)) ,? \s*+ (?&year) (?{ @buffer{qw(month unit)} = ($^N, 'months') }) )
219              
220             (?
221 142         1584 (\d{1,2}) (?{ $b1 = $^N })
222             \s++
223             ((?&month))
224             (?{
225 86         217 $buffer{month} = $^N;
226 86         658 $buffer{day} = $b1;
227             })
228             )
229              
230             (? (?&at_time_on) (?&full_no_time) | (?&full_no_time) (?&at_time))
231              
232             (? (?&dm_full) | (?&md_full) )
233              
234             (?
235 127         1556 (\d{1,2}) (?{ $b1 = $^N })
236             \s++
237 83         376 ((?&month)) (?{ $b2 = $^N })
238             ,? \s++
239             (\d{4})
240             (?{
241 83         198 $buffer{year} = $^N;
242 83         152 $buffer{month} = $b2;
243 83         685 $buffer{day} = $b1;
244             })
245             )
246              
247             (?
248 40         199 ((?&month)) (?{ $b2 = $^N })
249             \s++
250 40         200 (\d{1,2}) (?{ $b1 = $^N })
251             , \s++
252             (\d{4})
253             (?{
254 40         95 $buffer{year} = $^N;
255 40         76 $buffer{month} = $b2;
256 40         394 $buffer{day} = $b1;
257             })
258             )
259              
260             (? (?&full_weekday) | (?&short_weekday) )
261              
262             (? sunday | monday | tuesday | wednesday | thursday | friday | saturday )
263              
264             (? sun | mon | tue | wed | thu | fri | sat )
265              
266             (? (?&full_month) | (?&short_month) )
267              
268             (? january | february | march | april | may | june | july | august | september | october | november | december )
269              
270             (? jan | feb | mar | apr | may | jun | jul | aug | sep | oct | nov | dec )
271              
272             (? last | this | next )
273              
274             (? (?&modifier) | (?&termini) (?: \s++ of (?: \s++ the )? )? )
275            
276             (? week | month | year | (?&pay) )
277              
278             (? (?&modifier) | (?&termini) (?: \s++ of )? )
279              
280             (? (?: the \s++ )? (?: (?&beginning) | end ) )
281              
282             (? beg(?:in(?:ning)?)?)
283              
284             (? [-/.])
285              
286             )
287             }xi;
288              
289             # stows everything matched so far in %matches
290             sub stow {
291 1124     1124 0 4734 my %h = %buffer;
292 1124         3223 $matches{ $_[0] } = \%h;
293 1124         15128 %buffer = ();
294             }
295              
296              
297             sub daytime {
298 416     416 1 658 my $time = shift;
299              
300             #parse
301 416         2046 $time =~ /(?\d++)
302             (?:
303             : (?\d++)
304             (?:
305             : (?\d++)
306             )?
307             )?
308             (?: \s*+ (?[ap]) (\.?)m\g{-1})?
309             /ix;
310             my ( $hour, $minute, $second, $suffix ) =
311 416   50 1   8478 ( $+{hour}, $+{minute} || 0, $+{second} || 0, lc( $+{suffix} || 'x' ) );
  1   100     3521  
  1   100     451  
  1         5639  
312 416 50 33     1893 $hour += 12 if $suffix eq 'p' && $hour < 12;
313 416 50       1187 $suffix = 'p' if $hour > 11;
314 416 50 33     995 $hour = 0 if $hour == 12 && $suffix eq 'a';
315 416 50 33     4058 croak
      33        
      66        
      33        
316             "impossible time: $time" #<--- syntax error at (eval 4158) line 23, near "croak "impossible time: $time""
317              
318             if $hour > 23
319             || $minute > 59
320             || $second > 59
321             || $suffix eq 'a' && $hour > 12;
322 416 50 66     1635 $hour = 0 if $suffix eq 'a' && $hour == 12;
323             return (
324 416         2717 hour => $hour,
325             minute => $minute,
326             second => $second,
327             suffix => $suffix
328             );
329             }
330              
331              
332             sub parse {
333 445     445 1 536699 my $phrase = shift;
334 445         1403 local ( %matches, %buffer, $d1, $d2, $b1, $b2, $time_buffer );
335 445 50       6285 if ( $phrase =~ $re ) {
336 445 50       1064 if ( $matches{ever} ) {
337              
338             # we want the entire timespan of the log
339 0         0 my ($se) = App::JobLog::Log->new->first_event;
340 0 0       0 if ($se) {
341 0         0 return $se->start, now, 0;
342             }
343             else {
344 0         0 return now->subtract( seconds => 1 ), now, 0;
345             }
346             }
347              
348 445         807 my $h1 = $matches{$d1};
349 445         790 my $unit = delete $h1->{unit};
350 445         1029 normalize($h1);
351 445 100       874 if ($unit) {
352              
353             # $h1 is necessarily fixed and there is no time associated
354 4         12 $h1 = fix_date( $h1, 1 );
355 4         1246 my $h2 = $h1->clone->add( $unit => 1 )->subtract( seconds => 1 );
356 4         6328 return $h1, $h2, 1;
357             }
358             else {
359 441         942 my %t1 = extract_time( $h1, 1 );
360 441         823 my ( $h2, $count, %t2 );
361 441 100 66     1384 if ( $d2 && $matches{$d2} ) {
362 20         37 $h2 = $matches{$d2};
363 20         51 normalize($h2);
364 20         47 %t2 = extract_time($h2);
365 20         48 $count = 2;
366             }
367             else {
368 421         2018 $h2 = {%$h1};
369 421         1303 %t2 = ( hour => 23, minute => 59, second => 59 );
370 421         640 $count = 1;
371             }
372 441         905 infer_modifier( $h1, $h2 );
373 441         963 my ( $s1, $s2 ) = ( $t1{suffix}, $t2{suffix} );
374 441         914 delete $t1{suffix}, delete $t2{suffix};
375 441 100       859 if ( is_fixed($h1) ) {
    100          
376 356         822 ( $h1, $h2 ) = fixed_start( $h1, $h2, $count == 2 );
377             }
378             elsif ( is_fixed($h2) ) {
379 1         5 ( $h1, $h2 ) = fixed_end( $h1, $h2 );
380             }
381             else {
382 84         202 ( $h1, $h2 ) = before_now( $h1, $h2, $count == 2 );
383             }
384 441 50       4657 croak "dates in \"$phrase\" are out of order"
385             unless DateTime->compare( $h1, $h2 ) <= 0;
386 441         43212 $h1->set(%t1);
387 441         144524 $h2->set(%t2);
388 441 50       139745 if ( $h1 > $h2 ) {
389 0 0 0     0 if ( $h1->year == $h2->year
      0        
      0        
      0        
390             && $h1->month == $h2->month
391             && $h1->day == $h2->day
392             && $h2->hour < 12
393             && $s2 eq 'x' )
394             {
395              
396             # we inferred the 12 hour period of the second endpoint incorrectly;
397             # it was in the evening rather than morning
398 0         0 $h2->add( hours => 12 );
399             }
400             else {
401 0         0 croak "dates in \"$phrase\" are out of order";
402             }
403             }
404 441         45025 return $h1, $h2, $count == 2;
405             }
406             }
407 0         0 croak "cannot parse \"$phrase\" as a date expression";
408             }
409              
410             # if the sole expression is a unit identifier, infer the modifier 'this'
411             sub infer_modifier {
412 441     441 0 676 my ( $h1, $h2 ) = @_;
413 441 50 100     1730 if ( keys %$h1 == 2 && keys %$h2 == 2 && $h1->{period} && $h2->{period} ) {
      66        
      66        
414 3         10 $h1->{modifier} = $h2->{modifier} = 'this';
415             }
416             }
417              
418             # pulls time expression -- 11:00 am, e.g. -- out of hash and converts it
419             # to a series of units
420             sub extract_time {
421 461     461 0 808 my ( $h, $is_start ) = @_;
422 461         765 my $time = $h->{time};
423 461 100       954 if ( defined $time ) {
424 416         710 delete $h->{time};
425              
426 416         847 return daytime($time);
427             }
428             else {
429              
430             #return default values
431 45 100       318 return $is_start
432             ? ( hour => 0, minute => 0, second => 0, suffix => 'a' )
433             : ( hour => 23, minute => 59, second => 59, suffix => 'p' );
434             }
435             }
436              
437             # produces interpretation of date expression consistent with a fixed end date
438             sub fixed_end {
439 1     1 0 2 my ( $h1, $h2 ) = @_;
440 1         4 $h2 = fix_date($h2);
441 1 50       159 if ( is_fixed($h1) ) {
442 0         0 $h1 = fix_date( $h1, 1 );
443             }
444             else {
445 1         3 my ( $unit, $amt ) = time_unit($h1);
446 1         4 $h1 = decontextualized_date( $h1, 1 );
447 1 50       8 if ( ref $h1 eq 'DateTime' ) {
448 1         4 while ( DateTime->compare( $h1, $h2 ) > 0 ) {
449 3         1466 $h1->subtract( $unit => $amt );
450             }
451             }
452             else {
453              
454             # we just have a floating weekday
455 0         0 $h1 = adjust_weekday( $h1, $h2 );
456             }
457             }
458 1         714 return ( $h1, $h2 );
459             }
460              
461             # picks a day of the week before a given date
462             sub adjust_weekday {
463 81     81 0 136 my ( $ref, $date ) = @_;
464             my $delta = $ref->{day_of_week}
465 81   50     211 || die 'should always be day_of_week key at this point';
466 81         250 my $d = $date->clone;
467 81         970 $delta = $date->day_of_week - $delta;
468 81 50       392 $delta += 7 if $delta <= 0;
469 81         246 $d->subtract( days => $delta );
470 81         51666 return $d;
471             }
472              
473             # determines the finest grained unit of time by which a given date can be modified
474             sub time_unit {
475 172     172 0 232 my $h = shift;
476 172 100       369 if ( $h->{type} eq 'numeric' ) {
477 82 50       293 return 'years' => 1 if exists $h->{month};
478 0         0 return 'months' => 1;
479             }
480             else {
481 90 100       189 if ( my $period = $h->{period} ) {
482 2         5 for ($period) {
483 2         6 when ('mon') { return 'months' => 1 }
  0         0  
484 2         4 when ('wee') { return 'weeks' => 1 }
  0         0  
485 2         4 when ('pay') { return 'days' => pay_period_length() }
  2         6  
486             }
487             }
488             else {
489 88 100       213 return 'years' => 1 if exists $h->{month};
490 81 50       334 return 'weeks' => 1 if exists $h->{day};
491 0         0 return 'months' => 1;
492             }
493             }
494             }
495              
496             # produces interpretation of date expression consistent with a fixed start date
497             sub fixed_start {
498 356     356 0 619 my ( $h1, $h2, $two_endpoints ) = @_;
499 356         728 $h1 = fix_date( $h1, 1 );
500 356 100 100     58414 unless ( $two_endpoints || $h2->{type} ne 'numeric' ) {
501 164 100       723 return $h1, $h1->clone if defined $h2->{day};
502 1         47 return $h1, $h1->clone->add( years => 1 )->subtract( days => 1 );
503             }
504 192 100       510 if ( is_fixed($h2) ) {
505 189         373 $h2 = fix_date($h2);
506             }
507             else {
508 3         10 my ( $unit, $amt ) = time_unit($h2);
509 3         9 $h2 = decontextualized_date($h2);
510 3 100       14 $h2 = adjust_weekday( $h2, $h1 ) unless ref $h2 eq 'DateTime';
511 3         13 $h2->subtract( $unit => $amt ) while $h2 > $h1;
512 3         3751 $h2->add( $unit => $amt );
513             }
514 192         24480 return ( $h1, $h2 );
515             }
516              
517             # date relative to now not yet adjusted relative to its position in the span or
518             # another fixed date
519             sub decontextualized_date {
520 172     172 0 256 my ( $h, $is_start ) = @_;
521             return decontextualized_numeric_date( $h, $is_start )
522 172 100       454 if $h->{type} eq 'numeric';
523 90         199 for ( $h->{modifier} ) {
524 90         134 when ('end') { $is_start = 0 }
  0         0  
525 90         156 when ('beginning') { $is_start = 1 }
  0         0  
526             }
527 90 100       196 if ( my $period = $h->{period} ) {
528 2         6 my $date = today;
529 2         25 for ($period) {
530 2         5 when ('mon') {
531 0         0 $date->truncate( to => 'month' );
532 0 0       0 $date->add( months => 1 ) unless $is_start;
533             }
534 2         4 when ('wee') {
535 0         0 $date->truncate( to => 'week' );
536 0 0       0 $date->subtract( days => 1 ) if sunday_begins_week;
537 0 0       0 $date->add( weeks => 1 ) unless $is_start;
538             }
539 2         4 when ('pay') {
540 2         7 my $days =
541             $date->delta_days(start_pay_period)->in_units('days');
542 2         563 $days %= pay_period_length;
543 2         9 $date->subtract( days => $days );
544 2 100       1257 $date->add( days => pay_period_length ) unless $is_start;
545             }
546 0         0 default {
547 0         0 croak 'DEBUG'
548             }
549             }
550 2 100       531 $date->subtract( days => 1 ) unless $is_start;
551 2         614 return $date;
552             }
553             else {
554 88 100 100     525 if ( exists $h->{day} && $h->{day} !~ /^\d++$/ ) {
555 81         156 init_day_abbr();
556 81         223 $h->{day_of_week} = $day_abbr{ $h->{day} };
557 81         148 delete $h->{day};
558 81         209 return $h;
559             }
560 7 50       24 if ( exists $h->{month} ) {
561 7         13 init_month_abbr();
562 7         21 $h->{month} = $month_abbr{ $h->{month} };
563             }
564 7         13 return decontextualized_numeric_date( $h, $is_start );
565             }
566             }
567              
568             sub decontextualized_numeric_date {
569 89     89 0 134 my ( $h, $is_start ) = @_;
570 89         204 my $date = today;
571 89         904 delete $h->{type};
572 89         137 delete $h->{modifier};
573 89   33     351 $h->{year} //= $date->year;
574 89   33     573 $h->{month} //= $date->month;
575 89         159 my $day_unspecified = !exists $h->{day};
576 89   100     221 $date = DateTime->new( time_zone => tz(), %$h, day => $h->{day} // 1 );
577              
578 89 100 100     14789 if ( !( exists $h->{day} || $is_start ) ) {
579 2         9 $date->add( months => 1 );
580 2         1096 $date->subtract( days => 1 );
581             }
582 89         1450 return $date;
583             }
584              
585             sub fix_date {
586 550     550 0 874 my ( $d, $is_start ) = @_;
587 550 100       1458 if ( $d->{type} eq 'verbal' ) {
588 363 100       1044 if ( $d->{year} ) {
    100          
589 247         464 init_month_abbr();
590 247         560 $d->{month} = $month_abbr{ $d->{month} };
591 247         436 delete $d->{type};
592 247         708 return DateTime->new( time_zone => tz(), %$d );
593             }
594             elsif ( my $day = $d->{day} ) {
595 95         279 my $date = today;
596 95 100       1234 return $date if $day eq 'tod';
597 85 100       250 if ( $day eq 'yes' ) {
    100          
598 2         9 $date->subtract( days => 1 );
599 2         1313 return $date;
600             }
601             elsif ( $day eq 'tom' ) {
602 3         13 $date->add( days => 1 );
603 3         1597 return $date;
604             }
605 80         154 init_day_abbr();
606 80         160 my $day_num = $day_abbr{$day};
607 80         231 my $todays_num = $date->day_of_week;
608 80 50       355 if ( $d->{modifier} eq 'this' ) {
609 0 0       0 return $date if $day_num == $todays_num;
610 0 0       0 my $delta =
611             $day_num > $todays_num
612             ? $day_num - $todays_num
613             : 7 - $todays_num + $day_num;
614 0         0 $date->add( days => $delta );
615 0         0 return $date;
616             }
617             else {
618 80         102 my $delta = 7;
619 80 50       257 if ( $day_num < $todays_num ) {
    50          
620 0         0 $delta = $todays_num - $day_num;
621             }
622             elsif ( $day_num > $todays_num ) {
623 0         0 $delta = 7 - $day_num + $todays_num;
624             }
625 80         221 $date->subtract( days => $delta );
626 80 50       49461 $date->add( days => 14 ) if $d->{modifier} eq 'next';
627 80         188 return $date;
628             }
629             }
630              
631 21 100       63 if ( my $period = $d->{period} ) {
632 17         56 my $date = today;
633 17 100       216 if ( $d->{modifier} eq 'this' ) {
634 10         21 for ($period) {
635 10         21 when ('mon') {
636 4         13 $date->truncate( to => 'month' );
637 4 100       916 $date->add( months => 1 ) unless $is_start;
638             }
639 6         11 when ('wee') {
640 2         7 my $is_sunday = $date->day_of_week == 7;
641 2         14 $date->truncate( to => 'week' );
642 2 50       2034 if (sunday_begins_week) {
643 2 50       10 $date->subtract( days => $is_sunday ? -6 : 1 );
644             }
645 2 100       1215 $date->add( weeks => 1 ) unless $is_start;
646             }
647 4         6 when ('yea') {
648 2         7 $date->truncate( to => 'year' );
649 2 100       431 $date->add( years => 1 ) unless $is_start;
650             }
651 2         5 when ('pay') {
652 2         6 my $days =
653             $date->delta_days(start_pay_period)->in_units('days');
654 2         556 $days %= pay_period_length;
655 2         8 $date->subtract( days => $days );
656 2 100       1178 $date->add( days => pay_period_length )
657             unless $is_start;
658             }
659             }
660 10 100       2801 $date->subtract( days => 1 ) unless $is_start;
661             }
662             else {
663 7         17 for ($period) {
664 7         20 when ('mon') {
665 0         0 $date->truncate( to => 'month' );
666 0 0       0 if ($is_start) {
667 0         0 $date->subtract( months => 1 );
668             }
669             else {
670 0         0 $date->subtract( days => 1 );
671             }
672 0 0       0 $date->add( months => 2 ) if $d->{modifier} eq 'next';
673             }
674 7         13 when ('wee') {
675 5         21 my $is_sunday = $date->day_of_week == 7;
676 5         29 $date->truncate( to => 'week' );
677 5 50       4861 if (sunday_begins_week) {
678 5 50       24 $date->subtract( days => $is_sunday ? -6 : 1 );
679             }
680 5 100       4175 if ($is_start) {
681 3         11 $date->subtract( weeks => 1 );
682             }
683             else {
684 2         11 $date->subtract( days => 1 );
685             }
686 5 50       3784 $date->add( days => 14 ) if $d->{modifier} eq 'next';
687             }
688 2         5 when ('yea') {
689 2         8 $date->truncate( to => 'year' );
690 2 100       471 if ($is_start) {
691 1         5 $date->subtract( years => 1 );
692             }
693             else {
694 1         5 $date->subtract( days => 1 );
695             }
696 2 50       1228 $date->add( years => 2 ) if $d->{modifier} eq 'next';
697             }
698 0         0 when ('pay') {
699 0         0 my $days =
700             $date->delta_days(start_pay_period)->in_units('days');
701 0         0 $days %= pay_period_length;
702 0         0 $date->subtract( days => $days );
703 0 0       0 if ($is_start) {
704 0         0 $date->subtract( days => pay_period_length );
705             }
706             else {
707 0         0 $date->subtract( days => 1 );
708             }
709             $date->add( days => 2 * pay_period_length )
710 0 0       0 if $d->{modifier} eq 'next';
711             }
712             }
713             }
714 17         3019 return $date;
715             }
716              
717 4         10 init_month_abbr();
718 4         13 my $date = today;
719 4         50 $date->truncate( to => 'month' );
720 4         875 my $month_num = $month_abbr{ $d->{month} };
721 4         12 my $todays_num = $date->month;
722 4 100       25 if ( $d->{modifier} eq 'this' ) {
723 2         3 my $delta = 0;
724 2 50       10 if ( $todays_num > $month_num ) {
    50          
725 0         0 $delta = 12 - $todays_num + $month_num;
726             }
727             elsif ( $todays_num < $month_num ) {
728 2         3 $delta = $month_num - $todays_num;
729             }
730 2 100       6 $delta++ unless $is_start;
731 2         7 $date->add( months => $delta );
732             }
733             else {
734 2         3 my $delta = 12;
735 2 50       8 if ( $todays_num > $month_num ) {
    50          
736 0         0 $delta = $todays_num - $month_num;
737             }
738             elsif ( $todays_num < $month_num ) {
739 2         5 $delta -= $month_num - $todays_num;
740             }
741 2 100       5 $delta-- unless $is_start;
742 2         7 $date->subtract( months => $delta );
743             }
744 4 100       2305 $date->subtract( days => 1 ) unless $is_start;
745 4         1179 return $date;
746             }
747              
748             # numeric date
749 187         329 delete $d->{type};
750 187         558 return DateTime->new( time_zone => tz(), %$d );
751             }
752              
753             # lazy initialization of verbal -> numeric month map
754             sub init_month_abbr {
755 260 100   260 0 1194 unless (%month_abbr) {
756 2         13 my @months = qw(jan feb mar apr may jun jul aug sep oct nov dec);
757 2         9 init_hash( \%month_abbr, \@months );
758             }
759             }
760              
761             # lazy initialization of verbal -> numeric day of week map
762             sub init_day_abbr {
763 161 100   161 0 645 unless (%day_abbr) {
764 1         6 my @days = qw(mon tue wed thu fri sat sun);
765 1         4 init_hash( \%day_abbr, \@days );
766             }
767             }
768              
769             # generic verbal -> numeric map generator
770             sub init_hash {
771 3     3 0 7 my ( $h, $units ) = @_;
772 3         11 while (@$units) {
773 31         36 my $i = @$units;
774 31         38 my $u = pop @$units;
775 31         98 $h->{$u} = $i;
776             }
777             }
778              
779             # produces interpretation of date expression such that neither date ends after
780             # the present
781             sub before_now {
782 84     84 0 169 my ( $h1, $h2, $two_endpoints ) = @_;
783 84 100       174 infer_missing( $h1, $h2 ) if $two_endpoints;
784 84         273 my $now = today;
785 84         1042 my ( $u1, $amt1, $u2, $amt2 ) = ( time_unit($h1), time_unit($h2) );
786 84         206 ( $h1, $h2 ) =
787             ( decontextualized_date( $h1, 1 ), decontextualized_date($h2) );
788 84 100       298 $h2 = adjust_weekday( $h2, $now ) unless ref $h2 eq 'DateTime';
789 84 100       280 $h1 = adjust_weekday( $h1, $now ) unless ref $h1 eq 'DateTime';
790 84         300 while ( $now < $h2 ) {
791 1         94 $h2->subtract( $u2 => $amt2 );
792             }
793 84         8705 while ( $h2 < $h1 ) {
794 1         86 $h1->subtract( $u1 => $amt1 );
795             }
796              
797 84 100       8630 if ($two_endpoints) {
798              
799             # move the two dates as close together as possible
800 2         6 while ( $h1 < $h2 ) {
801 2         175 $h2->subtract( $u2 => $amt2 );
802             }
803 2         1417 $h2->add( $u2 => $amt2 );
804             }
805 84         1448 return $h1, $h2;
806             }
807              
808             # fill in missing fields in two date hashes, each using the other as context
809             # this is a bit of a hack, but a natural hack
810             sub infer_missing {
811 2     2 0 5 my ( $h1, $h2 ) = @_;
812 2 50       9 if ( $h1->{type} eq $h2->{type} ) {
    50          
813 0         0 while ( my ( $k, $v ) = each %$h1 ) {
814 0   0     0 $h2->{$k} //= $v;
815             }
816 0         0 while ( my ( $k, $v ) = each %$h2 ) {
817 0   0     0 $h1->{$k} //= $v;
818             }
819             }
820             elsif ( $h2->{type} eq 'numeric' ) {
821 2 50 33     17 if ( $h1->{month} && !$h2->{month} ) {
822 2         4 init_month_abbr();
823 2         7 $h2->{month} = $month_abbr{ $h1->{month} };
824             }
825             }
826             else {
827              
828             # I don't think we have any problems in this case
829             }
830             }
831              
832             # normalizes string values
833             sub normalize {
834 465     465 0 616 my $h = shift;
835 465         646 delete $h->{debug};
836 465 100       1406 if ( $h->{type} eq 'verbal' ) {
837 236         448 for my $key (qw(day month period)) {
838 708 100       2151 if ( my $value = $h->{$key} ) {
839 362 100       1217 next if $value =~ /\d/;
840 236         398 $value = lc $value;
841 236 100       619 if ( $value =~ /^p/ ) {
842 3 50       11 croak 'pay period not defined'
843             unless defined start_pay_period;
844 3         539 $h->{$key} = 'pay';
845             }
846             else {
847 233         659 $h->{$key} = substr $value, 0, 3;
848             }
849             }
850             }
851 236   100     1250 for ( $h->{modifier} || '' ) {
852 236         378 when (/beg/) { $h->{modifier} = 'beginning' }
  0         0  
853 236         360 when (/end/) { $h->{modifier} = 'end' }
  0         0  
854 236         322 when (/las/) { $h->{modifier} = 'last' }
  45         169  
855 191         234 when (/thi/) { $h->{modifier} = 'this' }
  3         11  
856 188         443 when (/nex/) { $h->{modifier} = 'next' }
  0         0  
857             }
858             }
859             }
860              
861             # whether the particular date expression refers to a fixed
862             # rather than relative date
863             sub is_fixed {
864 719     719 0 980 my $h = shift;
865             return 1
866 719 100       2211 if exists $h->{year};
867 289 100       718 if ( $h->{type} eq 'verbal' ) {
868 207 100       503 if ( exists $h->{modifier} ) {
869 101 50       701 return 1 if $h->{modifier} =~ /this|last|next/;
870             }
871 106 100       242 if ( exists $h->{day} ) {
872 100 100       496 return 1 if $h->{day} =~ /yes|tod|tom/;
873             }
874             }
875 173         499 return 0;
876             }
877              
878             1;
879              
880             __END__