Mercurial > getan
changeset 115:32dad62909c3
Convert classic getan into a module to be able to install it
author | Björn Ricks <bjoern.ricks@intevation.de> |
---|---|
date | Mon, 12 Dec 2011 09:36:21 +0100 |
parents | 6df408534f3f |
children | 67a89439e10d |
files | classic/__init__.py classic/getan classic/getan.py |
diffstat | 2 files changed, 915 insertions(+), 915 deletions(-) [+] |
line wrap: on
line diff
--- a/classic/getan Mon Dec 12 09:26:04 2011 +0100 +++ /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/classic/getan.py Mon Dec 12 09:36:21 2011 +0100 @@ -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: