Mercurial > getan
diff getan @ 0:7eb7886ed8fd
Initial import.
author | Sascha L. Teichmann <teichmann@intevation.de> |
---|---|
date | Mon, 28 Jul 2008 22:33:36 +0200 |
parents | |
children | a3fe8e4e9184 |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/getan Mon Jul 28 22:33:36 2008 +0200 @@ -0,0 +1,542 @@ +#!/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 + +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 +''' + +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 + +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): + id = self.getId(cur) + cur.execute(DELETE_PROJECT, { 'id': id }) + cur.connection.commit() + +class Worklog: + + def __init__(self, database): + self.initDB(database) + self.loadProjects() + self.state = PAUSED + self.current_project = None + + def initDB(self, database): + self.con = db.connect(database) + + def loadProjects(self): + cur = None + try: + cur = self.con.cursor() + cur.execute(LOAD_ACTIVE_PROJECTS) + self.projects = [Project(*row) for row in cur.fetchall()] + finally: + tolerantClose(cur) + + def shutdown(self): + self.con.close() + + 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) + + 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 isRunning(self): + return self.state in (RUNNING, RUNNING_ESC) + + 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_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 + + 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 run(self): + global stdscr + + stdscr.erase() + ofs = self.render() + stdscr.refresh() + + while True: + c = stdscr.getch() + if c == -1: continue + + if self.state == PAUSED: + + if c == curses.KEY_DC: + 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): + nproject = self.findProject(chr(c)) + if nproject is None: continue + 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) + + elif self.state == RUNNING: + 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() + + elif curses.ascii.isascii(c): + nproject = self.findProject(chr(c)) + if nproject is None or nproject == self.current_project: + continue + nproject.start_time = self.writeLog() + self.current_project = nproject + stdscr.erase() + ofs = self.render() + stdscr.refresh() + + elif self.state == PAUSED_ESC: + 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 + pass + else: + self.state = PAUSED + + elif self.state == RUNNING_ESC: + 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() + ofs = self.render() + stdscr.refresh() + signal.signal(signal.SIGALRM, alarm_handler) + signal.alarm(1) + else: + self.state = RUNNING + + elif self.state == PRE_EXIT: + if c == curses.KEY_DC: + break + else: + stdscr.erase() + ofs = 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: