File Coverage

blib/lib/Unit/Duration.pm
Criterion Covered Total %
statement 170 178 95.5
branch 54 68 79.4
condition 37 49 75.5
subroutine 20 21 95.2
pod 6 6 100.0
total 287 322 89.1


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