File Coverage

blib/lib/Unit/Duration.pm
Criterion Covered Total %
statement 169 177 95.4
branch 54 68 79.4
condition 36 47 76.6
subroutine 20 21 95.2
pod 6 6 100.0
total 285 319 89.3


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