diff options
Diffstat (limited to 'timeclock')
-rwxr-xr-x | timeclock | 461 |
1 files changed, 461 insertions, 0 deletions
diff --git a/timeclock b/timeclock new file mode 100755 index 00000000..496c3471 --- /dev/null +++ b/timeclock @@ -0,0 +1,461 @@ +#!/usr/bin/env python + +# timeclock, a time-keeping program based on the Ledger library +# +# Copyright (c) 2003-2004, New Artisans LLC. All rights reserved. +# +# This program is made available under the terms of the BSD Public +# License. See the LICENSE file included with the distribution for +# details and disclaimer. +# +# This program implements a simple timeclock, using the identical +# format as my timeclock.el module for Emacs (which is part of the +# Emacs 21 distribution). This allows you to use either this script +# or that module for creating time events. +# +# Usage is very simple: Set the environment variable TIMELOG to the +# path to your timelog file (if this variable is unset, any events +# created will simply be printed to stdout). Once this variable is +# set: +# +# timeclock in "project" what aspect of the project will I do today +# timeclock out what did I accomplish +# +# The description text is optional, but the project is required when +# clocking in. This project should be a account name, which means it +# can use ":" to separate the project from the task, for example: +# +# timeclock in Client:Meetings at the code review meeting +# +# To generate a balance report of time spent, use "timeclock" with no +# arguments, or "timeclock balance". The options available are the +# same as those used for ledger. + +import os +import sys +import string +import time + +true, false = 1, 0 + +from ledger import * + +home = os.getenv ("HOME") +config.init_file = home + "/.timeclockrc"; +config.cache_file = home + "/.timeclock-cache"; + +# Define some functions for reporting time quantities + +workday = 8 * 60 * 60 # length of a nominal workday + +def secstr (secs): + return "%d:%02d" % (abs (secs) / 60 / 60, (abs (secs) / 60) % 60) + +def daystr (amt): + dy = int (amt) / int (workday) + amt = amt - (dy * workday) + if dy >= 5: + wk = dy / 5 + dy = dy % 5 + if dy: amt = "%sw %sd %s" % (wk, dy, secstr (amt)) + else: amt = "%sw %s" % (wk, secstr (amt)) + else: + if dy: amt = "%sd %s" % (dy, secstr (amt)) + else: amt = secstr (amt) + return amt + +def adaystr (details): + result = "" + for amt in account_xdata(details.account).total: + if amt.commodity ().symbol == "s": + result = daystr (float (amt)) + break + return result + +config.amount_expr = "{1.00h}*(a/{3600.00h})" +config.total_expr = "{1.00h}*(O/{3600.00h})" +config.balance_format = "%20@adaystr() %8T %2_%-a\n"; + +# Help text specific to timeclock + +def show_version (arg): + print """Timeclock, a command-line timekeeping tool + +Copyright (c) 2003-2004, New Artisans LLC. All rights reserved. + +This program is made available under the terms of the BSD Public +License. See the LICENSE file included with the distribution for +details and disclaimer.""" + sys.exit (0) + +def option_help (arg): + print """usage: timeclock [options] COMMAND [ACCT REGEX]... + +Basic options: + -h, --help display this help text + -v, --version show version information + -i, --init FILE initialize ledger by loading FILE (def: ~/.ledgerrc) + --cache FILE use FILE as a binary cache when --file is not used + -f, --file FILE read ledger data from FILE + -o, --output FILE write output to FILE + +Report filtering: + -b, --begin DATE set report begin date + -e, --end DATE set report end date + -c, --current show only current and past entries (not future) + -C, --cleared consider only cleared transactions + -U, --uncleared consider only uncleared transactions + -R, --real consider only real (non-virtual) transactions + -Z, --actual consider only actual (non-automated) transactions + -r, --related calculate report using related transactions + +Output customization: + -F, --format STR use STR as the format; for each report type, use: + --balance-format --register-format + --plot-amount-format --plot-total-format + -y, --date-format STR use STR as the date format (def: %Y/%m/%d) + --wide for the default register report, use 132 columns + -E, --empty balance: show accounts with zero balance + -n, --collapse register: collapse entries with multiple transactions + -s, --subtotal balance: show sub-accounts; register: show subtotals + -S, --sort EXPR sort report according to the value expression EXPR + -p, --period STR report using the given period + --period-sort EXPR sort each report period's entries by EXPR + --dow show a days-of-the-week report + -W, --weekly show weekly sub-totals + -M, --monthly show monthly sub-totals + -Y, --yearly show yearly sub-totals + -l, --limit EXPR calculate only transactions matching EXPR + -d, --display EXPR display only transactions matching EXPR + -t, --amount EXPR use EXPR to calculate the displayed amount + -T, --total EXPR use EXPR to calculate the displayed total + -j, --amount-data print only raw amount data (useful for scripting) + -J, --total-data print only raw total data + +Commodity reporting: + -A, --average report average transaction amount + -D, --deviation report deviation from the average + +Commands: + balance [REGEXP]... show balance totals for matching accounts + register [REGEXP]... show register of matching events""" + sys.exit (0) + +# This call registers all of the default command-line options that +# Ledger supports into the option handling mechanism. Skip this call +# if you wish to do all of your own processing -- in which case simply +# modify the 'config' object however you like. + +add_config_option_handlers () + +add_option_handler ("help", "h", option_help) +add_option_handler ("version", "v", show_version) + +# Process the command-line arguments, test whether caching should be +# enabled, and then process any option settings from the execution +# environment. Some historical environment variable names are also +# supported. + +args = process_arguments (sys.argv[1:]) +config.use_cache = not config.data_file +process_environment (os.environ, "TIMECLOCK_") + +if os.environ.has_key ("TIMELOG"): + process_option ("file", os.getenv ("TIMELOG")) + +# The command word is in the first argument. Canonicalize it to a +# unique, simple form that the remaining code can use to find out +# which command was specified. + +if len (args) == 0: + args = ["balance"] + +command = args.pop (0); + +if command == "balance" or command == "bal" or command == "b": + command = "b" +elif command == "register" or command == "reg" or command == "r": + command = "r" +elif command == "entry": + command = "e" +elif command == "in" or command == "out": + if config.data_file: + log = open (config.data_file, "a") + else: + log = sys.stdout + + if command == "in": + if len (args) == 0: + print "A project name is required when clocking in." + sys.exit (1) + log.write ("i %s %s" % (time.strftime ("%Y/%m/%d %H:%M:%S"), + args.pop (0))) + if len (args) > 0: + log.write (" %s\n" % string.join (args, " ")) + else: + log.write ("o %s" % time.strftime ("%Y/%m/%d %H:%M:%S")) + if len (args) > 0: + log.write (" %s" % string.join (args, " ")) + + log.write ("\n") + log.close () + sys.exit (0) +else: + print "Unrecognized command:", command + sys.exit (1) + +# Create the main journal object, into which all entries will be +# recorded. Once done, the 'journal' may be iterated to yield those +# entries, in the same order as which they appeared in the journal +# file. + +journal = Journal () + +# This parser is intended only for timelog files. + +class Event: + def __init__(self, kind, when, desc): + self.kind = kind + self.when = when + self.desc = desc + +class Interval: + def __init__(self, begin, end): + self.begin = begin + self.end = end + + def length(self): + "Return the length of the interval in seconds." + return self.end.when - self.begin.when + +def parse_timelog(path, journal): + import re + if not os.path.exists (path): + print "Cannot read timelog file '%s'" % path + sys.exit (1) + file = open(path) + history = [] + begin = None + linenum = 0 + for line in file: + linenum += 1 + match = re.match("([iIoO])\s+([0-9/]+\s+[0-9:]+)\s*(.+)", line) + if match: + (kind, when, desc) = match.groups() + when = time.strptime(when, "%Y/%m/%d %H:%M:%S") + when = time.mktime(when) + event = Event(kind, when, desc) + if kind == "i" or kind == "I": + begin = event + else: + if begin.desc: + match = re.match ("(.+?) (.+)", begin.desc) + if match: + acct = match.group (1) + desc = match.group (2) + else: + acct = begin.desc + desc = "" + else: + acct = "Misc" + desc = event.desc + + l = Interval(begin, event).length () + e = Entry () + e.date = int (begin.when) + e.payee = desc + + x = Transaction (journal.find_account (acct), + Amount ("%ss" % l), TRANSACTION_VIRTUAL) + e.add_transaction (x) + + if not journal.add_entry (e): + print "%s, %d: Failed to entry" % (path, linenum) + sys.exit (1) + +parse_timelog (config.data_file, journal) + +# Now that everything has been correctly parsed (parse_ledger_data +# would have thrown an exception if not), we can take time to further +# process the configuration options. This changes the configuration a +# bit based on previous option settings, the command word, and the +# remaining arguments. + +if command == "b" and \ + config.amount_expr == "{1.00h}*(a/{3600.00h})": + config.amount_expr = "a" + +config.process_options (command, args); + +# Determine the format string to used, based on the command. + +if config.format_string: + format = config.format_string +elif command == "b": + format = config.balance_format +elif command == "r": + format = config.register_format +else: + format = config.print_format + +# The following two classes are responsible for outputing transactions +# and accounts to the user. There are corresponding C++ versions to +# these, but they rely on I/O streams, which Boost.Python does not +# provide a conversion layer for. + +class FormatTransactions (TransactionHandler): + last_entry = None + output = None + + def __init__ (self, fmt): + try: + i = string.index (fmt, '%/') + self.formatter = Format (fmt[: i]) + self.nformatter = Format (fmt[i + 2 :]) + except ValueError: + self.formatter = Format (fmt) + self.nformatter = None + + self.last_entry = None + + if config.output_file: + self.output = open (config.output_file, "w") + else: + self.output = sys.stdout + + TransactionHandler.__init__ (self) + + def __del__ (self): + if config.output_file: + self.output.close () + + def flush (self): + self.output.flush () + + def __call__ (self, xact): + if not transaction_has_xdata (xact) or \ + not transaction_xdata (xact).dflags & TRANSACTION_DISPLAYED: + if self.nformatter is not None and \ + self.last_entry is not None and \ + xact.entry == self.last_entry: + self.output.write (self.nformatter.format (xact)) + else: + self.output.write (self.formatter.format (xact)) + self.last_entry = xact.entry + transaction_xdata (xact).dflags |= TRANSACTION_DISPLAYED + +class FormatAccounts (AccountHandler): + output = None + + def __init__ (self, fmt, pred): + self.formatter = Format (fmt) + self.predicate = AccountPredicate (pred) + + if config.output_file: + self.output = open (config.output_file, "w") + else: + self.output = sys.stdout + + AccountHandler.__init__ (self) + + def __del__ (self): + if config.output_file: + self.output.close () + + def final (self, account): + if account_has_xdata (account): + xdata = account_xdata (account) + if xdata.dflags & ACCOUNT_TO_DISPLAY: + print "-------------------- ---------" + xdata.value = xdata.total + self.output.write (self.formatter.format (account)) + + def flush (self): + self.output.flush () + + def __call__ (self, account): + if display_account (account, self.predicate): + if not account.parent: + account_xdata (account).dflags |= ACCOUNT_TO_DISPLAY + else: + self.output.write (self.formatter.format (account)) + account_xdata (account).dflags |= ACCOUNT_DISPLAYED + +# Set the final transaction handler: for balances and equity reports, +# it will simply add the value of the transaction to the account's +# xdata, which is used a bit later to report those totals. For all +# other reports, the transaction data is sent to the configured output +# location (default is sys.stdout). + +if command == "b": + handler = SetAccountValue () +else: + handler = FormatTransactions (format) + +# Chain transaction filters on top of the base handler. Most of these +# filters customize the output for reporting. None of this is done +# for balance or equity reports, which don't need it. + +if command != "b": + if config.display_predicate: + handler = FilterTransactions (handler, config.display_predicate) + + handler = CalcTransactions (handler) + + if config.sort_string: + handler = SortTransactions (handler, config.sort_string) + + if config.show_revalued: + handler = ChangedValueTransactions (handler, config.show_revalued_only) + + if config.show_collapsed: + handler = CollapseTransactions (handler); + +if config.show_subtotal and not (command == "b" or command == "E"): + handler = SubtotalTransactions (handler) + +if config.days_of_the_week: + handler = DowTransactions (handler) +elif config.by_payee: + handler = ByPayeeTransactions (handler) + +if config.report_period: + handler = IntervalTransactions (handler, config.report_period, + config.report_period_sort) + handler = SortTransactions (handler, "d") + +# The next two transaction filters are used by all reports. + +if config.show_inverted: + handler = InvertTransactions (handler) + +if config.show_related: + handler = RelatedTransactions (handler, config.show_all_related) + +if config.predicate: + handler = FilterTransactions (handler, config.predicate) + +if config.comm_as_payee: + handler = SetCommAsPayee (handler) + +# Walk the journal's entries, and pass each entry's transaction to the +# handler chain established above. + +walk_entries (journal, handler) + +# Flush the handlers, causing them to output whatever data is still +# pending. + +handler.flush () + +# For the balance and equity reports, the account totals now need to +# be displayed. This is different from outputting transactions, in +# that we are now outputting account totals to display a summary of +# the transactions that were just walked. + +if command == "b": + acct_formatter = FormatAccounts (format, config.display_predicate) + sum_accounts (journal.master) + walk_accounts (journal.master, acct_formatter, config.sort_string) + acct_formatter.final (journal.master) + acct_formatter.flush () |