#!/usr/bin/perl -w # # $Id: idletime.pl,v 1.41 2003/01/13 05:28:42 jmates Exp $ # # Copyright (c) 2002, Jeremy Mates. This script is free software; # you can redistribute it and/or modify it under the same terms as # Perl itself. # # Run perldoc(1) on this file for additional documentation. # ###################################################################### # # REQUIREMENTS require 5; use strict; ###################################################################### # # MODULES use Carp; # better error reporting use Getopt::Std; # command line option processing use Data::Dumper; # stringified perl data structures use Date::Parse; # Parse date strings into time values # for file locking, temporary file handling use Fcntl qw(:DEFAULT :flock F_SETFD F_GETFD); use POSIX qw(strftime); # date and time formatting # persistency for perl data structures use Storable qw(fd_retrieve store_fd); use Text::Wrap; # line wrapping to form simple paragraphs ###################################################################### # # VARIABLES my $VERSION; ($VERSION = '$Revision: 1.41 $ ') =~ s/[^0-9.]//g; my (%opts, $data_fd, $target); my $events; my $data_file = (($ENV{"IDLETIME_HOME"}) ? $ENV{"IDLETIME_HOME"} : $ENV{"HOME"} . "/.idletime") . "/events"; # probably also want to handle command line options/validation # thereof with something in these?? my $modes = { 'status' => { 'lock' => 'read', 'handle' => \&handle_status, 'description' => "Displays currently active event, if any.", }, 'list' => { 'lock' => 'read', 'handle' => \&handle_list, 'description' => "Recent event listing.", }, 'dump' => { 'lock' => 'read', 'handle' => \&handle_dump, 'description' => "Show data structure via Data::Dumper.", }, 'xml' => { 'lock' => 'read', 'handle' => \&handle_xml, 'description' => "Testing xml output.", }, 'new' => { 'lock' => 'write', 'handle' => \&handle_new, 'description' => "Start a new event running.", }, 'end' => { 'lock' => 'write', 'handle' => \&handle_end, 'description' => "Terminate the active event.", }, 'alter' => { 'lock' => 'write', 'handle' => \&handle_alter, 'description' => "Change categories or add comments to a specified event.", }, }; ###################################################################### # # MAIN my $mode = shift; die "Unknown mode: $mode\n" unless exists $modes->{$mode}; # some modes reqire an ID to target if ($mode eq 'alter' or $mode eq 'end') { $target = shift or die "No target ID specified\n"; die "ID must be numeric\n" unless $target =~ m/^\d+$/; } # all runs need to load up the data data_load(); # command line option handling getopts('h?g:m:d:s:Mr', \%opts); help() if exists $opts{'h'} or exists $opts{'?'}; my $comment; if (exists $opts{'M'}) { eval { require File::Spec; require File::Temp; }; if ($@) { die "File::Spec or File::Temp not found, quitting.\n"; } else { require File::Spec; require File::Temp; } my $editor = $ENV{'IDLETIME_EDITOR'}; $editor ||= $ENV{'EDITOR'}; $editor ||= 'vi'; # TODO: would be nice if could find the record in question and # print data about it (if any) for reference into the following # temp file... for individual reference might be better to store # data as hash of hashes keyed off the ID... # (try to) setup a "secure" temporary file for the comment # KLUGE: leave behind on disk as we only check later as to whether # the ID is valid, which requires re-entering the message... File::Temp->safe_level(2); my ($tfh, $filename) = File::Temp::tempfile( "idletime.XXXXXXXXXX", DIR => File::Spec->tmpdir, UNLINK => 0 ); # set autoflush to make sure editor call gets the right data my $oldfh = select $tfh; $| = 1; select $oldfh; print $tfh <<"INTRO"; IDLE: ================================================================== IDLE: IDLE: Enter your comment. Lines beginning with IDLE: will be IDLE: removed automatically. IDLE: IDLE: ================================================================== INTRO # File::Temp locks the file by default, which is not productive # with external editors that also want to lock the file... flock $tfh, LOCK_UN; my $status = system "$editor $filename"; die "external editor failed with $?\n" unless $status == 0; # read user input back in, strip IDLE:, and shove into $comment # plus some cleanup on the data seek $tfh, 0, 0 or die "Error seeking on the temp file\n"; $comment = join '', grep { not m/^IDLE:/ } <$tfh>; $comment =~ s/^\s+//; $comment =~ s/\s+$//; close $tfh; } elsif (exists $opts{'m'}) { $comment = $opts{'m'}; $comment =~ s/^\s+//; $comment =~ s/\s+$//; } my $remove_event = 1 if exists $opts{'r'}; my @categories; if (exists $opts{'g'}) { die "Invalid groups format\n" unless $opts{'g'} =~ /^[\w,]+$/; @categories = split /,/, $opts{'g'}; { my %seen; @seen{@categories} = (); @categories = keys %seen; } die "No groups found\n" unless @categories; } # either raw minutes or '15m 5s' shorthand format; manual duration # needs to be in seconds. my $manual_duration; if (exists $opts{'d'}) { my $tmpdur = $opts{'d'}; # raw seconds if ($tmpdur =~ /^\d+$/) { $manual_duration = $tmpdur * 60; } elsif (my @matches = $tmpdur =~ /(\d+\s*[wdhms])/g) { my %factor = ( 'w' => 604800, 'd' => 86400, 'h' => 3600, 'm' => 60, 's' => 1, ); for my $match (@matches) { $match =~ /(\d+)(\w)/; $manual_duration += $1 * $factor{$2}; } } else { die "Unable to parse duration supplied.\n"; } } # for when you want to fudge the starting time my $manual_time; if (exists $opts{'s'}) { $manual_time = str2time($opts{'s'}); if (not defined $manual_time or time !~ /^\d+/) { die "Unable to parse supplied start time.\n"; } } my $now = time; # run appropriate sub for this mode $modes->{$mode}->{'handle'} (); exit; ###################################################################### # # SUBROUTINES sub handle_xml { eval { require XML::Writer; }; if ($@) { die "XML::Writer not found, quitting.\n"; } else { require XML::Writer; } my $w = XML::Writer->new(NEWLINES => 1); $w->xmlDecl; $w->startTag("idletime"); for my $event (@$events) { my @categories; for my $cat (@{$event->{'categories'}}) { push @categories, $cat->{'category'}; } $w->startTag("event"); $w->dataElement("time", $event->{'time'}); if ($event->{'duration'}) { $w->dataElement("duration", $event->{'duration'}); } $w->dataElement("id", $event->{'id'}); if (@categories) { $w->startTag("categories"); for my $cat (@categories) { $w->dataElement("category", $cat); } $w->endTag; } if ($event->{'comments'}) { $w->startTag("comments"); for my $rem (@{$event->{'comments'}}) { $w->dataElement("comment", $rem->{'comment'}); $w->dataElement("time", $rem->{'time'}); } $w->endTag; } $w->endTag; } $w->endTag; $w->end; } # changes categories for an event, or adds a comment sub handle_alter { my $altered; # TODO use HoH instead of AoH, so can target things by ID... my $count = 0; for my $event (@$events) { if ($event->{'id'} == $target) { last if $remove_event; if ($manual_duration) { $event->{'duration'} = $manual_duration; } if ($manual_time) { $event->{'time'} = $manual_time; } if (@categories) { # categories only can be replaced, so clear old list undef $event->{'categories'}; for my $cat (@categories) { push @{$event->{'categories'}}, {'category' => $cat}; } } if ($comment) { push @{$event->{'comments'}}, { 'time' => $now, 'comment' => $comment, }; } $altered = 1; last; } ++$count; } if ($remove_event) { splice @$events, $count, 1; $altered = 1; } if ($altered) { data_save(); } else { warn "No record matches id $target\n"; } } # this creates a new active event sub handle_new { my ($tmpid, $cattmp); # this will get slower as # events approaches 10000, # so need to archive off weekly at the longest, depending on # how many events get used... do { $tmpid = int rand 10000; } until (not grep { $_->{'id'} eq $tmpid } @$events and $tmpid > 999 ); my $event = { 'id' => $tmpid, 'categories' => undef, 'comments' => undef, }; if ($manual_duration) { $event->{'duration'} = $manual_duration; } if ($manual_time) { $event->{'time'} = $manual_time; } else { $event->{'time'} = $now; } if (@categories) { for my $cat (@categories) { push @{$event->{'categories'}}, {'category' => $cat}; } } if ($comment) { push @{$event->{'comments'}}, { 'time' => $now, 'comment' => $comment, }; } push @$events, $event; data_save(); # show event id for easier future reference print $tmpid, "\n"; } # closes out targetted event sub handle_end { my $altered; for my $event (@$events) { if ($event->{'id'} == $target) { if ($manual_duration) { $event->{'duration'} = $manual_duration; } else { $event->{'duration'} = $now - $event->{'time'}; } if ($manual_time) { $event->{'time'} = $manual_time; } if (@categories) { # categories only can be replaced, so clear old list undef $event->{'categories'}; for my $cat (@categories) { push @{$event->{'categories'}}, {'category' => $cat}; } } if ($comment) { push @{$event->{'comments'}}, { 'comment' => $comment, 'time' => $now, }; } $altered = 1; last; } } if ($altered) { data_save(); } else { warn "No record matches id $target\n"; } } # shows currently running events (those with no durations) sub handle_status { for my $event (@$events) { unless (exists $event->{'duration'}) { my $duration = $now - $event->{'time'}; my @categories; for my $cat (@{$event->{'categories'}}) { push @categories, $cat->{'category'}; } @categories = sort @categories; print join ( " ", sprintf("%4d", $event->{'id'}), strftime("%H:%M", localtime $event->{'time'}), deltatimefmt($duration), join (",", @categories) ), "\n"; } } } # for viewing various data from events... sub handle_list { for my $event (@$events) { my ($duration, $status, @categories); unless (exists $event->{'duration'}) { $duration = $now - $event->{'time'}; $status = "active"; } else { $duration = $event->{'duration'}; $status = "closed"; } for my $cat (@{$event->{'categories'}}) { push @categories, $cat->{'category'}; } @categories = sort @categories; print "ID: ", $event->{'id'}, "\n"; # print "Status: ", $status, "\n"; print "Start: ", strftime("%Y-%m-%d %H:%M:%S (%a)", localtime $event->{'time'}), "\n"; if (exists $event->{'duration'}) { print "End: ", strftime("%Y-%m-%d %H:%M:%S", localtime($event->{'time'} + $duration)), "\n"; } print "Length: ", deltatimefmt($duration), "\n"; if (@categories) { print "Groups: ", join (",", @categories), "\n"; } else { print "Groups: NONE\n" } if ($event->{'comments'}) { for my $comm (@{$event->{'comments'}}) { print wrap("", "\t", "Remark: ", $comm->{'comment'}, "\n"); } } else { print "Remarks: NONE\n"; } print "\n"; } } # DBG to easily see what is in the data structure... sub handle_dump { print Dumper $events; } # takes a interval of seconds, and returns abbreviated version # of more human-readable format sub deltatimefmt { my $difference = shift; $difference = int($difference); my $seconds = $difference % 60; $difference = ($difference - $seconds) / 60; my $minutes = $difference % 60; $difference = ($difference - $minutes) / 60; my $hours = $difference % 24; $difference = ($difference - $hours) / 24; my $days = $difference % 7; my $weeks = ($difference - $days) / 7; # better way to do this? my $temp = ($weeks) ? "${weeks}w " : ''; $temp .= ($days) ? "${days}d " : ''; $temp .= ($hours) ? "${hours}h " : ''; $temp .= ($minutes) ? "${minutes}m" : '0m'; return $temp; } # loads datafile, returns reference to data structure # (every run needs to load data up, only some need to lock for writing) sub data_load { sysopen $data_fd, $data_file, O_RDWR | O_CREAT or die "Couldn't open $data_file: $!\n"; if ($modes->{$mode}->{'lock'} eq 'write') { unless (flock $data_fd, LOCK_EX | LOCK_NB) { warn "Waiting for write lock on $data_file ...\n"; flock $data_fd, LOCK_EX or die "Can't write lock $data_file: $!\n"; } } elsif ($modes->{$mode}->{'lock'} eq 'read') { unless (flock $data_fd, LOCK_SH | LOCK_NB) { warn "Waiting for read lock on $data_file ...\n"; flock $data_fd, LOCK_SH or die "Can't read lock $data_file: $!\n"; } } else { die "No known lock preference found for $mode\n"; } # need eval to avoid "Magic number checking on storable file failed" # error from Storable when trying to read newly created/empty datafile eval { $events = fd_retrieve $data_fd; }; # close out file early if in read-only mode if ($modes->{$mode}->{'lock'} eq 'read') { data_close(); } } # saves datafile, if we are in a writing mode sub data_save { if ($modes->{$mode}->{'lock'} eq 'write') { die "Can't save to undefined data descriptor" unless $data_fd; seek $data_fd, 0, 0 or die "Problem seeking"; truncate $data_fd, 0 or die "Problem truncating"; store_fd $events, $data_fd; } else { warn "No write lock: cannot save data"; } } # to cleanup... sub data_close { if ($data_fd) { flock $data_fd, LOCK_UN; close $data_fd; undef $data_fd; } } sub END { data_close(); } # a generic help blarb sub help { print <<"HELP"; Usage: $0 mode [eventid] [opts] A time tracking utility. Options for version $VERSION: -h/-? Display this message Run perldoc(1) on this script for additional documentation. HELP exit; } ###################################################################### # # DOCUMENTATION =head1 NAME idletime.pl - time tracking utility =head1 SYNOPSIS Start a new event, specifying the group: $ idletime.pl new -g lunch Get status on running events: $ idletime.pl status Terminate an event with a remark: $ idletime.pl end 1234 -m "Finished lunch." =head1 DESCRIPTION =head2 Overview This script is a prototype time tracking utility that allows one to associate arbitrary groups (text strings) with blocks of time. Multiple time blocks can be tracked simultaneously. Additionally, remarks (random text) can be attatched to the time blocks. This allows one to record how much time was spent doing what using a command line tool, runnable from anywhere that has shell access. The current underlying data structure (stored with flock(2) locking to disk by Storable) could easily be accessed and modified from a GUI tool or the web environment, in theory. The interface is similar to that of CVS, as detailed below. =head2 Normal Usage $ idletime.pl mode [eventid] [options] See L<"MODES"> for the various operational modes. =head1 MODES Modes dictate how the script should interact with the data. =over 4 =item B [B<-g> I] [B<-M> | B<-m> I] [B<-s> I