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.040';
3             # ABSTRACT: parse natural (English) language time expressions
4              
5              
6 3     3   786 use Exporter 'import';
  3         7  
  3         160  
7             our @EXPORT = qw(
8             parse
9             daytime
10             );
11              
12 3     3   16 use Modern::Perl;
  3         6  
  3         25  
13 3     3   373 use DateTime;
  3         7  
  3         20  
14 3         19 use Class::Autouse qw(
15             App::JobLog::Log
16 3     3   75 );
  3         8  
17 3     3   259 use Carp 'croak';
  3         6  
  3         150  
18 3         28 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   16 );
  3         34  
25 3         13 use autouse 'App::JobLog::Time' => qw(
26             now
27             today
28             tz
29 3     3   575 );
  3         13  
30 3     3   1237 no if $] >= 5.018, warnings => "experimental::smartmatch";
  3         14  
  3         20  
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         1682 ((?&date)) (?{ $d1 = $^N; stow($d1) })
  911         1531  
57 213         357 (?: (?&span_divider) ((?&date)) (?{ $d2 = $^N; stow($d2) }) )?
  213         327  
58             )
59              
60             (? \s*+ (?: -++ | \b(?: through | thru | to | till?+ | until )\b ) \s*+)
61              
62             (? at | @ )
63              
64             (?
65 515         2635 (?{ $time_buffer = undef })
66             (?: (?: \s++ | \s*+ (?&at) \s*+ ) (?&time))?
67             )
68              
69             (? (?:(?&at) \s++)? (?&time) \s++ on \s++ )
70              
71             (?
72 487         1949 (?{ (%buffer, $b1, $b2, $time_buffer) = ()})
73             (?: (?&numeric) | (?&verbal) )
74 1124 50       4037 (?{ $buffer{time} = $time_buffer if $time_buffer })
75             )
76            
77             (?
78 1846         12821 (?{ $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         15961 (?{ $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         2132 (?{ $buffer{type} = 'numeric' })
104             )
105            
106 1004         4701 (? (?{ %buffer = () }) (\d{4}) (?{ $buffer{year} = $^N }) )
  153         950  
107            
108 91         453 (? (?&year) (?÷r) (\d{1,2}) (?{ @buffer{qw(month unit)} = ($^N, 'months') }) )
109              
110 557         4721 (? (?{ %buffer = () }) (?&us) | (?&iso) | (?&md) | (?&dom) )
111              
112             (?
113 480         2604 (\d{1,2}) (?{ $b1 = $^N })
114             ((?÷r))
115 142         704 (\d{1,2}) (?{ $b2 = $^N })
116             \g{-2}
117             (\d{4})
118             (?{
119 80         217 $buffer{year} = $^N;
120 80         137 $buffer{month} = $b1;
121 80         376 $buffer{day} = $b2;
122             })
123             )
124              
125             (?
126 102         614 (\d{4}) (?{ $b1 = $^N })
127             ((?÷r))
128 102         394 (\d{1,2}) (?{ $b2 = $^N })
129             \g{-2}
130             (\d{1,2})
131             (?{
132 102         228 $buffer{year} = $b1;
133 102         179 $buffer{month} = $b2;
134 102         487 $buffer{day} = $^N;
135             })
136             )
137              
138             (?
139 196         1022 (\d{1,2}) (?{ $b1 = $^N })
140             (?÷r)
141             (\d{1,2})
142             (?{
143 62         144 $buffer{month} = $b1;
144 62         335 $buffer{day} = $^N;
145             })
146             )
147              
148             (?
149             (\d{1,2})
150 154         852 (?{ $buffer{day} = $^N })
151             )
152              
153             (?
154             (?: (?&my) | (?&named_period) | (?&relative_period) | (?&month_day) | (?&full) )
155 448         1655 (?{ $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         5236 ((?:(?&modifier) \s++ )?) (?{ $b1 = $^N })
164             ((?&weekday))
165             (?{
166 83 100       297 $buffer{modifier} = $b1 if $b1;
167 83         527 $buffer{day} = $^N;
168             })
169             )
170              
171             (?
172 182         1886 ((?:(?&month_modifier) \s++ )?) (?{ $b1 = $^N })
173             ((?&month))
174             (?{
175 32 100       81 $buffer{modifier} = $b1 if $b1;
176 32         198 $buffer{month} = $^N;
177             })
178             )
179              
180             (?
181 172         3791 (?{ $b1 = undef })
182 6         38 (?:((?&period_modifier)) \s*+ (?{ $b1 = $^N }))?
183             ((?&period))
184             (?{
185 11 100       38 $buffer{modifier} = $b1 if $b1;
186 11         67 $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         62 (? now (?{ $buffer{day} = 'today' }))
201              
202 7         47 (? ( 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         174 ((?&month)) (?{ $b1 = $^N })
210             \s++
211             (\d{1,2})
212             (?{
213 43         87 $buffer{month} = $b1;
214 43         325 $buffer{day} = $^N;
215             })
216             )
217            
218 1         8 (? ((?&month)) ,? \s*+ (?&year) (?{ @buffer{qw(month unit)} = ($^N, 'months') }) )
219              
220             (?
221 142         1578 (\d{1,2}) (?{ $b1 = $^N })
222             \s++
223             ((?&month))
224             (?{
225 86         212 $buffer{month} = $^N;
226 86         687 $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         1543 (\d{1,2}) (?{ $b1 = $^N })
236             \s++
237 83         369 ((?&month)) (?{ $b2 = $^N })
238             ,? \s++
239             (\d{4})
240             (?{
241 83         203 $buffer{year} = $^N;
242 83         135 $buffer{month} = $b2;
243 83         733 $buffer{day} = $b1;
244             })
245             )
246              
247             (?
248 40         183 ((?&month)) (?{ $b2 = $^N })
249             \s++
250 40         164 (\d{1,2}) (?{ $b1 = $^N })
251             , \s++
252             (\d{4})
253             (?{
254 40         86 $buffer{year} = $^N;
255 40         66 $buffer{month} = $b2;
256 40         320 $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 4556 my %h = %buffer;
292 1124         3218 $matches{ $_[0] } = \%h;
293 1124         15313 %buffer = ();
294             }
295              
296              
297             sub daytime {
298 416     416 1 649 my $time = shift;
299              
300             #parse
301 416         2002 $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   8154 ( $+{hour}, $+{minute} || 0, $+{second} || 0, lc( $+{suffix} || 'x' ) );
  1   100     3414  
  1   100     456  
  1         5418  
312 416 50 33     1971 $hour += 12 if $suffix eq 'p' && $hour < 12;
313 416 50       1083 $suffix = 'p' if $hour > 11;
314 416 50 33     1014 $hour = 0 if $hour == 12 && $suffix eq 'a';
315 416 50 33     3973 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     1552 $hour = 0 if $suffix eq 'a' && $hour == 12;
323             return (
324 416         2616 hour => $hour,
325             minute => $minute,
326             second => $second,
327             suffix => $suffix
328             );
329             }
330              
331              
332             sub parse {
333 445     445 1 435663 my $phrase = shift;
334 445         1289 local ( %matches, %buffer, $d1, $d2, $b1, $b2, $time_buffer );
335 445 50       6249 if ( $phrase =~ $re ) {
336 445 50       1034 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         812 my $h1 = $matches{$d1};
349 445         704 my $unit = delete $h1->{unit};
350 445         978 normalize($h1);
351 445 100       921 if ($unit) {
352              
353             # $h1 is necessarily fixed and there is no time associated
354 4         17 $h1 = fix_date( $h1, 1 );
355 4         1470 my $h2 = $h1->clone->add( $unit => 1 )->subtract( seconds => 1 );
356 4         7398 return $h1, $h2, 1;
357             }
358             else {
359 441         912 my %t1 = extract_time( $h1, 1 );
360 441         878 my ( $h2, $count, %t2 );
361 441 100 66     1288 if ( $d2 && $matches{$d2} ) {
362 20         47 $h2 = $matches{$d2};
363 20         48 normalize($h2);
364 20         47 %t2 = extract_time($h2);
365 20         48 $count = 2;
366             }
367             else {
368 421         1909 $h2 = {%$h1};
369 421         1219 %t2 = ( hour => 23, minute => 59, second => 59 );
370 421         653 $count = 1;
371             }
372 441         877 infer_modifier( $h1, $h2 );
373 441         917 my ( $s1, $s2 ) = ( $t1{suffix}, $t2{suffix} );
374 441         904 delete $t1{suffix}, delete $t2{suffix};
375 441 100       862 if ( is_fixed($h1) ) {
    100          
376 356         877 ( $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         161 ( $h1, $h2 ) = before_now( $h1, $h2, $count == 2 );
383             }
384 441 50       4786 croak "dates in \"$phrase\" are out of order"
385             unless DateTime->compare( $h1, $h2 ) <= 0;
386 441         43162 $h1->set(%t1);
387 441         142542 $h2->set(%t2);
388 441 50       137563 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         44612 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 657 my ( $h1, $h2 ) = @_;
413 441 50 100     1732 if ( keys %$h1 == 2 && keys %$h2 == 2 && $h1->{period} && $h2->{period} ) {
      66        
      66        
414 3         8 $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 690 my ( $h, $is_start ) = @_;
422 461         737 my $time = $h->{time};
423 461 100       900 if ( defined $time ) {
424 416         719 delete $h->{time};
425              
426 416         770 return daytime($time);
427             }
428             else {
429              
430             #return default values
431 45 100       309 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         3 $h2 = fix_date($h2);
441 1 50       163 if ( is_fixed($h1) ) {
442 0         0 $h1 = fix_date( $h1, 1 );
443             }
444             else {
445 1         4 my ( $unit, $amt ) = time_unit($h1);
446 1         4 $h1 = decontextualized_date( $h1, 1 );
447 1 50       7 if ( ref $h1 eq 'DateTime' ) {
448 1         5 while ( DateTime->compare( $h1, $h2 ) > 0 ) {
449 3         1443 $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         664 return ( $h1, $h2 );
459             }
460              
461             # picks a day of the week before a given date
462             sub adjust_weekday {
463 81     81 0 117 my ( $ref, $date ) = @_;
464             my $delta = $ref->{day_of_week}
465 81   50     223 || die 'should always be day_of_week key at this point';
466 81         227 my $d = $date->clone;
467 81         909 $delta = $date->day_of_week - $delta;
468 81 50       365 $delta += 7 if $delta <= 0;
469 81         223 $d->subtract( days => $delta );
470 81         48299 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 217 my $h = shift;
476 172 100       358 if ( $h->{type} eq 'numeric' ) {
477 82 50       288 return 'years' => 1 if exists $h->{month};
478 0         0 return 'months' => 1;
479             }
480             else {
481 90 100       200 if ( my $period = $h->{period} ) {
482 2         5 for ($period) {
483 2         6 when ('mon') { return 'months' => 1 }
  0         0  
484 2         3 when ('wee') { return 'weeks' => 1 }
  0         0  
485 2         3 when ('pay') { return 'days' => pay_period_length() }
  2         7  
486             }
487             }
488             else {
489 88 100       205 return 'years' => 1 if exists $h->{month};
490 81 50       304 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 590 my ( $h1, $h2, $two_endpoints ) = @_;
499 356         665 $h1 = fix_date( $h1, 1 );
500 356 100 100     57720 unless ( $two_endpoints || $h2->{type} ne 'numeric' ) {
501 164 100       722 return $h1, $h1->clone if defined $h2->{day};
502 1         48 return $h1, $h1->clone->add( years => 1 )->subtract( days => 1 );
503             }
504 192 100       392 if ( is_fixed($h2) ) {
505 189         364 $h2 = fix_date($h2);
506             }
507             else {
508 3         9 my ( $unit, $amt ) = time_unit($h2);
509 3         11 $h2 = decontextualized_date($h2);
510 3 100       16 $h2 = adjust_weekday( $h2, $h1 ) unless ref $h2 eq 'DateTime';
511 3         12 $h2->subtract( $unit => $amt ) while $h2 > $h1;
512 3         3637 $h2->add( $unit => $amt );
513             }
514 192         23675 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 246 my ( $h, $is_start ) = @_;
521             return decontextualized_numeric_date( $h, $is_start )
522 172 100       454 if $h->{type} eq 'numeric';
523 90         181 for ( $h->{modifier} ) {
524 90         123 when ('end') { $is_start = 0 }
  0         0  
525 90         139 when ('beginning') { $is_start = 1 }
  0         0  
526             }
527 90 100       197 if ( my $period = $h->{period} ) {
528 2         7 my $date = today;
529 2         23 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         3 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         6 my $days =
541             $date->delta_days(start_pay_period)->in_units('days');
542 2         521 $days %= pay_period_length;
543 2         8 $date->subtract( days => $days );
544 2 100       1196 $date->add( days => pay_period_length ) unless $is_start;
545             }
546 0         0 default {
547 0         0 croak 'DEBUG'
548             }
549             }
550 2 100       510 $date->subtract( days => 1 ) unless $is_start;
551 2         577 return $date;
552             }
553             else {
554 88 100 100     474 if ( exists $h->{day} && $h->{day} !~ /^\d++$/ ) {
555 81         151 init_day_abbr();
556 81         229 $h->{day_of_week} = $day_abbr{ $h->{day} };
557 81         134 delete $h->{day};
558 81         196 return $h;
559             }
560 7 50       17 if ( exists $h->{month} ) {
561 7         16 init_month_abbr();
562 7         20 $h->{month} = $month_abbr{ $h->{month} };
563             }
564 7         16 return decontextualized_numeric_date( $h, $is_start );
565             }
566             }
567              
568             sub decontextualized_numeric_date {
569 89     89 0 143 my ( $h, $is_start ) = @_;
570 89         220 my $date = today;
571 89         947 delete $h->{type};
572 89         124 delete $h->{modifier};
573 89   33     381 $h->{year} //= $date->year;
574 89   33     588 $h->{month} //= $date->month;
575 89         145 my $day_unspecified = !exists $h->{day};
576 89   100     249 $date = DateTime->new( time_zone => tz(), %$h, day => $h->{day} // 1 );
577              
578 89 100 100     15293 if ( !( exists $h->{day} || $is_start ) ) {
579 2         11 $date->add( months => 1 );
580 2         1086 $date->subtract( days => 1 );
581             }
582 89         1479 return $date;
583             }
584              
585             sub fix_date {
586 550     550 0 784 my ( $d, $is_start ) = @_;
587 550 100       1473 if ( $d->{type} eq 'verbal' ) {
588 363 100       918 if ( $d->{year} ) {
    100          
589 247         424 init_month_abbr();
590 247         532 $d->{month} = $month_abbr{ $d->{month} };
591 247         389 delete $d->{type};
592 247         728 return DateTime->new( time_zone => tz(), %$d );
593             }
594             elsif ( my $day = $d->{day} ) {
595 95         299 my $date = today;
596 95 100       1253 return $date if $day eq 'tod';
597 85 100       242 if ( $day eq 'yes' ) {
    100          
598 2         9 $date->subtract( days => 1 );
599 2         1325 return $date;
600             }
601             elsif ( $day eq 'tom' ) {
602 3         11 $date->add( days => 1 );
603 3         1478 return $date;
604             }
605 80         133 init_day_abbr();
606 80         167 my $day_num = $day_abbr{$day};
607 80         225 my $todays_num = $date->day_of_week;
608 80 50       359 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         97 my $delta = 7;
619 80 50       224 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         213 $date->subtract( days => $delta );
626 80 50       48090 $date->add( days => 14 ) if $d->{modifier} eq 'next';
627 80         197 return $date;
628             }
629             }
630              
631 21 100       58 if ( my $period = $d->{period} ) {
632 17         56 my $date = today;
633 17 100       213 if ( $d->{modifier} eq 'this' ) {
634 10         21 for ($period) {
635 10         22 when ('mon') {
636 4         12 $date->truncate( to => 'month' );
637 4 100       874 $date->add( months => 1 ) unless $is_start;
638             }
639 6         9 when ('wee') {
640 2         7 my $is_sunday = $date->day_of_week == 7;
641 2         11 $date->truncate( to => 'week' );
642 2 50       1895 if (sunday_begins_week) {
643 2 50       9 $date->subtract( days => $is_sunday ? -6 : 1 );
644             }
645 2 100       1161 $date->add( weeks => 1 ) unless $is_start;
646             }
647 4         7 when ('yea') {
648 2         7 $date->truncate( to => 'year' );
649 2 100       422 $date->add( years => 1 ) unless $is_start;
650             }
651 2         4 when ('pay') {
652 2         7 my $days =
653             $date->delta_days(start_pay_period)->in_units('days');
654 2         502 $days %= pay_period_length;
655 2         8 $date->subtract( days => $days );
656 2 100       1162 $date->add( days => pay_period_length )
657             unless $is_start;
658             }
659             }
660 10 100       2817 $date->subtract( days => 1 ) unless $is_start;
661             }
662             else {
663 7         16 for ($period) {
664 7         15 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         12 when ('wee') {
675 5         19 my $is_sunday = $date->day_of_week == 7;
676 5         59 $date->truncate( to => 'week' );
677 5 50       4667 if (sunday_begins_week) {
678 5 50       25 $date->subtract( days => $is_sunday ? -6 : 1 );
679             }
680 5 100       4115 if ($is_start) {
681 3         13 $date->subtract( weeks => 1 );
682             }
683             else {
684 2         10 $date->subtract( days => 1 );
685             }
686 5 50       3747 $date->add( days => 14 ) if $d->{modifier} eq 'next';
687             }
688 2         4 when ('yea') {
689 2         7 $date->truncate( to => 'year' );
690 2 100       413 if ($is_start) {
691 1         4 $date->subtract( years => 1 );
692             }
693             else {
694 1         5 $date->subtract( days => 1 );
695             }
696 2 50       1311 $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         2962 return $date;
715             }
716              
717 4         9 init_month_abbr();
718 4         14 my $date = today;
719 4         49 $date->truncate( to => 'month' );
720 4         850 my $month_num = $month_abbr{ $d->{month} };
721 4         11 my $todays_num = $date->month;
722 4 100       27 if ( $d->{modifier} eq 'this' ) {
723 2         4 my $delta = 0;
724 2 50       9 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       8 $delta++ unless $is_start;
731 2         6 $date->add( months => $delta );
732             }
733             else {
734 2         3 my $delta = 12;
735 2 50       10 if ( $todays_num > $month_num ) {
    50          
736 0         0 $delta = $todays_num - $month_num;
737             }
738             elsif ( $todays_num < $month_num ) {
739 2         3 $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       2703 $date->subtract( days => 1 ) unless $is_start;
745 4         1165 return $date;
746             }
747              
748             # numeric date
749 187         272 delete $d->{type};
750 187         649 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 1054 unless (%month_abbr) {
756 2         15 my @months = qw(jan feb mar apr may jun jul aug sep oct nov dec);
757 2         8 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 613 unless (%day_abbr) {
764 1         5 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         42 my $i = @$units;
774 31         45 my $u = pop @$units;
775 31         103 $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 139 my ( $h1, $h2, $two_endpoints ) = @_;
783 84 100       184 infer_missing( $h1, $h2 ) if $two_endpoints;
784 84         249 my $now = today;
785 84         1014 my ( $u1, $amt1, $u2, $amt2 ) = ( time_unit($h1), time_unit($h2) );
786 84         189 ( $h1, $h2 ) =
787             ( decontextualized_date( $h1, 1 ), decontextualized_date($h2) );
788 84 100       258 $h2 = adjust_weekday( $h2, $now ) unless ref $h2 eq 'DateTime';
789 84 100       247 $h1 = adjust_weekday( $h1, $now ) unless ref $h1 eq 'DateTime';
790 84         282 while ( $now < $h2 ) {
791 1         96 $h2->subtract( $u2 => $amt2 );
792             }
793 84         8587 while ( $h2 < $h1 ) {
794 1         89 $h1->subtract( $u1 => $amt1 );
795             }
796              
797 84 100       8288 if ($two_endpoints) {
798              
799             # move the two dates as close together as possible
800 2         6 while ( $h1 < $h2 ) {
801 2         172 $h2->subtract( $u2 => $amt2 );
802             }
803 2         1524 $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 4 my ( $h1, $h2 ) = @_;
812 2 50       10 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         5 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 674 my $h = shift;
835 465         597 delete $h->{debug};
836 465 100       1433 if ( $h->{type} eq 'verbal' ) {
837 236         433 for my $key (qw(day month period)) {
838 708 100       1883 if ( my $value = $h->{$key} ) {
839 362 100       1216 next if $value =~ /\d/;
840 236         384 $value = lc $value;
841 236 100       524 if ( $value =~ /^p/ ) {
842 3 50       10 croak 'pay period not defined'
843             unless defined start_pay_period;
844 3         521 $h->{$key} = 'pay';
845             }
846             else {
847 233         659 $h->{$key} = substr $value, 0, 3;
848             }
849             }
850             }
851 236   100     1132 for ( $h->{modifier} || '' ) {
852 236         337 when (/beg/) { $h->{modifier} = 'beginning' }
  0         0  
853 236         315 when (/end/) { $h->{modifier} = 'end' }
  0         0  
854 236         319 when (/las/) { $h->{modifier} = 'last' }
  45         161  
855 191         249 when (/thi/) { $h->{modifier} = 'this' }
  3         12  
856 188         397 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 931 my $h = shift;
865             return 1
866 719 100       2142 if exists $h->{year};
867 289 100       685 if ( $h->{type} eq 'verbal' ) {
868 207 100       487 if ( exists $h->{modifier} ) {
869 101 50       672 return 1 if $h->{modifier} =~ /this|last|next/;
870             }
871 106 100       244 if ( exists $h->{day} ) {
872 100 100       450 return 1 if $h->{day} =~ /yes|tod|tom/;
873             }
874             }
875 173         418 return 0;
876             }
877              
878             1;
879              
880             __END__