File Coverage

blib/lib/Unit/Duration.pm
Criterion Covered Total %
statement 167 175 95.4
branch 54 68 79.4
condition 37 49 75.5
subroutine 19 20 95.0
pod 6 6 100.0
total 283 318 88.9


line stmt bran cond sub pod time code
1             package Unit::Duration;
2             # ABSTRACT: Work-time unit duration conversion and canonicalization
3              
4 2     2   410643 use 5.010;
  2         6  
5 2     2   10 use strict;
  2         6  
  2         36  
6 2     2   5 use warnings;
  2         3  
  2         101  
7 2     2   7 use Carp 'croak';
  2         5  
  2         4379  
8              
9             our $VERSION = '1.05'; # VERSION
10              
11             my $duration_element_re = qr/(?[-+*\/\d]+)\s*(?[A-z]+)\s*/;
12              
13             sub new {
14 2     2 1 244789 my ( $self, %params ) = @_;
15 2         11 my $params = {%params};
16              
17 2         7 my $name = delete $params->{name};
18 2         6 my $table = delete $params->{table};
19              
20 2 50 66     26 croak('must provide both "name" and "table" or neither to new()')
      66        
      33        
21             if ( $name and not $table or $table and not $name );
22              
23 2   100     12 $params->{intra_space} //= ' ';
24 2   100     16 $params->{extra_space} //= ', ';
25 2   100     9 $params->{pluralize} //= 1;
26 2   100     10 $params->{unit_type} //= 'short';
27 2   100     10 $params->{compress} //= 0;
28              
29 2         6 $self = bless( $params, $self );
30              
31 2         10 $self->set_table( default => q{
32             y | yr | year = 4 qtrs
33             q | qtr | quarter = 3 mons
34             o | mon | month = 4 wks
35             w | wk | week = 5 days
36             d | day = 8 hrs
37             h | hr | hour = 60 mins
38             m | min | minute = 60 secs
39             s | sec | second
40             } );
41              
42 2 100 66     17 $self->set_table( $name, $table ) if ( $name and $table );
43              
44 2         14 return $self;
45             }
46              
47             sub set_table {
48 8     8 1 10988 my ( $self, $name, $table ) = @_;
49 8 100       622 croak('no name provided to set_table()') unless ($name);
50 7         31 $self->_parse_table( $name, $table );
51 6         18 return $self;
52             }
53              
54             sub get_table_string {
55 5     5 1 2538 my ( $self, $name ) = @_;
56 5 50       17 croak('no name provided to get_table_string()') unless ($name);
57 5         36 return $self->{_tables}{$name}{string};
58             }
59              
60             sub get_table_structure {
61 4     4 1 23710 my ( $self, $name ) = @_;
62 4 50       16 croak('no name provided to get_table_structure()') unless ($name);
63 4         46 return $self->{_tables}{$name}{structure};
64             }
65              
66             sub canonicalize {
67 11     11 1 2917 my ( $self, $duration, $settings, $table ) = @_;
68              
69 11   66     58 $settings->{compress} //= $self->{compress};
70              
71 11         31 my $units = $self->_get_units_for_table($table);
72 11         36 my $duration_elements = $self->_merge_duration_elements( $self->_parse_duration( $duration, $units ) );
73              
74 11 100 66     84 if ( $settings->{compress} and not $settings->{_as} ) {
    100          
75 7         23 $duration_elements = $self->_compress_duration_elements( $duration_elements, $units );
76             }
77             elsif ( $settings->{_as} ) {
78 3         11 return $self->_total_duration_as( $duration_elements, $units, $settings->{_as} );
79             }
80              
81 8         20 return $self->_render_duration( $duration_elements, $settings );
82             }
83              
84             sub sum_as {
85 3     3 1 964 my ( $self, $unit_name, $duration, $table ) = @_;
86 3         15 return $self->canonicalize( $duration, { _as => $unit_name }, $table );
87             }
88              
89             sub _parse_table {
90 7     7   20 my ( $self, $name, $table ) = @_;
91 7 100       175 croak('no table data provided to set_table()') unless ($table);
92              
93 6 100       22 my $units = ( ref $table ) ? [ map { {%$_} } @$table ] : do {
  16         63  
94 4         14 $table =~ s/#.*//g;
95 4         276 $table =~ s/(?:^\s+|\s+$)//g;
96 4         51 $table =~ s/\v+/\n/g;
97 4         139 $table =~ s/\h+//g;
98 4         78 $table =~ s/[^\-\+\*\/\dA-z\n,;]+/\|/g;
99              
100             [ map {
101 4         24 my @parts = split(/\|/);
  32         93  
102 32         47 my $unit;
103              
104 32         53 my @elements = grep { /$duration_element_re/ } @parts;
  120         477  
105 32 50       80 croak(qq{>1 duration element on line of duration table: "$_"}) if ( @elements > 1 );
106              
107 32 100       100 $unit->{duration} = pop @parts if (@elements);
108 32         64 $unit->{letter} = shift @parts;
109 32         72 $unit->{short} = shift @parts;
110 32   66     85 $unit->{long} = shift @parts // $unit->{short};
111              
112 32         80 $unit;
113             } split( /\n/, $table ) ];
114             };
115              
116             croak('not exactly 1 unit in duration table with no duration')
117 6 50       20 if ( scalar( grep { not $_->{duration} } @$units ) != 1 );
  48         105  
118              
119 6         18 for my $unit (@$units) {
120 48   66     146 $unit->{long} //= $unit->{short};
121 48         143 my $match = '(' . join( '', map { $_ . '?' } split( '', $unit->{long} ) ) . ')';
  234         572  
122 48         2222 $unit->{match} = qr/$match/i;
123             }
124              
125             $_->{duration} = $self->_parse_duration( $_->{duration}, $units )
126 6         18 for ( grep { $_->{duration} } @$units );
  48         116  
127              
128 6         13 eval {
129 6     0   54 local $SIG{__WARN__} = sub { die @_ };
  0         0  
130              
131 6         14 my $flatten;
132             $flatten = sub {
133 48     48   96 for my $unit (@_) {
134 48 100       66 $flatten->(@_) if ( @_ = map { $_->{unit} } @{ $unit->{duration} || [] } );
  42 100       371  
  48         149  
135 48 50       117 unless ( $unit->{amount} ) {
136 48         65 my %amount;
137             $amount{ $_->{unit}{long} } += $_->{int} * ( $_->{unit}{amount} // 1 )
138 48 100 50     68 for ( @{ $unit->{duration} || [] } );
  48         230  
139 48         125 my ($amount) = map { $amount{$_} } keys %amount;
  42         86  
140 48 100       170 $unit->{amount} += $amount if ($amount);
141 48   100     200 $unit->{amount} //= 1;
142             }
143             }
144 6         31 };
145 6         19 $flatten->(@$units);
146             };
147 6 50       19 if ($@) {
148 0         0 croak('unable to properly interpret duration table');
149             }
150              
151 6         27 $units = [ sort { $b->{amount} <=> $a->{amount} } @$units ];
  72         141  
152              
153             my $structure = [
154             map {
155 6         17 my $unit = {
156             letter => $_->{letter},
157             short => $_->{short},
158             long => $_->{long},
159 48         293 };
160              
161 48 100       138 delete $unit->{long} if ( $unit->{long} eq $unit->{short} );
162              
163             $unit->{duration} = join(
164 42         163 ' ', map { $_->{int} . ' ' . $_->{unit}{short} } @{ $_->{duration} }
  42         93  
165 48 100       109 ) if ( $_->{duration} );
166              
167 48         98 $unit;
168             } @$units
169             ];
170              
171             my $string = join( "\n", map {
172 6         19 my $unit = $_;
  48         74  
173 48         76 my $line = join( ' | ', grep { defined } map { $unit->{$_} } qw( letter short long ) );
  144         292  
  144         301  
174 48 100       135 $line .= ' = ' . $unit->{duration} if ( exists $unit->{duration} );
175 48         112 $line;
176             } @$structure );
177              
178 6 50       97 $self->{_tables}{$name} = {
179             structure => $structure,
180             string => $string,
181             units => $units,
182             } if ($name);
183              
184 6         20 return $units;
185             }
186              
187             sub _parse_duration {
188 53     53   132 my ( $self, $duration, $units ) = @_;
189              
190 53   50     131 $duration //= '';
191 53         132 $duration =~ s/(\d+)\s*:\s*(\d+)(?:\s*:\s*(\d+))?/
192 0 0       0 $1 . 'h' . $2 . 'm' . ( ($3) ? $3 . 's' : '' )
193             /ge;
194              
195 53         990 $duration =~ s/[^\-\+\*\/\dA-z]+//g;
196 53 50       916 croak('unable to parse duration string') unless ( $duration =~ /^\s*(?:$duration_element_re)+$/ );
197              
198 53         109 my @elements;
199 53         360 while ( $duration =~ /$duration_element_re/g ) {
200 80         161 my $element = { map { $_ => $+{$_} } qw( expr unit ) };
  160         1189  
201              
202 80         5531 $element->{int} = eval delete $element->{expr};
203 80         444 $element->{unit} = $self->_match_unit_type( $element->{unit}, $units );
204              
205 80         554 push( @elements, $element );
206             }
207              
208 53         319 return \@elements;
209             }
210              
211             sub _match_unit_type {
212 83     83   217 my ( $self, $unit_name, $units ) = @_;
213              
214 83 50       216 unless ($unit_name) {
215 0         0 my ($unit) = grep { not $_->{duration} } @$units;
  0         0  
216 0         0 return $unit;
217             }
218              
219 83         360 $unit_name =~ s/s+$//i;
220 664         1129 my ($matched_unit) = map { $_->[0] } sort { $b->[1] <=> $a->[1] } map {
  1181         2117  
221 83         191 [
222             $_,
223             (
224             $unit_name eq $_->{letter} or
225             $unit_name eq $_->{short} or
226             $unit_name eq $_->{long}
227 664 100 100     3341 ) ? 100 : do {
228 581         2564 $unit_name =~ $_->{match};
229 581         1782 length $1;
230             },
231             ];
232             } @$units;
233 83         393 return $matched_unit;
234             }
235              
236             sub _get_units_for_table {
237 11     11   29 my ( $self, $table ) = @_;
238              
239 11 100       40 if ( not defined $table ) {
    50          
240 4 50       13 if ( exists $self->{_tables}{default} ) {
241 4         14 return $self->{_tables}{default}{units};
242             }
243             else {
244 0         0 croak('failure due to default table not defined');
245             }
246             }
247             elsif ( exists $self->{_tables}{$table} ) {
248 7         26 return $self->{_tables}{$table}{units};
249             }
250             else {
251 0         0 return $self->_parse_table( undef, $table );
252             }
253             }
254              
255             sub _merge_duration_elements {
256 11     11   26 my ( $self, $elements ) = @_;
257              
258 11         20 my %elements;
259 11         25 for my $element (@$elements) {
260             $element->{int} += $elements{ $element->{unit}{long} }{int}
261 38 100       157 if ( exists $elements{ $element->{unit}{long} } );
262 38         93 $elements{ $element->{unit}{long} } = $element;
263             }
264              
265             return [
266 11         58 sort { $b->{unit}{amount} <=> $a->{unit}{amount} }
267 11         35 map { $elements{$_} }
  22         58  
268             keys %elements
269             ];
270             }
271              
272             sub _render_duration {
273 8     8   19 my ( $self, $duration_elements, $settings ) = @_;
274 8   66     70 $settings->{$_} //= $self->{$_} for ( qw( intra_space extra_space pluralize unit_type ) );
275              
276             return join(
277             $settings->{extra_space},
278             map {
279 8         20 $_->{int}
280             . $settings->{intra_space}
281             . $_->{unit}{ $settings->{unit_type} }
282 14 100 100     159 . ( ( $settings->{pluralize} and $_->{int} != 1 ) ? 's' : '' )
283             } @$duration_elements
284             );
285             }
286              
287             sub _compress_duration_elements {
288 7     7   18 my ( $self, $duration_elements, $units ) = @_;
289              
290 7         21 my $total_seconds = $self->_total_duration_as( $duration_elements, $units );
291              
292 7         12 my @compressed_elements;
293 7         15 for my $unit (@$units) {
294 34         76 my $count = int( $total_seconds / $unit->{amount} );
295 34 100       73 if ( $count >= 1 ) {
296 12         34 push(
297             @compressed_elements,
298             {
299             int => $count,
300             unit => $unit,
301             },
302             );
303 12         28 $total_seconds -= $count * $unit->{amount};
304             }
305 34 100       81 last unless ($total_seconds);
306             }
307              
308 7         28 return \@compressed_elements;
309             }
310              
311             sub _total_duration_as {
312 10     10   83 my ( $self, $duration_elements, $units, $unit_type ) = @_;
313              
314 10         15 my $total_seconds;
315 10         23 $total_seconds += $_ for ( map { $_->{int} * $_->{unit}{amount} } @$duration_elements );
  20         67  
316 10 100       32 return $total_seconds unless ($unit_type);
317              
318 3         10 my $unit = $self->_match_unit_type( $unit_type, $units );
319 3         32 return $total_seconds / $unit->{amount};
320             }
321              
322             1;
323              
324             __END__