# HG changeset patch # User Ingo Weinzierl # Date 1283019418 -7200 # Node ID 9c4e8ba3c4fa9529e80baa1c6572da6e4993b47f # Parent 2dc893ca5072c60f27cf5dfdeb3eb64c6a9d7920 Added a new implementation of 'getan' based on urwid, a python console user interface library. diff -r 2dc893ca5072 -r 9c4e8ba3c4fa ChangeLog --- 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 + + 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 * contrib/zeiterfassung: left justify user sign in zeiterfassung diff -r 2dc893ca5072 -r 9c4e8ba3c4fa INTRODUCTION --- /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). diff -r 2dc893ca5072 -r 9c4e8ba3c4fa README --- 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 diff -r 2dc893ca5072 -r 9c4e8ba3c4fa TODO --- 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. diff -r 2dc893ca5072 -r 9c4e8ba3c4fa classic/getan --- /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 +# +# 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 = 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 = 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, " : ") + 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, " : ") + 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: diff -r 2dc893ca5072 -r 9c4e8ba3c4fa getan --- 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 -# -# 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 = 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 = 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, " : ") - 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, " : ") - 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: diff -r 2dc893ca5072 -r 9c4e8ba3c4fa getan.py --- /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 +# +# 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() diff -r 2dc893ca5072 -r 9c4e8ba3c4fa getan/backend.py --- /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 +# Ingo Weinzierl +# +# 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: diff -r 2dc893ca5072 -r 9c4e8ba3c4fa getan/config.py --- /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 +# +# 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() diff -r 2dc893ca5072 -r 9c4e8ba3c4fa getan/project.py --- /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 +# Ingo Weinzierl +# +# 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: diff -r 2dc893ca5072 -r 9c4e8ba3c4fa getan/states.py --- /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 +# +# 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 diff -r 2dc893ca5072 -r 9c4e8ba3c4fa getan/utils.py --- /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 +# Ingo Weinzierl +# +# 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) diff -r 2dc893ca5072 -r 9c4e8ba3c4fa getan/view.py --- /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 +# +# 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() +