#!/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 ()