#!/usr/local/bin/python

# No effort has been made to make this code MT-safe.

import os
import exceptions
import weakref
import time
import pygtk
pygtk.require('2.0')
import gtk
import sys
import getopt
import copy
import gobject
import datetime
import pdb

def timediff(e, s):
    e = datetime.datetime.fromtimestamp(e)
    s = datetime.datetime.fromtimestamp(s)
    d = e - s
    return d.seconds

def ascinterval(x):
    '''Convert a number of seconds into a human-readable interval
    such as "2h30m00s".
    '''
    return (('%02d' % (x / 3600)) + 'h'
            + ('%02d' % ((x / 60) % 60)) + 'm'
            + ('%02d' % (x % 60)) + 's')

def ascintervalshort(x):
    '''Convert a number of seconds into a human-readable interval
    such as "2h30m".
    '''
    return (('%d' % (x / 3600)) + 'h' + ('%d' % ((x / 60) % 60)) + 'm')

def timeToString(x):
    '''Convert a number of seconds since epoch into a human-readable
    date/time in the local timezone of the form YYYY-MM-DD HH:MM:SS.
    '''
    return time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(x))

def timeToDateString(x):
    '''Convert a number of seconds since epoch into a human-readable
    date/time in the local timezone of the form YYYY-MM-DD.
    '''
    return time.strftime('%Y-%m-%d', time.localtime(x))

# TODO: fix stringToTime so that it doesn't require all fields
# (eg seconds)
def stringToTime(x):
    '''Convert a date/time in the local timezone of the form YYYY-MM-DD
    HH:MM:SS into a number of seconds since epoch or None if the
    string passed is not in the proper format.
    '''
    try:
        return time.mktime(time.strptime(x, '%Y-%m-%d %H:%M:%S'))
    except Exception:
        return None

class Identifier:
    '''Identifier contains nothing.  Instances serve only as objects for
    enumeration values to point at.
    '''
    pass

class FatalError(exceptions.Exception):
    '''A "permanent" error; ie, retrying will not fix the problem
    until some other action has been taken.
    '''
    def  __init__(self, msg):
        self.args = (msg)
        self.errmsg = msg
        self.errno = None

class NonfatalError(exceptions.Exception):
    '''An exception that might not occur again if invoking action is
    retried.
    '''
    def  __init__(self, msg):
        self.args = (msg)
        self.errmsg = msg
        self.errno = None

class Table:
    '''A persistent table of rows uniquely keyed by integers.  Record
    values may be anything that can be recreated using the eval()
    function.  Use collection datatype syntax to access and modify
    table contents.
        On disk, records are stored one per line as (key, (value,))
    with the key and value written as generated by repr().  When
    subsequent tuples are seen with the same key, only the last is
    used.  A tuple containing no value considered deleted.  In a
    "tidy" file (which can be generated by calling Table.flush(tidy=True)),
    only one tuple will exist for each key, no tuples will have an
    empty value, and tuples will be ordered by key.
        The reason for permitting multiple lines with the same key and
    ignoring earlier ones is to enable updates to be written to disk
    in constant time without creating a file format unsuitable for use
    with vi.  Simiarly, keys without values indicate that the key does
    not exist so that deletions can be written in linear time.  The
    tidy option to Table.flush is provided since this technique will
    eventually produce a lot of trash in the data file.
    '''
    def __init__(self, filename):
        '''Construct a new table by passing the name of the file containing
        the table data.  If this file does not exist, it will be created.
        '''
        self._filename = filename
        self.filename = os.path.abspath(filename)
        if (os.path.exists(self.filename)
            and not os.path.isfile(self.filename)):
            raise FatalError('The path given for a Table, "'
                              + filename + '" exists but is not a file')
        else:
            try:
                if (os.path.exists(self.filename)): m = 'r+'
                else: m = 'w+'
                self.f = open(self.filename, m)
            except OSError, e:
                raise FatalError(
                    'The Table ' + filename + ' could not be opened: ' + str(e))
        self.populated = False
        self.dirty = False
        self.rows = {}
        self._nextid = 1
        self.mustFlushClean = False
    def __del__(self):
        '''Destroy the table object.  This will automatically cause changes
        to be saved.
        '''
        self.close()
    def _populate(self):
        '''Read table data off of disk, if necessary.'''
        if (self.populated): return
        self.f.seek(0)
        rows = {}
        for line in self.f.xreadlines():
            (key, value) = eval(line)
            if (not self.rows.has_key(key)):
                assert(type(key) == type(0))
                rows[key] = [value, False] # [(value), dirty]
        self.rows.update(rows)
        self.populated = True
        if (len(self.rows.keys())): self._nextid = max(self.rows.keys()) + 1
    def name(self):
        '''Return the name of this table.'''
        return os.path.basename(self._filename)
    def flush(self, **kwargs):
        '''Write out changes made since the last call to flush.  Pass
        tidy=True to cause the resulting data file to be suitable
        for human viewing/editing (specifically, it won't contain
        outdated rows and the rows will be sorted by key.)
        '''
        if (kwargs.get('tidy', False)): tidy = True
        else: tidy = False
        if (self.dirty or tidy):
            if (tidy):
                self._populate()
                keys = list(self.rows.keys())
                keys.sort()
                self.f.seek(0, 0)
                self.f.truncate(0)
            else:
                keys = self.rows.keys()
                self.f.seek(0, 2)
            for key in keys:
                (value, dirty) = self.rows[key]
                if (dirty or (tidy and value != ())):
                    self.f.write(repr((key, value)))
                    self.f.write('\n')
                    self.rows[key][1] = False
                if (value == ()):
                    del self.rows[key]
            self.dirty = False
        self.f.flush()
    def close(self):
        '''Flush changes and release the filehandle.'''
        if (self.f.closed): return
        self.flush()
        self.f.close()
    def __getitem__(self, key):
        '''Collection semantics.'''
        if (not self.has_key(key)):
            raise KeyError(key)
        return copy.deepcopy(self.rows[key][0][0])
    def __setitem__(self, key, value):
        '''Collection semantics.'''
        # We don't call Table.has_key here because we don't
        # necessarily need to populate the table with data.
        if (not self.rows.has_key(key)
            or len(self.rows[key][0]) == 0
            or self.rows[key][0][0] != value):
            self.rows[key] = [(copy.deepcopy(value),), True]
            self.dirty = True
    def __delitem__(self, key):
        '''Collection semantics.'''
        if (not self.has_key(key)): raise KeyError, key
        self.dirty = True
        self.rows[key] = [(), True]
    def has_key(self, key):
        '''Collection semantics.'''
        self._populate()
        return (self.rows.has_key(key) and self.rows[key][0] != ())
    def __contains__(self, key):
        '''Collection semantics.'''
        return self.has_key(key)
    def keys(self):
        '''Return a list of keys.'''
        self._populate()
        return self.rows.keys()
    def query(self, func):
        '''Run func on (key, value) for each record and return a list of
        records for which func returns true.
        '''
        self._populate()
        matches = []
        for rec in self.rows.items():
            if (rec[1][0] != () and func((rec[0], rec[1][0][0]))):
                matches.append((rec[0], rec[1][0][0]))
        return matches
    def nextid(self):
        '''Return an unused ID.'''
        self._populate()
        x = self._nextid
        self._nextid += 1
        return x

class Datastore:
    '''A Datastore is a collection of Tables, keyed by string name,
    that all live in the same directory.  Datastore manages creating
    table objects, returning them by name, and performing certain
    operations on all tables at once.  It is not strictly necessary
    to use Datastore to use Table.  Use collection datatype syntax
    to retrieve Table objects from the Datastore.
    '''
    def __init__(self, directory):
        '''Construct a new Datastore by passing the name of a directory
        where table data is located.  If the directory does not
        exist, it will be created (assuming that its parent exists).
        '''
        self.directory = os.path.abspath(directory)
        if (os.path.exists(self.directory)
            and not os.path.isdir(self.directory)):
            raise FatalError('The path given for the data store, "'
                + directory + '" exists but is a file')
        elif (not os.path.exists(self.directory)):
            try:
                os.mkdir(self.directory)
            except OSError, e:
                raise FatalError(
                    'The path given for the data store, "'
                    + directory
                    + '" does not exist and could not be created: "'
                    + str(e))
        if (not os.access(self.directory, os.W_OK|os.X_OK)):
            raise FatalError(
                'This process has insufficient privileges for '
                'the data store directory "' + directory + '"')
        self.tables = {}
    def __getitem__(self, key):
        '''Collection semantics.'''
        if (self.tables.has_key(key)):
            return self.tables[key]
        else:
            t = Table(self.directory + os.sep + key)
            self.tables[key] = t   
            return t
    def close(self):
        '''Close all tables.  This does not destroy the Table objects.
        To do that, you must destroy the Datastore object.
        '''
        while len(self.tables):
            self.tables.popitem()[1].close()
    def flush(self, **kw):
        '''Flush all tables, passing supplied keyword arguments to the
        Table objects.'''
        for t in self.tables.values():
            t.flush(**kw)

class NotificationCenter:
    '''NotificationCenter supplies a centralized point of distribution
    of events to interested parties.  Any type that can serve as a hash
    key can serve as an event.
    '''
    def __init__(self):
        self.observers = {}
    def addCallback(self, event, func, *args):
        '''Register a function callback for an event.  Functions will be
        called as
            func(ev, *args)
        where ev is the event that triggered the callback, and args
        are additional arguments, if any, passed to addCallback.  Note
        that registering a bound method will cause the relevant object
        to persist for the lifetime of the NotificationCenter.  If you
        wish to add a callback on an object that will be removed
        automatically when the object is destroyed, use addObserver.
        Calling addCallback repeatedly with the same event and func
        arguments will only update the *args value to be passed to
        func.
        '''
        if (not self.observers.has_key(event)): self.observers[event] = []
        self.observers[event].append((func, args))
        return (event, func)
    def addCallback(self, event, obj, func, *args):
        '''Register a callback to an object for an event.  Methods will be
        called as
            obj.func(ev, *args)
        ev is the event that triggered the callback, and args are
        additional arguments, if any, passed to addCallback.  More
        than one function can be registered on a single object for a
        single event.  Registering an observer does not cause the
        observer to persist.  When it is destroyed, the registration
        will automatically be removed.  Calling addObserver repeatedly
        with the same event, obj, and func arguments will only update
        the *args value to be passed to obj.func.
        '''
        if (not self.observers.has_key(event)): self.observers[event] = []
        self.observers[event].append((func, args))
        return (event, func)
    def removeCallback(self, event, func):
        '''Remove a callback.  The event and callback function specify which
        callback to remove.  Returns True iff a registered callback
        was removed.
        '''
        if (self.observers.has_key(event)):
            l = self.observers[event]
            c = 0
            for t in l:
                if (len(t) == 2 and t[0] == func):
                    del l[c]
                    return True
                c += 1
        return False
    def removeObserver(self, event, obj, *funcarg):
        '''Remove a callback to an observer.  The event, object, and callback
        function specify which observer to remove.  If no function is
        specified, all callbacks registered for the given observer
        will be removed.  Returns True iff a registered callback was
        removed.
        '''
        if (len(funcarg) == 0): func = None
        else: func = funcarg[0]
        x = False
        if (self.observers.has_key(event)):
            l = self.observers[event]
            c = 0
            for r in tuple(l):
                if (len(r) == 3 and r[0]() == obj
                        and (func == None or func == r[1])):
                    del l[c]
                    x = True
                else:
                    c += 1
        return x
    def notify(self, event):
        '''Post an event.'''
        l = self.observers.get(event, [])
        c = 0
        for r in tuple(l):
            if (len(r) == 3):
                o = r[0]()
                if (o):
                    r[1](o, event, r[2])
                    c += 1
                else:
                    del l[c]
            else:
                assert(len(r) == 2)
                r[0](event, r[1])

class Root:
    '''The root of the object heirarchy representing the in-memory
    portions of the database.  Be careful when using this class; if
    Items of type B are usually "owned" by Items of type A and you ask
    Root for a B Item, you will end up with two objects in memory
    representing the same record on disk, and the one created through
    Root won't properly know who its parent is.
    '''
    def __init__(self, dsdir):
        '''Create a new Root object, specifying the path to the datastore as
        dsdir.
        '''
        self.ds = Datastore(dsdir)
        self.nc = NotificationCenter()
        self._children = {}
    def flush(self):
        '''Flush unwritten changes to disk.'''
        self.ds.flush()
    def child(self, childType, childid):
        '''Return a specified child.'''
        for child in self.children(childType):
            if (child.id == childid):
                return child
        return None
    def children(self, childType):
        '''Return all of a specified type of Item.'''
        self._populateChildren(childType)
        return self._children[childType]
    def newChild(self, childType):
        '''Create a new Item of a specified type.'''
        self._populateChildren(childType)
        child = Item(self, self, childType)
        self._children[childType].append(child)
        return child
    def _populateChildren(self, childType):
        '''Create Item objects to represent children of some type, if they
        have not already been brought into memory.
        '''
        if (childType not in self._children):
            children = self._children[childType] = []
            for id in self.ds[childType].keys():
                children.append(Item(self, self, childType, id))
    def _childAdded(self, childType, id):
        '''A child is telling us that it has received an id, but we have no
        action to take on this information.
        '''
        pass

class Item:
    '''Item is a base class for classes representing table records.'''
    def __init__(self, root, parent, type, id=None):
        '''Create an Item.  The parent argument is the Item that owns this
        Item.  The table argument is the table in which this Item
        lives.  The id argument is the id of the Item to load or None
        if a new Item is being created.
        '''
        self.root = root
        self.parent = parent
        self.type = type
        self.table = self.root.ds[self.type]
        self.id = id
        if (self.id): self.cache = self.table[self.id]
        else: self.cache = {'children': {}}
        self._children = {}
    def __getitem__(self, key):
        '''Collection semantics.'''
        return self.cache[key]
    def __setitem__(self, key, value):
        '''Collection semantics.'''
        self.cache[key] = value
    def __delitem__(self, key):
        '''Collection semantics.'''
        del self.cache[key]
    def has_key(self, key):
        '''Collection semantics.'''
        return self.cache.has_key(key)
    def __contains__(self, key):
        '''Collection semantics.'''
        return self.has_key(key)
    def child(self, childType, childid):
        '''Return a specified child.'''
        for child in self.children(childType):
            if (child.id == childid):
                return child
        return None
    def children(self, childType):
        '''Return a list of all children of a given type.'''
        self._populateChildren(childType)
        return self._children[childType]
    def newChild(self, childType):
        '''Return a new child of a given type.'''
        assert(self.table.name() != childType) # that would be weird
        self._populateChildren(childType)
        child = Item(self.root, self, childType)
        self._children[childType].append(child)
        return child
    def _populateChildren(self, childType):
        '''Create Item objects to represent children of some type, if they
        have not already been brought into memory.
        '''
        if (childType not in self._children):
            children = self._children[childType] = []
            ids = self.cache['children'].get(childType, ())
            assert(type(ids) == type([]) or type(ids) == type(()))
            for id in ids:
                children.append(Item(self.root, self, childType, id))
    def save(self):
        '''Record changes in this record to the underlying table.'''
        if (self.id == None):
            self.id = self.table.nextid()
            self.parent._childAdded(self.table.name(), self.id)
        self.table[self.id] = self.cache
        self.table.flush()
    def _childAdded(self, childType, id):
        '''Receive notification from a freshly created child item
        that it has established an id for itself, which id we must
        track in our list.  This forces a save because the expectation
        is that the child has just been saved, and in order for
        that operation to really take effect, we must save as well.
        '''
        if (childType not in self.cache['children']):
            self.cache['children'][childType] = []
        self.cache['children'][childType].append(id)
        self.save()
    def update(self, map):
        for (k, v) in map.items():
            self[k] = v
    def parent(self):
        '''Return our parent Item.'''
        return parent
    def disown(self, child):
        '''Disown a child, leaving it with no parent.'''
        assert self._children[child.type].count(child) == 1
        self.cache['children'][child.type].remove(child.id)
        self._children[child.type].remove(child)
        child.parent = None
    def die(self):
        '''Cause this Item to be disowned, recursively call die on
        all its children, and delete it from the datastore.
        '''
        self.parent.disown(self)
        del self.table[self.id]
        for child in self._children: child.die()
        del self.root
        del self.type
        del self.table
        del self.id
        del self.cache
        del self._children

class BillWindow:

    def __init__(self, delegate, root):
        self.delegate = delegate
        self.root = root

        #### Main window ###############################
        self.treeData = gtk.TreeStore(gobject.TYPE_STRING,
                                      gobject.TYPE_PYOBJECT)
        self.treeView = gtk.TreeView(self.treeData)
        self.treeView.set_headers_visible(False)
        self.treeView.show()
        self.treeView.connect('row-activated', self.rowActivated)
        self.treeView.connect('button-press-event', self.treeClick)
        self.treeView.connect('key-press-event', self.keyPress)
        self.treeView.connect('cursor-changed', self.makeTreeCursorVisible)
        cell = gtk.CellRendererText()
        col = gtk.TreeViewColumn('', cell, text=0)
        self.treeView.append_column(col)
        self.treeWindow = gtk.ScrolledWindow()
        self.treeWindow.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
        self.treeWindow.add_with_viewport(self.treeView)
        self.treeWindow.set_border_width(5)
        self.treeWindow.set_size_request(300, 1)
        self.treeWindow.show()
        self.nothing = gtk.DrawingArea()
        self.nothing.show()
        self.panes = gtk.HPaned()
        self.panes.show()
        self.panes.add1(self.treeWindow)
        self.panes.add2(self.nothing)
        self.pane2 = self.nothing
        self.panes.show()
        self.window = gtk.Window(gtk.WINDOW_TOPLEVEL)
        self.window.set_size_request(750, 400)
        self.window.set_title('bill')
        self.window.connect('delete_event', self.delete)
        self.window.add(self.panes)
        #self.window.connect('key-press-event', self.keyPress)
        self.window.set_property('allow-grow', False)
        self.fillTree()
        self.window.show()

        #### Work pane ###############################
        table = gtk.Table(9, 4, False)
        table.set_border_width(10)
        table.set_row_spacings(10)
        table.set_col_spacings(10)
        table.show()
        # client
        label = gtk.Label('Client')
        label.set_alignment(1, 0.5)
        label.show()
        table.attach(label, 0, 1, 0, 1)
        self.workClient = gtk.Label()
        self.workClient.set_alignment(0, 0.5)
        self.workClient.show()
        table.attach(self.workClient, 1, 4, 0, 1)
        # project
        label = gtk.Label('Project')
        label.set_alignment(1, 0.5)
        label.show()
        table.attach(label, 0, 1, 1, 2)
        self.workProject = gtk.Label()
        self.workProject.set_alignment(0, 0.5)
        self.workProject.show()
        table.attach(self.workProject, 1, 4, 1, 2)
        # duration
        label = gtk.Label('Duration')
        label.set_alignment(1, 0.5)
        label.show()
        table.attach(label, 0, 1, 2, 3)
        self.workDuration = gtk.Label()
        self.workDuration.set_alignment(0, 0.5)
        self.workDuration.show()
        table.attach(self.workDuration, 1, 4, 2, 3)
        # start
        label = gtk.Label('Start')
        label.set_alignment(1, 0.5)
        label.show()
        table.attach(label, 0, 1, 3, 4)
        self.workStart = gtk.Entry()
        self.workStart.connect('changed', self.workChange, None)
        self.workStart.show()
        table.attach(self.workStart, 1, 2, 3, 4)
        button = gtk.Button('Fill')
        button.connect('clicked', self.fillWorkStart, None)
        button.show()
        table.attach(button, 2, 3, 3, 4, xoptions=gtk.FILL, yoptions=0)
        # end
        label = gtk.Label('End')
        label.set_alignment(1, 0.5)
        label.show()
        table.attach(label, 0, 1, 4, 5)
        self.workEnd = gtk.Entry()
        self.workEnd.connect('changed', self.workChange, None)
        self.workEnd.show()
        table.attach(self.workEnd, 1, 2, 4, 5)
        self.workEndFillButton = gtk.Button('Fill')
        self.workEndFillButton.connect('clicked', self.fillWorkEnd, None)
        self.workEndFillButton.show()
        table.attach(
            self.workEndFillButton, 2, 3, 4, 5, xoptions=gtk.FILL, yoptions=0)
        # billed checkbox
        label = gtk.Label('Billed')
        label.set_alignment(1, 0.5)
        label.show()
        table.attach(label, 0, 1, 5, 6)
        self.workBilledCheckbox = gtk.CheckButton()
        self.workBilledCheckbox.connect('clicked', self.workChange, None)
        self.workBilledCheckbox.show()
        table.attach(self.workBilledCheckbox, 1, 4, 5, 6)
        # notes
        label = gtk.Label('Notes')
        label.set_alignment(1, 0.1)
        label.show()
        table.attach(label, 0, 1, 6, 7)
        self.workNotesBuffer = gtk.TextBuffer()
        self.workNotesBuffer.connect('changed', self.workChange, None)
        self.workNotesText = gtk.TextView(self.workNotesBuffer)
        self.workNotesText.set_editable(True)
        self.workNotesText.set_wrap_mode(gtk.WRAP_WORD)
        self.workNotesText.show()
        swin = gtk.ScrolledWindow()
        swin.set_policy(gtk.POLICY_AUTOMATIC, gtk.POLICY_AUTOMATIC)
        swin.add_with_viewport(self.workNotesText)
        swin.set_size_request(300, 100)
        swin.show()
        table.attach(swin, 1, 4, 6, 7)
        # delete button
        self.workDeleteButton = gtk.Button('Delete')
        self.workDeleteButton.connect('clicked', self.deleteWork, None)
        self.workDeleteButton.show()
        table.attach(self.workDeleteButton, 3, 4, 8, 9, xoptions=gtk.FILL, yoptions=0)
        # save button
        self.workSaveButton = gtk.Button('Save')
        self.workSaveButton.connect('clicked', self.saveWork, None)
        self.workSaveButton.show()
        table.attach(self.workSaveButton, 2, 3, 8, 9, xoptions=gtk.FILL, yoptions=0)
        # end of widgets
        self.workFrame = gtk.Frame()
        self.workFrame.set_label(' Work ')
        self.workFrame.set_label_align(0.1, 0.5)
        self.workFrame.set_shadow_type(gtk.SHADOW_ETCHED_OUT)
        self.workFrame.set_border_width(5)
        self.workFrame.show()
        self.workFrame.add(table)
        self.workFrame.connect('hide', self.saveActiveItem, 'hide')
        self.workFrame.connect('unmap', self.saveActiveItem, 'unmap')
        self.workFrame.connect('unmap-event', self.saveActiveItem, 'unmap-event')
        self.workFrame.connect('delete-event', self.saveActiveItem, 'delete-event')
        self.workFrame.connect('destroy-event', self.saveActiveItem, 'destroy-event')

        #### Project pane ###############################
        table = gtk.Table(9, 4, False)
        table.set_row_spacings(10)
        table.set_col_spacings(10)
        table.set_border_width(10)
        table.show()
        # client name
        label = gtk.Label('Client')
        label.set_alignment(1, 0.5)
        label.show()
        table.attach(label, 0, 1, 0, 1)
        self.projectClientName = gtk.Label()
        self.projectClientName.set_alignment(0, 0.5)
        self.projectClientName.show()
        table.attach(self.projectClientName, 1, 4, 0, 1)
        # project name
        label = gtk.Label('Project')
        label.set_alignment(1, 0.5)
        label.show()
        table.attach(label, 0, 1, 1, 2)
        self.projectProjectName = gtk.Label()
        self.projectProjectName.set_alignment(0, 0.5)
        self.projectProjectName.show()
        table.attach(self.projectProjectName, 1, 4, 1, 2)
        # ctime
        label = gtk.Label('Created')
        label.set_alignment(1, 0.5)
        label.show()
        table.attach(label, 0, 1, 2, 3)
        self.projectCtime = gtk.Label()
        self.projectCtime.set_alignment(0, 0.5)
        self.projectCtime.show()
        table.attach(self.projectCtime, 1, 4, 2, 3)
        # total time
        label = gtk.Label('Total time')
        label.set_alignment(1, 0.5)
        label.show()
        table.attach(label, 0, 1, 3, 4)
        self.projectTotal = gtk.Label()
        self.projectTotal.set_alignment(0, 0.5)
        self.projectTotal.show()
        table.attach(self.projectTotal, 1, 4, 3, 4)
        # unbilled time
        label = gtk.Label('Unbilled time')
        label.set_alignment(1, 0.5)
        label.show()
        table.attach(label, 0, 1, 4, 5)
        self.projectUnbilled = gtk.Label()
        self.projectUnbilled.set_alignment(0, 0.5)
        self.projectUnbilled.show()
        table.attach(self.projectUnbilled, 1, 4, 4, 5)
        # weekly total
        label = gtk.Label('Time last week')
        label.set_alignment(1, 0.5)
        label.show()
        table.attach(label, 0, 1, 5, 6)
        self.projectWeeklyTotal = gtk.Label()
        self.projectWeeklyTotal.set_alignment(0, 0.5)
        self.projectWeeklyTotal.show()
        table.attach(self.projectWeeklyTotal, 1, 4, 5, 6)
        # total weeks
        label = gtk.Label('Weeks encompased')
        label.set_alignment(1, 0.5)
        label.show()
        table.attach(label, 0, 1, 6, 7)
        self.projectTotalWeeks = gtk.Label()
        self.projectTotalWeeks.set_alignment(0, 0.5)
        self.projectTotalWeeks.show()
        table.attach(self.projectTotalWeeks, 1, 4, 6, 7)
        # average hours per week
        label = gtk.Label('Time per week')
        label.set_alignment(1, 0.5)
        label.show()
        table.attach(label, 0, 1, 7, 8)
        self.projectAverageWeek = gtk.Label()
        self.projectAverageWeek.set_alignment(0, 0.5)
        self.projectAverageWeek.show()
        table.attach(self.projectAverageWeek, 1, 4, 7, 8)
        # end of widgets
        self.projectFrame = gtk.Frame()
        self.projectFrame.set_label(' Project ')
        self.projectFrame.set_label_align(0.1, 0.5)
        self.projectFrame.set_shadow_type(gtk.SHADOW_ETCHED_OUT)
        self.projectFrame.set_border_width(5)
        box = gtk.VBox()
        box.add(table)
        box.set_child_packing(table, False, False, 5, gtk.PACK_START)
        box.show()
        self.projectFrame.add(box)
        self.projectFrame.show()

        #### Client pane ###############################
        ## table
        table = gtk.Table(5, 4, False)
        table.set_row_spacings(10)
        table.set_col_spacings(10)
        table.set_border_width(10)
        table.show()
        # name label (table row 1, col 1)
        label = gtk.Label('Name')
        label.set_alignment(1, 0.5)
        label.show()
        table.attach(label, 0, 1, 0, 1)
        # name text (table row 2, col 2-4)
        self.clientName = gtk.Label()
        self.clientName.set_alignment(0, 0.5)
        self.clientName.show()
        table.attach(self.clientName, 1, 4, 0, 1)
        #### end table, end vbox widgets
        self.clientFrame = gtk.Frame()
        self.clientFrame.set_label(' Client ')
        self.clientFrame.set_label_align(0.1, 0.5)
        self.clientFrame.set_shadow_type(gtk.SHADOW_ETCHED_OUT)
        self.clientFrame.set_border_width(5)
        box = gtk.VBox()
        box.add(table)
        box.set_child_packing(table, False, False, 5, gtk.PACK_START)
        box.show()
        self.clientFrame.add(box)
        self.clientFrame.show()

        #### Popup menus ###############################
        self.clientsMenu = gtk.Menu()
        item = gtk.MenuItem('New client')
        item.connect('activate', self.newClient)
        item.show()
        self.clientsMenu.append(item)
        self.clientMenu = gtk.Menu()
        item = gtk.MenuItem('New project')
        item.connect('activate', self.newProject)
        item.show()
        self.clientMenu.append(item)
        self.projectMenu = gtk.Menu()
        item = gtk.MenuItem('New work')
        item.connect('activate', self.newWork)
        item.show()
        self.projectMenu.append(item)
        self.workMenu = self.projectMenu
        self.menus = [None, self.clientsMenu, self.clientMenu,
                      self.projectMenu, self.workMenu, None]

        #### New client dialog ###############################
        label = gtk.Label('Name')
        label.set_alignment(1, 0.5)
        label.show()
        self.newClientName = gtk.Entry()
        self.newClientName.show()
        self.newClientDialog = gtk.Dialog(
            'New Client',
            self.window,
            gtk.DIALOG_DESTROY_WITH_PARENT|gtk.DIALOG_MODAL,
            (gtk.STOCK_OK, gtk.RESPONSE_ACCEPT,
             gtk.STOCK_CANCEL, gtk.RESPONSE_REJECT))
        self.newClientName.connect(
            'activate',
            lambda e,*p: self.newClientDialog.response(gtk.RESPONSE_ACCEPT))
        box = gtk.HBox()
        box.set_spacing(10)
        self.newClientDialog.vbox.set_spacing(5)
        self.newClientDialog.set_border_width(5)
        box.pack_start(label, True, True, 0)
        box.pack_end(self.newClientName, True, True, 0)
        box.show()
        self.newClientDialog.vbox.pack_start(box, True, True, 0)

        #### New project dialog ###############################
        label = gtk.Label('Name')
        label.set_alignment(1, 0.5)
        label.show()
        self.newProjectName = gtk.Entry()
        self.newProjectName.show()
        self.newProjectDialog = gtk.Dialog(
            'New Project',
            self.window,
            gtk.DIALOG_DESTROY_WITH_PARENT|gtk.DIALOG_MODAL,
            (gtk.STOCK_OK, gtk.RESPONSE_ACCEPT,
             gtk.STOCK_CANCEL, gtk.RESPONSE_REJECT))
        self.newProjectName.connect(
            'activate',
            lambda e,*p: self.newProjectDialog.response(gtk.RESPONSE_ACCEPT))
        box = gtk.HBox()
        box.set_spacing(10)
        self.newProjectDialog.vbox.set_spacing(5)
        self.newProjectDialog.set_border_width(5)
        box.pack_start(label, True, True, 0)
        box.pack_end(self.newProjectName, True, True, 0)
        box.show()
        self.newProjectDialog.vbox.pack_start(box, True, True, 0)

	#### Set a timer for updating various displays #########
	self.activatedObject = None
	gobject.timeout_add(2000, self.timerUpdate)

    def saveActiveItem(self, *ignored):
        print 'saveActiveItem'

    def makeTreeCursorVisible(self, *ignored):
        it = self.treeView.get_selection().get_selected()[1]
        if (it == None): return
        p = self.treeData.get_path(it)
        self.treeView.expand_to_path(p)
        self.treeView.scroll_to_cell(p)

    def keyPress(self, widget, event):
        if (event.keyval == 110 and (event.state & gtk.gdk.META_MASK)):
            r = self.treeView.get_selection().get_selected_rows()[1]
            if (len(r) == 0 or len(r[0]) == 1):
                self.newClient()
            elif (len(r[0]) == 2):
                self.newProject()
            elif (len(r[0]) == 3):
                self.newWork()
        elif (widget == self.treeView and event.keyval == 65361): # left
            r = self.treeView.get_selection().get_selected_rows()[1]
            if (len(r) > 0 and len(r[0]) < 4):
                self.treeView.collapse_row(r[0])
        elif (widget == self.treeView and event.keyval == 65363): # right
            r = self.treeView.get_selection().get_selected_rows()[1]
            if (len(r) > 0 and len(r[0]) < 4):
                self.treeView.expand_row(r[0], False)

    def pathForItem(self, item):
        class Poo:
            def __init__(self):
                self.path = None
        p = Poo()
        def poo(m,p,i,u):
            if (self.treeData.get_value(i, 1) == item):
                u.path = p
        self.treeData.foreach(poo, p)
        return p.path

    def workChange(self, *ignored):
        self.workSaveButton.set_sensitive(True)

    def saveWork(self, *ignored):
        work = self.activatedObject
        # TODO: do this convert-and-convert-back whenever the focus leaves
        # the widget for both start and end
        # BUG/FIX: if the time doesn't get read correctly, it won't be
        # saved, but this information isn't reported to the user very
        # well.
        if (stringToTime(self.workStart.get_text()) != None):
            work['start'] = stringToTime(self.workStart.get_text())
            self.workStart.set_text(timeToString(work['start']))
        else:
            self.workStart.set_text('')
        if (stringToTime(self.workEnd.get_text()) != None):
            work['end'] = stringToTime(self.workEnd.get_text())
            self.workEnd.set_text(timeToString(work['end']))
        else:
            self.workEnd.set_text('')
        work['billed'] = self.workBilledCheckbox.get_active()
        sit = self.workNotesBuffer.get_start_iter()
        eit = self.workNotesBuffer.get_end_iter()
        work['notes'] = self.workNotesBuffer.get_text(sit, eit)
        work.save()
        # If the time has changed, update the tree view display
        path = self.pathForItem(work)
        self.treeData.set(self.treeData.get_iter(path), 0, workName(work))
        self.workSaveButton.set_sensitive(False)

    def deleteWork(self, *ignored):
        work = self.activatedObject
        # REV: Is the tree updated properly here?
        path = self.pathForItem(work)
        self.treeData.remove(self.treeData.get_iter(path))
        project = work.parent
        workTable = work.table
        work.die()
        workTable.flush()
        project.save()
        self.paneSwap(self.nothing)

    def timerUpdate(self):
        gobject.timeout_add(1000, self.timerUpdate)
        if (self.activatedObject != None and self.activatedObject.type == 'work'):
            self.fillWorkDuration(self.activatedObject)

    def fillWorkStart(self, *ignored):
        self.workStart.set_text(timeToString(int(time.time())))

    def fillWorkEnd(self, *ignored):
        self.workEnd.set_text(timeToString(int(time.time())))

    def treeClick(self, widget, event):
        if event.button == 3:
            x = int(event.x)            
            y = int(event.y)
            time = event.time
            pthinfo = self.treeView.get_path_at_pos(x, y)
            if pthinfo is not None and self.menus[len(pthinfo[0])]:
                path, col, cellx, celly = pthinfo
                self.treeView.grab_focus()
                self.treeView.set_cursor(path, col, 0)
                self.menus[len(pthinfo[0])].popup(
                    None, None, None, event.button, time)
            return 1

    def newClient(self, *ignored):
        self.newClientName.set_text('')
        try:
            response = self.newClientDialog.run()
        except Exception:
            self.newClientDialog.set_modal(False)
            response = self.newClientDialog.run()
        self.newClientDialog.hide()
        if (response == gtk.RESPONSE_ACCEPT):
            client = self.root.newChild('client')
            client['name'] = self.newClientName.get_text()
            client.save()
            it = self.treeData.get_iter((0,))
            it = self.treeData.prepend(it)
            self.treeData.set(it, 0, client['name'])
            self.treeData.set(it, 1, client)
            self.treeView.grab_focus()
            p = self.treeData.get_path(it)
            self.treeView.expand_to_path(p)
            self.treeView.scroll_to_cell(p)
            self.treeView.set_cursor(p)
            self.activateClient()

    def newProject(self, *ignored):
        client = self.selectedObject()
        self.newProjectName.set_text('')
        try:
            response = self.newProjectDialog.run()
        except:
            self.newProjectDialog.set_modal(False)
            response = self.newProjectDialog.run()
        self.newProjectDialog.hide()
        if (response == gtk.RESPONSE_ACCEPT):
            project = client.newChild('project')
            project['name'] = self.newProjectName.get_text()
            project['ctime'] = int(time.time())
            project.save()
            it = self.treeView.get_selection().get_selected()[1]
            it = self.treeData.prepend(it)
            self.treeData.set(it, 0, project['name'])
            self.treeData.set(it, 1, project)
            self.treeView.grab_focus()
            p = self.treeData.get_path(it)
            self.treeView.expand_to_path(p)
            self.treeView.scroll_to_cell(p)
            self.treeView.set_cursor(p)
            self.activateProject()

    def newWork(self, *ignored):
        project = self.selectedObject()
        if (project.type == 'work'):
            project = project.parent
        work = project.newChild('work')
        work['notes'] = ''
        work['billed'] = False
        work['start'] = int(time.time())
        work.save()
        it = self.treeView.get_selection().get_selected()[1]
        it = self.treeData.prepend(it)
        self.treeData.set(it, 0, workName(work))
        self.treeData.set(it, 1, work)
        self.treeView.grab_focus()
        p = self.treeData.get_path(it)
        self.treeView.expand_to_path(p)
        self.treeView.scroll_to_cell(p)
        self.treeView.set_cursor(p)
        self.activateWork()
	self.fillTree()

    def rowActivated(self, treeView, path, userParam):
        if (len(path) == 2):
            self.activateClient()
        elif (len(path) == 3):
            self.activateProject()
        elif(len(path) == 4):
            self.activateWork()

    def paneSwap(self, pane):
        self.panes.remove(self.pane2)
        if (pane == None): pane = self.nothing
        self.pane2 = pane
        self.panes.add2(self.pane2)
        self.pane2.show()
        self.panes.show()

    def activateClient(self):
        self.activatedObject = self.selectedObject()
        self.fillClientFrame(self.activatedObject)
        self.paneSwap(self.clientFrame)

    def activateProject(self):
        self.activatedObject = self.selectedObject()
        self.fillProjectFrame(self.activatedObject)
        self.paneSwap(self.projectFrame)

    def activateWork(self):
        self.activatedObject = self.selectedObject()
        self.workSaveButton.set_sensitive(False)
        self.fillWorkFrame(self.activatedObject)
        self.paneSwap(self.workFrame)
        #self.workEndFillButton.grab_focus()

    def selectedObject(self):
        r = self.treeView.get_selection().get_selected()[1]
        if (r == None):
            return None
        else:
            return self.treeData.get_value(r, 1)

    def callback(self, widget, data=None):
        print "callback " + str(widget) + " " + str(data)

    def fillClientFrame(self, client):
        self.clientName.set_text(client['name'])

    def fillWorkDuration(self, work):
        if (work.has_key('start')):
            if (work.has_key('end')):
                end = int(work['end'])
            elif (self.workEnd.get_text() == ''):
                end = time.time()
            else:
                end = stringToTime(self.workEnd.get_text())
            self.workDuration.set_text(
                ascinterval(timediff(end, int(work['start']))))
        else:
            self.workDuration.set_text('')

    def fillWorkFrame(self, work):
        self.workSaveButton.show() # seems to be a but w/ set_sensitive
        self.workClient.set_text(work.parent.parent['name'])
        self.workProject.set_text(work.parent['name'])
        if (work.has_key('start')):
            self.workStart.set_text(timeToString(work['start']))
            if (work.has_key('end')):
                self.workEnd.set_text(timeToString(work['end']))
            else:
                self.workEnd.set_text('')
        else:
            self.workStart.set_text('')
	self.fillWorkDuration(work)
        self.workNotesBuffer.set_text(work['notes'])
        self.workBilledCheckbox.set_active(work['billed'])
        self.workSaveButton.set_sensitive(False)

    def fillProjectFrame(self, project):
        self.projectClientName.set_text(project.parent['name'])
        self.projectProjectName.set_text(project['name'])
        self.projectCtime.set_text(timeToString(project['ctime']))
        weeks = totalProjectWeeks(project)
        totaltime = totalProjectTime(project)
        self.projectTotal.set_text(ascinterval(totaltime))
        if (totaltime == 0):
            return
        self.projectUnbilled.set_text(
            ascinterval(unbilledProjectTime(project)))
        self.projectWeeklyTotal.set_text(
            ascinterval(weeklyProjectTime(project)))
        self.projectTotalWeeks.set_text(str(weeks))
        avg = totaltime / weeks
        self.projectAverageWeek.set_text(ascinterval(avg))

    def fillTree(self):
        self.treeData.clear()
        root_iter = self.treeData.append(None)
        self.treeData.set(root_iter, 0, 'Clients')
        self.treeData.set(root_iter, 1, self.root)
        clients = self.root.children('client')
        clients.reverse()
        for client in clients:
            client_iter = self.treeData.append(root_iter)
            self.treeData.set(client_iter, 0, client['name'])
            self.treeData.set(client_iter, 1, client)
            projects = client.children('project')
            projects.sort(None, lambda o: o['ctime'], True)
            for project in projects:
                project_iter = self.treeData.append(client_iter)
                self.treeData.set(project_iter, 0, project['name'])
                self.treeData.set(project_iter, 1, project)
                works = project.children('work')
                works.sort(None, lambda o: o['start'], True)
                for work in works:
                    work_iter = self.treeData.append(project_iter)
                    self.treeData.set(work_iter, 0, workName(work))
                    self.treeData.set(work_iter, 1, work)
        self.treeView.expand_row((0,), True)

    def delete(self, widget, event):
        self.delegate.billWindowWillClose(self)

class BillController:
    def __init__(self, root):
        self.root = root
        self.billWindow = BillWindow(self, root)

    def billWindowWillClose(self, caller):
        self.root.flush()
        gtk.main_quit()

def totalProjectTime(project):
    works = project.children('work')
    sum = 0
    for work in works:
        if (work.has_key('end') and work.has_key('start')
            and int(work['end']) > 0 and int(work['start']) > 0):
            sum += timediff(work['end'], work['start'])
    return sum

def weeklyProjectTime(project):
    works = project.children('work')
    sum = 0
    oneweek = time.time() - (60 * 60 * 24 * 7)        # Seconds per week

    for work in works:
        if (work.has_key('end') and work.has_key('start')
            and int(work['end']) > 0 and int(work['start']) > 0
            and int(work['end']) > oneweek):
            sum += timediff(work['end'], work['start'])

    return sum

def totalProjectWeeks(project):
    works = project.children('work')
    oneweek = 60 * 60 * 24 * 7        # Seconds per week
    start = None
    end = None
    for work in works:
        if (work.has_key('end') and work.has_key('start')
            and int(work['end']) > 0 and int(work['start']) > 0):
            if (end == None or int(work['end']) > end):
                end = int(work['end'])
            if (start == None or int(work['start']) < start):
                start = int(work['start'])

    if (start == None or end == None):
        return 0
    duration = end - start
    weeks = duration / oneweek
    if (duration % oneweek != 0):
        weeks += 1
    return weeks

def unbilledProjectTime(project):
    works = project.children('work')
    sum = 0
    for work in works:
        if (work['billed'] == False and work.has_key('end')
            and work.has_key('start') and int(work['end']) > 0
            and int(work['start']) > 0):
            sum += timediff(work['end'], work['start'])
    return sum

def workToTuple(work):
    if (work.has_key('start')):
        if (work.has_key('end')):
            return (work.id,
                    timeToString(work['start']),
                    ascinterval(timediff(work['end'], work['start'])),
                    work['notes'])
        else:
            return (work.id,
                    timeToString(work['start']),
                    'Billing',
                    work['notes'])
    else:
        return (work.id,
                timeToString(work['start']),
                '',
                work['notes'])

def workName(work):
    duration = 0
    if (int(work['start']) > 0 and work.has_key('end')
        and int(work['end']) > 0):
        duration = int(work['end']) - int(work['start'])
    return "%s - %s %25s" % (timeToDateString(work['start']), ascintervalshort(duration), work['notes'])

def namedItemToTuple(o):
    return (o['name'], o.id)

def main():
    # parse command line options
    prog = os.path.basename(sys.argv[0])
    try:
        (opts, args) = getopt.getopt(sys.argv[1:], "hd:", ["help", "database="])
    except getopt.error, msg:
        print msg
        print prog + ": for help use --help"
        sys.exit(2)
    dsdir = None
    for o, a in opts:
        if o in ("-h", "--help"):
            print(prog + ": work tracking\n\n"
                  "usage: " + prog + " arguments\n\n"
                  "Arguments:\n"
                  "   -h, --help          Print help (this message) and exit\n"
                  "   -d, --database=dir  Specify the directory where the data"
                  " is stored\n"
                  "                       (Read from BILLDB environment"
                  " variable if not given)")
            sys.exit(0)
        if o in ("-d", "--database"):
            dsdir = a
    if (dsdir == None):
        if ('BILLDB' in os.environ):
            dsdir = os.environ['BILLDB']
        else:
            print(prog + ": you must specify a database directory"
                  " either vie either\nthe BILLDB environment variable or"
                  " the --database (-d for short) argument.")
            sys.exit(2)
    BillController(Root(dsdir))
    gtk.main()
    return 0     

if __name__ == "__main__":
    main()
