File Coverage

script/tt
Criterion Covered Total %
statement 349 472 73.9
branch 127 248 51.2
condition 97 224 43.3
subroutine 51 58 87.9
pod n/a
total 624 1002 62.2


line stmt bran cond sub pod time code
1             #!/usr/bin/env perl
2             BEGIN {
3 2 50 33 2   1831 if (@ARGV and @ARGV[0] =~ /^\w/) {
4 0 0       0 @ARGV = grep { (/^-{1,2}h\w{0,3}$/ ? ($ENV{APP_TT_HELP} = $ARGV[0], 0) : (1, 1))[1] } @ARGV;
  0         0  
5             }
6             }
7              
8             # Inspired by Mojo::File
9             package App::tt::file;
10 2     2   12 use strict;
  2         4  
  2         33  
11 2     2   7 use warnings;
  2         3  
  2         35  
12 2     2   7 use Cwd ();
  2         4  
  2         30  
13 2     2   10 use File::Basename ();
  2         2  
  2         39  
14 2     2   8 use File::Find qw(find);
  2         4  
  2         99  
15 2     2   9 use File::Path ();
  2         4  
  2         51  
16 2     2   11 use File::Spec::Functions qw(canonpath catfile splitdir);
  2         2  
  2         107  
17 2     2   1230 use JSON::PP ();
  2         24280  
  2         111  
18 2     2   12 use overload '@{}' => sub { [splitdir ${$_[0]}] }, '""' => sub { ${$_[0]} }, fallback => 1;
  2     11   4  
  2         11  
  0         0  
  0         0  
  83         839  
  83         760  
19              
20             sub new {
21 44     44   59 my $class = shift;
22 44 50       177 my $value = @_ == 1 ? $_[0] : @_ > 1 ? catfile @_ : canonpath(Cwd::getcwd());
    100          
23 44   66     266 return bless \$value, ref $class || $class;
24             }
25              
26 6     6   6 sub basename { File::Basename::basename ${shift()}, @_ }
  6         214  
27 24     24   90 sub child { $_[0]->new(${shift()}, @_) }
  24         51  
28 5     5   9 sub dirname { $_[0]->new(scalar File::Basename::dirname ${$_[0]}) }
  5         167  
29 1     1   2 sub make_path { File::Path::make_path(${$_[0]}, @_); shift }
  1         249  
  1         3  
30              
31             sub list_tree {
32 9     9   19 my ($self, $cb) = (shift, shift);
33 9         11 my %all;
34 9 100   13   25 my $wanted = sub { $all{$File::Find::name}++ unless -d $File::Find::name };
  13         719  
35 9 100       552 find {wanted => $wanted, no_chdir => 1}, $$self if -d $$self;
36 9         32 delete $all{$$self};
37 9 50       43 return map { my $f = $self->new(canonpath $_); $cb ? $cb->($f) : $f } sort keys %all;
  6         23  
  6         22  
38             }
39              
40             # ($date, $hms, $project)
41 6 50   6   13 sub parse { $_[0]->basename =~ m!^(\d+)-(\d+)_(.*)\.trc$! ? ($_[-2], $_[-1], $1, $2, $3) : () }
42              
43             sub slurp {
44 15     15   23 my $self = shift;
45 15 50       413 die qq{Can't open file "$self": $!} unless open my $file, '<', $$self;
46 15         36 my $content = '';
47 15         62 while ($file->sysread(my $buffer, 131072, 0)) { $content .= $buffer }
  15         229  
48 15         165 $content =~ s!\s+$!!s; # Remove newlines and extra space
49 15         181 return $content;
50             }
51              
52             sub spurt {
53 6     6   1678 my ($self, $content) = @_;
54 6 50       333 die qq{Can't open file "$self": $!} unless open my $file, '>', $$self;
55 6 50       35 die qq{Can't write to file "$self": $!} unless defined $file->syswrite($content);
56 6         310 return $self;
57             }
58              
59             package main;
60 2     2   2161 use Applify;
  2         9365  
  2         7  
61 2     2   1378 use File::Temp ();
  2         15099  
  2         41  
62 2     2   9 use List::Util qw(first uniq);
  2         4  
  2         150  
63 2     2   34 use Scalar::Util qw(blessed);
  2         4  
  2         71  
64 2     2   2272 use Time::Piece;
  2         16861  
  2         7  
65 2     2   121 use Time::Seconds;
  2         3  
  2         114  
66              
67 2   50 2   9 use constant DEBUG => $ENV{APP_TT_DEBUG} || 0;
  2         3  
  2         11297  
68              
69             option str => project => 'Project name. Normally autodetected', alias => 'p';
70             option str => tag => 'Tags for an event', alias => 't', n_of => '@';
71             option str => description => 'Description for an event', alias => 'd';
72             option str => group_by => 'Group log output: --group-by day', alias => 'g';
73             option str => month => 'Mass edit a month';
74             option str => year => 'Mass edit a year';
75              
76             documentation 'App::tt';
77             version 'App::tt';
78              
79             our $PTY = $ENV{TT_PTY} || -t STDOUT;
80             our $NOW = localtime;
81              
82             $SIG{__DIE__} = sub { Carp::confess($_[0]) }
83             if DEBUG;
84              
85             # Attributes
86             sub home {
87             shift->{home}
88 30   66 30   12905 //= App::tt::file->new($ENV{TT_HOME} || $ENV{TIMETRACKER_HOME} || ($ENV{HOME}, '.TimeTracker'));
      66        
89             }
90              
91             # Commands
92             sub command_edit {
93 0     0   0 my $self = shift;
94 0 0 0     0 return $self->_edit_with_editor($_[0]) if @_ and -f $_[0]; # Edit a given file
95 0 0 0     0 return $self->_mass_edit(@_) if $self->year or $self->month or !-t STDIN;
      0        
96 0         0 return $self->_edit_with_editor;
97             }
98              
99             sub command_export {
100 2     2   9204 my $self = shift;
101 2         8 my $res = $self->_log(@_);
102              
103 2         6 my @cols = split /,/, $self->config('export_columns');
104 2         6 my $format = join ',', map {'"%s"'} @cols;
  12         16  
105              
106 2         6 $res->{rounded} = 0;
107 2         7 $self->_print($format, @cols);
108              
109 2         4 for my $event (sort { $a->{start} <=> $b->{start} } @{$res->{log}}) {
  1         4  
  2         8  
110 2         23 $event->{date} = $event->{start};
111 2         7 $event->{hours} = int($event->{seconds} / 3600);
112 2         4 $event->{seconds} -= $event->{hours} * 3600;
113 2         4 $event->{minutes} = int($event->{seconds} / 60);
114             $event->{rounded}
115 2 100       6 = $event->{hours} + ($event->{minutes} >= $self->config('round_up_at') ? 1 : 0);
116 2         17 $event->{hours} += sprintf '%.1f', $event->{minutes} / 60;
117 2         3 $res->{rounded} += $event->{rounded};
118              
119             $self->_print(
120             $format,
121             map {
122 2   50     4 my $val = $event->{$_} // '';
  12         22  
123 12 100 66     58 $val = $val->ymd if blessed $val and $val->can('ymd');
124 12 100       42 $val = join ',', @$val if ref $val eq 'ARRAY';
125 12         20 $val =~ s!"!""!g;
126 12         19 $val;
127             } @cols
128             );
129             }
130              
131             $self->_print(
132             '2> Exact hours: %s. Rounded hours: %s. Events: %s.',
133             $self->_duration($res->{seconds}, 'hm'),
134 2         7 @$res{qw(rounded events)},
135             );
136              
137 2         16 return 0;
138             }
139              
140             sub command_help {
141 1     1   2 my $self = shift;
142 1   50     3 my $for = shift || 'app';
143 1 50       3 return $self->_script->print_help, 0 if $for eq 'app';
144              
145 1         5 my ($today, @help) = ($NOW->ymd);
146 1         514 require App::tt;
147 1 50       36 open my $POD, '<', $INC{'App/tt.pm'} or die "Cannot open App/tt.pm: $!";
148 1         14 while (<$POD>) {
149 198         227 s/\b2020-01-01(T\d+:)/$today$1/g; # make "register" command easier to copy/paste
150 198 100       474 push @help, $_ if /^=head2 $for/ ... /^=(head|cut)/;
151             }
152              
153 1         4 shift @help;
154 1         2 pop @help; # remove =head and =cut lines
155 1 50       3 die "Could not find help for $for.\n" unless @help;
156 1         7 $self->_print("@help");
157 1         13 return 0;
158             }
159              
160             sub command_log {
161 2     2   3945 my $self = shift;
162 2         8 my $res = $self->_log(@_);
163              
164 2         10 my @table = (['Month', 'Date', 'Start', 'Duration', 'Project', 'Tags'], '-');
165 2         4 for my $event (sort { $a->{start} <=> $b->{start} } @{$res->{log}}) {
  1         5  
  2         9  
166 2         25 my $start = $event->{start};
167             push @table,
168             [
169             $start->month,
170             sprintf('%2s', $start->mday),
171             sprintf('%2s:%02s', $start->hour, $start->minute),
172             $self->_duration($event->{seconds}, 'hm'),
173             $event->{project} || '---',
174 2   50     6 join(',', @{$event->{tags}}),
  2         8  
175             ];
176             }
177              
178             $res->{interval} eq 'month'
179 4         35 ? $self->_print('1> Log for %s %s', map { $res->{when}->$_ } qw(fullmonth year))
180 2 50       9 : $self->_print('1> Log for %s', $res->{when}->year);
181 2 100       7 push @table, '-' if @table > 3;
182 2         5 $self->_print(\@table);
183              
184 2         6 @table = ();
185 2         5 push @table, ['Total events', ':', $res->{events}];
186 2         8 push @table, ['Total time', ':', $self->_duration($res->{seconds}, 'hms')];
187              
188 2         5 my ($time_left, @time_left) = ('');
189 2 50 33     16 if ($self->config('hours_per_month') and $res->{interval} eq 'month') {
190 0         0 my ($days, $sec) = $self->_time_left($res);
191 0 0       0 push @table, ['Remaining days', ':', $days > 0 ? $days : 0];
192 0 0       0 push @table,
193             ['Remaining time', ':', $self->_duration($sec / ($days <= 0 ? 1 : $days), '-hm') . '/day'];
194             }
195              
196 2         7 $self->_print(\@table);
197              
198 2         20 return 0;
199             }
200              
201             sub command_register {
202 2     2   4549 my ($self, $start, $stop) = @_;
203 2 100 66     13 return $self->command_help('register') unless $start and $stop;
204              
205 1         4 $start = $self->_time(str => $start);
206 1         4 $stop = $self->_time(str => $stop, ref => $start);
207              
208 1         6 my $event = $self->_fill_event({start => $start, stop => $stop});
209 1         4 my $trc_file = $self->_trc_path($event->{project}, $start);
210 1 50       3 if (-e $trc_file) {
211 0         0 $self->_print("! $trc_file already exists.");
212 0         0 return 1;
213             }
214              
215 1 50       6 $self->_print('Registered "%s" at %s with duration %s.', @$event{qw(project start duration)})
216             if $self->_save($event);
217 1         22 return $!;
218             }
219              
220             sub command_start {
221 2     2   4937 my ($self, @args) = @_;
222             my $event
223             = $self->_fill_event({
224 2     1   17 project => (first {/^[A-Za-z0-9-]+$/} @args), start => $self->_time(first {/\d+\:/} @args),
  1         8  
  2         7  
225             });
226              
227 2 50 33     14 return $self->command_help('start') unless $event->{project} and $event->{start};
228              
229 2         70 $self->_stop_previous(@args);
230 2 50       100 return $! unless $self->_save($event);
231 2         6 $self->home->child('previous')->spurt($event->{path});
232 2         12 $self->_print('1> Started "%s" at %s.', $event->{project}, $event->{start}->hms(':'));
233 2         12 return 0;
234             }
235              
236             sub command_stop {
237 3     3   6760 my $self = shift;
238 3         9 my $exit_code = $self->_stop_previous(@_);
239 3 100       10 $self->_print('! No event to stop.') if $exit_code == 3;
240 3         8 return $exit_code;
241             }
242              
243             sub command_status {
244 3     3   7561 my $self = shift;
245 3         10 my $event = $self->_get_previous_event;
246 3         3 warn "[APP_TT] status $event->{path}\n" if DEBUG;
247              
248 3 100       8 if (!$event->{start}) {
    100          
249 1         3 $self->_print('1> No event is being tracked.');
250 1         4 return 3; # No such process
251             }
252             elsif ($event->{stop}) {
253             $self->_print(
254             '1> Stopped "%s" at %s after %s.',
255             @$event{qw(project stop)},
256 1         30 $self->_duration($event->{seconds}, 'hms')
257             );
258 1         17 return 0;
259             }
260             else {
261 1         15 my $duration = $NOW - $event->{start} + $NOW->tzoffset;
262             $self->_print(
263             '1> Tracking "%s" for %s since %s.', $event->{project},
264 1         141 $self->_duration($duration, 'hms'), $event->{start}->hms(':')
265             );
266 1         7 return 0;
267             }
268             }
269              
270             sub config {
271 11     11   89 my ($self, $key) = @_;
272              
273 11 100       25 unless ($self->{config}) {
274 1         78 local %ENV = %ENV;
275 1         6 for my $path ($self->home->child('config'), '.env') {
276 2 50       21 next unless -r $path;
277 0 0       0 open my $CONFIG, '<', $path or die qq{Can't open file "$path": $!};
278 0         0 while (my $line = readline $CONFIG) {
279 0 0       0 next unless $line =~ m!^(?:TT_)?(\w+)=(.+)!;
280 0         0 $ENV{$1} = $2;
281 0         0 $self->{config}{lc($1)} = $2;
282             }
283             }
284              
285             $self->{defaults} ||= {
286             editor => $ENV{EDITOR} || 'nano',
287             export_columns => $ENV{TT_COLUMNS} || 'date,project,hours,rounded,tags,description',
288             hours_per_month => $ENV{TT_HOURS_PER_MONTH} || 0,
289             min_time => $ENV{TT_MIN_TIME} || 300,
290 1   50     70 round_up_at => $ENV{TT_ROUND_UP_AT} || 30,
      50        
      50        
      50        
      50        
      50        
291             };
292             }
293              
294 11   66     75 return $self->{config}{$key} // $self->{defaults}{$key} // die "Missing option '$key'.\n";
      50        
295             }
296              
297             sub _edit_with_editor {
298 0     0   0 my ($self, $trc_file) = @_;
299 0 0       0 my ($event, $prev) = $trc_file ? ($self->_load($trc_file), 0) : ($self->_get_previous_event, 1);
300              
301 0   0     0 $trc_file = $event->{path} //= 'Not found';
302 0 0       0 die "Could not find file to edit. ($event->{path})\n" unless $event->{start};
303              
304 0         0 my $fh = File::Temp->new;
305 0         0 printf $fh "# %s\n", $event->{path};
306 0         0 for my $k (qw(project tags description start stop user)) {
307 0 0       0 $event->{$k} = join ', ', @{$event->{$k} || []} if $k eq 'tags';
  0 0       0  
308 0 0 0     0 $event->{$k} = $event->{$k}->datetime if $k eq 'start' or $k eq 'stop' and $event->{$k};
      0        
309 0   0     0 printf $fh "%-12s %s\n", "$k:", $event->{$k} // '';
310             }
311              
312 0         0 close $fh;
313 0         0 $self->_print("Edit $event->{path}");
314 0         0 system $self->config('editor') => "$fh";
315              
316 0         0 for (split /\n/, App::tt::file->new("$fh")->slurp) {
317 0 0       0 my ($k, $v) = /^(\w+)\s*:\s*(.+)/ or next;
318 0 0       0 $v = [grep {length} split /[\s,]+/, $v] if $k eq 'tags';
  0         0  
319 0 0       0 $v = $self->_time(str => $v) if $k eq 'start';
320 0 0       0 $v = $self->_time(str => $v, ref => $event->{start}) if $k eq 'stop';
321 0         0 $event->{$k} = $v;
322             }
323              
324 0 0       0 return $! unless $self->_save($event);
325 0 0       0 $self->home->child('previous')->spurt($event->{path}) if $prev;
326 0 0 0     0 unlink $trc_file or die "rm $trc_file: $!" unless $trc_file eq $event->{path};
327 0         0 return 0;
328             }
329              
330             sub _fill_event {
331 7     7   13 my ($self, $event) = @_;
332              
333 7         20 my $project = $self->project;
334 7 50 0     101 $project ||= App::tt::file->new->basename if -d '.git';
335 7   33     18 $project ||= $self->config('project');
336              
337 7   100     24 $event->{__CLASS__} ||= 'App::TimeTracker::Data::Task';
338 7   66     303 $event->{project} ||= $project;
339 7   50     27 $event->{seconds} ||= 0;
340 7   100     746 $event->{user} ||= scalar(getpwuid $<);
341 7   100     29 $event->{tags} ||= [$self->_tags];
342 7   50     38 $event->{description} ||= $self->description // '';
      33        
343              
344 7         52 return $event;
345             }
346              
347             sub _print {
348 22     22   80 my ($self, $data, @args) = @_;
349              
350 22 100       40 if (ref $data eq 'ARRAY') {
351 4 50       33 my $th = $data->[0][-1] eq '{X}' ? 1 : 0;
352 4 50       11 my @margin = $PTY ? ('') : ();
353 4         6 my ($width, @lines, @spec);
354              
355 4         6 for my $row (@$data) {
356 11 100 50     22 push @lines, $row and next unless ref $row eq 'ARRAY';
357 8         8 my $w = 0;
358 8         15 for my $col (0 .. $#$row) {
359 36   50     62 ($row->[$col] //= '') =~ y/\r\n//d;
360 36         40 my $len = length $row->[$col];
361 36 100 100     70 $spec[$col] = $len if $len >= ($spec[$col] // 0);
362 36         41 $w += $spec[$col] + 2;
363             }
364 8 50 100     28 $width = $w if $w >= ($width // 0);
365             }
366              
367 4         6 $width -= 2;
368 4         7 my @fm = (map({"\%-${_}s"} @spec[0 .. $#spec - 1]), '%s');
  14         26  
369             $data = join '', map {
370 4 100       6 ref $_
  11         55  
371             ? sprintf join(' ', @margin, @fm[0 .. $#$_]) . "\n", @$_
372             : ' ' . ($_ x $width) . "\n";
373             } @$data;
374             }
375              
376 22   50     45 my $fh = $self->{stdout} ||= \*STDOUT;
377 22 100       99 if ($data =~ s/^(\d*)(>|!)\s//) {
378 12 100 50     71 $fh = $self->{stderr} ||= \*STDERR if $1 eq '2' || $2 eq '!';
      100        
379 12         24 $data = "\n$data\n";
380 12 50       51 $data =~ s!\n!\n !g if $PTY;
381             }
382              
383 22 100       42 return print {$fh} "$data\n" if @_ == 2;
  7         22  
384 15         22 return printf {$fh} "$data\n", @args;
  15         82  
385             }
386              
387             sub _fill_log_days {
388 0     0   0 my ($self, $last, $now) = @_;
389 0         0 my $interval = int(($now - $last)->days);
390              
391             map {
392 0         0 my $t = $last + $_ * 86400;
  0         0  
393 0         0 +{seconds => 0, start => $t, tags => [$t->day]}
394             } 1 .. $interval;
395             }
396              
397             sub _get_previous_event {
398 8     8   11 my $self = shift;
399              
400             # $ROOT/previous contains path to last .trc file
401 8         15 my $previous = $self->home->child('previous');
402 8   66     21 my $trc_file = -r $previous && App::tt::file->new($previous->slurp);
403 8 100 100     22 return $self->_load($trc_file) if $trc_file && -r $trc_file;
404              
405 4 50   2   12 $self->home->child($NOW->year)->list_tree(sub { $_[0]->parse && ($trc_file = $_[0]) });
  2         8  
406 4 100 66     22 return $self->_load($trc_file) if $trc_file && -r $trc_file;
407              
408 2         7 return {path => $trc_file};
409             }
410              
411             sub _group_by_day {
412 0     0   0 my ($self, $res) = @_;
413 0         0 my $pl = 0;
414 0         0 my %log;
415              
416 0         0 for my $e (@{$res->{log}}) {
  0         0  
417 0         0 my $k = $e->{start}->ymd;
418 0   0     0 $log{$k} ||= {%$e, seconds => 0};
419 0         0 $log{$k}{seconds} += $e->{seconds};
420 0         0 $log{$k}{_project}{$e->{project}} = 1;
421 0         0 $log{$k}{_tags}{$_} = 1 for @{$e->{tags}};
  0         0  
422             }
423              
424             $res->{log} = [
425             map {
426 0         0 my $p = join ', ', keys %{$_->{_project}};
  0         0  
427 0 0       0 $pl = length $p if $pl < length $p;
428 0         0 +{%$_, project => $p, tags => [keys %{$_->{_tags}}]};
  0         0  
429 0         0 } map { $log{$_} } sort keys %log
  0         0  
430             ];
431             }
432              
433             sub _duration {
434 13     13   59 my ($self, $duration, $sep) = @_;
435 13 100       27 my $seconds = int(ref $duration ? $duration->seconds : $duration);
436 13         25 my ($hours, $minutes);
437              
438 13         23 $hours = int($seconds / 3600);
439 13         17 $seconds -= $hours * 3600;
440 13         23 $minutes = int($seconds / 60);
441 13         14 $seconds -= $minutes * 60;
442              
443 13 100       36 return sprintf '%s:%02s:%02s', $hours, $minutes, $seconds if !$sep;
444 9 50       15 return sprintf '%s:%02s', $hours, $minutes if $sep eq '-hm';
445 9 100       27 return sprintf '%2s:%02s', $hours, $minutes if $sep eq 'hm';
446 5         26 return sprintf '%sh %sm %ss', $hours, $minutes, $seconds;
447             }
448              
449             sub _load {
450 10     10   20 my ($self, $path) = @_;
451 10         19 my $event = JSON::PP::decode_json($path->slurp);
452 10         15822 $event->{path} = $path;
453 10 50       14 $event->{tags} = [map { split /\s*,\s*/, $_ } @{$event->{tags} || []}];
  30         71  
  10         40  
454 10         19 $event->{$_} = $self->_time($event->{$_}) for grep { $event->{$_} } qw(start stop);
  20         51  
455 10         25 return $event;
456             }
457              
458             sub _log {
459 4     4   6 my $self = shift;
460 4         11 my $tags = join ',', $self->_tags;
461 4   50     46 my @project_re = map {qr{^$_\b}} split /,/, $self->project || '.+';
  4         109  
462              
463 4         18 my $res = {events => 0, log => [], seconds => 0};
464              
465 4         11 for (@_) {
466 0 0 0     0 /^(-\d+)(m|y|month|year)$/ and ($res->{start_at} = $1 and $res->{interval} = $2);
467 0 0 0     0 /^(-\d+)$/ and $res->{start_at} ||= $1;
468 0 0 0     0 /^(month|year)/ and $res->{interval} ||= $1;
469 0 0 0     0 /^--fill/ and $res->{fill} ||= 1;
470             }
471              
472 4   50     20 $res->{fill} ||= 0;
473 4   50     16 $res->{interval} ||= 'month';
474 4   50     18 $res->{start_at} ||= 0;
475              
476 4 50       15 if ($res->{interval} =~ m!^y!) {
477 0         0 $res->{when} = $self->_time(Y => $NOW->year + $res->{start_at}, m => 1, d => 1);
478 0         0 $res->{path} = $self->home->child($res->{when}->year);
479             }
480             else {
481 4         20 $res->{when} = $self->_time(m => $NOW->mon + $res->{start_at}, d => 1);
482 4         9 $res->{path} = $self->home->child($res->{when}->year, sprintf '%02s', $res->{when}->mon);
483             }
484              
485             $res->{path}->list_tree(sub {
486 4 50   4   10 return unless my ($date, $hms, $project) = $_[0]->parse;
487 4         14 my $event = $self->_load($_[0]);
488 4 50 33     12 return if @project_re and !grep { $event->{project} =~ $_ } @project_re;
  4         31  
489 4 50 33     11 return if $tags and !grep { $tags =~ /\b$_\b/ } @{$event->{tags}};
  0         0  
  0         0  
490 4   33     9 $event->{stop} ||= $NOW + $NOW->tzoffset;
491 4   33     79 $event->{seconds} ||= $event->{stop} - $event->{start};
492 0         0 push @{$res->{log}},
493 0         0 $self->_fill_log_days(@{$res->{log}} ? $res->{log}[-1]{start} : $res->{when}, $event->{start})
494 4 0       9 if $res->{fill};
    50          
495 0         0 pop @{$res->{log}}
496 4         16 if @{$res->{log}}
497             and !$res->{log}[-1]{project}
498 4 50 66     5 and $res->{log}[-1]{start}->mday == $event->{start}->mday;
      33        
499 4         4 push @{$res->{log}}, $event;
  4         7  
500 4         5 $res->{events}++;
501 4         16 $res->{seconds} += $event->{seconds};
502 4         33 });
503              
504 4 50 50     67 if (my $method = $self->can(sprintf '_group_by_%s', $self->group_by || 'nothing')) {
505 0         0 $self->$method($res);
506             }
507              
508 4         56 return $res;
509             }
510              
511             sub _mass_edit {
512 0     0   0 my $self = shift;
513              
514 0 0 0     0 $self->year($NOW->year) if $self->month and !$self->year;
515 0         0 my $path = $self->home;
516 0 0       0 $path = $path->child($self->year) if $self->year;
517 0 0       0 $path = $path->child(sprintf '%02s', $self->month) if $self->month;
518              
519 0         0 my $re = '';
520 0   0     0 $re .= sprintf '(%s)', $self->year || '\d{4}'; # (year) = ($1)
521 0   0     0 $re .= sprintf '(%02s)', $self->month || '\d{2}'; # (month) = ($2)
522 0         0 $re .= '(\d{2})-(\d+)_'; # (day, hms) = ($3, $4)
523 0   0     0 $re .= sprintf '(%s)', $self->project || '[^.]+'; # (project) = ($5)
524 0         0 $re .= '\.trc';
525              
526             # Edit all files with code from STDIN
527 0         0 my $code = '';
528 0 0 0     0 if (!-t STDIN or !($self->year or $self->month)) {
      0        
529 0         0 $code .= $_ while ;
530 0 0       0 $code = "sub {$code}" unless $code =~ /^\s*sub\b/s;
531 0 0       0 $code = eval "use 5.10.1;use strict;use warnings;$code"
532             or die "Could not compile code from STDIN: $@\n";
533             }
534              
535             $path->list_tree(sub {
536 0     0   0 my %info = (file => shift);
537 0 0       0 return unless $info{file} =~ m!$re$!;
538 0         0 @info{qw(year month day date hms project)} = ($1, $2, $3, "$1$2$3", $4, $5);
539 0 0       0 $self->command_edit($info{file}), next unless $code;
540 0         0 my $event = $self->_load($info{file});
541 0         0 local %_ = %info;
542 0 0 0     0 $self->_save($event) if $code and $self->$code($event);
543 0 0 0     0 unlink $info{file} or die "rm $info{file}: $!" unless $info{file} eq $event->{path};
544 0         0 });
545              
546 0         0 return 0;
547             }
548              
549             sub _save {
550 4     4   8 my ($self, $event) = @_;
551 4         7 $event = $self->_fill_event($event);
552              
553 4 100 33     14 if (my $duration = $event->{stop} && $event->{start} && $event->{stop} - $event->{start}) {
554 2         213 $event->{seconds} = $duration->seconds;
555             }
556 4 50 66     17 if ($event->{stop} and $event->{seconds} < $self->config('min_time')) {
557 0         0 $self->_print('! Too short duration (%s)', $self->_duration($event->{duration}));
558 0         0 $! = 52;
559 0         0 return 0;
560             }
561              
562 4         11 $event->{duration} = $self->_duration($event->{seconds}); # Used by App::TimeTracker::Data::Task
563              
564 4         23 my %event = %$event;
565 4         8 delete $event{path};
566 4 50       9 $event{start} = $event->{start}->datetime if $event->{start};
567 4 100       183 $event{stop} = $event->{stop}->datetime if $event->{stop};
568              
569 4         82 $event->{path} = $self->_trc_path($event->{project}, $event->{start});
570 4 100       12 $event->{path}->dirname->make_path unless -d $event->{path}->dirname;
571 4         25 $event->{path}->spurt(JSON::PP::encode_json(\%event));
572 4         34 return 1;
573             }
574              
575             sub _tags {
576 7     7   12 my $self = shift;
577 7 50       17 return $self->tag(shift) if @_;
578 7 50       29 return uniq map { split /,/, $_ } @{$self->tag || []};
  9         52  
  7         22  
579             }
580              
581             sub _stop_previous {
582 5     5   13 my ($self, @args) = @_;
583              
584 5         8 my $event = $self->_get_previous_event;
585 5 100 100     17 return 3 if !$event->{start} or $event->{stop}; # 3 == No such process
586              
587 2         28 $event->{stop} = $self->_time(+(grep {/\d+\:/} @args)[0]);
  1         8  
588              
589 2         6 my $duration = $event->{stop} - $event->{start};
590 2 100       54 if ($duration->seconds < $self->config('min_time')) {
591             $self->_print(
592 1         2 qq(! Dropping "%s" since the event is shorter than @{[$self->config('min_time')]}s.),
593 1         3 $event->{project});
594 1 50       5 unlink $event->{path} or die "rm $event->{path}: $!";
595 1         9 return 52;
596             }
597              
598 1 50       5 if ($self->_save($event)) {
599             $self->_print(
600             '1> Stopped "%s" at %s after %s.',
601 1         6 @$event{qw(project stop)},
602             $self->_duration($duration, 'hms')
603             );
604 1         20 return 0;
605             }
606              
607 0   0     0 return $! || 1;
608             }
609              
610             sub _time {
611 34     34   16510 my $self = shift;
612 34 100       101 my %t = @_ == 1 ? (str => shift) : (@_);
613              
614 34 100       63 if ($t{str}) {
615 28         120 my ($ymd, $hms) = split /[T\s]/, $t{str};
616 28 100 66     82 ($hms, $ymd) = ($ymd, '') if !$hms and $ymd =~ m!:!;
617 28 50 66     122 die qq(Invalid date: "$t{str}"\n) if $ymd and $ymd !~ m!^[\d-]+$!;
618 28 50 33     95 die qq(Invalid time: "$t{str}"\n) if $hms and $hms !~ m!^[\d:]+$!;
619 28 100       121 $t{Y} = $1 if $ymd =~ s!(\d{4})!!;
620 28 100       116 $t{m} = $1 if $ymd =~ s!0?(\d{1,2})-(\d+)!$2!;
621 28 100       91 $t{d} = $1 if $ymd =~ s!0?(\d{1,2})!!;
622 28 50       94 $t{H} = $1 if $hms =~ s!0?(\d{1,2})!!;
623 28 100       88 $t{M} = $1 if $hms =~ s!0?(\d{1,2})!!;
624 28 100       86 $t{S} = $1 if $hms =~ s!0?(\d{1,2})!!;
625             }
626              
627 34   66     152 my $ref = $t{ref} || $NOW;
628 34 50       1202 $ref = $self->_time($ref) unless ref $ref;
629 34   66     103 $t{Y} ||= $ref->year;
630 34   66     155 $t{m} //= $ref->mon;
631 34   66     135 $t{d} //= $ref->mday;
632 34 100 66     119 $t{S} //= defined $t{H} || defined $t{M} ? 0 : $ref->second;
      100        
633 34 100 100     88 $t{M} //= defined $t{H} ? 0 : $ref->min;
634 34   66     76 $t{H} //= $ref->hour;
635              
636 34 50       77 @t{qw(m Y)} = (12 - $t{m}, $t{Y} - 1) if $t{m} <= 0;
637 34 50       55 @t{qw(m Y)} = (1, $t{Y} + 1) if $t{m} > 12;
638              
639             eval {
640 34         153 $t{iso} = sprintf '%s-%02s-%02sT%02s:%02s:%02s', @t{qw(Y m d H M S)};
641 34         136 $t{tp} = Time::Piece->strptime("$t{iso}+0000", '%Y-%m-%dT%H:%M:%S%z');
642 34 100       41 } or do {
643 1         19 $@ =~ s!\r?\n$!!;
644 1         5 $@ =~ s!\sat\s\W+.*!! unless DEBUG;
645 1 50       7 die "Invalid time: $t{str} ($t{iso}): $@\n" if $t{str};
646 0         0 die "Invalid time: $t{iso}: $@\n";
647             };
648              
649 33         2489 return $t{tp};
650             }
651              
652             sub _time_left {
653 0     0   0 my ($self, $res) = @_;
654 0         0 my $start = $self->_time(d => 1, H => 0, M => 0, S => 0);
655 0         0 my $end = $self->_time(d => 1, m => $start->mon + 1, H => 0, M => 0, S => 0);
656 0         0 my $total_days = 0;
657 0         0 my $worked_days = 0;
658 0         0 while ($start < $end) {
659 0 0 0     0 if ($start->day_of_week != 0 and $start->day_of_week != 6) {
660 0 0       0 $worked_days++ if $start < $NOW;
661 0         0 $total_days++;
662             }
663 0         0 $start += ONE_DAY;
664             }
665              
666 0 0       0 my $remaining_days = $total_days - $worked_days + ($NOW->hour > 12 ? 0 : 1);
667 0         0 my $total_seconds = $self->config('hours_per_month') * 3600;
668 0         0 my $remaining_seconds = $total_seconds - $res->{seconds};
669 0         0 return $remaining_days, $remaining_seconds;
670             }
671              
672             sub _trc_path {
673 5     5   9 my ($self, $project, $t) = @_;
674 5         18 $project =~ s!\W!_!g;
675              
676 5         12 my $file = sprintf '%s-%s_%s.trc', $t->ymd(''), $t->hms(''), $project;
677 5         84 my $month = sprintf '%02s', $t->mon;
678 5         26 return $self->home->child($t->year, $month, $file);
679             }
680              
681             app {
682             my ($self, $command) = (shift, shift);
683             return $self->command_help($ENV{APP_TT_HELP}) if $ENV{APP_TT_HELP};
684             return $self->command_status(@_) if !$command or $command eq 'status';
685             my $method = $self->can("command_$command");
686             return $self->$method(@_) if $method;
687             die qq(Unknown command "$command".\n);
688             };