File Coverage

blib/lib/Date/Manip/Range.pm
Criterion Covered Total %
statement 62 67 92.5
branch 22 26 84.6
condition 4 6 66.6
subroutine 14 14 100.0
pod 3 4 75.0
total 105 117 89.7


line stmt bran cond sub pod time code
1             =pod
2              
3             =head1 NAME
4              
5             Date::Manip::Range - Parses and holds a date range
6              
7             =head1 SYNOPSIS
8              
9             use Date::Manip::Range;
10             my $range = Date::Manip::Range->new();
11             $range->parse( 'today through tomorrow' );
12             $range->adjust( '3 days' );
13             print $range->printf;
14              
15             =head1 DESCRIPTION
16              
17             B<Date::Manip::Range> parses and holds a date range. The range is defined by a
18             start and end point. The module accepts ranges as a single string of two dates
19             separated by a range operator. Some examples...
20              
21             my $range = Date::Manip::Range->new( {parse => 'today - tommorrow'} );
22             my $range = Date::Manip::Range->new( {parse => 'Jan 21 through Feb 3'} );
23             my $range = Date::Manip::Range->new( {parse => '2015-01-29 to 2015-02-03'} );
24             my $range = Date::Manip::Range->new( {parse => 'from Jan 21 to Feb 3'} );
25             my $range = Date::Manip::Range->new( {parse => 'between Jan 21 and Feb 3'} );
26              
27             B<Date::Manip::Range> recognizes the following range operators...
28              
29             =over
30              
31             =item through
32              
33             =item thru
34              
35             =item to
36              
37             =item -
38              
39             =item ...
40              
41             =item ..
42              
43             =item between/and
44              
45             =item and
46              
47             =item from/through
48              
49             =item from/thru
50              
51             =item from/to
52              
53             =back
54              
55             B<Date::Manip::Range> splits the string on the operator, extracting the start
56             and end points. It creates L<Date::Manip> objects from those two points. The
57             dates can be anything parsable by L<Date::Manip>.
58              
59             =head2 Important Facts
60              
61             =over
62              
63             =item Date strings can be anything parsable by L<Date::Manip>.
64              
65             =item Dates must be in the correct order.
66              
67             =item Range operators are case insensetive.
68              
69             =item Ranges do not support times. Ranges only work on whole days.
70              
71             =back
72              
73             =head2 Implicit Ranges
74              
75             B<Date::Manip::Range> supports the concept of I<implicit ranges>. A range is
76             implied when you pass a single time period into L</parse>. For example,
77             C<April 2015> implies the range 2015-04-01 through 2015-04-30.
78             B<Date::Manip::Range> creates an implicit range when there is no range operator.
79              
80             B<Date::Manip::Range> accepts these forms of implicit ranges...
81              
82             =over
83              
84             =item yyyy
85              
86             Any four digit value translates into an entire year, from January 01 through
87             December 31.
88              
89             =item yyyy-mm
90              
91             =item Month YYYY
92              
93             =item mm/yyyy
94              
95             Any two part value implies a one month range from the first to the last day.
96             For the month, you can use a number, 3 letter abbreviation, or spell out the
97             full name.
98              
99             =back
100              
101             =head2 Implicit Start and End Dates
102              
103             B<Date::Manip::Range> also recognizes implied start and end dates. This is
104             where you give an implicit range as both the start and end, like these...
105              
106             January through March
107             April 2015 - August 2015
108             2014 to 2015
109              
110             The start date falls on the first day of the implied range. That would be
111             January 1 for years and the first day of the month for others.
112              
113             The end date falls on the last day of the implied range. For years, that's
114             December 31. For months, it is the last day of the month. The code correctly
115             calculates the last day of the month - even for Februrary and leap years.
116              
117             L</parse> sets the L</date_format> to the shortest implied range. For example,
118             L</printf> converts C<2014 to May 2015> into C<January 2014 to May 2015>. And
119             C<April 2015 to May 15, 2015> becomes C<April 01, 2015 to May 15, 2015>.
120              
121             =cut
122              
123             package Date::Manip::Range;
124              
125 1     1   127583 use 5.14.0;
  1         3  
126 1     1   3 use warnings;
  1         1  
  1         22  
127              
128 1     1   4 use Date::Manip;
  1         6  
  1         135  
129 1     1   570 use Moose;
  1         302371  
  1         6  
130 1     1   5453 use String::Util qw/hascontent trim/;
  1         2338  
  1         753  
131              
132              
133             our $VERSION = '1.20';
134              
135              
136             =head1 METHODS & ATTRIBUTES
137              
138             =head3 new
139              
140             B<new> creates a new object. You may pass default values in a hash reference.
141             B<new> accepts the following...
142              
143             =over
144              
145             =item parse
146              
147             A date range in string form passed directly into the L</parse> method. This
148             allows you to initialize the object in one statement instead of two. Check the
149             L</is_valid> method and L<error> attribute for error messages.
150              
151             =cut
152              
153             sub BUILD {
154 7     7 0 12 my ($self, $attributes) = @_;
155              
156 7         9 my $range = $attributes->{parse};
157 7 100       28 $self->parse( $range ) if hascontent( $range );
158             }
159              
160              
161             =item include_start
162              
163             =item include_end
164              
165             These attributes mark inclusive or exclusive ranges. By default, a range
166             includes dates that fall on the start or end. For example...
167              
168             $range->new( {parse => '2015-01-15 to 2015-01-31'} );
169             # returns true because the start is included
170             $range->includes( '2015-01-15' );
171             # retruns true because it is between the start and end
172             $range->includes( '2015-01-20' );
173             # retruns true because the end is included
174             $range->includes( '2015-01-31' );
175              
176             For exclusive ranges, set one or both of these values to B<false>.
177              
178             $range->new( {parse => '2015-01-15 to 2015-01-31'} );
179             $range->include_start( 0 );
180             # returns false because the start is excluded
181             $range->includes( '2015-01-15' );
182             # retruns true because it is between the start and end
183             $range->includes( '2015-01-20' );
184             # retruns true because the end is included
185             $range->includes( '2015-01-31' );
186              
187             =cut
188              
189             has 'include_start' => (
190             default => 1,
191             is => 'rw',
192             isa => 'Bool',
193             );
194              
195             has 'include_end' => (
196             default => 1,
197             is => 'rw',
198             isa => 'Bool',
199             );
200              
201              
202             =back
203              
204             =head3 parse
205              
206             This method takes a string, parses it, and configures the B<Date::Manip::Range>
207             object. C<parse> returns B<true> on success or B<false> for an error. Call
208             L</error> for a more specific error message.
209              
210             my $range = Date::Manip::Range->new();
211             $range->parse( 'June 2014 through May 2015' );
212              
213             =cut
214              
215             sub parse {
216             my ($self, $string) = @_;
217            
218             # Split the string into pieces around the operator.
219             my $prefix = '';
220              
221             if ($string =~ m/^\s*(between|from)\s(.*)$/i) {
222             $prefix = trim( $1 );
223             $string = trim( $2 );
224             }
225              
226             my ($first, $second, $operator) = ('', '', '');
227             if ($string =~ m/^(.*)\s(-|and|through|thru|to)\s(.*)$/i) {
228             $first = trim( $1 );
229             $operator = trim( $2 );
230             $second = trim( $3 );
231             } elsif ($string =~ m/^(.*)(\.\.\.)(.*)$/i) {
232             $first = trim( $1 );
233             $operator = trim( $2 );
234             $second = trim( $3 );
235             } elsif ($string =~ m/^(.*)(\.\.)(.*)$/i) {
236             $first = trim( $1 );
237             $operator = trim( $2 );
238             $second = trim( $3 );
239             }
240              
241             # Set the format so that the printed range looks like the original.
242             if ($prefix ne '' && $operator eq '') {
243             $self->_error( 'Missing range operator' );
244             return 0;
245             } elsif ($operator eq '') {
246             # Parse the implicit range using the first day as the start.
247             my $granularity;
248             ($first, $granularity) = $self->_normalize( $string );
249              
250             my $date1 = Date::Manip::Date->new;
251             if ($date1->parse( $first )) {
252             $self->_error( "$string is not a valid date" );
253             return 0;
254             }
255              
256             # Set the start and end dates to the implicit range.
257             $self->_start( $date1 );
258             $self->_end( $self->_add( $date1, $granularity, '-1 day' ) );
259             $self->_granularity( $granularity );
260              
261             # Set the output format correctly for implied ranges.
262             $self->format( "%s" );
263             $self->_date_format_for( $granularity );
264             } else {
265             # Parse the first date in the range.
266             my ($normal1, $range1) = $self->_normalize( $first );
267             my $date1 = Date::Manip::Date->new;
268             if ($date1->parse( $normal1 )) {
269             $self->_error( "$first is not a valid date" );
270             return 0;
271             }
272              
273             # Parse the second date in the range.
274             my ($normal2, $range2) = $self->_normalize( $second );
275             my $date2 = $date1->new;
276             if ($date2->parse( $normal2 )) {
277             $self->_error( "$second is an invalid date" );
278             return 0;
279             }
280             $date2 = $self->_add( $date2, $range2, '-1 day' );
281              
282             # Verify that the dates are in the correct order. Since I only accept
283             # a string as input, it makes no sense to allow reverse order. That
284             # would not read correctly in English.
285             if ($date1->cmp( $date2 ) > 0) {
286             $self->_error( 'Start date falls after the end date' );
287             return 0;
288             }
289              
290             # Now change the object, after we've checked everything.
291             $self->_start( $date1 );
292             $self->_end( $date2 );
293             $self->_granularity( '' );
294              
295             # Set the output format correctly for implied ranges.
296             if ($prefix eq '') {
297             $self->format( "%s $operator %s" );
298             } else {
299             $self->format( "$prefix %s $operator %s" );
300             }
301             $self->_date_format_for( $range1, $range2 );
302             }
303              
304             return 1;
305             }
306              
307              
308             =head3 adjust
309              
310             This method moves both the start and end dates by the same amount of time. It
311             allows you to shift an entire range.
312              
313             B<adjust> accepts a delta string suitable for L<Date::Manip::Delta>. In
314             addition, it you can use the following frequencies as the delta...
315              
316             =over
317              
318             =item * annual
319              
320             Add 1 year to both dates.
321              
322             =item * monthly
323              
324             Add 1 month to both dates.
325              
326             =item * weekly
327              
328             Add 1 week to both dates.
329              
330             =item * daily
331              
332             Add 1 day to both dates.
333              
334             =back
335              
336             B<adjust> returns a boolean flag indicating success. On failure, check L</error>
337             for a message.
338              
339             my $range = Date::Manip::Range( {parse => 'June 2014 to May 2015'} );
340             # Add 2 months to the start and end dates.
341             $range->adjust( '2 months' );
342             # Displays "August 2014 to July 2015" - a two month shift.
343             $range->printf();
344              
345             =cut
346              
347             sub adjust {
348             my ($self, $adjustment) = @_;
349            
350             if (!defined( $adjustment )) {
351             $self->_error( 'Delta string required' );
352             return 0;
353             }
354              
355             my $delta;
356             if ($adjustment eq 'annual') {
357             $delta = $self->start->new_delta( '1 year' );
358             } elsif ($adjustment eq 'monthly') {
359             $delta = $self->start->new_delta( '1 month' );
360             } elsif ($adjustment eq 'weekly') {
361             $delta = $self->start->new_delta( '1 week' );
362             } elsif ($adjustment eq 'daily') {
363             $delta = $self->start->new_delta( '1 day' );
364             } else {
365             $delta = $self->start->new_delta;
366             if ($delta->parse( $adjustment )) {
367             $self->_error( "$adjustment is an invalid delta" );
368             return 0;
369             }
370             }
371            
372             # Change the start and end dates by the same amount. Implicit ranges remain
373             # implicit. The code automatically adjusts the end date to the end of the
374             # period (year or month).
375             $self->_start( $self->start->calc( $delta ) );
376              
377             if ($self->is_implicit) {
378             $self->_end( $self->_add( $self->start, $self->granularity, '-1 day' ) );
379             } else { $self->_end( $self->end->calc( $delta ) ); }
380              
381             return 1;
382             }
383              
384              
385             =head3 printf
386              
387             This method returns the date range as a single string. The L</format> attribute
388             defines the resulting string. The method formates each date (start and end)
389             using the L</date_format> attribute. B<printf> then drops those formatted dates
390             into the string using L</format>.
391              
392             B<printf> accepts two optional parameters. The first parameter overrides the
393             L</date_format> attribute. The second parameter overrides the L</format>
394             attribute.
395              
396             my $range = Date::Manip::Range( {parse => 'June 2014 to May 2015'} );
397             # Displays "June 2014 to May 2015".
398             print $range->printf();
399             # Displays "06/2014 to 05/2015".
400             print $range->printf( '%m/%Y' );
401             # Displays "06/2014 - 05/2015".
402             print $range->printf( '%m/%Y', '%s - %s' );
403             # Displays "June 2014 through May 2015".
404             print $range->printf( undef, '%s through %s' );
405              
406             =cut
407              
408             sub printf {
409 17     17 1 51 my ($self, $date, $format) = @_;
410 17   66     244 $date //= $self->date_format;
411 17   66     344 $format //= $self->format;
412            
413 1     1   5 no warnings;
  1         3  
  1         699  
414 17         371 sprintf $format,
415             $self->start->printf( $date ),
416             $self->end->printf( $date )
417             ;
418             }
419              
420              
421             =item format
422              
423             This attributes formats the output of the L</printf> method. It follows the same
424             rules as L<sprintf>. The format can have up to two placeholders: one for the
425             start date and one for the end date.
426              
427             Behind the scenes, the code actually calls L<sprintf>. The start is passed as
428             the first argument and the end date as the second.
429              
430             L</parse> sets B<format> based on the appearance of the original input string.
431             You can change B<format> after calling L</parse>.
432              
433             # Default format is "%s to %s".
434             my $range = Date::Manip::Range( {parse => 'June 2014 to May 2015'} );
435             # Customize the format of "printf". It doesn't have to be a valid range.
436             $range->format( 'starting %s until ending %s' );
437             # Displays "starting June 2014 until ending May 2015".
438             $range->printf();
439              
440             =cut
441              
442             has 'format' => (
443             default => '%s to %s',
444             init_arg => undef,
445             is => 'rw',
446             isa => 'Str',
447             );
448              
449              
450             =item date_format
451              
452             This attribute formats the dates when you call the L</printf> method. It uses
453             the directives defined in
454             L<Date::Manip::Date|Date::Manip::Date/PRINTF-DIRECTIVES>. Both the start and
455             end dates use the same format.
456              
457             L</parse> sets B<date_format> based on the appearance of the original input
458             string. You can change B<date_format> after calling L</parse>.
459              
460             # Default format is "%B %Y".
461             my $range = Date::Manip::Range( {parse => 'June 2014 to May 2015'} );
462             # Customize the dates for "printf".
463             $range->date_format( '%m/%Y' );
464             # Displays "06/2014 to 05/2015".
465             $range->printf();
466              
467             =cut
468              
469             has 'date_format' => (
470             default => '%B %Y',
471             init_arg => undef,
472             is => 'rw',
473             isa => 'Str',
474             );
475              
476              
477             =head3 includes
478              
479             This method tells you if a given date falls within the range. A B<true> value
480             means that the date is inside of the range. B<false> says that the date falls
481             outside of the range.
482              
483             The date can be a string or L<Date::Manip> object. Strings accept any valid
484             input for L<Date::Manip::Date>. If the date is invalid, C<includes> sets the
485             L</error> attribute and returns B<false>.
486              
487             Note that B<includes> does not tell you if the date comes before or after the
488             range. That didn't seem relevant.
489              
490             =cut
491              
492             sub includes {
493             my ($self, $check) = @_;
494              
495             # Parse the date parameter.
496             my $date;
497             if (ref( $check ) eq '') {
498             $date = Date::Manip::Date->new;
499             if ($date->parse( $check )) {
500             $self->_error( "$check is not a valid date" );
501             return 0;
502             }
503             } elsif (ref( $check ) eq 'Date::Manip::Date') {
504             $date = $check;
505             } else {
506             $self->_error( "$check is not a valid date" );
507             return 0;
508             }
509              
510             # Compare the date with the start/end points.
511             my $after_start = 0;
512             if ($self->include_start) {
513             $after_start = 1 if $date->cmp( $self->start ) >= 0;
514             } else {
515             $after_start = 1 if $date->cmp( $self->start ) > 0;
516             }
517              
518             my $before_end = 0;
519             if ($self->include_end) {
520             $before_end = 1 if $date->cmp( $self->end ) <= 0;
521             } else {
522             $before_end = 1 if $date->cmp( $self->end ) < 0;
523             }
524            
525             # Return the result.
526             return ($after_start && $before_end ? 1 : 0);
527             }
528              
529              
530             =head3 is_valid
531              
532             This method tells you if the object holds a valid date range. Use this after
533             calling the L</new> or L</parse> methods. If anything failed (invalid dates),
534             C<is_valid> returns B<false>.
535              
536             if (!$range->is_valid()) {
537             print $range->error;
538             }
539              
540             =cut
541              
542             sub is_valid {
543 35     35 1 1042 my ($self) = @_;
544              
545 35 100       826 return 0 if !defined( $self->start );
546 34 50       771 return 0 if !defined( $self->end );
547 34 100       734 return 0 if $self->error ne '';
548 26         136 return 1;
549             }
550              
551              
552             =head3 error
553              
554             Returns the last error message. This attribute can be set by the L</new>,
555             L</parse>, L</adjust>, or L</includes> methods. An empty string indicates no
556             problem. You should check this value after calling one of those methods.
557              
558             The object automatically clears the error message with each call to L</parse>,
559             L</includes>, or </adjust>. That way previous errors do not make the changed
560             object invalid.
561              
562             =cut
563              
564             has 'error' => (
565             default => '',
566             init_arg => undef,
567             isa => 'Str',
568             reader => 'error',
569             writer => '_error',
570             );
571              
572             before qr/(adjust|parse|includes)/ => sub {
573             my $self = shift;
574             $self->_error( '' );
575             };
576              
577              
578             =head3 start / end
579              
580             The L<Date::Manip::Date> objects representing the end points of the range. Note
581             that you cannot set B<start> or B<end>. Use the L</parse> or L</adjust> methods
582             instead.
583              
584             =cut
585              
586             has 'start' => (
587             init_arg => undef,
588             isa => 'Date::Manip::Date',
589             reader => 'start',
590             writer => '_start',
591             );
592              
593             has 'end' => (
594             init_arg => undef,
595             isa => 'Date::Manip::Date',
596             reader => 'end',
597             writer => '_end',
598             );
599              
600              
601             =head3 is_implicit
602              
603             This method signals if the object holds an implicit range. Implicit ranges
604             occur when passing a single date value into L</new> or L</parse>.
605             B<is_implicit> returns B<true> if the range is implicit.
606              
607             =cut
608              
609             sub is_implicit {
610 10     10 1 19 my ($self) = @_;
611            
612 10 100       263 return 1 if hascontent( $self->granularity );
613 8         53 return 0;
614             }
615              
616              
617             =head3 granularity
618              
619             B<granularity> defines the amount of time covered by an implicit range. It
620             has a value like C<1 year> or C<1 month> or C<1 day>. B<granularity> is a
621             read-only attribute. It is set by the L</new> and L</parse> methods.
622              
623             =cut
624              
625             has 'granularity' => (
626             default => '',
627             init_arg => undef,
628             isa => 'Str',
629             reader => 'granularity',
630             writer => '_granularity',
631             );
632              
633              
634             #-------------------------------------------------------------------------------
635             # Internal methods and attributes...
636              
637             # This method adds a delta to a date. It accepts a list of deltas as strings.
638             # The code applies each delta in turn. It returns the final
639             # L<Date::Manip::Date> object.
640             #
641             # Pass the starting L<Date::Manip::Date> object followed by the delta strings.
642             #
643             # I was constantly creating L<Date::Manip::Delta> objects for one-off
644             # calculations. This saved me a lot of copy-and-pasting.
645              
646             sub _add {
647 33     33   48 my $self = shift;
648 33         35 my $date = shift;
649            
650 33         47 foreach my $string (@_) {
651 66         9145 my $delta = $date->new_delta( $string );
652 66         20341 $date = $date->calc( $delta );
653             }
654 33         7861 return $date;
655             }
656              
657              
658             # This method formats the date based on the input format. It chooses the
659             # shortest implied range, copying the lowest level of detail from the input
660             # string.
661              
662             sub _date_format_for {
663 32     32   39 my $self = shift;
664            
665 32         97 my $shortest = (sort @_)[0];
666              
667 32 100       126 if ($shortest =~ m/year/i) { $self->date_format( '%Y' ); }
  2 100       53  
668 9         236 elsif ($shortest =~ m/month/i) { $self->date_format( '%B %Y' ); }
669 21         501 else { $self->date_format( '%B %d, %Y' ); }
670             }
671              
672              
673             # This method normalizes a date string. It does the actual parsing of implied
674             # ranges. This code allows me to use strings like C<January to March> as a
675             # valid date range.
676             #
677             # The method returns a list with two elements. The first element is a date
678             # string suitable for L<Date::Manip::Date>. The second is the L</granularity>
679             # of the implicit range.
680              
681             sub _normalize {
682 63     63   79 my ($self, $string) = @_;
683 63         111 my ($date, $granularity) = ('', '');
684            
685 63         195 my @pieces = split( /[-\s\/,]+/, trim( $string ) );
686 63 50       845 if (scalar( @pieces ) == 0) {
    100          
    100          
687 0         0 $self->_error( 'The dates are missing' );
688             } elsif (scalar( @pieces ) == 1) {
689 8 100       20 if ($string =~ m/^\d{4}$/) {
690 7         11 $granularity = '1 year';
691 7         13 $date = "$string-January-01";
692             } else {
693 1         2 $granularity = '1 month';
694 1         2 $date = "$string-01";
695             }
696             } elsif (scalar( @pieces ) == 2) {
697 12 50       50 if ($pieces[0] =~ m/^\d{4}$/) {
    50          
698 0         0 $granularity = '1 month';
699 0         0 $date = join '-', $pieces[0], $pieces[1], '01';
700             } elsif ($pieces[1] =~ m/^\d{4}$/) {
701 12         16 $granularity = '1 month';
702 12         29 $date = join '-', $pieces[1], $pieces[0], '01';
703             } else {
704 0         0 $granularity = '1 day';
705 0         0 $date = $string;
706             }
707             } else {
708 43         48 $granularity = '1 day';
709 43         53 $date = $string;
710             }
711              
712 63         134 return ($date, $granularity);
713             }
714              
715             #-------------------------------------------------------------------------------
716              
717              
718             =head1 BUGS/CAVEATS/etc
719              
720             B<Date::Manip::Range> only supports English range operators. Translations
721             welcome.
722              
723             =head1 SEE ALSO
724              
725             L<Date::Manip>
726              
727             =head1 REPOSITORY
728              
729             L<https://github.com/rbwohlfarth/Date-Manip-Range>
730              
731             =head1 AUTHOR
732              
733             Robert Wohlfarth <rbwohlfarth@gmail.com>
734              
735             =head1 COPYRIGHT AND LICENSE
736              
737             Copyright (c) 2016 Robert Wohlfarth
738              
739             This module is free software; you can redistribute it and/or modify it
740             under the same terms as Perl 5.10.0. For more details, see the full text
741             of the licenses in the directory LICENSES.
742              
743             This program is distributed in the hope that it will be useful, but
744             without any warranty; without even the implied
745              
746             =cut
747              
748 1     1   5 no Moose;
  1         1  
  1         6  
749             __PACKAGE__->meta->make_immutable;