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   394554 use 5.010;
  2         9  
5 2     2   13 use strict;
  2         10  
  2         61  
6 2     2   11 use warnings;
  2         3  
  2         162  
7 2     2   15 use Carp 'croak';
  2         4  
  2         7170  
8              
9             our $VERSION = '1.06'; # VERSION
10              
11             my $duration_element_re = qr/(?[-+*\/\d]+)\s*(?[A-z]+)\s*/;
12              
13             sub new {
14 2     2 1 179457 my ( $self, %params ) = @_;
15 2         11 my $params = {%params};
16              
17 2         8 my $name = delete $params->{name};
18 2         7 my $table = delete $params->{table};
19              
20 2 50 66     24 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     14 $params->{intra_space} //= ' ';
24 2   100     13 $params->{extra_space} //= ', ';
25 2   100     10 $params->{pluralize} //= 1;
26 2   100     12 $params->{unit_type} //= 'short';
27 2   100     10 $params->{compress} //= 0;
28              
29 2         5 $self = bless( $params, $self );
30              
31 2         9 $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     15 $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 9124 my ( $self, $name, $table ) = @_;
49 8 100       242 croak('no name provided to set_table()') unless ($name);
50 7         30 $self->_parse_table( $name, $table );
51 6         19 return $self;
52             }
53              
54             sub get_table_string {
55 5     5 1 1698 my ( $self, $name ) = @_;
56 5 50       17 croak('no name provided to get_table_string()') unless ($name);
57 5         34 return $self->{_tables}{$name}{string};
58             }
59              
60             sub get_table_structure {
61 4     4 1 22226 my ( $self, $name ) = @_;
62 4 50       14 croak('no name provided to get_table_structure()') unless ($name);
63 4         25 return $self->{_tables}{$name}{structure};
64             }
65              
66             sub canonicalize {
67 11     11 1 2133 my ( $self, $duration, $settings, $table ) = @_;
68              
69 11   66     86 $settings->{compress} //= $self->{compress};
70              
71 11         36 my $units = $self->_get_units_for_table($table);
72 11         37 my $duration_elements = $self->_merge_duration_elements( $self->_parse_duration( $duration, $units ) );
73              
74 11 100 66     81 if ( $settings->{compress} and not $settings->{_as} ) {
    100          
75 7         24 $duration_elements = $self->_compress_duration_elements( $duration_elements, $units );
76             }
77             elsif ( $settings->{_as} ) {
78 3         12 return $self->_total_duration_as( $duration_elements, $units, $settings->{_as} );
79             }
80              
81 8         23 return $self->_render_duration( $duration_elements, $settings );
82             }
83              
84             sub sum_as {
85 3     3 1 536 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       166 croak('no table data provided to set_table()') unless ($table);
92              
93 6 100       19 my $units = ( ref $table ) ? [ map { {%$_} } @$table ] : do {
  16         62  
94 4         15 $table =~ s/#.*//g;
95 4         3343 $table =~ s/(?:^\s+|\s+$)//g;
96 4         59 $table =~ s/\v+/\n/g;
97 4         171 $table =~ s/\h+//g;
98 4         84 $table =~ s/[^\-\+\*\/\dA-z\n,;]+/\|/g;
99              
100             [ map {
101 4         24 my @parts = split(/\|/);
  32         103  
102 32         48 my $unit;
103              
104 32         56 my @elements = grep { /$duration_element_re/ } @parts;
  120         437  
105 32 50       111 croak(qq{>1 duration element on line of duration table: "$_"}) if ( @elements > 1 );
106              
107 32 100       93 $unit->{duration} = pop @parts if (@elements);
108 32         66 $unit->{letter} = shift @parts;
109 32         70 $unit->{short} = shift @parts;
110 32   66     88 $unit->{long} = shift @parts // $unit->{short};
111              
112 32         84 $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         101  
118              
119 6         16 for my $unit (@$units) {
120 48   66     174 $unit->{long} //= $unit->{short};
121 48         146 my $match = '(' . join( '', map { $_ . '?' } split( '', $unit->{long} ) ) . ')';
  234         572  
122 48         1373 $unit->{match} = qr/$match/i;
123             }
124              
125             $_->{duration} = $self->_parse_duration( $_->{duration}, $units )
126 6         18 for ( grep { $_->{duration} } @$units );
  48         121  
127              
128 6         13 eval {
129 6     0   54 local $SIG{__WARN__} = sub { die @_ };
  0         0  
130              
131 6         12 my $flatten;
132             $flatten = sub {
133 48     48   89 for my $unit (@_) {
134 48 100       69 $flatten->(@_) if ( @_ = map { $_->{unit} } @{ $unit->{duration} || [] } );
  42 100       334  
  48         143  
135 48 50       118 unless ( $unit->{amount} ) {
136 48         68 my %amount;
137             $amount{ $_->{unit}{long} } += $_->{int} * ( $_->{unit}{amount} // 1 )
138 48 100 50     69 for ( @{ $unit->{duration} || [] } );
  48         295  
139 48         134 my ($amount) = map { $amount{$_} } keys %amount;
  42         87  
140 48 100       163 $unit->{amount} += $amount if ($amount);
141 48   100     209 $unit->{amount} //= 1;
142             }
143             }
144 6         29 };
145 6         50 $flatten->(@$units);
146             };
147 6 50       19 if ($@) {
148 0         0 croak('unable to properly interpret duration table');
149             }
150              
151 6         28 $units = [ sort { $b->{amount} <=> $a->{amount} } @$units ];
  72         157  
152              
153             my $structure = [
154             map {
155 6         19 my $unit = {
156             letter => $_->{letter},
157             short => $_->{short},
158             long => $_->{long},
159 48         231 };
160              
161 48 100       122 delete $unit->{long} if ( $unit->{long} eq $unit->{short} );
162              
163             $unit->{duration} = join(
164 42         187 ' ', map { $_->{int} . ' ' . $_->{unit}{short} } @{ $_->{duration} }
  42         86  
165 48 100       121 ) if ( $_->{duration} );
166              
167 48         94 $unit;
168             } @$units
169             ];
170              
171             my $string = join( "\n", map {
172 6         17 my $unit = $_;
  48         73  
173 48         73 my $line = join( ' | ', grep { defined } map { $unit->{$_} } qw( letter short long ) );
  144         287  
  144         297  
174 48 100       150 $line .= ' = ' . $unit->{duration} if ( exists $unit->{duration} );
175 48         132 $line;
176             } @$structure );
177              
178 6 50       69 $self->{_tables}{$name} = {
179             structure => $structure,
180             string => $string,
181             units => $units,
182             } if ($name);
183              
184 6         15 return $units;
185             }
186              
187             sub _parse_duration {
188 53     53   136 my ( $self, $duration, $units ) = @_;
189              
190 53   50     136 $duration //= '';
191 53         138 $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         236 $duration =~ s/[^\-\+\*\/\dA-z]+//g;
196 53 50       731 croak('unable to parse duration string') unless ( $duration =~ /^\s*(?:$duration_element_re)+$/ );
197              
198 53         141 my @elements;
199 53         397 while ( $duration =~ /$duration_element_re/g ) {
200 80         163 my $element = { map { $_ => $+{$_} } qw( expr unit ) };
  160         1171  
201              
202 80         5627 $element->{int} = eval delete $element->{expr};
203 80         502 $element->{unit} = $self->_match_unit_type( $element->{unit}, $units );
204              
205 80         624 push( @elements, $element );
206             }
207              
208 53         299 return \@elements;
209             }
210              
211             sub _match_unit_type {
212 83     83   226 my ( $self, $unit_name, $units ) = @_;
213              
214 83 50       285 unless ($unit_name) {
215 0         0 my ($unit) = grep { not $_->{duration} } @$units;
  0         0  
216 0         0 return $unit;
217             }
218              
219 83         401 $unit_name =~ s/s+$//i;
220 664         1163 my ($matched_unit) = map { $_->[0] } sort { $b->[1] <=> $a->[1] } map {
  1181         2026  
221 83         206 [
222             $_,
223             (
224             $unit_name eq $_->{letter} or
225             $unit_name eq $_->{short} or
226             $unit_name eq $_->{long}
227 664 100 100     3437 ) ? 100 : do {
228 581         2584 $unit_name =~ $_->{match};
229 581         1664 length $1;
230             },
231             ];
232             } @$units;
233 83         426 return $matched_unit;
234             }
235              
236             sub _get_units_for_table {
237 11     11   23 my ( $self, $table ) = @_;
238              
239 11 100       41 if ( not defined $table ) {
    50          
240 4 50       14 if ( exists $self->{_tables}{default} ) {
241 4         13 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         23 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   24 my ( $self, $elements ) = @_;
257              
258 11         20 my %elements;
259 11         27 for my $element (@$elements) {
260             $element->{int} += $elements{ $element->{unit}{long} }{int}
261 38 100       115 if ( exists $elements{ $element->{unit}{long} } );
262 38         87 $elements{ $element->{unit}{long} } = $element;
263             }
264              
265             return [
266 11         54 sort { $b->{unit}{amount} <=> $a->{unit}{amount} }
267 11         36 map { $elements{$_} }
  22         73  
268             keys %elements
269             ];
270             }
271              
272             sub _render_duration {
273 8     8   16 my ( $self, $duration_elements, $settings ) = @_;
274 8   66     75 $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   15 my ( $self, $duration_elements, $units ) = @_;
289              
290 7         17 my $total_seconds = $self->_total_duration_as( $duration_elements, $units );
291              
292 7         11 my @compressed_elements;
293 7         14 for my $unit (@$units) {
294 34         74 my $count = int( $total_seconds / $unit->{amount} );
295 34 100       71 if ( $count >= 1 ) {
296 12         40 push(
297             @compressed_elements,
298             {
299             int => $count,
300             unit => $unit,
301             },
302             );
303 12         25 $total_seconds -= $count * $unit->{amount};
304             }
305 34 100       100 last unless ($total_seconds);
306             }
307              
308 7         34 return \@compressed_elements;
309             }
310              
311             sub _total_duration_as {
312 10     10   41 my ( $self, $duration_elements, $units, $unit_type ) = @_;
313              
314 10         16 my $total_seconds;
315 10         20 $total_seconds += $_ for ( map { $_->{int} * $_->{unit}{amount} } @$duration_elements );
  20         66  
316 10 100       35 return $total_seconds unless ($unit_type);
317              
318 3         8 my $unit = $self->_match_unit_type( $unit_type, $units );
319 3         55 return $total_seconds / $unit->{amount};
320             }
321              
322             1;
323              
324             __END__