changeset 23:9c4e8ba3c4fa

Added a new implementation of 'getan' based on urwid, a python console user interface library.
author Ingo Weinzierl <ingo_weinzierl@web.de>
date Sat, 28 Aug 2010 20:16:58 +0200
parents 2dc893ca5072
children c89721a3f0f8
files ChangeLog INTRODUCTION README TODO classic/getan getan getan.py getan/__init__.py getan/backend.py getan/config.py getan/project.py getan/states.py getan/utils.py getan/view.py
diffstat 13 files changed, 2239 insertions(+), 917 deletions(-) [+]
line wrap: on
line diff
--- a/ChangeLog	Mon Aug 24 14:59:37 2009 +0200
+++ b/ChangeLog	Sat Aug 28 20:16:58 2010 +0200
@@ -1,3 +1,29 @@
+2010-08-28  Ingo Weinzierl <ingo.weinzierl@intevation.de>
+
+	This commit introduces a new implementation of 'getan's user interface based
+	on urwid ( >= 0.9.9.1).
+
+	* getan.py: The 'getan' controller that is also used to start the
+	  application.
+
+	* classic/getan: Moved the 'old' getan to this folder - there is a new
+	  implementation based on python urwid library.
+
+	* getan/__init__.py,
+	  getan/backend.py,
+	  getan/config.py,
+	  getan/project.py,
+	  getan/states.py,
+	  getan/utils.py,
+	  getan/view.py: Model, backend, view and config modules used by getan.
+
+	* README: Added urwid >= 0.9.9.1 as further dependendy of getan and a hint
+	  of the classic version of 'getan' based on curses.
+
+	* TODO: Idea of implementing a 'Help-Widget' to display all available keys.
+
+	* INTRODUCTION: An overview about possible keys in the user interface.
+
 2009-08-18  Thomas Arendsen Hein  <thomas@intevation.de>
 
 	* contrib/zeiterfassung: left justify user sign in zeiterfassung
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/INTRODUCTION	Sat Aug 28 20:16:58 2010 +0200
@@ -0,0 +1,23 @@
+This file gives some information about the available keys in the 'getan' view.
+Some further keys are self-explanatory while working with 'getan'.
+
+|     key           |         description
+================================================================================
+| up/down (arrows)  |  Navigate up/down in ProjectList and EntryList.
+| tab               |  Switch focus between ProjectList and EntryList
+| enter             |  start/stop a project (in ProjectList), mark an entry (in
+                       EntryList).
+| [project key]     |  Start the specified project.
+| space             |  Pause a running project (the time that will be inserted
+                       into database is the real running time without the break.
+| F1                |  Switch time display mode in ProjectList.
+| d                 |  Delete marked entries (in EntryList).
+| m                 |  Start the 'wizzard' to move selected entries to another
+                       project.
+| esc               |  Exit the application. This is only possible if no project
+                       is running (in ProjectList).
+| +                 |  Start a dialog to add time to a running project. Only
+                       possible if there is a running project (in ProjectList).
+| -                 |  Start a dialog to subtract time from running project.
+                       Only possible if there is a running project (in
+                       ProjectList).
--- a/README	Mon Aug 24 14:59:37 2009 +0200
+++ b/README	Sat Aug 28 20:16:58 2010 +0200
@@ -3,6 +3,9 @@
     On Debian GNU/Linux just do:
 
     # apt-get install python-pysqlite2 
+    # apt-get install python-urwid
+
+    NOTE: getan requires urwid >= 0.9.9.1.
 
 1 - create new time.db
 
@@ -29,9 +32,15 @@
 
 3 - starting getan:
 
-    $ ./getan 
+    $ ./getan.py
 
     or
 
-    $ ./getan mytime.db
+    $ ./getan.py mytime.db
 
+    or (for the classic version of 'getan' based on curses):
+    $ classic/getan
+
+    or
+
+    $ classic/getan mytime.db
--- a/TODO	Mon Aug 24 14:59:37 2009 +0200
+++ b/TODO	Sat Aug 28 20:16:58 2010 +0200
@@ -1,1 +1,4 @@
 - Create empty databases from within getan.
+
+- Create a 'Help-Widget' to give the user an information about all possible
+  keys.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/classic/getan	Sat Aug 28 20:16:58 2010 +0200
@@ -0,0 +1,915 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+#
+# getan
+# -----
+# (c) 2008 by Sascha L. Teichmann <sascha.teichmann@intevation.de>
+#
+# A python worklog-alike to log what you have 'getan' (done).
+#
+# This is Free Software licensed under the terms of GPLv3 or later.
+# For details see LICENSE coming with the source of 'getan'.
+#
+import sys
+import re
+import curses
+import curses.ascii
+import traceback
+import signal
+
+from datetime import datetime, timedelta, tzinfo
+
+from pysqlite2 import dbapi2 as db
+
+PAUSED      = 0
+RUNNING     = 1
+PRE_EXIT    = 2
+PAUSED_ESC  = 3
+RUNNING_ESC = 4
+
+SPACE = re.compile("[\\t ]+")
+
+DEFAULT_DATABASE = "time.db"
+
+LOAD_ACTIVE_PROJECTS = '''
+SELECT id, key, description, total
+FROM projects LEFT JOIN
+(SELECT 
+    project_id, 
+    sum(strftime('%s', stop_time) - strftime('%s', start_time)) AS total
+    FROM entries 
+    GROUP BY project_id) ON project_id = id
+    WHERE active
+'''
+
+WRITE_LOG = '''
+INSERT INTO entries (project_id, start_time, stop_time, description)
+VALUES(:project_id, :start_time, :stop_time, :description)
+'''
+
+CREATE_PROJECT = '''
+INSERT INTO projects (key, description) VALUES (:key, :description)
+'''
+
+LAST_PROJECT_ID = '''
+SELECT last_insert_rowid()
+'''
+
+RENAME_PROJECT = '''
+UPDATE projects set key = :key, description = :description WHERE id = :id
+'''
+
+ASSIGN_LOGS = '''
+UPDATE entries SET project_id = :new_id WHERE project_id = :old_id
+'''
+
+DELETE_PROJECT = '''
+DELETE FROM projects WHERE id = :id
+'''
+
+# XXX: This is not very efficent!
+LAST_ENTRY = '''
+SELECT id, strftime('%s', start_time), strftime('%s', stop_time) FROM entries
+WHERE project_id = :project_id
+ORDER by strftime('%s', stop_time) DESC LIMIT 1
+'''
+
+DELETE_ENTRY = '''
+DELETE FROM entries WHERE id = :id
+'''
+
+UPDATE_STOP_TIME = '''
+UPDATE entries SET stop_time = :stop_time WHERE id = :id
+'''
+
+worklog = None
+stdscr  = None
+
+orig_vis = None
+
+def cursor_visible(flag):
+    global orig_vis
+    try:
+        old = curses.curs_set(flag)
+        if orig_vis is None: orig_vis = old
+        return old
+    except:
+        pass
+    return 1
+
+def restore_cursor():
+    global orig_vis
+    if not orig_vis is None:
+        curses.curs_set(orig_vis)
+
+def render_header(ofs=0):
+    global stdscr
+    stdscr.attron(curses.A_BOLD)
+    stdscr.addstr(ofs,   5, "getan v0.1")
+    stdscr.addstr(ofs+1, 3, "--------------")
+    stdscr.attroff(curses.A_BOLD)
+    return ofs + 2
+
+def render_quit(ofs=0):
+    global stdscr
+    stdscr.addstr(ofs + 2, 3, "Press DEL once more to quit")
+    return ofs + 3
+
+def tolerantClose(cur):
+    if cur:
+        try: cur.close()
+        except: pass
+
+def ifNull(v, d):
+    if v is None: return d
+    return v
+
+def human_time(delta):
+    seconds = delta.seconds
+    s = seconds % 60
+    if delta.microseconds >= 500000: s += 1
+    seconds /= 60
+    m = seconds % 60
+    seconds /= 60
+    out = "%02d:%02d:%02d" % (seconds, m, s)
+    if delta.days:
+        out = "%dd %s" % (delta.days, out)
+    return out
+
+FACTORS = {
+    's':        1,
+    'm':       60,
+    'h'   : 60*60,
+    'd': 24*60*60}
+
+def human_seconds(timespec):
+    """Translate human input to seconds, default factor is minutes"""
+    total = 0
+    for v in timespec.split(':'):
+        factor = FACTORS.get(v[-1])
+        if factor: v = v[:-1]
+        else:      factor = 60
+        total += int(v) * factor
+    return total
+
+ESC_MAP = {
+    curses.KEY_F1 : ord('1'),
+    curses.KEY_F2 : ord('2'),
+    curses.KEY_F3 : ord('3'),
+    curses.KEY_F4 : ord('4'),
+    curses.KEY_F5 : ord('5'),
+    curses.KEY_F6 : ord('6'),
+    curses.KEY_F7 : ord('7'),
+    curses.KEY_F8 : ord('8'),
+    curses.KEY_F9 : ord('9'),
+    curses.KEY_F10: ord('0'),
+}
+
+ZERO = timedelta(0)
+
+class UTC(tzinfo):
+    """UTC"""
+
+    def utcoffset(self, dt):
+        return ZERO
+
+    def tzname(self, dt):
+        return "UTC"
+
+    def dst(self, dt):
+        return ZERO    
+
+class Project:
+
+    def __init__(self, id = None, key = None, desc = None, total = 0):
+        self.id         = id
+        self.key        = key
+        self.desc       = desc
+        self.total      = timedelta(seconds = ifNull(total, 0))
+        self.start_time = None
+
+    def checkExistence(self, cur):
+        if self.id is None:
+            cur.execute(CREATE_PROJECT, {
+                'key'        : self.key,
+                'description': self.desc})
+            cur.execute(LAST_PROJECT_ID)
+            row = cur.fetchone()
+            cur.connection.commit()
+            self.id = row[0]
+
+    def writeLog(self, cur, description = None):
+        if self.start_time is None: return
+        self.checkExistence(cur)
+        now = datetime.now()
+        cur.execute(WRITE_LOG, {
+            'project_id' : self.id,
+            'start_time' : self.start_time,
+            'stop_time'  : now,
+            'description': description})
+        self.total += now-self.start_time
+        return now
+
+    def getId(self, cur):
+        self.checkExistence(cur)
+        return self.id
+
+    def rename(self, cur, key, desc):
+        self.key  = key
+        self.desc = desc
+        self.checkExistence(cur)
+        cur.execute(RENAME_PROJECT, {
+            'key'        : key,
+            'description': desc,
+            'id'         : self.id })
+        cur.connection.commit()
+
+    def assignLogs(self, cur, anon):
+        self.total += anon.total
+        anon.total = timedelta(seconds=0)
+        old_id = anon.getId(cur)
+        new_id = self.getId(cur)
+        cur.execute(ASSIGN_LOGS, {
+            'new_id': new_id,
+            'old_id': old_id})
+        cur.connection.commit()
+
+    def delete(self, cur):
+        pid = self.getId(cur)
+        cur.execute(DELETE_PROJECT, { 'id': pid })
+        cur.connection.commit()
+
+    def subtractTime(self, cur, seconds):
+        subtractTimeed, zero = timedelta(), timedelta()
+        pid = {'project_id': self.getId(cur)}
+        utc = UTC()
+        while seconds > zero:
+            cur.execute(LAST_ENTRY, pid)
+            row = cur.fetchone()
+            if row is None: break
+            # TODO: evaluate egenix-mx
+            start_time = datetime.fromtimestamp(float(row[1]), utc)
+            stop_time  = datetime.fromtimestamp(float(row[2]), utc)
+            runtime = stop_time - start_time
+            if runtime <= seconds:
+                cur.execute(DELETE_ENTRY, { 'id': row[0] })
+                cur.connection.commit()
+                seconds -= runtime
+                subtractTimeed += runtime
+            else:
+                stop_time -=  seconds
+                cur.execute(UPDATE_STOP_TIME, {
+                    'id': row[0],
+                    'stop_time': stop_time})
+                cur.connection.commit()
+                subtractTimeed += seconds
+                break
+
+        self.total -= subtractTimeed
+        return subtractTimeed
+
+    def addTime(self, cur, seconds, description):
+        now = datetime.now()
+        cur.execute(WRITE_LOG, {
+            'project_id' : self.getId(cur),
+            'start_time' : now - seconds,
+            'stop_time'  : now,
+            'description': description
+        })
+        cur.connection.commit()
+        self.total += seconds
+
+def build_tree(project, depth):
+    if len(project.key) == depth+1:
+        return ProjectNode(project, project.key[depth])
+    node = ProjectNode(None, project.key[depth])
+    node.children.append(build_tree(project, depth+1))
+    return node
+
+class ProjectNode:
+
+    def __init__(self, project = None, key = None):
+        self.children = []
+        self.project  = project
+        self.key      = key
+
+    def insertProject(self, project, depth = 0):
+
+        if not project.key: # anonym -> end
+            node = ProjectNode(project)
+            self.children.append(node)
+            return
+
+        for i, child in enumerate(self.children):
+            if not child.key: # before anonym projects
+                self.children.insert(i, build_tree(project, depth))
+                return
+            if child.key == project.key[depth]:
+                child.insertProject(project, depth+1)
+                return
+        self.children.append(build_tree(project, depth))
+
+    def removeProject(self, project):
+
+        if self.isLeaf(): return
+        stack = [self]
+        while stack:
+            parent = stack.pop()
+            for child in parent.children:
+                if not child.isLeaf():
+                    stack.append(child)
+                    continue
+                if child.project == project:
+                    parent.children.remove(child)
+                    return
+
+    def isLeaf(self):
+        return not self.project is None
+
+    def findProject(self, key):
+        l, lower = key.lower(), None
+        for child in self.children:
+            if child.key == key:
+                return child
+            if child.key and child.key.lower() == l:
+                lower = child
+        return lower
+
+    def dump(self, depth = 0):
+        out = []
+        indent = "  " * depth
+        out.append("%skey: %s" % (indent, self.key))
+        if self.project:
+            out.append("%sdescription: %s" % (indent, self.project.desc))
+        for child in self.children:
+            out.append(child.dump(depth+1))
+        return "\n".join(out)
+
+class Worklog:
+
+    def __init__(self, database):
+        self.initDB(database)
+        self.projects        = []
+        self.tree            = ProjectNode()
+        self.state           = PAUSED
+        self.current_project = None
+        self.selection       = self.tree
+        self.stack           = []
+        self.loadProjects()
+
+    def initDB(self, database):
+        self.con = db.connect(database)
+
+    def loadProjects(self):
+        cur = None
+        try:
+            cur = self.con.cursor()
+            cur.execute(LOAD_ACTIVE_PROJECTS)
+            while True:
+                row = cur.fetchone()
+                if not row: break
+                project = Project(*row)
+                self.projects.append(project)
+                self.tree.insertProject(project)
+        finally:
+            tolerantClose(cur)
+
+    def shutdown(self):
+        self.con.close()
+
+    def fetchStack(self):
+        cut = ''.join([chr(i) for i in self.stack])
+        self.stack = []
+        return cut
+
+    def findProject(self, key):
+        key_lower = key.lower()
+        lower = None
+
+        for p in self.projects:
+            if p.key == key:
+                return p
+            if p.key and p.key.lower() == key_lower:
+                lower = p
+
+        return lower
+
+    def findAnonymProject(self, num):
+        count = 0
+        for p in self.projects:
+            if p.key is None:
+                if count == num:
+                    return p
+                count += 1
+        return None
+
+    def renameAnonymProject(self, num, key, description):
+        project = self.findAnonymProject(num)
+        if project:
+            cur = None
+            try:
+                cur = self.con.cursor()
+                project.rename(cur, key, description)
+            finally:
+                tolerantClose(cur)
+            self.tree.removeProject(project)
+            self.tree.insertProject(project)
+                    
+    def assignLogs(self, num, key):
+        anon = self.findAnonymProject(num)
+        if anon is None: return
+        project = self.findProject(key)
+        if project is None: return
+        cur = None
+        try:
+            cur = self.con.cursor()
+            project.assignLogs(cur, anon)
+            self.projects.remove(anon)
+            anon.delete(cur)
+        finally:
+            tolerantClose(cur)
+
+    def addTime(self, key, seconds, description = None):
+        project = self.findProject(key)
+        if project is None: return
+        cur = None
+        try:
+            cur = self.con.cursor()
+            project.addTime(cur, seconds, description)
+        finally:
+            tolerantClose(cur)
+
+    def subtractTime(self, key, seconds):
+        project = self.findProject(key)
+        if project is None: return
+        cur = None
+        try:
+            cur = self.con.cursor()
+            project.subtractTime(cur, seconds)
+        finally:
+            tolerantClose(cur)
+
+    def isRunning(self):
+        return self.state in (RUNNING, RUNNING_ESC)
+
+    def totalTime(self):
+        sum = timedelta()
+        for p in self.projects:
+            sum += p.total
+        return sum
+
+    def render(self, ofs=0):
+        ofs = render_header(ofs)
+        ml = max([len(p.desc and p.desc or "unknown") for p in self.projects])
+        unknown = 0
+
+        if self.current_project and self.current_project.start_time:
+            current_delta      = datetime.now() - self.current_project.start_time 
+            current_time_str   = "%s " % human_time(current_delta)
+            current_time_space = " " * len(current_time_str)
+        else:
+            current_delta      = timedelta()
+            current_time_str   = ""
+            current_time_space = ""
+
+        for project in self.projects:
+            is_current = project == self.current_project
+            pref = is_current and " -> " or "    "
+            if project.key is None:
+                key = "^%d" % unknown
+                unknown += 1
+            else:
+                key = " %s" % project.key
+            desc = project.desc is None and "unknown" or project.desc
+            stdscr.attron(curses.A_BOLD)
+            stdscr.addstr(ofs, 0, "%s%s" % (pref, key))
+            stdscr.attroff(curses.A_BOLD)
+            stdscr.addstr(" %s" % desc)
+
+            diff = ml - len(desc) + 1
+            stdscr.addstr(" " * diff)
+            if is_current: stdscr.attron(curses.A_UNDERLINE)
+
+            if is_current:
+                stdscr.addstr("%s(%s)" % (
+                    current_time_str,
+                    human_time(project.total + current_delta)))
+            else:
+                stdscr.addstr("%s(%s)" % (
+                    current_time_space,
+                    human_time(project.total)))
+
+            if is_current: stdscr.attroff(curses.A_UNDERLINE)
+            ofs += 1
+
+        total_str   = "(%s)" % human_time(self.totalTime() + current_delta) 
+        total_x_pos = ml + 8 + len(current_time_space)
+
+        stdscr.addstr(ofs, total_x_pos, "=" * len(total_str))
+        ofs += 1
+        stdscr.addstr(ofs, total_x_pos, total_str)
+        ofs += 1
+
+        return ofs
+
+    def writeLog(self, description = None):
+        if self.current_project is None:
+            return datetime.now()
+        cur = None
+        try:
+            cur = self.con.cursor()
+            now = self.current_project.writeLog(cur, description)
+            self.con.commit()
+            return now
+        finally:
+            tolerantClose(cur)
+
+    def pausedState(self, c):
+        c2 = ESC_MAP.get(c)
+        if c2:
+            self.pausedEscapeState(c2)
+            return
+
+        global stdscr
+        if c in (curses.KEY_DC, curses.KEY_BACKSPACE):
+            stdscr.erase()
+            ofs = render_quit(self.render())
+            stdscr.refresh()
+            self.state = PRE_EXIT
+
+        elif c == curses.ascii.ESC:
+            self.state = PAUSED_ESC
+
+        elif curses.ascii.isascii(c):
+            if c == ord('-'):
+                self.selection = self.tree
+                stdscr.erase()
+                ofs = self.render()
+                old_cur = cursor_visible(1)
+                curses.echo()
+                stdscr.addstr(ofs + 1, 3, "<key> <minutes>: ")
+                key = stdscr.getstr()
+                curses.noecho()
+                cursor_visible(old_cur)
+                key = key.strip()
+                if key:
+                    parts = SPACE.split(key, 1)
+                    if len(parts) > 1:
+                        key, timespec = parts[0], parts[1]
+                        try:
+                            seconds = human_seconds(timespec)
+                            if seconds > 0:
+                                seconds = timedelta(seconds=seconds)
+                                self.subtractTime(key, seconds)
+                        except ValueError:
+                            pass
+                stdscr.erase()
+                self.render()
+                stdscr.refresh()
+
+            elif c == ord('+'):
+                self.selection = self.tree
+                stdscr.erase()
+                ofs = self.render()
+                old_cur = cursor_visible(1)
+                curses.echo()
+                stdscr.addstr(ofs + 1, 3, "<key> <minutes> [<description>]: ")
+                key = stdscr.getstr()
+                curses.noecho()
+                cursor_visible(old_cur)
+                key = key.strip()
+                if key:
+                    parts = SPACE.split(key, 2)
+                    if len(parts) > 1:
+                        key, timespec = parts[0], parts[1]
+                        if len(parts) > 2: desc = parts[2]
+                        else:              desc = None
+                        try:
+                            seconds = human_seconds(timespec)
+                            if seconds > 0:
+                                seconds = timedelta(seconds=seconds)
+                                self.addTime(key, seconds, desc)
+                        except ValueError:
+                            pass
+                stdscr.erase()
+                self.render()
+                stdscr.refresh()
+
+            else:
+                node = self.selection.findProject(chr(c))
+                if not node:
+                    self.selection = self.tree
+                    return
+                if node.isLeaf():
+                    self.selection = self.tree
+                    nproject = node.project
+                    self.current_project = nproject
+                    nproject.start_time = datetime.now()
+                    stdscr.erase()
+                    ofs = self.render()
+                    stdscr.refresh()
+                    self.state = RUNNING
+                    signal.signal(signal.SIGALRM, alarm_handler)
+                    signal.alarm(1)
+                else:
+                    self.selection = node
+
+    def runningState(self, c):
+        global stdscr
+        c2 = ESC_MAP.get(c)
+        if c2:
+            self.runningEscapeState(c2)
+            return
+
+        if c == curses.ascii.ESC:
+            self.state = RUNNING_ESC
+
+        elif c == curses.ascii.NL:
+            signal.signal(signal.SIGALRM, signal.SIG_IGN)
+            self.state = PAUSED
+            stdscr.erase()
+            ofs = self.render()
+            old_cur = cursor_visible(1)
+            curses.echo()
+            stdscr.addstr(ofs + 1, 3, "Description: ")
+            description = stdscr.getstr()
+            curses.noecho()
+            cursor_visible(old_cur)
+            self.writeLog(description)
+            self.current_project = None
+            stdscr.erase()
+            ofs = self.render()
+            stdscr.refresh()
+            signal.signal(signal.SIGALRM, alarm_handler)
+            signal.alarm(1)
+        elif c == ord('+'):
+            signal.signal(signal.SIGALRM, signal.SIG_IGN)
+            stdscr.erase()
+            ofs = self.render()
+            if self.stack:
+                timespec = self.fetchStack()
+            else:
+                old_cur = cursor_visible(1)
+                curses.echo()
+                stdscr.addstr(ofs + 1, 3, "Enter time to add: ")
+                timespec = stdscr.getstr()
+                curses.noecho()
+                cursor_visible(old_cur)
+                stdscr.erase()
+                ofs = self.render()
+            try:
+                seconds = human_seconds(timespec)
+                if seconds > 0:
+                    seconds = timedelta(seconds=seconds)
+                    self.current_project.start_time -= seconds
+                    stdscr.addstr(ofs + 1, 3, "added %s" % human_time(seconds))
+            except (ValueError, IndexError):
+                pass
+            stdscr.refresh()
+            signal.signal(signal.SIGALRM, alarm_handler)
+            signal.alarm(1)
+        elif c == ord('-'):
+            signal.signal(signal.SIGALRM, signal.SIG_IGN)
+            stdscr.erase()
+            ofs = self.render()
+            if self.stack:
+                timespec = self.fetchStack()
+            else:
+                old_cur = cursor_visible(1)
+                curses.echo()
+                stdscr.addstr(ofs + 1, 3, "Enter time to subtract: ")
+                timespec = stdscr.getstr()
+                curses.noecho()
+                cursor_visible(old_cur)
+                stdscr.erase()
+                ofs = self.render()
+            try:
+                seconds = human_seconds(timespec)
+                if seconds > 0:
+                    now = datetime.now()
+                    seconds = timedelta(seconds=seconds)
+                    self.current_project.start_time += seconds
+                    stdscr.addstr(ofs + 1, 3, "subtracted %s" % human_time(seconds))
+                    if self.current_project.start_time > now:
+                        seconds = self.current_project.start_time - now
+                        self.current_project.start_time = now
+                        cur = None
+                        try:
+                            cur = self.con.cursor()
+                            self.current_project.subtractTime(cur, seconds)
+                        finally:
+                            tolerantClose(cur)
+            except (ValueError, IndexError):
+                pass
+            stdscr.refresh()
+            signal.signal(signal.SIGALRM, alarm_handler)
+            signal.alarm(1)
+        elif self.stack or curses.ascii.isdigit(c):
+            self.stack.append(c)
+        elif curses.ascii.isascii(c):
+            project_node = self.selection.findProject(chr(c))
+            if project_node is None:
+                self.selection = self.tree
+                return
+
+            if project_node.isLeaf():
+                self.selection = self.tree
+                nproject = project_node.project
+                if nproject == self.current_project:
+                    return
+                nproject.start_time = self.writeLog()
+                self.current_project = nproject
+                stdscr.erase()
+                ofs = self.render()
+                stdscr.refresh()
+            else:
+                self.selection = project_node
+
+    def pausedEscapeState(self, c):
+        global stdscr
+        if curses.ascii.isdigit(c):
+            pnum = c - ord('0')
+            nproject = self.findAnonymProject(pnum)
+            if nproject is None:
+                nproject = Project()
+                self.projects.append(nproject)
+
+            nproject.start_time = self.writeLog()
+            self.current_project = nproject
+            self.state = RUNNING
+            stdscr.erase()
+            ofs = self.render()
+            stdscr.refresh()
+            signal.signal(signal.SIGALRM, alarm_handler)
+            signal.alarm(1)
+        elif curses.ascii.isalpha(c):
+            if c == ord('n'):
+                stdscr.erase()
+                ofs = self.render()
+                old_cur = cursor_visible(1)
+                curses.echo()
+                stdscr.addstr(ofs + 1, 3, "<num> <key> <description>: ")
+                stdscr.refresh()
+                description = stdscr.getstr()
+                curses.noecho()
+                cursor_visible(old_cur)
+
+                description = description.strip()
+                if description:
+                    num, key, description = SPACE.split(description, 2)
+                    try:
+                        num = int(num)
+                        self.renameAnonymProject(num, key, description)
+                    except ValueError:
+                        pass
+
+                stdscr.erase()
+                ofs = self.render()
+                stdscr.refresh()
+                self.state = PAUSED
+
+            elif c == ord('a'):
+                stdscr.erase()
+                ofs = self.render()
+                old_cur = cursor_visible(1)
+                curses.echo()
+                stdscr.addstr(ofs + 1, 3, "<num> <key>: ")
+                stdscr.refresh()
+                key = stdscr.getstr()
+                curses.noecho()
+                cursor_visible(old_cur)
+
+                key = key.strip()
+                if key:
+                    num, key = SPACE.split(key, 1)
+                    try:
+                        num = int(num)
+                        self.assignLogs(num, key)
+                    except ValueError:
+                        pass
+
+                stdscr.erase()
+                ofs = self.render()
+                stdscr.refresh()
+                self.state = PAUSED
+            else:
+                self.state = PAUSED
+        else:
+            self.state = PAUSED
+
+    def runningEscapeState(self, c):
+        global stdscr
+        if curses.ascii.isdigit(c):
+            signal.signal(signal.SIGALRM, signal.SIG_IGN)
+            pnum = c - ord('0')
+            nproject = self.findAnonymProject(pnum)
+            if nproject is None:
+                nproject = Project()
+                self.projects.append(nproject)
+
+            nproject.start_time = self.writeLog()
+            self.current_project = nproject
+            self.state = RUNNING
+            stdscr.erase()
+            self.render()
+            stdscr.refresh()
+            signal.signal(signal.SIGALRM, alarm_handler)
+            signal.alarm(1)
+        else:
+            self.state = RUNNING
+        
+
+    def run(self):
+        global stdscr
+
+        stdscr.erase()
+        self.render()
+        stdscr.refresh()
+
+        while True:
+            c = stdscr.getch()
+            if c == -1: continue
+
+            if self.state == PAUSED:
+                self.pausedState(c)
+
+            elif self.state == RUNNING:
+                self.runningState(c)
+
+            elif self.state == PAUSED_ESC:
+                self.pausedEscapeState(c)
+
+            elif self.state == RUNNING_ESC:
+                self.runningEscapeState(c)
+
+            elif self.state == PRE_EXIT:
+                if c in (curses.KEY_DC, curses.KEY_BACKSPACE):
+                    break
+                else:
+                    stdscr.erase()
+                    self.render()
+                    stdscr.refresh()
+                    self.state = PAUSED
+
+def alarm_handler(flag, frame):
+    global worklog
+    global stdscr
+
+    stdscr.erase()
+    worklog.render()
+    stdscr.refresh()
+    if worklog.isRunning():
+        signal.alarm(1)
+
+def exit_handler(flag, frame):
+    exit_code = 0
+    global worklog
+    try:
+        worklog.shutdown()
+    except:
+        traceback.print_exc(file=sys.stderr)
+        exit_code = 1
+
+    restore_cursor()
+    curses.nocbreak()
+    stdscr.keypad(0)
+    curses.echo()
+    curses.endwin()
+    sys.exit(exit_code)
+
+def main():
+
+    database = len(sys.argv) < 2 and DEFAULT_DATABASE or sys.argv[1]
+    # TODO: create database file if it does not exist.
+
+    global worklog
+    try:
+        worklog = Worklog(database)
+    except:
+        traceback.print_exc(file=sys.stderr)
+        sys.exit(1)
+
+    global stdscr
+    stdscr = curses.initscr()
+    curses.noecho()
+    curses.cbreak()
+    stdscr.keypad(1)
+    cursor_visible(0)
+
+    signal.signal(signal.SIGHUP,  exit_handler)
+    signal.signal(signal.SIGINT,  exit_handler)
+    signal.signal(signal.SIGQUIT, exit_handler)
+    signal.signal(signal.SIGTERM, exit_handler)
+    
+    try:
+        try:
+            worklog.run()
+        except:
+            traceback.print_exc(file=sys.stderr)
+    finally:
+        exit_handler(0, None)
+
+if __name__ == '__main__':
+    main()
+
+# vim:set ts=4 sw=4 si et sta sts=4 fenc=utf8:
--- a/getan	Mon Aug 24 14:59:37 2009 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,915 +0,0 @@
-#!/usr/bin/env python
-# -*- coding: utf-8 -*-
-#
-# getan
-# -----
-# (c) 2008 by Sascha L. Teichmann <sascha.teichmann@intevation.de>
-#
-# A python worklog-alike to log what you have 'getan' (done).
-#
-# This is Free Software licensed under the terms of GPLv3 or later.
-# For details see LICENSE coming with the source of 'getan'.
-#
-import sys
-import re
-import curses
-import curses.ascii
-import traceback
-import signal
-
-from datetime import datetime, timedelta, tzinfo
-
-from pysqlite2 import dbapi2 as db
-
-PAUSED      = 0
-RUNNING     = 1
-PRE_EXIT    = 2
-PAUSED_ESC  = 3
-RUNNING_ESC = 4
-
-SPACE = re.compile("[\\t ]+")
-
-DEFAULT_DATABASE = "time.db"
-
-LOAD_ACTIVE_PROJECTS = '''
-SELECT id, key, description, total
-FROM projects LEFT JOIN
-(SELECT 
-    project_id, 
-    sum(strftime('%s', stop_time) - strftime('%s', start_time)) AS total
-    FROM entries 
-    GROUP BY project_id) ON project_id = id
-    WHERE active
-'''
-
-WRITE_LOG = '''
-INSERT INTO entries (project_id, start_time, stop_time, description)
-VALUES(:project_id, :start_time, :stop_time, :description)
-'''
-
-CREATE_PROJECT = '''
-INSERT INTO projects (key, description) VALUES (:key, :description)
-'''
-
-LAST_PROJECT_ID = '''
-SELECT last_insert_rowid()
-'''
-
-RENAME_PROJECT = '''
-UPDATE projects set key = :key, description = :description WHERE id = :id
-'''
-
-ASSIGN_LOGS = '''
-UPDATE entries SET project_id = :new_id WHERE project_id = :old_id
-'''
-
-DELETE_PROJECT = '''
-DELETE FROM projects WHERE id = :id
-'''
-
-# XXX: This is not very efficent!
-LAST_ENTRY = '''
-SELECT id, strftime('%s', start_time), strftime('%s', stop_time) FROM entries
-WHERE project_id = :project_id
-ORDER by strftime('%s', stop_time) DESC LIMIT 1
-'''
-
-DELETE_ENTRY = '''
-DELETE FROM entries WHERE id = :id
-'''
-
-UPDATE_STOP_TIME = '''
-UPDATE entries SET stop_time = :stop_time WHERE id = :id
-'''
-
-worklog = None
-stdscr  = None
-
-orig_vis = None
-
-def cursor_visible(flag):
-    global orig_vis
-    try:
-        old = curses.curs_set(flag)
-        if orig_vis is None: orig_vis = old
-        return old
-    except:
-        pass
-    return 1
-
-def restore_cursor():
-    global orig_vis
-    if not orig_vis is None:
-        curses.curs_set(orig_vis)
-
-def render_header(ofs=0):
-    global stdscr
-    stdscr.attron(curses.A_BOLD)
-    stdscr.addstr(ofs,   5, "getan v0.1")
-    stdscr.addstr(ofs+1, 3, "--------------")
-    stdscr.attroff(curses.A_BOLD)
-    return ofs + 2
-
-def render_quit(ofs=0):
-    global stdscr
-    stdscr.addstr(ofs + 2, 3, "Press DEL once more to quit")
-    return ofs + 3
-
-def tolerantClose(cur):
-    if cur:
-        try: cur.close()
-        except: pass
-
-def ifNull(v, d):
-    if v is None: return d
-    return v
-
-def human_time(delta):
-    seconds = delta.seconds
-    s = seconds % 60
-    if delta.microseconds >= 500000: s += 1
-    seconds /= 60
-    m = seconds % 60
-    seconds /= 60
-    out = "%02d:%02d:%02d" % (seconds, m, s)
-    if delta.days:
-        out = "%dd %s" % (delta.days, out)
-    return out
-
-FACTORS = {
-    's':        1,
-    'm':       60,
-    'h'   : 60*60,
-    'd': 24*60*60}
-
-def human_seconds(timespec):
-    """Translate human input to seconds, default factor is minutes"""
-    total = 0
-    for v in timespec.split(':'):
-        factor = FACTORS.get(v[-1])
-        if factor: v = v[:-1]
-        else:      factor = 60
-        total += int(v) * factor
-    return total
-
-ESC_MAP = {
-    curses.KEY_F1 : ord('1'),
-    curses.KEY_F2 : ord('2'),
-    curses.KEY_F3 : ord('3'),
-    curses.KEY_F4 : ord('4'),
-    curses.KEY_F5 : ord('5'),
-    curses.KEY_F6 : ord('6'),
-    curses.KEY_F7 : ord('7'),
-    curses.KEY_F8 : ord('8'),
-    curses.KEY_F9 : ord('9'),
-    curses.KEY_F10: ord('0'),
-}
-
-ZERO = timedelta(0)
-
-class UTC(tzinfo):
-    """UTC"""
-
-    def utcoffset(self, dt):
-        return ZERO
-
-    def tzname(self, dt):
-        return "UTC"
-
-    def dst(self, dt):
-        return ZERO    
-
-class Project:
-
-    def __init__(self, id = None, key = None, desc = None, total = 0):
-        self.id         = id
-        self.key        = key
-        self.desc       = desc
-        self.total      = timedelta(seconds = ifNull(total, 0))
-        self.start_time = None
-
-    def checkExistence(self, cur):
-        if self.id is None:
-            cur.execute(CREATE_PROJECT, {
-                'key'        : self.key,
-                'description': self.desc})
-            cur.execute(LAST_PROJECT_ID)
-            row = cur.fetchone()
-            cur.connection.commit()
-            self.id = row[0]
-
-    def writeLog(self, cur, description = None):
-        if self.start_time is None: return
-        self.checkExistence(cur)
-        now = datetime.now()
-        cur.execute(WRITE_LOG, {
-            'project_id' : self.id,
-            'start_time' : self.start_time,
-            'stop_time'  : now,
-            'description': description})
-        self.total += now-self.start_time
-        return now
-
-    def getId(self, cur):
-        self.checkExistence(cur)
-        return self.id
-
-    def rename(self, cur, key, desc):
-        self.key  = key
-        self.desc = desc
-        self.checkExistence(cur)
-        cur.execute(RENAME_PROJECT, {
-            'key'        : key,
-            'description': desc,
-            'id'         : self.id })
-        cur.connection.commit()
-
-    def assignLogs(self, cur, anon):
-        self.total += anon.total
-        anon.total = timedelta(seconds=0)
-        old_id = anon.getId(cur)
-        new_id = self.getId(cur)
-        cur.execute(ASSIGN_LOGS, {
-            'new_id': new_id,
-            'old_id': old_id})
-        cur.connection.commit()
-
-    def delete(self, cur):
-        pid = self.getId(cur)
-        cur.execute(DELETE_PROJECT, { 'id': pid })
-        cur.connection.commit()
-
-    def subtractTime(self, cur, seconds):
-        subtractTimeed, zero = timedelta(), timedelta()
-        pid = {'project_id': self.getId(cur)}
-        utc = UTC()
-        while seconds > zero:
-            cur.execute(LAST_ENTRY, pid)
-            row = cur.fetchone()
-            if row is None: break
-            # TODO: evaluate egenix-mx
-            start_time = datetime.fromtimestamp(float(row[1]), utc)
-            stop_time  = datetime.fromtimestamp(float(row[2]), utc)
-            runtime = stop_time - start_time
-            if runtime <= seconds:
-                cur.execute(DELETE_ENTRY, { 'id': row[0] })
-                cur.connection.commit()
-                seconds -= runtime
-                subtractTimeed += runtime
-            else:
-                stop_time -=  seconds
-                cur.execute(UPDATE_STOP_TIME, {
-                    'id': row[0],
-                    'stop_time': stop_time})
-                cur.connection.commit()
-                subtractTimeed += seconds
-                break
-
-        self.total -= subtractTimeed
-        return subtractTimeed
-
-    def addTime(self, cur, seconds, description):
-        now = datetime.now()
-        cur.execute(WRITE_LOG, {
-            'project_id' : self.getId(cur),
-            'start_time' : now - seconds,
-            'stop_time'  : now,
-            'description': description
-        })
-        cur.connection.commit()
-        self.total += seconds
-
-def build_tree(project, depth):
-    if len(project.key) == depth+1:
-        return ProjectNode(project, project.key[depth])
-    node = ProjectNode(None, project.key[depth])
-    node.children.append(build_tree(project, depth+1))
-    return node
-
-class ProjectNode:
-
-    def __init__(self, project = None, key = None):
-        self.children = []
-        self.project  = project
-        self.key      = key
-
-    def insertProject(self, project, depth = 0):
-
-        if not project.key: # anonym -> end
-            node = ProjectNode(project)
-            self.children.append(node)
-            return
-
-        for i, child in enumerate(self.children):
-            if not child.key: # before anonym projects
-                self.children.insert(i, build_tree(project, depth))
-                return
-            if child.key == project.key[depth]:
-                child.insertProject(project, depth+1)
-                return
-        self.children.append(build_tree(project, depth))
-
-    def removeProject(self, project):
-
-        if self.isLeaf(): return
-        stack = [self]
-        while stack:
-            parent = stack.pop()
-            for child in parent.children:
-                if not child.isLeaf():
-                    stack.append(child)
-                    continue
-                if child.project == project:
-                    parent.children.remove(child)
-                    return
-
-    def isLeaf(self):
-        return not self.project is None
-
-    def findProject(self, key):
-        l, lower = key.lower(), None
-        for child in self.children:
-            if child.key == key:
-                return child
-            if child.key and child.key.lower() == l:
-                lower = child
-        return lower
-
-    def dump(self, depth = 0):
-        out = []
-        indent = "  " * depth
-        out.append("%skey: %s" % (indent, self.key))
-        if self.project:
-            out.append("%sdescription: %s" % (indent, self.project.desc))
-        for child in self.children:
-            out.append(child.dump(depth+1))
-        return "\n".join(out)
-
-class Worklog:
-
-    def __init__(self, database):
-        self.initDB(database)
-        self.projects        = []
-        self.tree            = ProjectNode()
-        self.state           = PAUSED
-        self.current_project = None
-        self.selection       = self.tree
-        self.stack           = []
-        self.loadProjects()
-
-    def initDB(self, database):
-        self.con = db.connect(database)
-
-    def loadProjects(self):
-        cur = None
-        try:
-            cur = self.con.cursor()
-            cur.execute(LOAD_ACTIVE_PROJECTS)
-            while True:
-                row = cur.fetchone()
-                if not row: break
-                project = Project(*row)
-                self.projects.append(project)
-                self.tree.insertProject(project)
-        finally:
-            tolerantClose(cur)
-
-    def shutdown(self):
-        self.con.close()
-
-    def fetchStack(self):
-        cut = ''.join([chr(i) for i in self.stack])
-        self.stack = []
-        return cut
-
-    def findProject(self, key):
-        key_lower = key.lower()
-        lower = None
-
-        for p in self.projects:
-            if p.key == key:
-                return p
-            if p.key and p.key.lower() == key_lower:
-                lower = p
-
-        return lower
-
-    def findAnonymProject(self, num):
-        count = 0
-        for p in self.projects:
-            if p.key is None:
-                if count == num:
-                    return p
-                count += 1
-        return None
-
-    def renameAnonymProject(self, num, key, description):
-        project = self.findAnonymProject(num)
-        if project:
-            cur = None
-            try:
-                cur = self.con.cursor()
-                project.rename(cur, key, description)
-            finally:
-                tolerantClose(cur)
-            self.tree.removeProject(project)
-            self.tree.insertProject(project)
-                    
-    def assignLogs(self, num, key):
-        anon = self.findAnonymProject(num)
-        if anon is None: return
-        project = self.findProject(key)
-        if project is None: return
-        cur = None
-        try:
-            cur = self.con.cursor()
-            project.assignLogs(cur, anon)
-            self.projects.remove(anon)
-            anon.delete(cur)
-        finally:
-            tolerantClose(cur)
-
-    def addTime(self, key, seconds, description = None):
-        project = self.findProject(key)
-        if project is None: return
-        cur = None
-        try:
-            cur = self.con.cursor()
-            project.addTime(cur, seconds, description)
-        finally:
-            tolerantClose(cur)
-
-    def subtractTime(self, key, seconds):
-        project = self.findProject(key)
-        if project is None: return
-        cur = None
-        try:
-            cur = self.con.cursor()
-            project.subtractTime(cur, seconds)
-        finally:
-            tolerantClose(cur)
-
-    def isRunning(self):
-        return self.state in (RUNNING, RUNNING_ESC)
-
-    def totalTime(self):
-        sum = timedelta()
-        for p in self.projects:
-            sum += p.total
-        return sum
-
-    def render(self, ofs=0):
-        ofs = render_header(ofs)
-        ml = max([len(p.desc and p.desc or "unknown") for p in self.projects])
-        unknown = 0
-
-        if self.current_project and self.current_project.start_time:
-            current_delta      = datetime.now() - self.current_project.start_time 
-            current_time_str   = "%s " % human_time(current_delta)
-            current_time_space = " " * len(current_time_str)
-        else:
-            current_delta      = timedelta()
-            current_time_str   = ""
-            current_time_space = ""
-
-        for project in self.projects:
-            is_current = project == self.current_project
-            pref = is_current and " -> " or "    "
-            if project.key is None:
-                key = "^%d" % unknown
-                unknown += 1
-            else:
-                key = " %s" % project.key
-            desc = project.desc is None and "unknown" or project.desc
-            stdscr.attron(curses.A_BOLD)
-            stdscr.addstr(ofs, 0, "%s%s" % (pref, key))
-            stdscr.attroff(curses.A_BOLD)
-            stdscr.addstr(" %s" % desc)
-
-            diff = ml - len(desc) + 1
-            stdscr.addstr(" " * diff)
-            if is_current: stdscr.attron(curses.A_UNDERLINE)
-
-            if is_current:
-                stdscr.addstr("%s(%s)" % (
-                    current_time_str,
-                    human_time(project.total + current_delta)))
-            else:
-                stdscr.addstr("%s(%s)" % (
-                    current_time_space,
-                    human_time(project.total)))
-
-            if is_current: stdscr.attroff(curses.A_UNDERLINE)
-            ofs += 1
-
-        total_str   = "(%s)" % human_time(self.totalTime() + current_delta) 
-        total_x_pos = ml + 8 + len(current_time_space)
-
-        stdscr.addstr(ofs, total_x_pos, "=" * len(total_str))
-        ofs += 1
-        stdscr.addstr(ofs, total_x_pos, total_str)
-        ofs += 1
-
-        return ofs
-
-    def writeLog(self, description = None):
-        if self.current_project is None:
-            return datetime.now()
-        cur = None
-        try:
-            cur = self.con.cursor()
-            now = self.current_project.writeLog(cur, description)
-            self.con.commit()
-            return now
-        finally:
-            tolerantClose(cur)
-
-    def pausedState(self, c):
-        c2 = ESC_MAP.get(c)
-        if c2:
-            self.pausedEscapeState(c2)
-            return
-
-        global stdscr
-        if c in (curses.KEY_DC, curses.KEY_BACKSPACE):
-            stdscr.erase()
-            ofs = render_quit(self.render())
-            stdscr.refresh()
-            self.state = PRE_EXIT
-
-        elif c == curses.ascii.ESC:
-            self.state = PAUSED_ESC
-
-        elif curses.ascii.isascii(c):
-            if c == ord('-'):
-                self.selection = self.tree
-                stdscr.erase()
-                ofs = self.render()
-                old_cur = cursor_visible(1)
-                curses.echo()
-                stdscr.addstr(ofs + 1, 3, "<key> <minutes>: ")
-                key = stdscr.getstr()
-                curses.noecho()
-                cursor_visible(old_cur)
-                key = key.strip()
-                if key:
-                    parts = SPACE.split(key, 1)
-                    if len(parts) > 1:
-                        key, timespec = parts[0], parts[1]
-                        try:
-                            seconds = human_seconds(timespec)
-                            if seconds > 0:
-                                seconds = timedelta(seconds=seconds)
-                                self.subtractTime(key, seconds)
-                        except ValueError:
-                            pass
-                stdscr.erase()
-                self.render()
-                stdscr.refresh()
-
-            elif c == ord('+'):
-                self.selection = self.tree
-                stdscr.erase()
-                ofs = self.render()
-                old_cur = cursor_visible(1)
-                curses.echo()
-                stdscr.addstr(ofs + 1, 3, "<key> <minutes> [<description>]: ")
-                key = stdscr.getstr()
-                curses.noecho()
-                cursor_visible(old_cur)
-                key = key.strip()
-                if key:
-                    parts = SPACE.split(key, 2)
-                    if len(parts) > 1:
-                        key, timespec = parts[0], parts[1]
-                        if len(parts) > 2: desc = parts[2]
-                        else:              desc = None
-                        try:
-                            seconds = human_seconds(timespec)
-                            if seconds > 0:
-                                seconds = timedelta(seconds=seconds)
-                                self.addTime(key, seconds, desc)
-                        except ValueError:
-                            pass
-                stdscr.erase()
-                self.render()
-                stdscr.refresh()
-
-            else:
-                node = self.selection.findProject(chr(c))
-                if not node:
-                    self.selection = self.tree
-                    return
-                if node.isLeaf():
-                    self.selection = self.tree
-                    nproject = node.project
-                    self.current_project = nproject
-                    nproject.start_time = datetime.now()
-                    stdscr.erase()
-                    ofs = self.render()
-                    stdscr.refresh()
-                    self.state = RUNNING
-                    signal.signal(signal.SIGALRM, alarm_handler)
-                    signal.alarm(1)
-                else:
-                    self.selection = node
-
-    def runningState(self, c):
-        global stdscr
-        c2 = ESC_MAP.get(c)
-        if c2:
-            self.runningEscapeState(c2)
-            return
-
-        if c == curses.ascii.ESC:
-            self.state = RUNNING_ESC
-
-        elif c == curses.ascii.NL:
-            signal.signal(signal.SIGALRM, signal.SIG_IGN)
-            self.state = PAUSED
-            stdscr.erase()
-            ofs = self.render()
-            old_cur = cursor_visible(1)
-            curses.echo()
-            stdscr.addstr(ofs + 1, 3, "Description: ")
-            description = stdscr.getstr()
-            curses.noecho()
-            cursor_visible(old_cur)
-            self.writeLog(description)
-            self.current_project = None
-            stdscr.erase()
-            ofs = self.render()
-            stdscr.refresh()
-            signal.signal(signal.SIGALRM, alarm_handler)
-            signal.alarm(1)
-        elif c == ord('+'):
-            signal.signal(signal.SIGALRM, signal.SIG_IGN)
-            stdscr.erase()
-            ofs = self.render()
-            if self.stack:
-                timespec = self.fetchStack()
-            else:
-                old_cur = cursor_visible(1)
-                curses.echo()
-                stdscr.addstr(ofs + 1, 3, "Enter time to add: ")
-                timespec = stdscr.getstr()
-                curses.noecho()
-                cursor_visible(old_cur)
-                stdscr.erase()
-                ofs = self.render()
-            try:
-                seconds = human_seconds(timespec)
-                if seconds > 0:
-                    seconds = timedelta(seconds=seconds)
-                    self.current_project.start_time -= seconds
-                    stdscr.addstr(ofs + 1, 3, "added %s" % human_time(seconds))
-            except (ValueError, IndexError):
-                pass
-            stdscr.refresh()
-            signal.signal(signal.SIGALRM, alarm_handler)
-            signal.alarm(1)
-        elif c == ord('-'):
-            signal.signal(signal.SIGALRM, signal.SIG_IGN)
-            stdscr.erase()
-            ofs = self.render()
-            if self.stack:
-                timespec = self.fetchStack()
-            else:
-                old_cur = cursor_visible(1)
-                curses.echo()
-                stdscr.addstr(ofs + 1, 3, "Enter time to subtract: ")
-                timespec = stdscr.getstr()
-                curses.noecho()
-                cursor_visible(old_cur)
-                stdscr.erase()
-                ofs = self.render()
-            try:
-                seconds = human_seconds(timespec)
-                if seconds > 0:
-                    now = datetime.now()
-                    seconds = timedelta(seconds=seconds)
-                    self.current_project.start_time += seconds
-                    stdscr.addstr(ofs + 1, 3, "subtracted %s" % human_time(seconds))
-                    if self.current_project.start_time > now:
-                        seconds = self.current_project.start_time - now
-                        self.current_project.start_time = now
-                        cur = None
-                        try:
-                            cur = self.con.cursor()
-                            self.current_project.subtractTime(cur, seconds)
-                        finally:
-                            tolerantClose(cur)
-            except (ValueError, IndexError):
-                pass
-            stdscr.refresh()
-            signal.signal(signal.SIGALRM, alarm_handler)
-            signal.alarm(1)
-        elif self.stack or curses.ascii.isdigit(c):
-            self.stack.append(c)
-        elif curses.ascii.isascii(c):
-            project_node = self.selection.findProject(chr(c))
-            if project_node is None:
-                self.selection = self.tree
-                return
-
-            if project_node.isLeaf():
-                self.selection = self.tree
-                nproject = project_node.project
-                if nproject == self.current_project:
-                    return
-                nproject.start_time = self.writeLog()
-                self.current_project = nproject
-                stdscr.erase()
-                ofs = self.render()
-                stdscr.refresh()
-            else:
-                self.selection = project_node
-
-    def pausedEscapeState(self, c):
-        global stdscr
-        if curses.ascii.isdigit(c):
-            pnum = c - ord('0')
-            nproject = self.findAnonymProject(pnum)
-            if nproject is None:
-                nproject = Project()
-                self.projects.append(nproject)
-
-            nproject.start_time = self.writeLog()
-            self.current_project = nproject
-            self.state = RUNNING
-            stdscr.erase()
-            ofs = self.render()
-            stdscr.refresh()
-            signal.signal(signal.SIGALRM, alarm_handler)
-            signal.alarm(1)
-        elif curses.ascii.isalpha(c):
-            if c == ord('n'):
-                stdscr.erase()
-                ofs = self.render()
-                old_cur = cursor_visible(1)
-                curses.echo()
-                stdscr.addstr(ofs + 1, 3, "<num> <key> <description>: ")
-                stdscr.refresh()
-                description = stdscr.getstr()
-                curses.noecho()
-                cursor_visible(old_cur)
-
-                description = description.strip()
-                if description:
-                    num, key, description = SPACE.split(description, 2)
-                    try:
-                        num = int(num)
-                        self.renameAnonymProject(num, key, description)
-                    except ValueError:
-                        pass
-
-                stdscr.erase()
-                ofs = self.render()
-                stdscr.refresh()
-                self.state = PAUSED
-
-            elif c == ord('a'):
-                stdscr.erase()
-                ofs = self.render()
-                old_cur = cursor_visible(1)
-                curses.echo()
-                stdscr.addstr(ofs + 1, 3, "<num> <key>: ")
-                stdscr.refresh()
-                key = stdscr.getstr()
-                curses.noecho()
-                cursor_visible(old_cur)
-
-                key = key.strip()
-                if key:
-                    num, key = SPACE.split(key, 1)
-                    try:
-                        num = int(num)
-                        self.assignLogs(num, key)
-                    except ValueError:
-                        pass
-
-                stdscr.erase()
-                ofs = self.render()
-                stdscr.refresh()
-                self.state = PAUSED
-            else:
-                self.state = PAUSED
-        else:
-            self.state = PAUSED
-
-    def runningEscapeState(self, c):
-        global stdscr
-        if curses.ascii.isdigit(c):
-            signal.signal(signal.SIGALRM, signal.SIG_IGN)
-            pnum = c - ord('0')
-            nproject = self.findAnonymProject(pnum)
-            if nproject is None:
-                nproject = Project()
-                self.projects.append(nproject)
-
-            nproject.start_time = self.writeLog()
-            self.current_project = nproject
-            self.state = RUNNING
-            stdscr.erase()
-            self.render()
-            stdscr.refresh()
-            signal.signal(signal.SIGALRM, alarm_handler)
-            signal.alarm(1)
-        else:
-            self.state = RUNNING
-        
-
-    def run(self):
-        global stdscr
-
-        stdscr.erase()
-        self.render()
-        stdscr.refresh()
-
-        while True:
-            c = stdscr.getch()
-            if c == -1: continue
-
-            if self.state == PAUSED:
-                self.pausedState(c)
-
-            elif self.state == RUNNING:
-                self.runningState(c)
-
-            elif self.state == PAUSED_ESC:
-                self.pausedEscapeState(c)
-
-            elif self.state == RUNNING_ESC:
-                self.runningEscapeState(c)
-
-            elif self.state == PRE_EXIT:
-                if c in (curses.KEY_DC, curses.KEY_BACKSPACE):
-                    break
-                else:
-                    stdscr.erase()
-                    self.render()
-                    stdscr.refresh()
-                    self.state = PAUSED
-
-def alarm_handler(flag, frame):
-    global worklog
-    global stdscr
-
-    stdscr.erase()
-    worklog.render()
-    stdscr.refresh()
-    if worklog.isRunning():
-        signal.alarm(1)
-
-def exit_handler(flag, frame):
-    exit_code = 0
-    global worklog
-    try:
-        worklog.shutdown()
-    except:
-        traceback.print_exc(file=sys.stderr)
-        exit_code = 1
-
-    restore_cursor()
-    curses.nocbreak()
-    stdscr.keypad(0)
-    curses.echo()
-    curses.endwin()
-    sys.exit(exit_code)
-
-def main():
-
-    database = len(sys.argv) < 2 and DEFAULT_DATABASE or sys.argv[1]
-    # TODO: create database file if it does not exist.
-
-    global worklog
-    try:
-        worklog = Worklog(database)
-    except:
-        traceback.print_exc(file=sys.stderr)
-        sys.exit(1)
-
-    global stdscr
-    stdscr = curses.initscr()
-    curses.noecho()
-    curses.cbreak()
-    stdscr.keypad(1)
-    cursor_visible(0)
-
-    signal.signal(signal.SIGHUP,  exit_handler)
-    signal.signal(signal.SIGINT,  exit_handler)
-    signal.signal(signal.SIGQUIT, exit_handler)
-    signal.signal(signal.SIGTERM, exit_handler)
-    
-    try:
-        try:
-            worklog.run()
-        except:
-            traceback.print_exc(file=sys.stderr)
-    finally:
-        exit_handler(0, None)
-
-if __name__ == '__main__':
-    main()
-
-# vim:set ts=4 sw=4 si et sta sts=4 fenc=utf8:
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/getan.py	Sat Aug 28 20:16:58 2010 +0200
@@ -0,0 +1,157 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+#
+# (c) 2010 by Ingo Weinzierl <ingo.weinzierl@intevation.de>
+#
+# A python worklog-alike to log what you have 'getan' (done).
+#
+# This is Free Software licensed under the terms of GPLv3 or later.
+# For details see LICENSE coming with the source of 'getan'.
+#
+
+import logging
+import sys
+from   datetime import datetime
+
+import getan.config  as config
+from   getan.backend import *
+from   getan.view    import *
+from   getan.utils   import format_time
+
+logger = logging.getLogger()
+
+class GetanController:
+    def __init__(self, backend, pv_class, ev_class):
+        self.ev_class = ev_class
+        self.pv_class = pv_class
+
+        self.projects = backend.load_projects()
+        entries       = backend.load_entries(self.projects[0].id)
+        self.running  = []
+
+        self.backend      = backend
+        self.project_view = pv_class(self, self.projects)
+        self.entries_view = ev_class(entries)
+
+        self.view  = GetanView(self, self.project_view, self.entries_view)
+        self.state = PausedProjectsState(self, self.project_view)
+
+    def main(self):
+        self.view.run()
+
+    def unhandled_keypress(self, key):
+        self.state = self.state.keypress(key)
+
+    def input_filter(self, input, raw_input):
+        self.state = self.state.keypress(input)
+
+    def update_entries(self, project):
+        logger.debug("GetanController: update entries.")
+        self.entries_view.set_rows(self.backend.load_entries(project.id))
+        self.view.update_view()
+
+    def move_selected_entries(self, project):
+        old_project = None
+        entries = []
+        try:
+            while True:
+                node = self.entries_view.selection.pop()
+                if node.selected: node.select()
+                entries.append(node.entry)
+                logger.info("GetanController: move entry '%s' (id = %d, "\
+                            "project id = %d) to project '%s'"
+                            % (node.entry.desc, node.entry.id,
+                               node.entry.project_id, project.desc))
+
+                if not old_project:
+                    old_project = self.project_by_id(node.entry.project_id)
+        except IndexError, err:
+            pass
+        finally:
+            self.backend.move_entries(entries, project.id)
+            if not old_project: return
+            project.entries     = self.backend.load_entries(project.id)
+            old_project.entries = self.backend.load_entries(old_project.id)
+            self.update_entries(old_project)
+            self.project_view.update_all()
+
+    def delete_entries(self, entry_nodes):
+        if not entry_nodes: return
+        proj    = None
+        entries = []
+        try:
+            while True:
+                node = self.entries_view.selection.pop()
+                if node.selected: node.select()
+                entries.append(node.entry)
+                logger.info("GetanController: delete entry '%s' (id = %d, "\
+                            "project id = %d)"
+                            % (node.entry.desc, node.entry.id,
+                               node.entry.project_id))
+
+                if proj is None:
+                    proj = self.project_by_id(node.entry.project_id)
+        except IndexError, err:
+            pass
+        finally:
+            self.backend.delete_entries(entries)
+            proj.entries = self.backend.load_entries(proj.id)
+            self.update_entries(proj)
+            self.project_view.update()
+
+    def update_project_list(self):
+        self.project_view.update()
+        self.view.update_view()
+
+    def exit(self):
+        self.view.exit()
+
+    def project_by_key(self, key):
+        for proj in self.projects:
+            if proj.key == key:
+                return proj
+        return None
+
+    def project_by_id(self, id):
+        for proj in self.projects:
+            if proj.id == id:
+                return proj
+        return None
+
+    def start_project(self, project):
+        self.running.append(project)
+        project.start = datetime.now()
+        logger.info("Start project '%s' at %s."
+                    % (project.desc, format_time(datetime.now())))
+        self.view.set_footer_text(" Running on '%s'" % project.desc, 'running')
+        logger.debug('All running projects: %r' % self.running)
+
+    def stop_project(self):
+        project = self.running.pop()
+        desc    = self.view.get_frame().get_footer().get_edit_text()
+        logger.info("Stop project '%s' at %s."
+                    % (project.desc, format_time(datetime.now())))
+        project.stop = datetime.now()
+        self.backend.insert_project_entry(project, datetime.now(), desc)
+        self.update_entries(project)
+        self.update_project_list()
+        logger.debug('Still running projects: %r' % self.running)
+
+
+def main():
+    config.initialize()
+    global logger
+
+    if len(sys.argv) > 1:
+        backend = Backend(sys.argv[1])
+        logging.info("Use database '%s'." % sys.argv[1])
+    else:
+        backend = Backend()
+        logging.info("Use database '%s'." % DEFAULT_DATABASE)
+
+    controller = GetanController(backend, ProjectList, EntryList)
+    controller.main()
+
+
+if __name__ == '__main__':
+    main()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/getan/backend.py	Sat Aug 28 20:16:58 2010 +0200
@@ -0,0 +1,166 @@
+# -*- coding: utf-8 -*-
+#
+# (c) 2008, 2009, 2010 by
+#   Sascha L. Teichmann <sascha.teichmann@intevation.de>
+#   Ingo Weinzierl <ingo.weinzierl@intevation.de>
+#
+# This is Free Software licensed unter the terms of GPLv3 or later.
+# For details see LICENSE coming with the source of 'getan'.
+#
+
+import logging
+import sqlite3 as db
+
+from getan.project import Project, Entry
+
+DEFAULT_DATABASE = "time.db"
+
+LOAD_ACTIVE_PROJECTS = '''
+SELECT id, key, description, total
+FROM projects LEFT JOIN
+(SELECT 
+    project_id, 
+    sum(strftime('%s', stop_time) - strftime('%s', start_time)) AS total
+    FROM entries 
+    GROUP BY project_id) ON project_id = id
+    WHERE active
+'''
+
+LOAD_PROJECT_ENTRIES = '''
+SELECT
+    id,
+    project_id,
+    start_time as "[timestamp]",
+    stop_time  as "[timestamp]",
+    description
+FROM
+    entries
+WHERE
+    project_id = %i
+ORDER BY
+    id
+DESC
+'''
+
+INSERT_PROJECT_ENTRY = '''
+INSERT INTO entries (project_id, start_time, stop_time, description)
+VALUES(?,?,?,?)
+'''
+
+DELETE_PROJECT_ENTRY = 'DELETE FROM entries WHERE id = %i'
+
+MOVE_ENTRY = 'UPDATE entries SET project_id = ? WHERE id = ?'
+
+logger = logging.getLogger()
+
+class Backend:
+
+    def __init__(self, database = DEFAULT_DATABASE):
+        self.database = database
+        self.con      = db.connect(database,
+                                   detect_types=db.PARSE_DECLTYPES |
+                                   db.PARSE_COLNAMES)
+        self.con.text_factory = lambda x: unicode(x, "utf-8", "ignore")
+
+
+    def load_projects(self):
+        """ Loads active projects from database and returns them as array """
+        logger.debug("load active projects from database.")
+        cur = None
+        try :
+            cur = self.con.cursor()
+            cur.execute(LOAD_ACTIVE_PROJECTS)
+
+            projects = []
+            while True:
+                row = cur.fetchone()
+
+                if not row: break
+                proj          = Project(*row)
+                proj.entries = self.load_entries(proj.id)
+                projects.append(proj)
+
+            logger.info("found %i active projects." % len(projects))
+            return projects
+
+        finally:
+            close(cur)
+
+
+    def load_entries(self, project_id):
+        """ Loads all entries that belong to a specific project """
+        logger.debug("load entries that belong to project %s" % project_id)
+        cur = None
+        try:
+            cur = self.con.cursor()
+            cur.execute(LOAD_PROJECT_ENTRIES % project_id)
+
+            entries = []
+            while True:
+                try:
+                    row = cur.fetchone()
+
+                    if not row: break
+                    entries.append(Entry(*row))
+                except:
+                    logger.warn("found invalid entry.")
+
+            logger.debug("Found %i entries that belong to project '%i'"
+                         % (len(entries), project_id))
+            return entries
+        finally:
+            close(cur)
+
+
+    def insert_project_entry(self, project, stop_time, desc):
+        if project is None: return
+        cur = None
+        try:
+            cur = self.con.cursor()
+            cur.execute(INSERT_PROJECT_ENTRY, (
+                project.id, project.start, stop_time, desc))
+            self.con.commit()
+            logger.debug("Added new entry '%s' of project '%s' into db"
+                         % (desc, project.desc))
+
+            project.entries = self.load_entries(project.id)
+        finally:
+            close(cur)
+
+
+    def delete_entries(self, entries):
+        if entries is None: return
+
+        cur = None
+        try:
+            cur = self.con.cursor()
+            for entry in entries:
+                cur.execute(DELETE_PROJECT_ENTRY % entry.id)
+                logger.debug("Deleted entry: %s (%d)" % (entry.desc, entry.id))
+            self.con.commit()
+        finally:
+            close(cur)
+
+
+    def move_entries(self, entries, new_project_id):
+        if entries is None or new_project_id is None: return
+
+        cur = None
+        try:
+            cur = self.con.cursor()
+            for entry in entries:
+                cur.execute(MOVE_ENTRY, (new_project_id, entry.id))
+                logger.debug("Moved entry '%s' (id=%d) to project with id %d."
+                             % (entry.desc, entry.id, new_project_id))
+            self.con.commit()
+        finally:
+            close(cur)
+
+def close(cur):
+    """ This function closes a database cursor if it is existing """
+    if cur:
+        try: cur.close()
+        except:
+            logger.warn("could not close database cursor.")
+
+# vim:set ts=4 sw=4 si et sta sts=4 fenc=utf8:
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/getan/config.py	Sat Aug 28 20:16:58 2010 +0200
@@ -0,0 +1,19 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+#
+# (c) 2010 by Ingo Weinzierl <ingo.weinzierl@intevation.de>
+#
+# This is Free Software licensed under the terms of GPLv3 or later.
+# For details see LICENSE coming with the source of 'getan'.
+#
+
+import logging
+
+logger = None
+
+def initialize():
+    logging.basicConfig(level=logging.INFO,
+                        format='%(asctime)s %(levelname)s %(message)s',
+                        filename='getan.log',
+                        filemode='w')
+    logger = logging.getLogger()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/getan/project.py	Sat Aug 28 20:16:58 2010 +0200
@@ -0,0 +1,78 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+#
+# (c) 2008, 2009, 2010 by
+#   Sascha L. Teichmann <sascha.teichmann@intevation.de>
+#   Ingo Weinzierl <ingo.weinzierl@intevation.de>
+#
+# This is Free Software licensed unter the terms of GPLv3 or later.
+# For details see LICENSE coming with the source of 'getan'.
+
+from datetime import datetime, timedelta
+
+class Project:
+
+    def __init__(self, id, key, desc, total):
+        self.id      = id
+        self.key     = key
+        self.desc    = desc
+        self.entries = []
+        self.total   = total
+        self.start   = None
+        self.stop    = None
+
+    def year(self):
+        total = 0
+        now   = datetime.now()
+        for entry in self.entries:
+            start = entry.start
+            if start.year == now.year:
+                total += (entry.end - start).seconds
+        return total
+
+    def month(self):
+        total = 0
+        now   = datetime.now()
+        for entry in self.entries:
+            start = entry.start
+            if start.month == now.month and start.year == now.year:
+                total += (entry.end - start).seconds
+        return total
+
+    def week(self):
+        total = 0
+        now   = datetime.now()
+        tweek = now.strftime('%W')
+        for entry in self.entries:
+            start = entry.start
+            if start.strftime('%W') == tweek and start.year == now.year:
+                total += (entry.end - start).seconds
+        return total
+
+    def day(self):
+        total = 0
+        now   = datetime.now()
+        for entry in self.entries:
+            start = entry.start
+            if start.month == now.month and start.year == now.year and start.day  == now.day:
+                total += (entry.end - start).seconds
+        return total
+
+
+class Entry:
+
+    def __init__(self, id, project_id, start, end, desc):
+        self.id         = id
+        self.project_id = project_id
+        self.start      = start
+        self.end        = end
+        self.desc       = desc
+
+    def duration(self):
+        return (self.end - self.start)
+
+    def __str__(self):
+        return ("[%s | %s | %s | %s | %s]" %
+               (self.id, self.project_id, self.start, self.end, self.desc))
+        
+# vim:set ts=4 sw=4 si et sta sts=4 fenc=utf8:
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/getan/states.py	Sat Aug 28 20:16:58 2010 +0200
@@ -0,0 +1,427 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+#
+# (c) 2010 by Ingo Weinzierl <ingo.weinzierl@intevation.de>
+#
+# This is Free Software licensed under the terms of GPLv3 or later.
+# For details see LICENSE coming with the source of 'getan'.
+#
+
+
+import logging
+import signal
+from   datetime import datetime, timedelta
+
+from getan.utils import human_time
+
+logger = logging.getLogger()
+
+class State(object):
+    messages = {
+    }
+
+    def __init__(self, controller, view):
+        self.controller = controller
+        self.view       = view
+
+    def msg(self, key):
+        return self.messages[key]
+
+
+class ProjectState(State):
+    def keypress(self, key):
+        logger.debug("ProjectState: handle key '%r'" % key)
+        if 'f1' in key:
+            self.view.switch_time_mode()
+            return None
+
+        if 'tab' in key:
+            self.controller.entries_view.focused = 0
+            self.controller.entries_view.update_focus(0)
+            return DefaultEntryListState(self, self.controller,
+                                  self.controller.entries_view)
+
+
+class PausedProjectsState(ProjectState):
+    messages = {
+        'choose_proj': u" Wählen Sie ein Projekt:"
+    }
+
+    def keypress(self, key):
+        logger.debug("PausedProjectsState: handle key '%r'" % key)
+        ret = super(PausedProjectsState, self).keypress(key)
+        if ret:
+            return ret
+
+        if 'up' in key:
+            return self.up()
+
+        if 'down' in key:
+            return self.down()
+
+        if 'enter' in key:
+            return self.select()
+
+        if 'esc' in key:
+            return ExitState(self.controller, self.view)
+
+        else:
+            if len(key) > 0:
+                proj = self.controller.project_by_key(key[0])
+                if proj:
+                    self.view.select_project(proj)
+                    self.controller.start_project(self.view.item_in_focus())
+                    self.controller.update_entries(
+                        self.view.item.in_focus().project)
+                    return RunningProjectsState(self.controller, self.view)
+        return self
+
+    def up(self):
+        self.view.up()
+        self.controller.update_entries(self.view.item_in_focus())
+        return self
+
+    def down(self):
+        self.view.down()
+        self.controller.update_entries(self.view.item_in_focus())
+        return self
+
+    def select(self):
+        proj_node = self.view.select()
+        self.controller.start_project(self.view.item_in_focus())
+        return RunningProjectsState(self.controller, self.view)
+
+
+class ExitState(ProjectState):
+    messages = {
+        'quit'  : u" Wirklich beenden? (y/n)",
+        'choose': u" Wählen Sie ein Projekt:"
+    }
+
+    def __init__(self, controller, view):
+        super(ExitState, self).__init__(controller, view)
+        self.controller.view.set_footer_text(self.msg('quit'), 'question')
+
+    def keypress(self, key):
+        logger.debug("ExitState: handle key '%r'" % key)
+        ret = super(ExitState, self).keypress(key)
+        if ret:
+            return ret
+
+        if 'y' in key or 'Y' in key:
+            self.controller.exit()
+
+        if 'n' in key or 'N' in key:
+            self.controller.view.set_footer_text(self.msg('choose'), 'question')
+            return PausedProjectsState(self.controller, self.view)
+
+        return self
+
+
+class RunningProjectsState(ProjectState):
+    messages = {
+        'description': u" Geben Sie eine Beschreibung ein: ",
+        'add_time'   : u" Geben Sie die zu addierende Zeit ein [min]: ",
+        'min_time'   : u" Geben Sie die zu abzuziehende Zeit ein [min]: ",
+        'paused'     : u" 'Space' zum Fortzusetzen",
+    }
+
+    sec         = 0
+    break_start = None
+
+    def __init__(self, controller, view):
+        super(RunningProjectsState, self).__init__(controller, view)
+        signal.signal(signal.SIGALRM, self.handle_signal)
+        signal.alarm(1)
+
+    def handle_signal(self, signum, frame):
+        proj = self.view.item_in_focus()
+        if not self.break_start:
+            self.controller.view.set_footer_text(" Running ( %s ) on '%s'" %
+                                                 (human_time(self.sec),
+                                                  proj.desc),
+                                                 'running')
+            self.controller.view.loop.draw_screen()
+            self.sec = self.sec + 1
+        else:
+            self.view.set_footer_text(
+                ' Break   ( %s )%s' %
+                (human_time((datetime.now()-self.break_start).seconds),
+                 self.msg('paused')),
+                'paused_running')
+            self.controller.view.loop.draw_screen()
+
+        signal.signal(signal.SIGALRM, self.handle_signal)
+        signal.alarm(1)
+
+    def keypress(self, key):
+        logger.debug("RunningProjectsState: handle key '%r'" % key)
+        ret = super(RunningProjectsState, self).keypress(key)
+        if ret:
+            return ret
+
+        if 'enter' in key:
+            return self.stop()
+        if '+' in key:
+            self.view.set_footer_text(self.msg('add_time'),
+                                                 'question', 1)
+            self.view.frame.set_focus('footer')
+            return AddTimeState(self.controller, self.view, self)
+        if '-' in key:
+            self.view.set_footer_text(self.msg('min_time'),
+                                                 'question', 1)
+            self.view.frame.set_focus('footer')
+            return SubtractTimeState(self.controller, self.view, self)
+        if ' ' in key and not self.break_start:
+            self.break_start = datetime.now()
+            return self
+        if ' ' in key and self.break_start:
+            self.view._total_time()
+            proj             = self.view.item_in_focus()
+            proj.start      += datetime.now() - self.break_start
+            self.break_start = None
+            signal.signal(signal.SIGALRM, self.handle_signal)
+            signal.alarm(1)
+        return self
+
+    def stop(self):
+        signal.alarm(0)
+        self.view.select()
+        if self.break_start:
+            proj = self.view.item_in_focus()
+            proj.start += datetime.now() - self.break_start
+        self.controller.view.set_footer_text(self.msg('description'),'question',1)
+        self.controller.view.get_frame().set_focus('footer')
+        return DescriptionProjectsState(
+            self.controller, self.view, self,
+            self.controller.view.get_frame().get_footer())
+
+
+class HandleUserInputState(State):
+    def __init__(self, controller, view, state, footer):
+        self.controller = controller
+        self.view       = view
+        self.state      = state
+        self.footer     = footer
+
+    def keypress(self, key):
+        logger.debug("HandleUserInputState: handle key '%r'" % key)
+        pos = self.footer.edit_pos
+
+        if 'esc' in key:
+            return self.exit()
+        elif 'enter' in key:
+            return self.enter()
+        elif 'left' in key:
+            self.footer.set_edit_pos(pos-1)
+        elif 'right' in key:
+            self.footer.set_edit_pos(pos+1)
+        elif 'backspace' in key:
+            text = self.footer.edit_text
+            self.footer.set_edit_text(
+                '%s%s' % (text[0:pos-1], text[pos:len(text)]))
+            self.footer.set_edit_pos(pos-1)
+        elif 'delete' in key:
+            text = self.footer.edit_text
+            self.footer.set_edit_text(
+                '%s%s' % (text[0:pos], text[pos+1:len(text)]))
+            self.footer.set_edit_pos(pos)
+        elif len(key) >= 1 and len(key[0]) == 1:
+            return self.insert(key)
+        return self   
+
+    def exit(self):
+        return self.state
+
+    def insert(self, key):
+        logger.debug("Enter key: %r" % key)
+        self.footer.insert_text(key[0])
+        return self
+
+
+class BaseTimeState(HandleUserInputState):
+    def __init__(self, controller, view, running_state):
+        super(BaseTimeState, self).__init__(controller, view, running_state,
+                                           view.frame.get_footer())
+
+    def exit(self):
+        self.view._total_time()
+        return self.running_state
+
+    def insert(self, key):
+        if key[0] in ['0','1','2','3','4','5','6','7','8','9']:
+            self.footer.insert_text(key[0])
+        else:
+            logger.debug("BaseTimeState: invalid character for "\
+                         "adding/subtracting time: '%r'" % key)
+        return self
+
+
+class AddTimeState(BaseTimeState):
+    def enter(self):
+        minutes         = int(self.view.frame.get_footer().get_edit_text())
+        project         = self.view.item_in_focus()
+        project.start  -= timedelta(minutes=minutes)
+        self.state.sec += minutes * 60
+        logger.info("AddTimeState: add %d minutes to project '%s'"
+                    % (minutes, project.desc))
+        self.view._total_time()
+        return self.state
+
+
+class SubtractTimeState(BaseTimeState):
+    def enter(self):
+        minutes         = int(self.view.frame.get_footer().get_edit_text())
+        project         = self.view.item_in_focus()
+        project.start  += timedelta(minutes=minutes)
+        self.state.sec -= minutes * 60
+        logger.info("SubtractTimeState: subtract %d minutes from project '%s'"
+                    % (minutes, project.desc))
+        self.view._total_time()
+        return self.state
+
+
+class DescriptionProjectsState(HandleUserInputState):
+    messages = {
+        'choose_proj': u" Wählen Sie ein Projekt."
+    }
+
+    def enter(self):
+        text = self.footer.get_edit_text()
+        if text == '':
+            return self
+        self.controller.stop_project()
+        self.controller.view.set_footer_text(self.msg('choose_proj'), 'question')
+        return PausedProjectsState(self.controller, self.view)
+
+    def exit(self):
+        project = self.view.item_in_focus()
+        time    = (datetime.now() - project.start).seconds
+        self.state.sec = time
+        signal.signal(signal.SIGALRM, self.state.handle_signal)
+        signal.alarm(1)
+        return self.state
+ 
+
+class EntryListState(State):
+    def __init__(self, state, controller, view):
+        self.projectlist_state = state
+        self.controller        = controller
+        self.view              = view
+
+    def keypress(self, key):
+        logger.debug("EntryListState: pressed key '%r'" % key)
+        if 'tab' in key:
+            self.view.clear()
+            return self.projectlist_state
+        if 'up' in key:
+            return self.up()
+        if 'down' in key:
+            return self.down()
+        if 'enter' in key:
+            return self.select()
+        return None
+        
+    def up(self):
+        self.view.up()
+        return self
+
+    def down(self):
+        self.view.down()
+        return self
+
+    def select(self):
+        self.view.select()
+        return self
+
+    def renew_focus(self):
+        e_len = self.view.row_count()
+        f     = self.view.focused
+        if f >= e_len: f = e_len - 1
+        self.view.focused = f
+        self.view.update_focus(f)
+
+
+class DefaultEntryListState(EntryListState):
+    def keypress(self, key):
+        ret = super(DefaultEntryListState, self).keypress(key)
+        if ret:
+            return ret
+
+        if 'd' in key:
+            return DeleteEntryState(self.projectlist_state,
+                                    self.controller, self.view)
+        if 'm' in key:
+            return MoveEntryState(self.projectlist_state,
+                                  self.controller, self.view)
+        return self
+
+
+class DeleteEntryState(EntryListState):
+    messages = {
+        'delete'  : u" Wirklich löschen? (y/n)",
+    }
+
+    def __init__(self, state, controller, view):
+        super(DeleteEntryState, self).__init__(state, controller, view)
+        self.view.set_footer_text(self.msg('delete'), 'question')
+
+    def keypress(self, key):
+        ret = super(DeleteEntryState, self).keypress(key)
+        if ret:
+            return ret
+
+        if 'y' in key:
+            self.controller.delete_entries(self.view.selection)
+            self.renew_focus()
+            return DefaultEntryListState(self.projectlist_state,
+                                         self.controller, self.view)
+
+        if 'n' in key:
+            self.view.set_footer_text("", 'entry_footer')
+            return DefaultEntryListState(self.projectlist_state,
+                                         self.controller, self.view)
+
+        return self
+
+
+class MoveEntryState(EntryListState):
+    messages = {
+        'project': u" In welches Projekt möchten Sie die Einträge verschieben?",
+        'really':  u" Sind sie sich sicher? (y/n)",
+    }
+
+    proj = None
+
+    def __init__(self, state, controller, view):
+        super(MoveEntryState, self).__init__(state, controller, view)
+        self.view.set_footer_text(self.msg('project'), 'question')
+
+    def keypress(self, key):
+        if 'y' in key and self.proj:
+            logger.debug("MoveEntryState: move selected entries.")
+            self.controller.move_selected_entries(self.proj)
+            self.view.set_footer_text('', 'entry_footer')
+            self.proj = None
+            self.renew_focus()
+            return DefaultEntryListState(self.projectlist_state,
+                                         self.controller, self.view)
+
+        if 'n' in key:
+            self.view.set_footer_text('', 'entry_footer')
+            return DefaultEntryListState(self.projectlist_state,
+                                         self.controller, self.view)
+
+        if 'esc' in key:
+            self.view.set_footer_text('', 'entry_footer')
+            return DefaultEntryListState(self.projectlist_state,
+                                         self.controller, self.view)
+
+        if len(key) > 0 and self.proj is None:
+            self.proj = self.controller.project_by_key(key[0])
+            if self.proj:
+                logger.debug("MoveEntryState: prepared entries to be moved to "\
+                             "project '%s'" % self.proj.desc)
+                self.view.set_footer_text(self.msg('really'), 'question')
+
+        return self
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/getan/utils.py	Sat Aug 28 20:16:58 2010 +0200
@@ -0,0 +1,46 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+#
+# (c) 2008, 2009, 2010 by
+#   Sascha L. Teichmann <sascha.teichmann@intevation.de>
+#   Ingo Weinzierl <ingo.weinzierl@intevation.de>
+#
+# This is Free Software licensed under the terms of GPLv3 or later.
+# For details see LICENSE coming with the source of 'getan'.
+#
+
+import logging
+
+from datetime import datetime, timedelta
+
+global DATETIME_FORMAT
+DATETIME_FORMAT = "%Y-%m-%d"
+TIME_FORMAT     = "%H:%M:%S"
+
+logger = logging.getLogger()
+
+def human_time(seconds):
+    if seconds == None or seconds == 0: return "--:--:--"
+    s = seconds % 60
+    seconds /= 60
+    m = seconds % 60
+    seconds /= 60
+    out = "%02d:%02d:%02d" % (seconds, m, s)
+    return out
+
+
+def short_time(seconds):
+    if seconds is None:
+        logger.warn("short_time(): No seconds given to format to 'short_time'.")
+        return "0:00h"
+    seconds /= 60
+    m = seconds % 60
+    seconds /= 60
+    return "%d:%02dh" % (seconds, m)
+
+
+def format_datetime(datetime):
+    return datetime.strftime(DATETIME_FORMAT)
+
+def format_time(datetime):
+    return datetime.strftime(TIME_FORMAT)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/getan/view.py	Sat Aug 28 20:16:58 2010 +0200
@@ -0,0 +1,368 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+#
+# (c) 2010 by Ingo Weinzierl <ingo.weinzierl@intevation.de>
+#
+# This is Free Software licensed under the terms of GPLv3 or later.
+# For details see LICENSE coming with the source of 'getan'.
+#
+
+import logging
+
+import urwid
+import urwid.raw_display
+
+from getan.states import *
+from getan.utils  import short_time, format_datetime, format_time
+
+APP_TITLE          = u" .: getan next generation :."
+APP_REALLY_QUIT    = u" Wollen Sie wirklich beenden? "
+APP_CHOOSE_PROJECT = u" Wählen Sie ein Projekt: "
+
+PROJECTS_TITLE = u" Liste registrierter Projekte"
+ENTRIES_TITLE  = u" Liste der letzten Einträge"
+
+logger = logging.getLogger()
+
+class ListWidget(urwid.BoxWidget):
+    def _update_view(self):
+        logger.debug("ListWidget: update view now.")
+        self.frame.set_body(self.body)
+        self.frame.set_footer(self.footer)
+        self.frame.set_header(self.header)
+
+    def update_focus(self, focus, unfocus=-1):
+        logger.debug("ListWidget: focus row (index = %d)" % focus)
+        logger.debug("ListWidget: unfocus row (index = %d)" % unfocus)
+        if focus >= 0 and focus <= len(self.rows)-1:
+            self.rows[focus].focus = True
+            self.rows[focus].update_w()
+        if unfocus >= 0:
+            self.rows[unfocus].focus = False
+            self.rows[unfocus].update_w()
+
+    def render(self, size, focus=False):
+        maxcol, maxrow = size
+        return self.frame.render((maxcol, maxrow), focus)
+
+    def set_footer_text(self, text, attr, edit=False):
+        if edit:
+            logger.debug("ListWidget: set footer text (edit) = '%s'" % text)
+            self.frame.set_footer(urwid.AttrWrap(urwid.Edit(text),attr))
+        else:
+            logger.debug("ListWidget: set footer text = '%s'" % text)
+            self.frame.set_footer(urwid.AttrWrap(urwid.Text(text),attr))
+
+    def row_count(self):
+        if not self.rows: return 0
+        return len(self.rows)
+
+    def item_in_focus(self):
+        return self.rows[self.focused].get_item()
+
+    def up(self):
+        logger.debug("ListWidget: navigate to upper row.")
+        if self.focused > 0:
+            self.focused = self.focused - 1
+            self.update_focus(self.focused, self.focused+1)
+
+    def down(self):
+        logger.debug("ListWidget: navigate to lower row.")
+        if self.focused < len(self.rows) - 1:
+            self.focused = self.focused + 1
+            self.update_focus(self.focused, self.focused-1)
+
+    def select(self):
+        node = self.rows[self.focused]
+        logger.debug("ListWidget: select row '%s'" % self.focused)
+        node.select()
+        if node.selected:
+            self.selection.append(node)
+        else:
+            if node in self.selection:
+                self.selection.pop()
+        logger.debug("ListWidget: all selected rows: %r" % self.selection)
+        return node
+
+    def clear(self):
+        logger.debug("EntryList: clear focus and selection of all entries.")
+        for node in self.selection:
+            if node.selected: node.select()
+        self.update_focus(-1, self.focused)
+        self.focused = False
+
+
+class ProjectNode(urwid.WidgetWrap):
+    MODES = [
+        (0, 'Gesamt'),
+        (1, 'Jahr'),
+        (2, 'Monat'),
+        (3, 'Woche'),
+        (4, 'Tag')
+    ]
+
+    def __init__(self, proj, mode=3):
+        self.selected   = False
+        self.focus      = False
+        self.mode       = self.MODES[mode]
+        self.item       = proj
+        w               = self.update()
+        self.__super.__init__(w)
+        self.update_w()
+
+    def update(self):
+        logger.debug("Update ProjectNode '%s'" % self.item.desc)
+        time_str    = self._get_formatted_time()
+        description = urwid.Text('%s %s' % (self.item.key, self.item.desc))
+        time        = urwid.Text('%s (%s)' % (self.mode[1], time_str))
+        self.widget = urwid.AttrWrap(urwid.Columns([description, time]),None)
+        self._w     = self.widget
+        self.update_w()
+        return self._w
+
+    def _get_formatted_time(self):
+        return human_time(self._get_time())
+
+    def _get_time(self):
+        if self.mode == self.MODES[0]:
+            return self.item.total
+        if self.mode == self.MODES[1]:
+            return self.item.year()
+        if self.mode == self.MODES[2]:
+            return self.item.month()
+        if self.mode == self.MODES[3]:
+            return self.item.week()
+        if self.mode == self.MODES[4]:
+            return self.item.day()
+        return self.item.week()
+
+    def get_item(self):
+        return self.item
+
+    def switch_time_mode(self):
+        tmp = self.mode[0] + 1
+        if tmp > 4: self.mode = self.MODES[0]
+        else:       self.mode = self.MODES[tmp]
+        self.update()
+
+    def update_w(self):
+        if self.focus:
+            if self.selected:
+                self._w.focus_attr = 'selected focus'
+                self._w.attr       = 'selected focus'
+            else:
+                self._w.focus_attr = 'focus'
+                self._w.attr       = 'focus'
+        else:
+            if self.selected:
+                self._w.focus_attr = 'selected'
+                self._w.attr       = 'selected'
+            else:
+                self._w.focus_attr = 'body'
+                self._w.attr       = 'body'
+
+    def select(self):
+        self.selected = not self.selected
+        self.update_w()
+
+
+class ProjectList(ListWidget):
+    def __init__(self, controller, rows):
+        self.selection    = []
+        self.focused      = 0
+        self.controller   = controller
+        self.raw_projects = rows
+
+        self.head       = urwid.LineBox(
+            urwid.AttrWrap(urwid.Text("\n%s\n" % PROJECTS_TITLE),'project_header'))
+        self.foot      = urwid.Edit()
+        self.rows  = [ProjectNode(x) for x in rows]
+        self.listbox   = urwid.ListBox(urwid.SimpleListWalker(self.rows))
+        self.body      = urwid.LineBox(urwid.Padding(urwid.AttrWrap(
+            self.listbox, 'entries'),('fixed left',1),('fixed right',1)))
+        self.frame     = urwid.Frame(self.body, header=self.head,
+                                    footer=self.foot)
+        self.update_focus(self.focused)
+        self._total_time()
+
+    def _total_time(self):
+        logger.debug("ProjectList: update projects total time.")
+        total = 0
+        for proj in self.rows:
+            total += proj._get_time()
+        self.frame.set_footer(urwid.AttrWrap(
+            urwid.Text(' Alle Projekte: %s %s'
+                       % (proj.mode[1],human_time(total))), 'project_footer'))
+
+    def update(self):
+        logger.debug("ProjectList: update focused project row now.")
+        self.rows[self.focused].update()
+        self._total_time()
+        self.controller.view.loop.draw_screen()
+
+    def update_all(self):
+        logger.debug("ProjectList: update all project rows now.")
+        for proj in self.rows:
+            proj.update()
+        self._total_time()
+        self.controller.view.loop.draw_screen()
+
+    def switch_time_mode(self):
+        logger.debug("ProjectList: switch time mode now.")
+        for proj in self.rows:
+            proj.switch_time_mode()
+        self.controller.view.loop.draw_screen()
+
+    def unhandled_keypress(self, key):
+        logger.debug("ProjectList: unhandled keypress '%r'" % key)
+
+    def select_project(self, project):
+        for proj_node in self.rows:
+            if proj_node.project.key == project.key:
+                idx = self.rows.index(proj_node)
+                self.update_focus(idx, self.focused)
+                self.focused = idx
+                self.select()
+                break
+
+
+class EntryNode(urwid.WidgetWrap):
+    def __init__(self, entry):
+        self.selected = False
+        self.focus    = False
+        self.item     = entry
+        w             = self.update()
+        self.__super.__init__(w)
+        self.update_w()
+
+    def update(self):
+        logger.debug("EntryNode: update entry '%s'." % self.item.desc)
+        row = urwid.Text(' %s [%s] %s' \
+                         % (format_datetime(self.item.start), 
+                            short_time(self.item.duration().seconds),
+                            self.item.desc), wrap='clip')
+        self.widget = urwid.AttrWrap(row, None)
+        self._w     = self.widget
+        self.update_w()
+        return self._w
+
+    def update_w(self):
+        if self.focus:
+            if self.selected:
+                self._w.focus_attr = 'selected focus entry'
+                self._w.attr       = 'selected focus entry'
+            else:
+                self._w.focus_attr = 'focus entry'
+                self._w.attr       = 'focus entry'
+        else:
+            if self.selected:
+                self._w.focus_attr = 'selected entry'
+                self._w.attr       = 'selected entry'
+            else:
+                self._w.focus_attr = 'entry body'
+                self._w.attr       = 'entry body'
+
+    def select(self):
+        logger.debug("EntryNode: update selection of entry '%s'"
+                     % self.item.desc)
+        self.selected = not self.selected
+        self.update_w()
+
+
+class EntryList(ListWidget):
+    def __init__(self, rows):
+        self.selection = []
+        self.focused   = 0
+        self.rows      = [EntryNode(x) for x in rows]
+        listbox        = urwid.ListBox(urwid.SimpleListWalker(self.rows))
+        self.body      = urwid.LineBox(urwid.Padding(urwid.AttrWrap(
+            listbox, 'entry_body'),
+            ('fixed left', 1), ('fixed right', 1)))
+        self.header    = urwid.LineBox(urwid.AttrWrap(urwid.Text(
+            "\n%s\n"
+            % ENTRIES_TITLE),'entry_header'))
+        self.footer    = urwid.AttrWrap(urwid.Text(""), 'entry_footer')
+        self.frame     = urwid.Frame(self.body, header=self.header,
+                                     footer=self.footer)
+
+    def set_rows(self, rows):
+        logger.debug("EntryList: set new entries.")
+        self.rows = [EntryNode(x) for x in rows]
+        listbox      = urwid.LineBox(urwid.ListBox(urwid.SimpleListWalker(
+            self.rows)))
+        self.body    = urwid.AttrWrap(listbox, 'entry_body')
+        self._update_view()
+
+
+class GetanView:
+    palette = [
+        ('header',               'white',  'dark blue'),
+        ('project_header',       'white',  'dark cyan'),
+        ('entry_header',         'white',  'dark cyan'),
+        ('footer',               'yellow', 'dark blue'),
+        ('entry_footer',         'white',  'dark blue'),
+        ('project_footer',       'white',  'dark blue'),
+        ('body',                 'white',  'black'),
+        ('entry body',           'white',  'dark blue'),
+        ('entries',              'white',  'black'),
+        ('entry_body',           'white',  'dark blue'),
+        ('focused entry',        'white',  'dark cyan'),
+        ('selected entry',       'yellow', 'light cyan'),
+        ('selected focus entry', 'yellow', 'dark cyan'),
+        ('info',                 'white',  'dark red'),
+        ('focus',                'white',  'dark blue'),
+        ('selected',             'black',  'light green'),
+        ('selected focus',       'yellow', 'dark cyan'),
+        ('question',             'white',  'dark red'),
+        ('running',              'yellow', 'dark green'),
+        ('paused_running',       'white',  'dark red'),
+    ]
+
+    def __init__(self, controller, proj_list, entr_list):
+        urwid.set_encoding("UTF-8")
+        self.controller = controller
+        self.proj_list  = proj_list
+        self.entr_list  = entr_list
+        self.columns    = urwid.Columns([
+            urwid.Padding(self.proj_list, ('fixed left',0),('fixed right',1)),
+            self.entr_list], 0)
+
+        self.header   = urwid.AttrWrap(urwid.Text('%s\n' % APP_TITLE), 'header')
+        self.footer   = urwid.AttrWrap(urwid.Text(APP_CHOOSE_PROJECT),
+                                       'question')
+        self.col_list = self.columns.widget_list
+        view          = urwid.AttrWrap(self.columns, 'body')
+        self.view     = urwid.Frame(view, header=self.header,footer=self.footer)
+        self.state    = PausedProjectsState(controller, self.proj_list)
+
+    def update_view(self):
+        logger.debug("GetanView: update view now.")
+        view = urwid.AttrWrap(self.columns, 'body')
+        self.view.set_body(view)
+        self.loop.draw_screen()
+
+    def get_frame(self):
+        return self.view
+
+    def set_footer_text(self, text, attr, edit=False):
+        if edit:
+            logger.debug("GetanView: set footer text (edit): '%s'" % text)
+            self.view.set_footer(urwid.AttrWrap(urwid.Edit(text),attr))
+        else:
+            logger.debug("GetanView: set footer text: '%s'" % text)
+            self.view.set_footer(urwid.AttrWrap(urwid.Text(text),attr))
+
+    def run(self):
+        self.loop = urwid.MainLoop(self.view, self.palette,
+                                   screen=urwid.raw_display.Screen(),
+                                   unhandled_input=self.controller.unhandled_keypress,
+                                   input_filter=self.controller.input_filter)
+        self.loop.run()
+
+    def unhandled_keypress(self, k):
+        logger.warn("GetanView: unhandled keypress '%r'" % k)
+
+    def exit(self):
+        logger.info("GetanView: shutdown view.")
+        raise urwid.ExitMainLoop()
+
This site is hosted by Intevation GmbH (Datenschutzerklärung und Impressum | Privacy Policy and Imprint)