File Coverage

blib/lib/Date/PeriodParser.pm
Criterion Covered Total %
statement 217 219 99.0
branch 72 82 87.8
condition 52 68 76.4
subroutine 19 19 100.0
pod 1 1 100.0
total 361 389 92.8


line stmt bran cond sub pod time code
1             package Date::PeriodParser;
2              
3 13     13   272106 use 5.010;
  13         48  
  13         480  
4 13     13   75 use strict;
  13         21  
  13         460  
5 13     13   80 use warnings;
  13         21  
  13         477  
6 13     13   6863 use Time::Local;
  13         19771  
  13         787  
7 13     13   11969 use Lingua::EN::Words2Nums qw(words2nums);
  13         42618  
  13         1693  
8 13         1614 use Date::Calc qw(
9             Add_Delta_Days
10             Add_Delta_YM
11             Date_to_Time
12             Day_of_Week
13             Days_in_Month
14             Decode_Month
15 13     13   12125 );
  13         557116  
16              
17 13     13   170 use constant GIBBERISH => -1;
  13         24  
  13         927  
18 13     13   67 use constant AMBIGUOUS => -2;
  13         26  
  13         548  
19 13     13   70 use constant DEPENDENCY => -3;
  13         27  
  13         46797  
20              
21             # Boring administrative details
22             require Exporter;
23             our @ISA = qw(Exporter);
24             our %EXPORT_TAGS = ( 'all' => [ qw( parse_period) ] );
25             our @EXPORT_OK = ( @{ $EXPORT_TAGS{'all'} } );
26             our @EXPORT = qw( parse_period);
27             our $VERSION = '0.17';
28              
29             $Date::PeriodParser::DEBUG = 0;
30              
31             our $TestTime; # This is set by our tests so we don't have to
32             # dynamically figure out acceptable ranges for our test results
33              
34             my $roughly = qr/((?:a?round(?: about)?|about|roughly|circa|sometime)\s*)+/;
35              
36             # Emit debug messages if the package global $DEBUG is set.
37              
38             sub _debug {
39 407 50   407   1012 print STDERR "# @_\n" if $Date::PeriodParser::DEBUG;
40             }
41              
42             # The actual parsing routine. Detailed below in the pod.
43              
44             sub parse_period {
45 171   100 171 1 214689 local $_ = lc (shift // ''); # Since we're doing lots of regexps on it.
46 171   66     492 my $now = $TestTime || time;
47 171         3253 my ($s, $m, $h, $day, $mon, $year) = my @now =
48             (localtime $now)[0..5];
49              
50             # Tidy slightly.
51 171         561 s/^\s+//;s/\s+$//;
  171         521  
52 171 100       416 return (GIBBERISH, "You didn't supply an argument.") unless $_;
53              
54             # We're trying to find two things: from and to.
55             # We also want to keep track of how vague the user's being, so we
56             # provide a flexibility score - for instance "about two weeks ago"
57             # means maybe three days either side, but "around last September"
58             # means perhaps twelve days either side.
59 168         207 my ($from, $to, $leeway);
60 168         1921 my $vague = s/^$roughly\s*//;
61 168         404 _debug("this is a vague time");
62            
63             # Stupid cases first.
64             # "now": precisely now, or +/- 5 minutes if vague (e.g. "about now")
65 168 100       438 return _apply_leeway($now, $now, 300 * $vague)
66             if /^now$/;
67              
68 164 100 66     448 if ($_ eq "" and $vague) { # Biggest range possible.
69 2         3 $from = 0; $to = 2**31-1;
  2         2  
70 2         9 return ($from, $to);
71             }
72              
73             # "this week", "last week", "next week"
74 162 100       441 if ( m/(this|last|next) week/ ) {
75 6         15 my $modifier = $1;
76 6         15 my @today = _today();
77 6 100       21 if ( $modifier eq 'last' ) {
    100          
78 2         11 @today = Add_Delta_Days( @today, -7 );
79             }
80             elsif ( $modifier eq 'next' ) {
81 2         7 @today = Add_Delta_Days( @today, +7 );
82             }
83 6         286 my $today = Day_of_Week(@today);
84 6         169 my $monday = 1;
85 6         6 my $sunday = 7;
86              
87             # Monday at midnight and sunday just before midnight
88 6         21 my @monday = ( Add_Delta_Days(@today, $monday - $today), 0, 0, 0 );
89 6         418 my @sunday = ( Add_Delta_Days(@today, $sunday - $today), 23, 59, 59 );
90            
91 6         332 return ( _timelocal(@monday), _timelocal(@sunday) );
92             }
93              
94             # "this month", "last month", "next month"
95 156 100       384 if (m/(this|last|next) month/) {
96 6         11 my $modifier = $1;
97 6         12 my ( $year, $month, $day ) = _today();
98              
99             # find a day in the previous month
100 6 100       22 if ( $modifier eq 'last' ) {
    100          
101 2         9 ( $year, $month ) = Add_Delta_YM( $year, $month, $day, 0, -1 );
102             }
103             elsif ( $modifier eq 'next' ) {
104 2         8 ( $year, $month ) = Add_Delta_YM( $year, $month, $day, 0, 1 );
105             }
106              
107 6         154 my @first = ( $year, $month, 1, 0, 0, 0 ); # first day at midnight
108 6         19 my $last_day_of_month = Days_in_Month( $year, $month );
109 6         70 my @last = ( $year, $month, $last_day_of_month , 23, 59, 59 );
110              
111 6         22 return ( _timelocal(@first), _timelocal(@last) );
112             }
113              
114             # "january 2007", "dec 1991", etc
115 150 100       736 if (m{\A (\w+) \s+ (\d{4}) \z}xms) {
116 15         32 my $month = $1;
117 15         22 my $year = $2;
118              
119 15 50       48 if ( $month = Decode_Month($month) ) {
120 15         2354 my @first = ( $year, $month, 1, 0, 0, 0 ); # first day at midnight
121 15         66 my $last_day_of_month = Days_in_Month( $year, $month );
122 15         206 my @last = ( $year, $month, $last_day_of_month, 23, 59, 59 );
123 15         40 return ( _timelocal(@first), _timelocal(@last) );
124             }
125             }
126              
127             # Recent times
128 135 100 100     1828 if (/(the day (before|after) )?(yesterday|today|tomorrow)/ ||
      100        
      100        
      100        
129             /^this (morning|afternoon|evening|lunchtime)/ ||
130             /^at lunchtime$/ ||
131             /^(in the) (morning|afternoon|evening)/ ||
132             /^(last |to)night/) {
133              
134 36 100       144 if (s/the day (before|after)//) {
135 13 100       48 my $wind = $1 eq "before" ? -1 : 1;
136 13         43 _debug("Modifying day by $wind");
137 13         26 $day += $wind;
138             }
139 36 100       140 if (/yesterday/) { $day--; _debug("Back 1 day") }
  11 100       15  
  11         64  
140 10         14 elsif (/tomorrow/) { $day++; _debug("Forward 1 day") }
  10         19  
141              
142             # if it's later than the morning and the phrase is
143             # "in the morning", add a day.
144 36 100 66     435 if ($h>12 and /in the morning$/) {$day++}
  1 100 66     2  
  1 100 33     2  
      66        
145              
146             # if it's later than the afternoon and the phrase is
147             # "in the afternoon", add a day.
148 1         2 elsif ($h>18 and /in the afternoon$/) {$day++}
149              
150             # if it's nighttime, and the phrase is "in the evening", add a day.
151             elsif (($h>21 or $h<6) and /in the evening$/) {$day++}
152              
153 36 100       281 $day-- if /last/;
154              
155 36         81 ($day, $mon, $year) = _zyprexa($day, $mon, $year);
156              
157 36         102 ($from, $to, $leeway) = _period_or_all_day($day, $mon, $year, $now);
158 36         99 return _apply_leeway($from, $to, $leeway * $vague);
159              
160             }
161              
162             # "ago" and "from now" are both pretty limited: only an offset in
163             # days is currently supported.
164 99         150 s/a (week|month|day|year)/one $1/g;
165              
166 99 100 100     537 if (/^(.*) day(?:s)? ago$/ || /^in (.*) day(?:s)?(?: time)?$/ ||
      100        
167             /^(.*) day(?:s)? (?:away)?\s*(?:from now)?$/) {
168 94         207 my $days = $1;
169             {
170 94         143 local $_;
  94         106  
171             # words2nums() trashes $_.
172 94         296 $days = Lingua::EN::Words2Nums::words2nums($days);
173             }
174 94 50       5730 if (defined $days) {
175 94 100       304 $days *= -1 if /ago/;
176 94         252 _debug("Modifying day by $days");
177 94         131 $day += $days;
178 94         196 ($day, $mon, $year) = _zyprexa($day, $mon, $year);
179 94         206 ($from, $to, $leeway) =
180             _period_or_all_day($day, $mon, $year, $now);
181 94         277 return _apply_leeway($from, $to, $leeway * $vague);
182             }
183             }
184              
185 5 100 66     54 if (/^(.*) week(?:s)? ago$/ || /^in (.*) week(?:s)?(?: time)?$/ ||
      100        
186             /^(.*) week(?:s)? (?:away)?\s*(?:from now)?$/) {
187 2         5 my $weeks = $1;
188 2         3 my $days;
189             {
190 2         4 local $_;
  2         3  
191             # words2nums() trashes $_.
192 2         7 $days = 7 * Lingua::EN::Words2Nums::words2nums($weeks);
193             }
194 2 50       73 if (defined $days) {
195 2 100       8 $days *= -1 if /ago/;
196 2         9 _debug("Modifying day by $days");
197 2         3 $day += $days;
198 2         5 ($day, $mon, $year) = _zyprexa($day, $mon, $year);
199 2         6 ($from, $to, $leeway) =
200             _period_or_all_day($day, $mon, $year, $now);
201 2         6 return _apply_leeway($from, $to, $leeway * $vague);
202             }
203             }
204              
205 3 100 66     61 if (/^(.*) month(?:s)? ago$/ || /^in (.*) month(?:s)?(?: time)?$/ ||
      66        
206             /^(.*) month(?:s)? (?:away)?\s*(?:from now)?$/) {
207 1         4 my $months = $1;
208             {
209 1         3 local $_;
  1         2  
210             # words2nums() trashes $_.
211 1         4 $months = Lingua::EN::Words2Nums::words2nums($months);
212             }
213 1 50       46 if (defined $months) {
214 1 50       5 $months *= -1 if /ago/;
215 1         4 _debug("Modifying month by $months");
216 1         2 $mon += $months;
217 1         2 ($day, $mon, $year) = _zyprexa($day, $mon, $year);
218 1         3 ($from, $to, $leeway) =
219             _period_or_all_day($day, $mon, $year, $now);
220 1         5 return _apply_leeway($from, $to, $leeway * $vague);
221             }
222             }
223              
224 2 50 66     24 if (/^(.*) year(?:s)? ago$/ || /^in (.*) year(?:s)?(?: time)?$/ ||
      66        
225             /^(.*) year(?:s)? (?:away)?\s*(?:from now)?$/) {
226 1         4 my $years = $1;
227             {
228 1         2 local $_;
  1         2  
229             # words2nums() trashes $_.
230 1         5 $years = Lingua::EN::Words2Nums::words2nums($years);
231             }
232 1 50       44 if (defined $years) {
233 1 50       5 $years *= -1 if /ago/;
234 1         5 _debug("Modifying year by $years");
235 1         2 $year += $years;
236 1         4 ($day, $mon, $year) = _zyprexa($day, $mon, $year);
237 1         4 ($from, $to, $leeway) =
238             _period_or_all_day($day, $mon, $year, $now);
239 1         4 return _apply_leeway($from, $to, $leeway * $vague);
240             }
241             }
242              
243             # We got nothing. Warn the caller.
244 1 50 33     15 if (!$from and !$to) {
245 1         7 return (GIBBERISH, "I couldn't parse that at all.");
246             }
247             }
248              
249             # Define the basic ranges for a day. (earliest,latest) pairs.
250             my %points_of_day = (
251              
252             # Technically, after midnight is the morning of the next day.
253             # Morning runs until noon.
254             morning => [
255             [0, 0, 0],
256             [12, 0, 0]
257             ],
258              
259             # Must be English rules for how long lunch is :) [JM]
260             lunchtime => [
261             [12, 0, 0],
262             [13, 30, 0]
263             ],
264             # Afternoon runs till 6 PM.
265             afternoon => [
266             [13, 30, 0], # "It is not afternoon until a gentleman
267             [18, 0, 0] # has had his luncheon."
268             ],
269             # Evening runs up to but not including midnight.
270             evening => [
271             [18, 0, 0], # Regardless of what Mediterraneans think
272             [23, 59, 59]
273             ],
274             # The entire day.
275             day => [
276             [0, 0, 0],
277             [23, 59, 59],
278             ]
279             );
280              
281             # _apply_point_of_day takes the word specifying the portion of the
282             # day and transforms it into a range of hours.
283              
284             sub _apply_point_of_day {
285 140     140   4365 my ($d, $m, $y, $point) = @_;
286 140         141 my ($from, $to);
287 140 100       318 if ($point eq "night") { # Special case
288             # Nights are a special case because they run over the
289             # day boundary. (9PM to 5:59:59AM the next day).
290 4         27 $from = timelocal(0,0,21,$d,$m,$y);
291 4         276 $to = timelocal(59,59,5,$d+1,$m,$y);
292             } else {
293             # Look up the appropriate range and set the hours
294             # in the specified day.
295 136         262 my $spec = $points_of_day{$point};
296 136         163 my @from = (reverse(@{$spec->[0]}),$d,$m,$y);
  136         426  
297 136         169 my @to = (reverse(@{$spec->[1]}),$d,$m,$y);
  136         312  
298 136         370 $from = timelocal(@from);
299 136         6899 $to = timelocal(@to);
300             }
301 140         6945 return ($from, $to);
302             }
303              
304             # _period_or_all_day determines the size of leeway to
305             # be applied to a date (closer dates get less, dates
306             # further in the future or past get more). It also
307             # applies the appropriate point-of-day.
308             sub _period_or_all_day {
309 134     134   145 my $point;
310 134         177 my ($day, $mon, $year, $now) = @_;
311 134         133 my $leeway;
312              
313 134 100       559 if (/(morning|afternoon|evening|lunchtime|night)/) {
314 27         34 $leeway = 60*60*2;
315 27         64 $point = $1;
316             } else {
317             # To determine the leeway, consider how many days ago this was;
318             # we want to be more specific about recent events than ancient
319             # ones.
320 107         303 my $was = timelocal(0,0,0, $day, $mon, $year);
321 107         6113 my $days_ago = int(($now-$was)/(60*60*24))+1;
322 107         130 $leeway = 60*60*3*$days_ago;
323             # Up to a maximum of five days
324 107 100       238 $leeway > 24*60*60*5 and $leeway = 24*60*60*5;
325 107         351 _debug("Wanted around $days_ago, allowing $leeway either side");
326 107         172 $point = "day";
327             }
328 134         260 return (_apply_point_of_day($day, $mon, $year, $point), $leeway);
329             }
330              
331             # _apply_leeway just applies the necessary leeway to the
332             # current date range.
333             sub _apply_leeway {
334 141     141   2415 my ($from, $to, $leeway) = @_;
335 141         184 my $new_from = $from - $leeway;
336 141         175 my $new_to = $to + $leeway;
337             # watch for daylight savings crossovers!
338 141         591 for my $pair ( [\$new_from, $from], [\$new_to, $to] ) {
339 282 100 100     326 if ( _isnt_dst(${ $pair->[0] }) and _is_dst($pair->[1]) ) {
  282         626  
340 18         21 ${ $pair->[0] } += 60 * 60; # forward an hour to match
  18         31  
341             }
342 282 100 100     534 if ( _is_dst(${ $pair->[0] }) and _isnt_dst($pair->[1]) ) {
  282         554  
343 2         3 ${ $pair->[0] } -= 60 * 60; # back an hour to match
  2         7  
344             }
345             }
346 141         775 return ($new_from, $new_to);
347             }
348              
349             sub _is_dst {
350 846     846   1002 my ($given_time) = shift;
351 846         10229 return (localtime $given_time)[-1];
352             }
353              
354             sub _isnt_dst {
355 304     304   545 return not _is_dst(@_);
356             }
357              
358             # similar to Time::Local::timelocal but accepts the offsets returned by
359             # Date::Calc::Today_and_Now()
360             sub _timelocal {
361 54     54   1277 my ( $year, $mon, $day, $hour, $min, $sec ) = @_;
362              
363             # make offsets as expected by timelocal
364 54         66 $year -= 1900;
365 54         54 $mon--;
366              
367 54         133 return timelocal( $sec, $min, $hour, $day, $mon, $year );
368             }
369              
370             # same as Date::Calc::Today but respect $TestTime so that
371             # we can test periods based on today's date
372             sub _today {
373 12   33 12   27 my $now = $TestTime || time;
374 12         192 my ( $day, $month, $year ) = ( localtime $now )[ 3 .. 5 ];
375 12         52 $year += 1900;
376 12         34 $month++;
377 12         129 return ($year, $month, $day);
378             }
379              
380             # Our date math can lead to days or months out of range, so we need to get
381             # them back to something sane before we pass them to any of the date functions.
382             # (Zyprexa is an anti-psychotic.)
383             sub _zyprexa {
384 134     134   188 my( $day, $month, $year) = @_;
385              
386 134         138 $day++;
387 134         137 $month++;
388 134         171 $year += 1900;
389              
390 134         302 while ($month > 12) {
391 1         1 $year++;
392 1         3 $month -= 12;
393             }
394              
395 134         274 while ($month < 1) {
396 0         0 $year--;
397 0         0 $month += 12;
398             }
399              
400 134         450 while ($day > Days_in_Month($year, $month)) {
401 32856         355130 $day -= Days_in_Month ($year, $month);
402 32856         298582 $month++;
403 32856 100       94094 if ($month > 12) {
404 2738         3035 $month = 1;
405 2738         6030 $year++;
406             }
407             }
408              
409 134         2076 while ($day < 0) {
410 69         707 $month--;
411 69 100       123 if ($month < 1) {
412 6         7 $month = 12;
413 6         8 $year--;
414             }
415 69         148 $day += Days_in_Month($year, $month);
416             }
417              
418 134         160 $day--;
419 134         129 $month--;
420 134         160 $year -= 1900;
421              
422 134         327 return ($day, $month, $year);
423             }
424              
425             1;
426             __END__