File Coverage

blib/lib/DateTimeX/Easy.pm
Criterion Covered Total %
statement 121 127 95.2
branch 59 82 71.9
condition 12 21 57.1
subroutine 11 11 100.0
pod 1 1 100.0
total 204 242 84.3


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