##
# name: YamlTime
# abstract: YAML-based Personal Time Tracking App
# author: Ingy döt Net <ingy@cpan.org>
# license: perl
# copyright: 2011
# see:
# - YAML
# - YamlTime::Extension
# TODO:
# - Sell to cdent and bsb and toby and russell
# - Write::Excel reporting plugin
# - Git plugin
# - Add option to round numbers
use 5.008003;
package YamlTime;
our $VERSION = '0.11';
# Requires
BEGIN { $ENV{PERL_RL} = 'Gnu o=0' }
use Mouse 0.93;
use MouseX::App::Cmd 0.08;
use Capture::Tiny 0.11 ();
use DateTime 0.70 ();
use DateTime::Format::Natural 0.96 ();
use File::ShareDir 1.03 ();
use IO::All 0.43 ();
use Template::Toolkit::Simple 0.13 ();
use Template::Plugin::YAMLVal 0.10 ();
use Term::ReadLine 1.07 ();
use Text::CSV_XS 0.82 ();
use Text::ParseWords 3.27 ();
use YAML::XS 0.35 ();
my $requires = "
use Term::ReadLine::Gnu 1.20 ();
";
#-----------------------------------------------------------------------------#
# package YamlTime::Extension;
#-----------------------------------------------------------------------------#
package YamlTime::Command;
use App::Cmd::Setup -command;
use Mouse;
extends qw[MouseX::App::Cmd::Command];
use IO::All;
use Cwd qw[cwd abs_path];
# use XXX;
# _ keeps these from becoming cli options
has _conf => (
is => 'ro',
lazy => 1,
reader => 'conf',
builder => sub {
my ($self) = @_;
return $YamlTime::Conf =
YamlTime::Conf->new(base => $self->base);
},
);
has _base => (
is => 'ro',
reader => 'base',
default => sub {
my $base =
$ENV{YAMLTIME_BASE} ? $ENV{YAMLTIME_BASE} :
-d "$ENV{HOME}/.yamltime/" ? "$ENV{HOME}/.yamltime/" :
'.';
$base =~ s!/+$!!;
return abs_path $base;
},
);
# Not validating any args. Checking the working environment.
sub validate_args {
my ($self) = @_;
my $base = $self->base;
chdir $base
or $self->error__cant_chdir_base;
if (ref($self) !~ /::init$/) {
$self->error__not_init
unless -e 'conf' and glob('20*');
$self->conf;
}
}
# Semi-brutal hack to suppress extra options I don't care about.
around usage => sub {
my $orig = shift;
my $self = shift;
my $opts = $self->{usage}->{options};
@$opts = grep { $_->{name} ne 'help' } @$opts;
return $self->$orig(@_);
};
#-----------------------------------------------------------------------------#
package YamlTime;
use App::Cmd::Setup -app;
use Mouse;
extends 'MouseX::App::Cmd';
use Module::Pluggable
require => 1,
search_path => [ 'YamlTime' ];
YamlTime->plugins;
use YamlTime::Conf;
use YamlTime::Task;
# Global pointer to the YamlTime::Conf singleton object.
our $Conf;
# App::Cmd help helpers
use constant usage => 'YamlTime';
use constant text => "yt command [<options>] [<arguments>]\n";
sub default_args {
my $default = $ENV{YAMLTIME_DEFAULT_ARGS} or return [];
return [ Text::ParseWords::shellwords($default) ];
}
#-----------------------------------------------------------------------------#
# A role for time/tag range options
#-----------------------------------------------------------------------------#
package YamlTime::RangeOpts;
use Mouse::Role;
my $time = time;
has from => (
is => 'ro',
isa => 'Str',
default => 0,
documentation =>
'Range start date/time (natural format) default(midnight)',
);
has to => (
is => 'ro',
isa => 'Str',
default => 0,
documentation =>
'Range end date/time (natural format) default(now)',
);
has tags => (
is => 'ro',
isa => 'ArrayRef[Str]',
default => sub{[]},
documentation =>
'Comma separated tags. Can be used more than once',
trigger => sub {
my ($self, $new, $old) = @_;
$self->{tags} = [
map {
[ split /\s*,\s*/ ]
} @$new
];
},
);
#-----------------------------------------------------------------------------#
# YamlTime (yt) Commands
#
# This is the set of App::Cmd classes that support each command.
#-----------------------------------------------------------------------------#
package YamlTime::Command::init;
YamlTime->import( -command );
use Mouse;
extends qw[YamlTime::Command];
use constant abstract => 'Initialize a new YamlTime store directory';
use constant usage_desc => 'yt init [--force]';
has force => (
is => 'ro',
isa => 'Bool',
documentation => 'Force an init operation',
);
sub execute {
my ($self, $opt, $args) = @_;
if ($self->empty_directory or $self->force) {
my $share = $self->share;
$self->copy_files("$share/conf", "./conf");
mkdir($self->date('now')->year);
}
else {
$self->error__wont_init;
}
$self->log(sprintf "Initialized YamlTime directory: %s", $self->base);
$self->log("\nNow edit the conf files and run 'yt help'.")
}
#-----------------------------------------------------------------------------#
package YamlTime::Command::new;
use Mouse;
YamlTime->import( -command );
extends qw[YamlTime::Command];
use constant abstract => 'Create a new task and start the timer';
use constant usage_desc => 'yt new ["Task description"]';
sub execute {
my ($self, $opt, $args) = @_;
$self->error__already_in_progress
if $self->current_task and
$self->current_task->in_progress;
my $task = YamlTime::Task->new(id => undef);
$self->populate($task, $args);
$task->start;
$self->log(sprintf "Started task %s.", $task->id);
$self->log("\nGet to work!")
unless $self->conf->be_serious;
}
#-----------------------------------------------------------------------------#
package YamlTime::Command::stop;
use Mouse;
YamlTime->import( -command );
extends qw[YamlTime::Command];
use constant abstract => 'Stop the timer on a running task';
use constant usage_desc => 'yt stop';
sub execute {
my ($self, $opt, $args) = @_;
my $task = $self->current_task or
$self->error__no_current_task;
$self->error__not_in_progress
unless $task->in_progress;
$task->stop;
$self->log(sprintf "Stopped task %s. Time: %s", $task->id, $task->elapsed);
$self->log("\nSTOP! ... YAML TIME!")
unless $self->conf->be_serious;
}
#-----------------------------------------------------------------------------#
package YamlTime::Command::go;
use Mouse;
YamlTime->import( -command );
extends qw[YamlTime::Command];
use constant abstract => 'Restart the timer on a task';
use constant usage_desc => 'yt go [<task-id>]';
sub execute {
my ($self, $opt, $args) = @_;
my $id = $args->[0];
my $task = $self->get_task(@$args)
or $self->error__no_current_task;
$self->error__already_in_progress
if $task->in_progress;
$task->start;
$self->log(sprintf "Restarted task %s - %s.", $task->id, $task->task);
$self->log("\nGet back to work!")
unless $self->conf->be_serious;
}
#-----------------------------------------------------------------------------#
package YamlTime::Command::status;
use Mouse;
YamlTime->import( -command );
extends qw[YamlTime::Command];
with 'YamlTime::RangeOpts';
use constant abstract => 'Show the status of a range of tasks';
use constant usage_desc => do { $_ = <<'...'; chomp; $_ };
yt # today's tasks
yt status [<task-ids>]
yt status [--from=...] [--to=...]
...
sub execute {
my ($self, $opt, $args) = @_;
my $total = 0;
for my $task ($self->get_task_range(@$args)) {
if ($task->elapsed =~ /^(\d+):(\d+)$/) {
$total += $1 * 60 + $2;
}
printf "%1s %12s %5s %s\n",
($task->in_progress ? '+' : '-'),
$task->id,
$task->elapsed,
$task->task;
}
my $hours = int($total / 60);
my $mins = $total % 60;
printf ' ' x 11 . "Total: % 2d:%02d\n", $hours, $mins
if $total;
}
#-----------------------------------------------------------------------------#
package YamlTime::Command::check;
use Mouse;
YamlTime->import( -command );
extends qw[YamlTime::Command];
with 'YamlTime::RangeOpts';
use IO::All;
use constant abstract => 'Check the validity of a range of tasks';
use constant usage_desc => 'yt check [--verbose] [--from=...] [--to=...]';
has verbose => (
is => 'ro',
isa => 'Bool',
default => 0,
documentation => 'Increase output verbosity',
);
sub execute {
my ($self, $opt, $args) = @_;
my $errors = 0;
for my $task ($self->get_task_range(@$args)) {
my @errors = $task->check;
if (@errors) {
$errors++;
printf "\n%s - found errors:\n", $task->id;
my $dump = YAML::XS::Dump \@errors;
$dump =~ s/^---\n//;
my $text = io($task->id)->all;
$text =~ s/^/ |/gm;
print $dump, $text;
}
elsif ($self->verbose) {
printf "%s - no errors found\n", $task->id;
}
}
if ($self->verbose and not $errors) {
print "No errors found\n";
}
}
#-----------------------------------------------------------------------------#
package YamlTime::Command::report;
use Mouse;
YamlTime->import( -command );
extends 'YamlTime::Command';
with 'YamlTime::RangeOpts';
use Text::CSV_XS;
use Cwd qw[abs_path];
use constant abstract => 'Produce a billing report from a range of tasks';
use constant usage_desc => 'yt report [--file=...] [--from=...] [--to=...]';
has file => (
is => 'ro',
isa => 'Str',
default => abs_path('report.csv'),
documentation => 'Name of file to write the report to',
);
sub execute {
my ($self, $opt, $args) = @_;
my $report_file = $self->file;
my $csv = Text::CSV_XS->new ({ binary => 1 }) or die;
$csv->eol ("\r\n");
open my $fh, ">:encoding(utf8)", $report_file or die "new.csv: $!";
$csv->print(
$fh,
[qw(Date Time Hours Project Task Tags Refs Notes)]
);
for my $task ($self->get_task_range(@$args)) {
$task->id =~ m!^(.*)/(\d\d)(\d\d)$! or die $task->id;
my $date = $1;
my $time = "$2:$3";
my $row = [
$date,
$time,
$task->time,
$task->proj,
$task->task,
join(', ', @{$task->tags}),
join(', ', @{$task->refs}),
$task->note || '',
];
$csv->print ($fh, $row);
}
close $fh or die "report.csv: $!";
print "Created $report_file\n";
}
#-----------------------------------------------------------------------------#
package YamlTime::Command::edit;
use Mouse;
YamlTime->import( -command );
extends qw[YamlTime::Command];
use constant abstract => 'Edit a task\'s YAML in $EDITOR';
use constant usage_desc => 'yt edit [<task-id>]';
sub execute {
my ($self, $opt, $args) = @_;
my $editor = $ENV{EDITOR}
or $self->error('You need to set $EDITOR env var to edit');
my $task = $self->get_task(@$args)
or $self->error("No task to edit");
exec $editor . " " . $task->id;
}
#-----------------------------------------------------------------------------#
package YamlTime::Command::base;
use Mouse;
YamlTime->import( -command );
extends qw[YamlTime::Command];
use IO::All;
use constant abstract => 'Print the YamlTime base directory to STDOUT';
use constant usage_desc => 'yt base';
sub execute {
my ($self, $opt, $args) = @_;
print $self->base . "\n";
}
#-----------------------------------------------------------------------------#
package YamlTime::Command::dump;
use Mouse;
YamlTime->import( -command );
extends qw[YamlTime::Command];
use IO::All;
use constant abstract => 'Print a task file to STDOUT';
use constant usage_desc => 'yt dump [<task-id>]';
sub execute {
my ($self, $opt, $args) = @_;
my $task = $self->get_task(@$args)
or $self->error("No task to dump");
print io($task->id)->all;
}
#-----------------------------------------------------------------------------#
package YamlTime::Command::create;
use Mouse;
YamlTime->import( -command );
extends qw[YamlTime::Command];
use constant abstract => 'Create a new task for a particular date/time';
use constant usage_desc => 'yt create <new-task-id>';
sub execute {
my ($self, $opt, $args) = @_;
$self->error("Requires a task-id of the form '2011/12/31/1234'")
unless @$args == 1 and $args->[0] =~ m!^(20\d\d/\d\d/\d\d/\d\d\d\d)$!;
my $id = $args->[0];
my $task = $self->get_task($id);
$self->error("Task '$id' already exists")
if $task->exists;
$self->populate($task, []);
$task->write;
$self->log("Created task $id");
}
#-----------------------------------------------------------------------------#
package YamlTime::Command::delete;
use Mouse;
YamlTime->import( -command );
extends qw[YamlTime::Command];
use constant abstract => 'Delete an entry by task-id';
use constant usage_desc => 'yt delete <task-id>';
sub execute {
my ($self, $opt, $args) = @_;
my $id = $args->[0];
$self->error("'yt delete' require a single task-id") if
@$args != 1 or
$id !~ m!^20\d{2}/\d{2}/\d{2}/\d{4}! or
not(-f $id);
my $task = $self->get_task($id);
$self->error("'$id' is in progress. Run 'stop' first")
if $task->in_progress;
$task->delete();
}
#-----------------------------------------------------------------------------#
# Guts of the machine
#-----------------------------------------------------------------------------#
package YamlTime::Command;
sub run {
my ($self, $cmd, $options) = @_;
my $error;
my $output = Capture::Tiny::capture_merged {
system($cmd) == 0 or $error = 1;
};
print $output;
if ($error) {
return 0;
}
return 1;
}
sub current_task {
my ($self) = @_;
return $self->get_task;
}
sub get_task {
my ($self, $id) = @_;
$id ||= readlink('_') or return;
return YamlTime::Task->new(id => $id);
}
sub get_task_range {
my $self = shift;
my @files;
if (@_) {
@files = @_;
}
else {
my $from = $self->format_date($self->from || 'today');
my $to = $self->format_date($self->to || 'now');
OUTER:
for my $dir (sort grep /^20\d\d$/, glob("20*")) {
for my $file (sort map $_->name, -d $dir ? io->dir($dir)->All_Files : ()) {
next if $file lt $from;
last OUTER if $file gt $to;
push @files, $file;
}
}
}
return grep {
$self->match_tags($_);
} map {
$self->get_task($_);
} sort @files;
}
sub match_tags {
my ($self, $task) = @_;
my $want = $self->tags;
return 1 unless @$want;
my $have = $task->tags;
return 0 unless @$have;
OUTER:
for my $w (@$want) {
for my $t (@$w) {
next OUTER unless grep {$_ eq $t} @$have;
}
return 1;
}
return 0;
}
sub format_date {
my ($self, $str) = @_;
my $date = DateTime::Format::Natural->new(
time_zone => $self->conf->timezone,
)->parse_datetime($str);
return sprintf "%4d/%02d/%02d/%02d%02d",
$date->year,
$date->month,
$date->day,
$date->hour,
$date->minute;
}
my $date_parser = DateTime::Format::Natural->new;
sub date {
my ($self, $string) = @_;
return eval {
$date_parser->parse_datetime($string);
} || undef;
}
sub empty_directory {
io('.')->empty;
}
sub share {
my $class = shift;
my $path = $INC{'YamlTime.pm'} or die;
if ($path =~ s!(\S.*?)[\\/]?\bb?lib\b.*!$1! and
-e "$path/Makefile.PL" and
-e "$path/share"
) {
return abs_path "$path/share";
}
else {
return File::ShareDir::dist_dir('YamlTime');
}
}
sub copy_files {
my ($self, $source, $target) = @_;
for my $file (io($source)->All_Files) {
my $short = $file->name;
$short =~ s!^\Q$source\E/?!! or die $short;
next if $short =~ /^\./;
io("$target/$short")->assert->print($file->all);
}
}
my $prompts = {
task => 'Task Description: ',
cust => 'Customer Id: ',
tags => 'A Tag Word: ',
proj => 'Project Id: ',
};
sub prompt {
my ($self, $key, $default, $task) = @_;
my $prompt = $prompts->{$key};
my $term = new Term::ReadLine 'YamlTime';
$term->Attribs->{completion_function} = sub{
return sort keys %{$self->conf->{$key}}
if $key =~ /^(cust|tags)$/;
return sort keys %{$self->conf->{proj}{$task->{cust}}}
if $key =~ /^(proj)$/;
return ();
};
my $val = $term->readline($prompt, $default);
exit unless defined $val;
$val =~ s/\A\s*(.*?)\s*\z/$1/s;
$term->addhistory($val) if $val =~ /\S/;
return $val;
}
# Prompt the user for the info needed in a task
sub populate {
my ($self, $task, $args) = @_;
my $old = $self->current_task || {};
$task->{task} = join ' ', @$args if @$args;
for my $key (qw[task cust proj tags]) {
my $val = $task->$key;
my $list = ref($val);
my $default = $list ? '' : ($task->{$key} || $old->{$key});
while (1) {
my $new_val = $self->prompt($key, $default, $task);
if (not length $new_val) {
if ($key =~ /^(task|cust)$/) {
warn " Required field.\n";
next;
}
else {
last;
}
}
elsif ($key =~ /^(cust|tags)$/) {
if (not exists $self->conf->{$key}{$new_val}) {
warn " '$new_val' is invalid.\n";
next;
}
}
elsif ($key =~ /^(proj)$/) {
if (not exists $self->conf->{proj}{$task->{cust}}{$new_val}) {
warn " '$new_val' is invalid.\n";
next;
}
}
if ($list) {
push @$val, $new_val;
}
else {
$task->$key($new_val);
last;
}
}
}
}
sub log {
my $self = shift;
print "@_\n";
}
#-----------------------------------------------------------------------------#
# Errors happen
sub error {
my ($self, $msg) = splice(@_, 0, 2);
chomp $msg;
$msg .= $/;
die sprintf($msg, @_);
}
sub error__cant_chdir_base {
my ($self) = @_;
my $base = $self->base;
$self->error(<<"...");
Can't chdir to '$base'.
YAMLTIME_BASE is set to '$base',
but it does not yet exist. You should create it and rerun your command.
...
}
sub error__not_init {
my ($self) = @_;
my $base = $self->base;
if ($ENV{YAMLTIME_BASE} or $self->empty_directory) {
$self->error(<<"...");
'$base' not yet initialized.
Run 'yt init'.
...
}
else {
$self->error(<<"...");
'$base' is not a yt directory and it's not empty.
You should mkdir and cd into a new directory, or set the YAMLTIME_BASE
environment variable to such a directory.
...
}
}
sub error__wont_init {
my ($self) = @_;
$self->error(
"Won't 'init' in a non empty directory, unless you use --force"
);
}
sub error__already_in_progress {
my ($self) = @_;
$self->error(<<'...');
Command invalid.
A task is already in progress.
Stop the current one first.
...
}
sub error__not_in_progress {
my ($self) = @_;
$self->error(<<'...');
Command invalid.
There is no task is currently in progress.
...
}
sub error__no_current_task {
my ($self) = @_;
$self->error(<<'...');
Command invalid.
There is no current task.
You may need to specify one.
...
}
1;
=head1 SYNOPSIS
> yt help
=head1 DESCRIPTION
YamlTime is an application that allows you do your personal project time
tracking from the command line. It saves your data in plain text YAML files.
You can use a version control system (like git) to back up the data.
YamlTime can supports multiple customers, multiple projects and multiple
rates. It has reporting, spreadsheets, tagging, time/tag selection and 3rd
party plugin modules.
YamlTime comes with a command line app called C<yt> that does everything.
=head1 COMMAND LINE USAGE
The following commands are supported.
yt - Show current yt status of today's tasks
yt help - Get help
yt help <command> - Get help for a specific command
yt init - Create a new YamlTime store
yt new - Start a new task
yt stop - Stop the current task
yt go - Restart the current task
yt create <task> - Create a new task for a specific date/time
yt edit <task> - Edit a task's yaml file in $EDITOR
yt dump <task> - Read a task file and print to STDOUT
yt delete <task> - Delete a task file
yt check <tasks> - Check the data in the range for errors
yt status <tasks> - Show the current yt status
yt report <tasks> - Create a report for a time period
using a certain reporting style
The <yt new> command will prompt you for some information. You can use tab
completion for many of the fields. The values that you put in your config
files are the values that are offered (and the only ones you can use).
Some of the fields may be left blank. You can use C<yt edit> to fix up any
task, later on.
=head2 Options
yt commands have the following options:
=over
=item --from=<date_string>
Commands that need a time range, use this to set the start time. The default
is the previous midnight. A human friendly string can be used, like: '3 days
ago'.
=item --to=<date_string>
Commands that need a time range, use this to set the end time. The default
is now.
=item --tag=<tag_list>
A comma separated list of tags. Matches tasks the match all the tags. You can
specify more than once to combine ('or' logig) groups.
=item --style=<report-style>
This names a YamlTime reporting style. The default is CSV, which can be used
as a spreadsheet.
=back
=head1 KUDOS
Many thanks to the good people of Strategic Data in Melbourne Victoria
Australia, for supporting me and this project. \o/