File Coverage

blib/lib/App/TimelogTxt.pm
Criterion Covered Total %
statement 38 230 16.5
branch 1 60 1.6
condition 0 32 0.0
subroutine 13 44 29.5
pod 16 16 100.0
total 68 382 17.8


line stmt bran cond sub pod time code
1             package App::TimelogTxt;
2              
3 1     1   24276 use warnings;
  1         1  
  1         31  
4 1     1   4 use strict;
  1         1  
  1         23  
5 1     1   16 use 5.010;
  1         6  
  1         25  
6              
7 1     1   38637 use autodie;
  1         28333  
  1         4  
8 1     1   5570 use App::CmdDispatch;
  1         7265  
  1         29  
9 1     1   711 use Getopt::Long qw(:config posix_default);
  1         10026  
  1         8  
10 1     1   960 use App::TimelogTxt::Utils;
  1         2  
  1         29  
11 1     1   405 use App::TimelogTxt::Day;
  1         1  
  1         24  
12 1     1   338 use App::TimelogTxt::File;
  1         1  
  1         22  
13 1     1   349 use App::TimelogTxt::Event;
  1         3  
  1         271  
14              
15             our $VERSION = '0.22';
16              
17             # Initial configuration information.
18             my %config = (
19             editor => '',
20             dir => '',
21             defcmd => '',
22             );
23              
24             my $config_file = _normalize_path( '~/.timelogrc' );
25              
26             # Dispatch table for commands
27             my %commands = (
28             'start' => {
29             code => \&start_event,
30             clue => 'start {event description}',
31             abstract => 'Start timing a new event.',
32             help => 'Stop the current event and start timing a new event.',
33             },
34             App::TimelogTxt::Utils::STOP_CMD() => {
35             code => sub { my $app = shift; log_event( $app, App::TimelogTxt::Utils::STOP_CMD() ); },
36             clue => App::TimelogTxt::Utils::STOP_CMD(),
37             abstract => 'Stop timing the current event.',
38             help => 'Stop timing the current event.',
39             },
40             'init' => {
41             code => \&init_timelog,
42             clue => 'init [directory]',
43             abstract => 'Create the timelog directory and configuration.',
44             help => 'Create the directory and configuration file used by timelog
45             if they do not already exist.',
46             },
47             'push' => {
48             code => \&push_event,
49             clue => 'push {event description}',
50             abstract => 'Save the current event and start timing new.',
51             help => 'Save the current event on stack and start timing new event.',
52             },
53             'pop' => {
54             code => \&pop_event,
55             clue => 'pop',
56             abstract => 'Return to last pushed event.',
57             help => 'Stop last event and restart top event on stack.',
58             },
59             'drop' => {
60             code => \&drop_event,
61             clue => 'drop [all|{n}]',
62             abstract => 'Drop items from stack.',
63             help => 'Drop one or more events from top of event stack, or all
64             if argument supplied.',
65             },
66             'ls' => {
67             code => \&list_events,
68             clue => 'ls [date]',
69             abstract => 'List events.',
70             help => 'List events for the specified day. Default to today.',
71             },
72             'lsproj' => {
73             code => \&list_projects,
74             clue => 'lsproj',
75             abstract => 'List known projects.',
76             help => 'List known projects.',
77             },
78             'lstk' => {
79             code => \&list_stack,
80             clue => 'lstk',
81             abstract => 'Display items on the stack.',
82             help => 'Display items on the stack.',
83             },
84             'edit' => {
85             code => \&edit_logfile,
86             clue => 'edit',
87             abstract => 'Edit the timelog file.',
88             help => 'Open the timelog file in the current editor',
89             },
90             'report' => {
91             code => \&daily_report,
92             clue => 'report [date [end date]] [project regexes]',
93             abstract => 'Task report.',
94             help => 'Display a report for the specified days and projects.',
95             },
96             'summary' => {
97             code => \&daily_summary,
98             clue => 'summary [date [end date]] [project regexes]',
99             abstract => 'Short summary report.',
100             help => q{Display a summary of the appropriate days' projects.},
101             },
102             'hours' => {
103             code => \&report_hours,
104             clue => 'hours [date [end date]] [project regexes]',
105             abstract => 'Hours report.',
106             help => q{Display the hours worked for each of the appropriate days
107             and projects.},
108             },
109             'curr' => {
110             code => \¤t_event,
111             clue => 'curr',
112             abstract => 'Display current event.',
113             help => q{Display the current event (if any) and the time since the
114             event started.},
115             },
116             );
117              
118             # Sub class of App::CmdDispatch that initializes configuration information
119             # specific to this program. It also provides access to that configuration.
120             {
121             package Timelog::CmdDispatch;
122 1     1   6 use base 'App::CmdDispatch';
  1         1  
  1         2364  
123              
124             sub new
125             {
126 0     0   0 my $self = App::CmdDispatch::new( @_ );
127 0         0 $self->init();
128 0         0 $self->{_timelog_out_fh} = \*STDOUT;
129 0         0 return $self;
130             }
131              
132 0     0   0 sub _logfile { return $_[0]->get_config()->{'logfile'}; }
133 0     0   0 sub _stackfile { return $_[0]->get_config()->{'stackfile'}; }
134 0     0   0 sub _out_fh { return $_[0]->{_timelog_out_fh}; }
135              
136             # Support injection of an output filehandle for testing.
137 0     0   0 sub _set_out_fh { return $_[0]->{_timelog_out_fh} = $_[1]; }
138              
139             sub init
140             {
141 0     0   0 my ($self) = @_;
142 0         0 my $config = $self->get_config();
143              
144 0   0     0 $config->{editor} ||= $config{editor} || $ENV{'VISUAL'} || $ENV{'EDITOR'} || 'vim';
      0        
145 0   0     0 $config->{dir} ||= $config{dir} || App::TimelogTxt::_normalize_path( '~/timelog' );
      0        
146 0   0     0 $config->{defcmd} ||= $config{defcmd} || App::TimelogTxt::Utils::STOP_CMD();
      0        
147 0         0 $config->{dir} = App::TimelogTxt::_normalize_path( $config->{'dir'} );
148 0         0 foreach my $d ( [qw/logfile timelog.txt/], [qw/stackfile stack.txt/] )
149             {
150 0         0 $config->{ $d->[0] } = "$config->{'dir'}/$d->[1]";
151             }
152 0         0 return;
153             }
154              
155             }
156              
157             sub run
158             {
159 0     0 1 0 GetOptions(
160             "dir=s" => \$config{'dir'},
161             "editor=s" => \$config{'editor'},
162             "conf=s" => \$config_file,
163             );
164              
165 0 0       0 my $options = {
166             config => (-f $config_file ? $config_file : undef),
167             default_commands => 'help shell',
168             'help:post_hint' =>
169             "\nwhere [date] is an optional string specifying a date of the form YYYY-MM-DD
170             or a day name: yesterday, today, or sunday .. saturday and [project regexes]
171             is a list of strings of regular expressions matching project names.\n",
172             'help:post_help' =>
173             "\nwhere [date] is an optional string specifying a date of the form YYYY-MM-DD
174             or a day name: yesterday, today, or sunday .. saturday and [project regexes]
175             is a list of strings of regular expressions matching project names.\n",
176             };
177 0         0 my $app = Timelog::CmdDispatch->new( \%commands, $options );
178              
179             # Handle default command if none specified
180 0 0       0 @ARGV = split / /, $app->get_config()->{'defcmd'} unless @ARGV;
181              
182 0         0 $app->run( @ARGV );
183              
184 0         0 return;
185             }
186              
187             # Command handlers
188              
189             sub init_timelog
190             {
191 0     0 1 0 my ($app, $dir) = @_;
192 0         0 require File::Path;
193 0         0 my $config = $app->get_config();
194 0   0     0 $dir //= $config->{'dir'};
195 0         0 $dir = _normalize_path( $dir );
196 0 0       0 File::Path::mkpath( $dir ) unless -d $dir;
197 0 0       0 unless( -f $config_file )
198             {
199 0         0 open my $fh, '>', $config_file;
200             # don't supply the editor value, default to the environment
201 0         0 print {$fh} <<"EOF";
  0         0  
202             dir=$dir
203             defcmd=$config->{defcmd}
204              
205             [alias]
206             EOF
207             }
208 0         0 print "timelog initialized\n";
209 0         0 return;
210             }
211              
212             sub log_event
213             {
214 0     0 1 0 my $app = shift;
215 0         0 my $task = "@_";
216 0 0 0     0 if( App::TimelogTxt::Utils::is_stop_cmd( $task ) || App::TimelogTxt::Utils::has_project( $task ) )
217             {
218 0         0 open my $fh, '>>', $app->_logfile;
219 0         0 my $event = App::TimelogTxt::Event->new( $task, time );
220 0         0 print {$fh} $event->to_string, "\n";
  0         0  
221             }
222             else
223             {
224 0         0 die "Event has no project.\n";
225             }
226 0         0 return;
227             }
228              
229             sub edit_logfile
230             {
231 0     0 1 0 my ( $app ) = @_;
232 0         0 system $app->get_config()->{'editor'}, $app->_logfile;
233 0         0 return;
234             }
235              
236             sub list_events
237             {
238 0     0 1 0 my ( $app, $day ) = @_;
239 0         0 my $stamp = App::TimelogTxt::Utils::day_stamp( $day );
240              
241 0 0   0   0 _each_logline( $app, sub { print if 0 == index $_, $stamp; } );
  0         0  
242 0         0 return;
243             }
244              
245             sub list_projects
246             {
247 0     0 1 0 my ( $app ) = @_;
248 0         0 my %projects;
249             _each_logline(
250             $app,
251             sub {
252 0     0   0 my ( @projs ) = m/\+(\S+)/g;
253 0 0       0 @projects{@projs} = ( 1 ) x @projs if @projs;
254             }
255 0         0 );
256 0         0 print "$_\n" foreach sort keys %projects;
257 0         0 return;
258             }
259              
260             sub daily_report
261             {
262 0     0 1 0 my ( $app, @filters ) = @_;
263              
264 0         0 my $summaries = extract_day_tasks( $app, @filters );
265              
266 0         0 foreach my $summary ( @{$summaries} )
  0         0  
267             {
268 0         0 $summary->print_day_detail( $app->_out_fh );
269             }
270 0         0 return;
271             }
272              
273             sub daily_summary
274             {
275 0     0 1 0 my ( $app, @filters ) = @_;
276              
277 0         0 my $summaries = extract_day_tasks( $app, @filters );
278              
279 0         0 foreach my $summary ( @{$summaries} )
  0         0  
280             {
281 0         0 $summary->print_day_summary( $app->_out_fh );
282             }
283 0         0 return;
284             }
285              
286             sub report_hours
287             {
288 0     0 1 0 my ( $app, @filters ) = @_;
289              
290 0         0 my $summaries = extract_day_tasks( $app, @filters );
291              
292 0         0 foreach my $summary ( @{$summaries} )
  0         0  
293             {
294 0         0 $summary->print_hours( $app->_out_fh );
295             }
296 0         0 return;
297             }
298              
299             sub start_event
300             {
301 0     0 1 0 my ( $app, @event ) = @_;
302 0         0 log_event( $app, @event );
303 0         0 return;
304             }
305              
306             sub push_event
307             {
308 0     0 1 0 my ( $app, @event ) = @_;
309 0         0 my $stack = _stack( $app );
310 0         0 $stack->push( _get_last_event( $app ) );
311 0         0 log_event( $app, @event );
312 0         0 return;
313             }
314              
315             sub pop_event
316             {
317 0     0 1 0 my ( $app ) = @_;
318 0 0       0 return unless -f $app->_stackfile;
319 0         0 my $stack = _stack( $app );
320 0         0 my $event = $stack->pop;
321 0 0       0 die "Event stack is empty.\n" unless $event;
322 0         0 log_event( $app, $event );
323 0         0 return;
324             }
325              
326             sub drop_event
327             {
328 0     0 1 0 my ( $app, $arg ) = @_;
329 0 0       0 return unless -f $app->_stackfile;
330 0         0 my $stack = _stack( $app );
331 0         0 $stack->drop( $arg );
332 0         0 return;
333             }
334              
335             sub list_stack
336             {
337 0     0 1 0 my ($app) = @_;
338 0 0       0 return unless -f $app->_stackfile;
339 0         0 my $stack = _stack( $app );
340 0         0 $stack->list( $app->_out_fh );
341 0         0 return;
342             }
343              
344             sub current_event
345             {
346 0     0 1 0 my ($app) = @_;
347 0         0 my $fh = $app->_out_fh;
348              
349 0         0 my $event = _get_last_full_event( $app );
350 0 0       0 if( $event->is_stop )
351             {
352 0         0 print {$fh} "Not in event.\n";
  0         0  
353 0         0 return;
354             }
355              
356             # Use day summary object to get duration and to report.
357 0         0 my $summary = App::TimelogTxt::Day->new( $event->stamp );
358 0         0 $summary->start_task( $event );
359 0         0 $summary->update_dur( $event, time );
360              
361 0         0 print {$fh} $event->to_string, "\nDuration: ";
  0         0  
362 0         0 $summary->print_duration( $fh );
363              
364 0         0 return;
365             }
366              
367             # Extract the daily events from the timelog.txt file and generate the list of Day
368             # objects that encapsulates them.
369              
370             sub extract_day_tasks
371             {
372 0     0 1 0 my ( $app, @args ) = @_;
373              
374 0         0 my ($stamp, $estamp, $pstamp, @filters) = _process_extraction_args( @args );
375 0         0 my ( $summary, $last, @summaries );
376 0         0 my $prev_stamp = '';
377              
378 0         0 open my $fh, '<', $app->_logfile;
379 0         0 my $file = App::TimelogTxt::File->new( $fh, $pstamp, $estamp );
380              
381 0         0 while( defined( my $line = $file->readline ) )
382             {
383 0         0 my $event;
384 0 0       0 eval {
385 0         0 $event = App::TimelogTxt::Event->new_from_line( $line );
386             } or next;
387 0 0       0 if( $prev_stamp ne $event->stamp )
388             {
389 0         0 my $new_stamp = $event->stamp;
390 0         0 my $new_summary = App::TimelogTxt::Day->new( $new_stamp );
391 0 0 0     0 if( $summary and !$summary->is_complete() )
392             {
393 0         0 $summary->close_day( $last );
394             # Need to build a new last item beginning at midnight for the
395             # Previous event.
396 0         0 my $start = $summary->day_end()+1;
397 0         0 $last = App::TimelogTxt::Event->new( $last->task(), $start );
398 0         0 $new_summary->start_task( $last );
399             }
400 0         0 $summary = $new_summary;
401 0         0 push @summaries, $summary;
402 0         0 $prev_stamp = $new_stamp;
403             }
404 0         0 $summary->update_dur( $last, $event->epoch );
405 0         0 $summary->start_task( $event );
406 0 0       0 $last = ($event->is_stop() ? undef : $event );
407             }
408              
409             # If the first summary is the day before we were supposed to report,
410             # drop it.
411 0 0 0     0 shift @summaries if @summaries && $summaries[0]->date_stamp() eq $pstamp;
412              
413 0 0       0 return [] unless $summary;
414 0         0 my $end_time;
415 0 0       0 if( $summary->is_complete() )
416             {
417 0         0 $summary->update_dur( $last, $end_time );
418             }
419             else
420             {
421 0   0     0 my $datestamp = $summary->date_stamp() || $stamp;
422 0 0       0 if( App::TimelogTxt::Utils::is_today( $datestamp ) )
423             {
424 0         0 $end_time = time;
425 0         0 $summary->update_dur( $last, $end_time );
426             }
427             else
428             {
429 0         0 $summary->close_day( $last );
430 0         0 $end_time = App::TimelogTxt::Utils::stamp_to_localtime( $datestamp );
431             }
432             }
433              
434 0 0       0 return if $summary->is_empty;
435              
436 0         0 return _filter_summaries( \@filters, \@summaries );
437             }
438              
439             sub _filter_summaries
440             {
441 0     0   0 my ( $filters, $summaries ) = @_;
442              
443 0 0       0 return $summaries unless @{$filters};
  0         0  
444              
445 0         0 my $filter = join( '|', map { "(?:$_)" } @{$filters} );
  0         0  
  0         0  
446 0         0 my $filter_re = qr/$filter/;
447             return [
448 0         0 grep { $_->has_tasks }
  0         0  
449 0         0 map { $_->day_filtered_by_project( $filter_re ) }
450 0         0 @{$summaries}
451             ];
452             }
453              
454             sub _process_extraction_args
455             {
456 0     0   0 my ($day, @args) = @_;
457              
458             # First argument is always the day
459 0         0 my $stamp = App::TimelogTxt::Utils::day_stamp( $day );
460 0 0       0 die "No day provided.\n" unless defined $stamp;
461 0         0 my $eday = shift @args;
462 0 0       0 my $estamp = App::TimelogTxt::Utils::day_end( $eday ? App::TimelogTxt::Utils::day_stamp( $eday ) : $stamp );
463 0 0       0 if( !defined $estamp )
464             {
465 0         0 $estamp = App::TimelogTxt::Utils::day_end( $stamp );
466 0 0       0 unshift @args, $eday if $eday;
467             }
468              
469             # I need to start one day before to deal with the possibility that first
470             # task was held over midnight.
471 0         0 my $pstamp = App::TimelogTxt::Utils::prev_stamp( $stamp );
472              
473 0         0 return ($stamp, $estamp, $pstamp, @args);
474             }
475              
476             # Utility functions
477              
478             # Find user's home directory
479             sub _home
480             {
481 1 50   1   6 return $ENV{HOME} if defined $ENV{HOME};
482 0 0       0 if( $^O eq 'MSWin32' )
483             {
484 0 0       0 return "$ENV{HOMEDRIVE}$ENV{HOMEPATH}" if defined $ENV{HOMEPATH};
485 0 0       0 return $ENV{USERPROFILE} if defined $ENV{USERPROFILE};
486             }
487 0         0 return '/';
488             }
489              
490             # Resolve ~ notation and convert to an absolute path.
491             sub _normalize_path
492             {
493 1     1   1 my ($path) = @_;
494              
495 1         4 my $home = _home();
496 1         5 $path =~ s/~/$home/;
497              
498 1         2 return $path;
499             }
500              
501             sub _each_logline
502             {
503 0     0     my ( $app, $code ) = @_;
504 0           open my $fh, '<', $app->_logfile;
505 0           $code->() while( <$fh> );
506 0           return;
507             }
508              
509             sub _stack
510             {
511 0     0     my ($app) = @_;
512 0           require App::TimelogTxt::Stack;
513 0           return App::TimelogTxt::Stack->new( $app->_stackfile );
514             }
515              
516             sub _get_last_full_event
517             {
518 0     0     my ( $app ) = @_;
519 0           my $event_line;
520 0     0     _each_logline( $app, sub { $event_line = $_; } );
  0            
521 0           return App::TimelogTxt::Event->new_from_line( $event_line );
522             }
523              
524             sub _get_last_event
525             {
526 0     0     my $event = _get_last_full_event( @_ );
527              
528 0           return $event->task;
529             }
530              
531             1;
532             __END__