File Coverage

blib/lib/DateTimeX/Easy.pm
Criterion Covered Total %
statement 122 127 96.0
branch 61 82 74.3
condition 13 21 61.9
subroutine 11 11 100.0
pod 1 1 100.0
total 208 242 85.9


line stmt bran cond sub pod time code
1              
2             use warnings;
3 5     5   431447 use strict;
  5         31  
  5         147  
4 5     5   23  
  5         9  
  5         107  
5             use constant DEBUG => 0;
6 5     5   33  
  5         8  
  5         366  
7             # ABSTRACT: Parse a date/time string using the best method available
8              
9             # VERSION
10             our $VERSION = "0.089";
11              
12              
13             use base qw/Exporter/;
14 5     5   25 our @EXPORT_OK
  5         8  
  5         553  
15             = qw/datetime parse parse_datetime parse_date new_datetime new_date date/;
16              
17             use DateTime;
18 5     5   3439 use DateTime::Format::Natural;
  5         2067817  
  5         201  
19 5     5   2452 use DateTime::Format::Flexible;
  5         167091  
  5         276  
20 5     5   2541  
  5         1007650  
  5         105  
21             # use DateTime::Format::DateParse; # Unfortunately, not as useful to use because of that default "local" time zone business.
22             use DateTimeX::Easy::DateParse; # Using this instead, hrm.
23 5     5   2332 use Scalar::Util qw/blessed/;
  5         13  
  5         145  
24 5     5   26 use Carp;
  5         7  
  5         191  
25 5     5   25  
  5         8  
  5         8011  
26             my $have_ICal;
27             eval {
28             require DateTime::Format::ICal;
29             $have_ICal = 1;
30             };
31              
32             my $have_DateManip;
33             eval {
34             require DateTime::Format::DateManip;
35             $have_DateManip = 1;
36             };
37             my $natural_parser = DateTime::Format::Natural->new;
38              
39             my %_truncate_range = qw/
40             month year
41             day month
42             hour day
43             minute hour
44             second minute
45             nanosecond second
46             /;
47             my %_delta_range = (
48             month => [qw/years months/],
49             day => [qw/months days/],
50             hour => [qw/days hours/],
51             minute => [qw/hours minutes/],
52             second => [qw/minutes seconds/],
53             );
54             my %_first_or_last = qw/
55             first first
56             last last
57             begin first
58             beginning first
59             start first
60             end last
61             ending last
62             /;
63              
64             my @_parser_order = qw/
65             Flexible
66             DateParse
67             Natural
68             /;
69             unshift @_parser_order, qw/ICal/ if $have_ICal;
70             push @_parser_order, qw/DateManip/ if $have_DateManip;
71             my %_parser_source = (
72             ICal => sub {
73             return DateTime::Format::ICal->parse_datetime(shift);
74             },
75              
76             DateParse => sub {
77             return DateTimeX::Easy::DateParse->parse_datetime(shift);
78             },
79              
80             Natural => sub {
81             local $SIG{__WARN__} = sub {
82             }; # Make sure ::Natural/Date::Calc stay quiet... don't really like this, oh well...
83             my $dt = $natural_parser->parse_datetime(shift);
84             return unless $natural_parser->success;
85             return $dt;
86             },
87              
88             Flexible => sub {
89             my $parse = shift;
90             my $time_zone;
91              
92             # First, try to extract out any timezone information
93             {
94             ##################################################
95             # 2008-09-16 13:23:57 Eastern Daylight (?:Time)? #
96             ##################################################
97             if ($parse
98             =~ s/\s+(?:(Eastern|Central|Mountain|Pacific)\s+(?:Daylight|Standard)(?:\s+Time)?).*$//
99             )
100             {
101             $time_zone = "US/$1";
102             }
103             ##################################
104             # 2008-09-16 13:23:57 US/Eastern #
105             ##################################
106             elsif ($parse =~ s/\s+([A-Za-z][A-Za-z0-9\/\._]*)\s*$//)
107             { # Look for a timezone-like string at the end of $parse
108             $time_zone = $1;
109             $parse = "$parse $time_zone" and undef $time_zone
110             if $time_zone
111             && $time_zone =~ m/^[ap]\.?m\.?$/i
112             ; # Put back AM/PM if we accidentally slurped it out
113             }
114             #########################################################
115             # 2008-09-16 13:23:57 Eastern Daylight Time (GMT+05:00) #
116             #########################################################
117             elsif ($parse
118             =~ s/(?:\s+[A-Z]\w+)*\s+\(?(?:GMT|UTC)?([-+]\d{2}:\d{2})\)?\s*$//
119             )
120             {
121             $time_zone = $1;
122             }
123              
124             # Flexible can't seem to parse (GMT+0:500)
125             # elsif ($parse =~ s/(?:\s+[A-Z]\w+)*(\s+\(GMT[-+]\d{2}:\d{2}\)\s*)$//) {
126             # $parse = "$parse $1";
127             # }
128             #############################
129             # 2008-09-16 13:23:57 +0500 #
130             #############################
131             elsif ($parse =~ s/\s+([-+]\d{3,})\s*$//) {
132             $time_zone = $1;
133             }
134             }
135             return unless my $dt = DateTime::Format::Flexible->build($parse);
136             if ($time_zone) {
137             $dt->set_time_zone("floating");
138             $dt->set_time_zone($time_zone);
139             }
140             return $dt;
141             },
142              
143             DateManip => sub {
144             return DateTime::Format::DateManip->parse_datetime(shift);
145             },
146             );
147              
148             shift if $_[0] && $_[0] eq __PACKAGE__;
149              
150 61 100 66 61 1 64526 my $parse;
151             $parse = shift if @_ % 2;
152 61         79  
153 61 100       137 my %in = @_;
154             $parse = delete $in{parse} if exists $in{parse};
155 61         100 my $truncate = delete $in{truncate};
156 61 100       106 my $soft_time_zone_conversion = delete $in{soft_time_zone_conversion};
157 61         74 my $time_zone_if_floating = delete $in{default_time_zone};
158 61         69 $time_zone_if_floating = delete $in{time_zone_if_floating}
159 61         67 if exists $in{time_zone_if_floating};
160             my $parser_order = delete $in{parser_order};
161 61 100       91 my $parser_exclude = delete $in{parser_exclude};
162 61         66 my $ambiguous = 1;
163 61         63 $ambiguous = delete $in{ambiguous} if exists $in{ambiguous};
164 61         66  
165 61 100       82 my ($time_zone);
166             $time_zone = delete $in{tz} if exists $in{tz};
167 61         60 $time_zone = delete $in{timezone} if exists $in{timezone};
168 61 50       84 $time_zone = delete $in{time_zone}
169 61 100       84 if exists $in{time_zone}
170             ; # "time_zone" takes precedence over "timezone"
171              
172 61 100       97 my @delta;
173              
174 61         64 my $original_parse = $parse;
175             my $parse_dt;
176 61         61 if ($parse) {
177 61         62 if (blessed $parse && $parse->isa("DateTime"))
178 61 50       95 { # We have a DateTime object as $parse
179 61 100 66     192 $parse_dt = $parse;
180             }
181 6         7 else {
182              
183             if (1) {
184             my $got_ambiguous;
185 55         54 my ($last_delta);
186 55         58 while ($parse
187             =~ s/^\s*(start|first|last|(?:begin|end)(?:ning)?)\s+(year|month|day|hour|minute|second)\s+of\s+//i
188 55         220 )
189             {
190             my $first_or_last = $1;
191             $first_or_last = $_first_or_last{ lc $first_or_last };
192 8         17 my $period = $2;
193 8         17 $last_delta->{add} = ["${period}s" => 1] if $last_delta;
194 8         23 push @delta,
195 8 50       14 $last_delta = my $delta = { period => $period };
196 8         33 if ($first_or_last ne "first") {
197             $delta->{last} = 1;
198 8 100       16 $delta->{subtract} = ["${period}s" => 1];
199 6         8 }
200 6         23 else {
201             $delta->{first} = 1;
202             }
203 2         6 }
204             my $last_parse = $parse;
205             my $period;
206 55         66 if ($parse
207 55         52 =~ s/^\s*(start|this|next|first|last|(?:begin|end)(?:ning)?)\s+(year|month|day|hour|minute|second)(?:\s+of\s+)?//
208 55 50       193 )
    100          
    100          
209             {
210             $period = $2;
211             $last_delta->{add} = ["${period}s" => 1]
212 0         0 if $last_delta && $last_delta->{last};
213             push @delta, { truncate => $period };
214 0 0 0     0 $parse = $last_parse unless $parse;
215 0         0 }
216 0 0       0 elsif ($parse
217             =~ s/^\s*(year|month|day|hour|minute|second)\s+of\s+//i)
218             {
219             $period = $1;
220             $last_delta->{add} = ["${period}s" => 1]
221 4         6 if $last_delta && $last_delta->{last};
222             push @delta, { truncate => $period };
223 4 100 66     18 }
224 4         8 elsif (@delta) {
225             $got_ambiguous = 1;
226             $period = $last_delta->{period};
227 4         5 my $truncate = $_truncate_range{$period};
228 4         5 push @delta, my $delta = { truncate => $truncate };
229 4         7 my $delta_range = $_delta_range{$period};
230 4         7 if ($delta_range) {
231 4         7 my ($add, $subtract) = @$delta_range;
232 4 50       6 if ($last_delta->{last}) {
233 4         8 $last_delta->{add} = ["${add}" => 1];
234 4 100       7 }
235 3         7 }
236             }
237              
238             croak
239             "Can't parse \"$original_parse\" since it's too ambiguous"
240             if $got_ambiguous && !$ambiguous;
241 55 100 100     129 }
242              
243             my @parser_order
244             = $parser_order
245             ? (
246 54 0       133 ref $parser_order eq "ARRAY"
    50          
247             ? @$parser_order
248             : ($parser_order))
249             : @_parser_order;
250             my (%parser_exclude);
251             %parser_exclude
252 54         58 = map { $_ => 1 }
253             (
254 54 0       63 ref $parser_exclude eq "ARRAY"
  0 50       0  
255             ? @$parser_exclude
256             : ($parser_exclude))
257             if $parser_exclude;
258             my %parser_source = %_parser_source;
259             if (DEBUG) {
260 54         166 warn "Parse $parse\n";
261 54         61 }
262              
263             # TODO Kinda hackish
264             if ($parse =~ m/^[1-9]\d{3}$/)
265             { # If it's a four digit year... yeah, arbitrary
266 54 100       125 $parse_dt = DateTime->new(year => $parse);
267             }
268 3         12 while (!$parse_dt && @parser_order) {
269             my $parser = shift @parser_order;
270 54   66     790 next if $parser_exclude{$parser};
271 116         9335  
272 116 50       191 # warn "Try $parser:\n" if DEBUG;
273             my $parser_code = $parser_source{$parser};
274             eval { $parse_dt = $parser_code->($parse); };
275 116         137 if (DEBUG) {
276 116         115 if ($@) {
  116         248  
277 116         37664 warn "FAIL $parser: $@\n";
278             }
279             elsif ($parse_dt) {
280             warn "PASS $parser: $parse_dt\n";
281             }
282             else {
283             warn "FAIL $parser\n";
284             }
285             }
286             undef $parse_dt if $@;
287             }
288 116 100       375 }
289             return unless $parse_dt;
290             }
291 60 50       282  
292             my %DateTime;
293             $DateTime{time_zone} = "floating";
294 60         210 if ($parse_dt) {
295 60         97 $DateTime{$_} = $parse_dt->$_
296 60 50       82 for qw/year month day hour minute second nanosecond time_zone/;
297             }
298 60         305 @DateTime{ keys %in } = values %in;
299              
300 60         1605 return unless my $dt = DateTime->new(%DateTime);
301              
302 60 50       176 if ($time_zone) {
303             if ($soft_time_zone_conversion) {
304 60 100 66     15016 $dt->set_time_zone("floating");
    100          
305 13 100       23 }
306 1         4 $dt->set_time_zone($time_zone);
307             }
308 13         200 elsif ($time_zone_if_floating && $dt->time_zone->is_floating) {
309             $dt->set_time_zone($time_zone_if_floating);
310             }
311 1         8  
312             if ($truncate) {
313             $truncate = $truncate->[1] if ref $truncate eq "ARRAY";
314 60 100       4326 $truncate = (values %$truncate)[0] if ref $truncate eq "HASH";
    100          
315 1 50       10 $dt->truncate(to => $truncate);
316 1 50       3 }
317 1         34 elsif (@delta) {
318             if (DEBUG) {
319             require YAML;
320 7         8 warn "$original_parse => $parse => $dt";
321             }
322             for my $delta (reverse @delta) {
323             warn YAML::Dump($delta) if DEBUG;
324 7         14 if ($delta->{truncate}) {
325 14         1268 $dt->truncate(to => $delta->{truncate});
326 14 100       21 }
327 7         15 else {
328             $dt->add(@{ $delta->{add} }) if $delta->{add};
329             $dt->subtract(@{ $delta->{subtract} }) if $delta->{subtract};
330 7 100       17 }
  5         13  
331 7 100       3757 }
  5         16  
332             }
333              
334             return $dt;
335             }
336 60         4631 *parse = \&new;
337             *parse_date = \&new;
338             *parse_datetime = \&new;
339             *date = \&new;
340             *datetime = \&new;
341             *new_date = \&new;
342             *new_datetime = \&new;
343              
344             1; # End of DateTimeX::Easy
345              
346              
347             =pod
348              
349             =encoding UTF-8
350              
351             =head1 NAME
352              
353             DateTimeX::Easy - Parse a date/time string using the best method available
354              
355             =head1 VERSION
356              
357             version 0.090
358              
359             =head1 SYNOPSIS
360              
361             # Make DateTimeX object for "now":
362             my $dt = DateTimeX::Easy->new("today");
363              
364             # Same thing:
365             my $dt = DateTimeX::Easy->new("now");
366              
367             # Uses ::F::Natural's coolness (similar in capability to Date::Manip)
368             my $dt = DateTimeX::Easy->new("last monday");
369              
370             # ... but in 1969:
371             my $dt = DateTimeX::Easy->new("last monday", year => 1969);
372              
373             # ... at the 100th nanosecond:
374             my $dt = DateTimeX::Easy->new("last monday", year => 1969, nanosecond => 100);
375              
376             # ... in US/Eastern: (This will NOT do a timezone conversion)
377             my $dt = DateTimeX::Easy->new("last monday", year => 1969, nanosecond => 100, timezone => "US/Eastern");
378              
379             # This WILL do a proper timezone conversion:
380             my $dt = DateTimeX::Easy->new("last monday", year => 1969, nanosecond => 100, timezone => "US/Pacific");
381             $dt->set_time_zone("US/Eastern");
382              
383             # Custom DateTimeX ability:
384             my $dt = DateTimeX::Easy->new("last second of last month");
385             $dt = DateTimeX::Easy->new("last second of first month of last year");
386             $dt = DateTimeX::Easy->new("last second of first month of 2000");
387              
388             =head1 DESCRIPTION
389              
390             DateTimeX::Easy makes DateTime object creation quick and easy. It uses a
391             variety of DateTime::Format packages to do the bulk of the parsing, with some
392             custom tweaks to smooth out the rough edges (mainly concerning timezone
393             detection and selection).
394              
395             =head1 PARSING
396              
397             Currently, DateTimeX::Easy will attempt to parse input in the following order:
398              
399             =over
400              
401             =item DateTime - Is the input a DateTime object?
402              
403             =item ICal - Was DT::F::ICal able to parse the input?
404              
405             =item DateParse - Was DT::F::DateParse able to parse the input?
406              
407             A caveat, I actually use a modified version of DateParse in order to avoid
408             DateParse's default timezone selection.
409              
410             =item Natural - Was DT::F::Natural able to parse the input?
411              
412             Since this module barfs pretty loudly on strange input, we use a silent
413             $SIG{__WARN__} to hide errors.
414              
415             =item Flexible - Was DT::F::Flexible able to parse the input?
416              
417             This step also looks at the string to see if there is any timezone information
418             at the end.
419              
420             =item DateManip - Was DT::F::DateManip able to parse the input?
421              
422             DateManip isn't very nice with preserving the input timezone, but it's here as
423             a last resort.
424              
425             =back
426              
427             =head1 "last second of first month of year of 2005"
428              
429             DateTimeX::Easy also provides additional parsing and transformation for input
430             like:
431              
432             "first day of last month"
433             "last day of last month"
434             "last day of this month"
435             "last day of next month"
436             "last second of first month of last year"
437             "ending day of month of 2007-10-02"
438             "last second of first month of year of 2005"
439             "last second of last month of year of 2005"
440             "beginning day of month of 2007-10-02"
441             "last month of year of 2007"
442              
443             It will look at each sequence of "<first|last> of <period>" and do ->add,
444             ->subtract, and ->truncate operations on the parsed DateTime object
445              
446             Also, It's best to be as explicit as possible; the following will work:
447              
448             "last month of 2007"
449             "last second of last month of 2005"
450             "beginning day of 2007-10-02"
451              
452             This won't, though:
453              
454             "last day of 2007"
455              
456             You'll have to do this instead:
457              
458             "last day of year of 2007"
459              
460             The reason is that the date portion is opaque to the parser. It doesn't know
461             whether it has "2007" or "2007-10" or "now" as the last input. To fix this, you
462             can give a hint to the parser, like "<period> of <date/time>" (as in "year of
463             2007" above).
464              
465             WARNING: This feature is still somewhat new, so there may be bugs lurking
466             about. Please forward failing tests/scenarios.
467              
468             =head1 METHODS
469              
470             =head2 DateTimeX::Easy->new( ... )
471              
472             =head2 DateTimeX::Easy->parse( ... )
473              
474             =head2 DateTimeX::Easy->parse_date( ... )
475              
476             =head2 DateTimeX::Easy->parse_datetime( ... )
477              
478             =head2 DateTimeX::Easy->date( ... )
479              
480             =head2 DateTimeX::Easy->datetime( ... )
481              
482             =head2 DateTimeX::Easy->new_date( ... )
483              
484             =head2 DateTimeX::Easy->new_datetime( ... )
485              
486             Parse the given date/time specification using ::F::Flexible or ::F::Natural and use the result to create a L<DateTime> object. Returns a L<DateTime> object.
487              
488             You can pass the following in:
489              
490             parse # The string or DateTime object to parse.
491              
492             year # A year to override the result of parsing
493             month # A month to override the result of parsing
494             day # A day to override the result of parsing
495             hour # A hour to override the result of parsing
496             minute # A minute to override the result of parsing
497             second # A second to override the result of parsing
498              
499             truncate # A truncation parameter (e.g. year, day, month, week, etc.)
500              
501             time_zone # - Can be:
502             timezone # * A timezone (e.g. US/Pacific, UTC, etc.)
503             tz # * A DateTime special timezone (e.g. floating, local)
504             #
505             # - If neither "tz", "timezone", nor "time_zone" is set, then it'll use whatever is parsed.
506             # - If no timezone is parsed, then the default is floating.
507             # - If the given timezone is different from the parsed timezone,
508             # then a time conversion will take place (unless "soft_time_zone_conversion" is set).
509             # - Either "time_zone", "timezone", "tz" will work (in that order), with "time_zone" having highest precedence
510             # - See below for examples!
511              
512             soft_time_zone_conversion # Set this flag to 1 if you don't want the time to change when a given timezone is
513             # different from a parsed timezone. For example, "10:00 UTC" soft converted to
514             # PST8PDT would be "10:00 PST8PDT".
515              
516             time_zone_if_floating # The value of this option should be a valid timezone. If this option is set, then a DateTime object
517             # with a floating timezone has it's timezone set to the value.
518             default_time_zone # Same as "time_zone_if_floating"
519              
520             ambiguous # Set this flag to 0 if you want to disallow ambiguous input like:
521             # "last day of 2007" or "first minute of April"
522             # This will require you to specify them as "last day of year of 2007" and "first minute of month of April"
523             # instead. This flag is 1 (false) by default.
524              
525             ... and anything else that you want to pass to the DateTime->new constructor
526              
527             If C<truncate> is specified, then DateTime->truncate will be run after object creation.
528              
529             Furthermore, you can simply pass the value for "parse" as the first positional argument of the DateTimeX::Easy call, e.g.:
530              
531             # This:
532             DateTimeX::Easy->new("today", year => 2008, truncate => "hour");
533              
534             # ... is the same as this:
535             DateTimeX::Easy->new(parse => "today", year => 2008, truncate => "hour");
536              
537             Timezone processing can be a little complicated. Here are some examples:
538              
539             DateTimeX::Easy->parse("today"); # Will use a floating timezone
540              
541             DateTimeX::Easy->parse("2007-07-01 10:32:10"); # Will ALSO use a floating timezone
542              
543             DateTimeX::Easy->parse("2007-07-01 10:32:10 US/Eastern"); # Will use US/Eastern as a timezone
544              
545             DateTimeX::Easy->parse("2007-07-01 10:32:10"); # Will use the floating timezone
546              
547             DateTimeX::Easy->parse("2007-07-01 10:32:10", time_zone_if_floating => "local"); # Will use the local timezone
548              
549             DateTimeX::Easy->parse("2007-07-01 10:32:10 UTC", time_zone => "US/Pacific"); # Will convert from UTC to US/Pacific
550              
551             my $dt = DateTime->now->set_time_zone("US/Eastern");
552             DateTimeX::Easy->parse($dt); # Will use US/Eastern as the timezone
553              
554             DateTimeX::Easy->parse($dt, time_zone => "floating"); # Will use a floating timezone
555              
556             DateTimeX::Easy->parse($dt, time_zone => "US/Pacific", soft_time_zone_conversion => 1);
557             # Will use US/Pacific as the timezone with NO conversion
558             # For example, "22:00 US/Eastern" will become "22:00 PST8PDT"
559              
560             DateTimeX::Easy->parse($dt)->set_time_zone("US/Pacific"); # Will use US/Pacific as the timezone WITH conversion
561             # For example, "22:00 US/Eastern" will become "19:00 PST8PDT"
562              
563             DateTimeX::Easy->parse($dt, time_zone => "US/Pacific"); # Will ALSO use US/Pacific as the timezone WITH conversion
564              
565             =head1 EXPORT
566              
567             =head2 parse( ... )
568              
569             =head2 parse_date( ... )
570              
571             =head2 parse_datetime( ... )
572              
573             =head2 date( ... )
574              
575             =head2 datetime( ... )
576              
577             =head2 new_date( ... )
578              
579             =head2 new_datetime( ... )
580              
581             Same syntax as above. See above for more information.
582              
583             =head1 MOTIVATION
584              
585             Although I really like using DateTime for date/time handling, I was often
586             frustrated by its inability to parse even the simplest of date/time strings.
587             There does exist a wide variety of DateTime::Format::* modules, but they all
588             have different interfaces and different capabilities. Coming from a
589             Date::Manip background, I wanted something that gave me the power of ParseDate
590             while still returning a DateTime object. Most importantly, I wanted explicit
591             control of the timezone setting at every step of the way. DateTimeX::Easy is
592             the result.
593              
594             =head1 THANKS
595              
596             Dave Rolsky and crew for writing L<DateTime>
597              
598             =head1 SEE ALSO
599              
600             L<DateTime>
601              
602             L<DateTime::Format::Natural>
603              
604             L<DateTime::Format::Flexible>
605              
606             L<DateTime::Format::DateManip>
607              
608             L<DateTime::Format::ParseDate>
609              
610             L<DateTime::Format::ICal>
611              
612             L<Date::Manip>
613              
614             =head1 AUTHOR
615              
616             Robert Krimen <rokr@cpan.org>
617              
618             =head1 COPYRIGHT AND LICENSE
619              
620             This software is copyright (c) 2022 by Robert Krimen and others, see the git log.
621              
622             This is free software; you can redistribute it and/or modify it under
623             the same terms as the Perl 5 programming language system itself.
624              
625             =cut