#!/usr/bin/perl # # Copyright © Jon Langseth # # Permission is hereby granted, free of charge, to any person obtaining a copy # of this software and associated documentation files (the "Software"), to deal # in the Software without restriction, including without limitation the rights # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell # copies of the Software, and to permit persons to whom the Software is # furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE # SOFTWARE. use Time::Local; use Digest::MD5 qw(md5_hex); use File::Basename; use POSIX; use strict; my $trk_dir = "$ENV{HOME}/.trk"; if ( $ENV{TRK_DIR} ) { if ( -d $ENV{TRK_DIR} ) { $trk_dir = $ENV{TRK_DIR} if -d $ENV{TRK_DIR}; } else { printf("Environment variable TRK_DIR is not a directory\n"); exit(1); } } use constant { START => 1, TIMEFORMAT => 2, STOP => 3, EDIT => 4, TASK => 5, }; sub help { my $code = shift; printf("Error in invocation. Syntax summary:\n\n"); printf(" %s {start|on} [at yyyy-mm-dd hh:mm] \n", $0); printf(" %s {stop|off} [at yyyy-mm-dd hh:mm] \n", $0); printf(" %s {activity|task} [at yyyy-mm-dd hh:mm] \n", $0); printf(" %s main [at yyyy-mm-dd hh:mm]\n", $0); printf(" %s {projects|list} [verbose]\n", $0); printf(" %s report [{terse|standard|verbose|details}] []\n", $0); printf(" %s edit \n", $0); printf(" %s status\n", $0); printf("\nSee README.txt for more information\n"); exit(-1); } sub gen_puuid (;$) { my $id_length = shift; $id_length = 32 if not defined $id_length; my $w = time; for(my $i=0 ; $i<128;) { my $tc = chr(int(rand(127))); if($tc =~ /[a-zA-Z0-9]/) { $w .=$tc; $i++; } } $w = md5_hex( $w ); while ( length($w) < $id_length ) { $w .= gen_puuid( $id_length - length( $w ) ); } $w = substr( $w, 0, $id_length ); return $w; } # Input to parse_time is: # * date -> date-string in the form YYYY-MM-DD # * time -> time-string in the form HH:MM # Return value is a unix timestamp, as returned by time() sub parse_time ($$) { my ( $Y, $M, $D ) = split ("-", shift ); my ( $h, $m ) = split(":", shift ); return timelocal(0, $m, $h, $D, ($M-1), $Y); } sub str2time ($) { my $i = shift; return 0 if not $i =~ m/(\d\d\d\d-\d\d-\d\d) (\d\d:\d\d)/; return parse_time($1, $2); } sub time2str ($) { my $t = shift; return strftime("%Y-%m-%d %H:%M", localtime($t)); } sub delta2str ($) { my $delta = shift; my $t = $delta; my $hours = $t / 3600; $t = $delta % 3600; my $minutes = $t / 60; return sprintf("%d hours %d minutes", $hours, $minutes); } sub parse_arguments ($) { my $step = shift; my $start_time = time; my $title = undef; if (( $#ARGV >= 1) && ( $ARGV[1] eq "at" )) { # Start and Activity require a title to be present. # All other (stop, main...) do not ^^. if ( ($step == START) || ($step == TASK) ) { # TODO: Allow no title! # If no title is given, read ID of previously used track in stead :) help($step) unless $#ARGV > 3; $title = join(" ", @ARGV[4..$#ARGV]); } help(TIMEFORMAT) unless ( $ARGV[2] =~ m/\d\d\d\d-\d\d-\d\d/ && $ARGV[3] =~ m/\d\d:\d\d/); $start_time = parse_time( $ARGV[2], $ARGV[3] ); } elsif ( ($step == START) || ($step == TASK) || ($step == EDIT)) { shift(@ARGV); $title = join(" ", @ARGV); } if ( not defined $title ) { return $start_time; } else { return ( $start_time, $title ); } } sub get_last_id (;$) { my $trk_id = shift; my $wrk_dir = $trk_dir; $wrk_dir = $trk_dir . "/" . $trk_id if $trk_id; return undef if ( ! -f $wrk_dir . "/last" ); open ( CUR, "<" . $wrk_dir . "/last" ) or die ("Unable to read last track file"); my $id = ; chomp($id); close(CUR); return $id; } sub get_current_id (;$) { my $trk_id = shift; my $wrk_dir = $trk_dir; $wrk_dir = $trk_dir . "/" . $trk_id if $trk_id; return undef if ( ! -f $wrk_dir . "/current" ); open ( CUR, "<" . $wrk_dir . "/current" ) or die ("Unable to read current track file"); my $id = ; chomp($id); close(CUR); return $id; } sub set_current_id ($;$) { my $id = shift; my $trk_id = shift; my $wrk_dir = $trk_dir; $wrk_dir = $trk_dir . "/" . $trk_id if $trk_id; return undef if ( -f $wrk_dir . "/current" ); open ( CUR, ">" . $wrk_dir . "/current" ) or die ("Unable to write current track file"); printf(CUR "%s\n", $id ); close(CUR); open ( LAST, ">" . $wrk_dir . "/last" ) or die ("Unable to write last track file"); printf(LAST "%s\n", $id ); close(LAST); } sub get_tracks (;$) { my $trk_id = shift; my $wrk_dir = $trk_dir; $wrk_dir = $trk_dir . "/" . $trk_id if $trk_id; my %tracks; foreach my $d ( <$wrk_dir/*> ) { next if not -d $d; next if not -f $d . "/info"; my $id = basename($d); my $title = get_track_name( $id, $trk_id ); $tracks{$id} = $title unless not defined $title; } return \%tracks; } sub get_track_id ($;$) { my $title = shift; my $trk_id = shift; # Get hash of track-id's and -names from get_tracks my $tracks = get_tracks($trk_id); # Look up name in list foreach my $id ( keys %$tracks ) { # Return ID for name return $id if ( $tracks->{$id} eq $title ) } # If no match, return undef. return undef; } sub get_track_name ($;$) { my $id = shift; my $trk_id = shift; my $wrk_dir = $trk_dir; $wrk_dir = $trk_dir . "/" . $trk_id if $trk_id; open(PRO, "<" . $wrk_dir . "/" . $id . "/info" ) or die ("Unable to read track medatata file!"); my $title = undef; while () { next if not $_ =~ /^title:(.*)/; $title = $1; } close(PRO); return $title; } sub create_track ($;$) { my $title = shift; my $trk_id = shift; my $wrk_dir = $trk_dir; $wrk_dir = $trk_dir . "/" . $trk_id if $trk_id; my $id; do { $id = gen_puuid(8); } while ( -d $wrk_dir . "/" . $id ); mkdir ( $wrk_dir . "/" . $id ); open(PRO, ">" . $wrk_dir . "/" . $id . "/info" ) or die ("Unable to create track medatata file!"); printf(PRO "title:%s\n", $title); close(PRO); return $id; } sub start_track ($$;$) { my $start_time = shift; my $title = shift; my $trk_id = shift; my $wrk_dir = $trk_dir; $wrk_dir = $trk_dir . "/" . $trk_id if $trk_id; my $current = get_current_id($trk_id); if ( not $current ) { if ( not $title ) { $current = get_last_id( $trk_id ); } else { $current = get_track_id( $title, $trk_id ); if ( not $current ) { printf("No track by that name! Creating a new one.\n"); $current = create_track($title, $trk_id); } } # Break off here if we haven't gotten an ID yet. return undef if not $current; set_current_id($current, $trk_id); # First iteration is VERY naive: simply add the start time to the bottom of the tracking file # Will have to do more logic: if the start point is before one of the times already in the track, # the file will have to be manipulated to get coherent tracking! open (TRACK, ">>" . $wrk_dir . "/" . $current . "/tracking" ) or die ("Unable to open file, $!"); printf(TRACK "[%s]", time2str($start_time)); close (TRACK); return $current; } return undef; } sub close_track ($;$) { my $stop_time = shift; my $trk_id = shift; my $wrk_dir = $trk_dir; $wrk_dir = $trk_dir . "/" . $trk_id if $trk_id; my $current = get_current_id( $trk_id ); die ("Project exists, but tracking file does not!") if ( not -f $wrk_dir . "/" . $current . "/tracking" ); # First iteration is VERY naive: simply add the stop time to the bottom line of the tracking file # Will have to do more logic: if the start point is before one of the times already in the track, # the file will have to be manipulated to get coherent tracking! # In addtion to this: actually do some file sanity checking! open (TRACK, ">>" . $wrk_dir . "/" . $current . "/tracking" ) or die ("Unable to open file, $!"); printf(TRACK " to [%s]\n", time2str($stop_time)); close (TRACK); unlink ( $wrk_dir . "/current" ); } sub report ($$;$) { my $current = shift; my $silent = shift; my $trk_id = shift; my $wrk_dir = $trk_dir; $wrk_dir = $trk_dir . "/" . $trk_id if $trk_id; my $total = 0; my $name = get_track_name( $current, $trk_id ); printf("# Report for '%s':\n\n", $name) unless $silent; my $check = get_current_id( $trk_id ); open (TRACK, "<" . $wrk_dir . "/" . $current . "/tracking" ) or die ("Unable to open file, $!"); while ( ) { next if not $_ =~ m/\[(.*)\] to \[(.*)\]/; my $start = $1; my $end = $2; my $t_start = str2time( $start ); my $t_end = str2time( $end ); my $delta = $t_end - $t_start; if ( not $silent ) { printf(" %s to %s => %s\n", $start, $end, delta2str($delta) ); } $total += $delta; } close ( TRACK ); if ( $check eq $current ) { my $t; if ( $t = current_starttime( $trk_id ) ) { my $delta = time - $t; printf(" %s to NOW (active) => %s\n", time2str( $t ), delta2str( $delta ) ) if not $silent; $total += $delta; } } return $total; } sub current_starttime (;$) { my $trk_id = shift; my $wrk_dir = $trk_dir; $wrk_dir = $trk_dir . "/" . $trk_id if $trk_id; my $line = undef; my $current = get_current_id( $trk_id ); return 0 if not $current; open (TRACK, "<" . $wrk_dir . "/" . $current . "/tracking" ) or die ("Unable to open file, $!"); while ( ) { $line = $_; } close ( TRACK ); return 0 if not $line =~ m/^\[(\d\d\d\d-\d\d-\d\d \d\d:\d\d)\]$/; return str2time($1); } ############################################################ if ( ! -d $trk_dir ) { mkdir $trk_dir or die("Unable to create data directory"); } if ( $#ARGV < 0 ) { help(); } my $command = $ARGV[0]; if ( ( $command eq "start") || ($command eq "on" ) ) { if ( $#ARGV < 1) { help(START); } my ( $start_time, $title ) = parse_arguments(START); my $current = get_current_id(); if ( not $current ) { $current = start_track( $start_time, $title ); if ( not $current ) { printf("Something weird happened.\n"); exit(1); } } else { printf("A project is being tracked: %s\n", get_track_name( $current ) ); close_track($start_time); $current = start_track( $start_time, $title ); } printf("Started tracking of '%s' at %s\n\n", $title, scalar localtime $start_time); } elsif ( ( $command eq "stop") || ($command eq "off" ) ) { if ( $#ARGV < 0) { help(STOP); } my $stop_time = parse_arguments(STOP); my $current = get_current_id(); if ( not $current ) { printf("No project is currently tracked. To stop, please start first\n"); exit(0); } my $title = get_track_name( $current ); my $activity = get_current_id($current); if ( $activity ) { printf("An active subtask is running: '%s'. Closing it.\n", get_track_name( $activity, $current )); close_track($stop_time, $current); } close_track($stop_time); printf("Stopped tracking of '%s' at %s\n\n", $title, scalar localtime $stop_time); } elsif ( ( $command eq "activity") || ($command eq "task" ) ) { if ( $#ARGV < 1) { help(START); } my ( $start_time, $title ) = parse_arguments(START); my $trk_id = get_current_id(); if ( not $trk_id ) { printf("Starting a task/activity requires an active main track.\n"); exit(1); } else { my $current = get_current_id( $trk_id ); if ( not $current ) { $current = start_track( $start_time, $title, $trk_id ); if ( not $current ) { printf("Something weird happened.\n"); exit(1); } } else { printf("A task/activity is being tracked: %s\n", get_track_name( $current, $trk_id ) ); close_track($start_time, $trk_id); $current = start_track( $start_time, $title, $trk_id ); } printf("Started tracking of '%s' at %s\n\n", $title, scalar localtime $start_time); } } elsif ( $command eq "main" ) { if ( $#ARGV < 0) { help(STOP); } my $stop_time = parse_arguments(STOP); my $trk_id = get_current_id(); if ( not $trk_id ) { printf("Stopping a task/activity requires an active main track.\n"); exit(1); } else { my $current = get_current_id( $trk_id ); if ( not $current ) { printf("No activity/task is currently tracked. To stop, please start first\n"); exit(0); } my $title = get_track_name( $current, $trk_id ); close_track($stop_time, $trk_id); printf("Stopped tracking of '%s' at %s\n\n", $title, scalar localtime $stop_time); } } #### Mark elsif ( ( $command eq "projects" ) || ( $command eq "list" ) ) { # Todo/future extensions: # TODO: Sort list of names alphabetically # TODO: Get total-hours for projects # TODO: my $tracks = get_tracks(); printf("Currently tracked project names:\n\n"); my $current = get_current_id(); #foreach my $id ( keys %$tracks ) foreach my $id ( sort { $tracks->{$a} cmp $tracks->{$b} || $a cmp $b } keys %$tracks ) { if (( $#ARGV >= 1) && ( $ARGV[1] eq "verbose" )) { printf(" %s %s %s\n", ($id eq $current ? ">" : " " ), $id , $tracks->{$id}); } else { printf(" %s %s\n", ($id eq $current ? ">" : " " ),$tracks->{$id} ); } } print("\n"); } #### Mark elsif ( ( $command eq "activities" ) || ( $command eq "tasks" ) ) { # Todo/future extensions: # TODO: Sort list of names alphabetically # TODO: Get total-hours for projects # TODO: my $trk_id = get_current_id(); if ( not $trk_id ) { printf("Starting a task/activity requires an active main track.\n"); exit(1); } my $verbose = (( $#ARGV >= 1) && ( $ARGV[1] eq "verbose" )); my $tracks = get_tracks( $trk_id ); printf("Current track is '%s'\n", get_track_name( $trk_id )); printf("Track ID for for current track is %s\n", $trk_id) if $verbose; printf("Currently tracked activities/tasks for track '%s':\n\n", get_track_name( $trk_id )); my $current = get_current_id( $trk_id ); #foreach my $id ( keys %$tracks ) foreach my $id ( sort { $tracks->{$a} cmp $tracks->{$b} || $a cmp $b } keys %$tracks ) { if ( $verbose ) { printf(" %s %s %s\n", ($id eq $current ? ">" : " " ), $id , $tracks->{$id}); } else { printf(" %s %s\n", ($id eq $current ? ">" : " " ),$tracks->{$id} ); } } print("\n"); } elsif ( $command eq "report" ) { my $format = "standard"; my $output = 0; if (( $#ARGV >= 1) && ( ( $ARGV[1] eq "standard" ) || ( $ARGV[1] eq "terse" ) || ( $ARGV[1] eq "verbose" ) || ( $ARGV[1] eq "details" ) ) ) { $format = $ARGV[1]; shift @ARGV; } $output = 0 if $format eq "terse"; $output = 1 if $format eq "standard"; $output = 2 if $format eq "verbose"; $output = 3 if $format eq "details"; my ( undef, $title ) = parse_arguments(START); printf("Report format: %s\nTitle: %s\n", $format, $title); my $track = undef; if ( $title ) { $track = get_track_id( $title ); } else { $track = get_last_id(); } if ( not $track ) { printf ("Unable to get info for that track\n"); exit(1); } my $total = 0; my $subtotals = 0; my $activities = get_tracks( $track ); if ( keys %$activities ) { printf("# Reporting for sub-task/activities:\n\n") if $output >= 2; foreach my $id ( sort { $activities->{$a} cmp $activities->{$b} || $a cmp $b } keys %$activities ) #foreach my $id ( keys %$activities ) { $subtotals += report( $id, ( $output >= 2 ? 0 : 1 ), $track ); printf("# --------------------------------------------------------------\n\n") if $output >= 2; } } printf("# Reporting for main track/project/task\n") if $output >= 2; $total += report($track, ( ( $output >= 1 ? 0 : 1 ) ) ); printf("\n# ==============================================================\n\n") if $output >= 2; print("\n") if $output >= 1; printf("Total: %s\n", delta2str($total) ); if ( $output >= 2 ) { printf("Time logged on tasks: %s\n", delta2str($subtotals) ); } print("# End of report\n") if $output >= 1; } elsif ( $command eq "status" ) { my $trk_id = get_current_id(); if ( not $trk_id ) { printf("Not currently tracking anything.\n"); $trk_id = get_last_id(); if ( $trk_id ) { printf("Last track was: %s\n", get_track_name( $trk_id ) ); } exit(1); } printf("Currently tracking: %s\n", get_track_name( $trk_id ) ); my $t = current_starttime(); printf("Tracking started at %s\n", scalar localtime $t); printf("Time elapsed since start of session: %s\n", delta2str(time - $t) ); my $activity = get_current_id( $trk_id ); if ( $activity ) { printf("\nCurrent sub-task/activity is: %s\n", get_track_name( $activity, $trk_id ) ); my $t = current_starttime($trk_id); printf("Activity started at %s\n", scalar localtime $t); printf("Time elapsed since start of activity: %s\n", delta2str(time - $t) ); } else { $activity = get_last_id( $trk_id ); if ( $activity ) { printf("\nLast track was: %s\n", get_track_name( $activity, $trk_id ) ); } } } elsif ( $command eq "edit" ) { my ( undef, $title ) = parse_arguments(EDIT); my $id = get_last_id(); if ( $title ) { $id = get_track_id($title); if ( not $id ) { printf("No project by that name. Try 'list'\n"); exit(0); } } system( "/usr/bin/editor " . $trk_dir . "/" . $id . "/tracking" ); } else { help(); }