Mercurial > getan
comparison getan @ 0:7eb7886ed8fd
Initial import.
author | Sascha L. Teichmann <teichmann@intevation.de> |
---|---|
date | Mon, 28 Jul 2008 22:33:36 +0200 |
parents | |
children | a3fe8e4e9184 |
comparison
equal
deleted
inserted
replaced
-1:000000000000 | 0:7eb7886ed8fd |
---|---|
1 #!/usr/bin/env python | |
2 # -*- coding: utf-8 -*- | |
3 # | |
4 # getan | |
5 # ----- | |
6 # (c) 2008 by Sascha L. Teichmann <sascha.teichmann@intevation.de> | |
7 # | |
8 # A python worklog-alike to log what you have 'getan' (done). | |
9 # | |
10 # This is Free Software licensed under the terms of GPLv3 or later. | |
11 # For details see LICENSE coming with the source of 'getan'. | |
12 # | |
13 import sys | |
14 import re | |
15 import curses | |
16 import curses.ascii | |
17 import traceback | |
18 import signal | |
19 | |
20 from datetime import datetime, timedelta | |
21 | |
22 from pysqlite2 import dbapi2 as db | |
23 | |
24 PAUSED = 0 | |
25 RUNNING = 1 | |
26 PRE_EXIT = 2 | |
27 PAUSED_ESC = 3 | |
28 RUNNING_ESC = 4 | |
29 | |
30 SPACE = re.compile("[\\t ]+") | |
31 | |
32 DEFAULT_DATABASE = "time.db" | |
33 | |
34 LOAD_ACTIVE_PROJECTS = ''' | |
35 SELECT id, key, description, total | |
36 FROM projects LEFT JOIN | |
37 (SELECT | |
38 project_id, | |
39 sum(strftime('%s', stop_time) - strftime('%s', start_time)) AS total | |
40 FROM entries | |
41 GROUP BY project_id) ON project_id = id | |
42 WHERE active | |
43 ''' | |
44 | |
45 WRITE_LOG = ''' | |
46 INSERT INTO entries (project_id, start_time, stop_time, description) | |
47 VALUES(:project_id, :start_time, :stop_time, :description) | |
48 ''' | |
49 | |
50 CREATE_PROJECT = ''' | |
51 INSERT INTO projects (key, description) VALUES (:key, :description) | |
52 ''' | |
53 | |
54 LAST_PROJECT_ID = ''' | |
55 SELECT last_insert_rowid() | |
56 ''' | |
57 | |
58 RENAME_PROJECT = ''' | |
59 UPDATE projects set key = :key, description = :description WHERE id = :id | |
60 ''' | |
61 | |
62 ASSIGN_LOGS = ''' | |
63 UPDATE entries SET project_id = :new_id WHERE project_id = :old_id | |
64 ''' | |
65 | |
66 DELETE_PROJECT = ''' | |
67 DELETE FROM projects WHERE id = :id | |
68 ''' | |
69 | |
70 worklog = None | |
71 stdscr = None | |
72 | |
73 orig_vis = None | |
74 | |
75 def cursor_visible(flag): | |
76 global orig_vis | |
77 try: | |
78 old = curses.curs_set(flag) | |
79 if orig_vis is None: orig_vis = old | |
80 return old | |
81 except: | |
82 pass | |
83 return 1 | |
84 | |
85 def restore_cursor(): | |
86 global orig_vis | |
87 if not orig_vis is None: | |
88 curses.curs_set(orig_vis) | |
89 | |
90 def render_header(ofs=0): | |
91 global stdscr | |
92 stdscr.attron(curses.A_BOLD) | |
93 stdscr.addstr(ofs, 5, "getan v0.1") | |
94 stdscr.addstr(ofs+1, 3, "--------------") | |
95 stdscr.attroff(curses.A_BOLD) | |
96 return ofs + 2 | |
97 | |
98 def render_quit(ofs=0): | |
99 global stdscr | |
100 stdscr.addstr(ofs + 2, 3, "Press DEL once more to quit") | |
101 return ofs + 3 | |
102 | |
103 def tolerantClose(cur): | |
104 if cur: | |
105 try: cur.close() | |
106 except: pass | |
107 | |
108 def ifNull(v, d): | |
109 if v is None: return d | |
110 return v | |
111 | |
112 def human_time(delta): | |
113 seconds = delta.seconds | |
114 s = seconds % 60 | |
115 if delta.microseconds >= 500000: s += 1 | |
116 seconds /= 60 | |
117 m = seconds % 60 | |
118 seconds /= 60 | |
119 out = "%02d:%02d:%02d" % (seconds, m, s) | |
120 if delta.days: | |
121 out = "%dd %s" % (delta.days, out) | |
122 return out | |
123 | |
124 class Project: | |
125 | |
126 def __init__(self, id = None, key = None, desc = None, total = 0): | |
127 self.id = id | |
128 self.key = key | |
129 self.desc = desc | |
130 self.total = timedelta(seconds = ifNull(total, 0)) | |
131 self.start_time = None | |
132 | |
133 def checkExistence(self, cur): | |
134 if self.id is None: | |
135 cur.execute(CREATE_PROJECT, { | |
136 'key' : self.key, | |
137 'description': self.desc}) | |
138 cur.execute(LAST_PROJECT_ID) | |
139 row = cur.fetchone() | |
140 cur.connection.commit() | |
141 self.id = row[0] | |
142 | |
143 def writeLog(self, cur, description = None): | |
144 if self.start_time is None: return | |
145 self.checkExistence(cur) | |
146 now = datetime.now() | |
147 cur.execute(WRITE_LOG, { | |
148 'project_id' : self.id, | |
149 'start_time' : self.start_time, | |
150 'stop_time' : now, | |
151 'description': description}) | |
152 self.total += now-self.start_time | |
153 return now | |
154 | |
155 def getId(self, cur): | |
156 self.checkExistence(cur) | |
157 return self.id | |
158 | |
159 def rename(self, cur, key, desc): | |
160 self.key = key | |
161 self.desc = desc | |
162 self.checkExistence(cur) | |
163 cur.execute(RENAME_PROJECT, { | |
164 'key' : key, | |
165 'description': desc, | |
166 'id' : self.id }) | |
167 cur.connection.commit() | |
168 | |
169 def assignLogs(self, cur, anon): | |
170 self.total += anon.total | |
171 anon.total = timedelta(seconds=0) | |
172 old_id = anon.getId(cur) | |
173 new_id = self.getId(cur) | |
174 cur.execute(ASSIGN_LOGS, { | |
175 'new_id': new_id, | |
176 'old_id': old_id}) | |
177 cur.connection.commit() | |
178 | |
179 def delete(self, cur): | |
180 id = self.getId(cur) | |
181 cur.execute(DELETE_PROJECT, { 'id': id }) | |
182 cur.connection.commit() | |
183 | |
184 class Worklog: | |
185 | |
186 def __init__(self, database): | |
187 self.initDB(database) | |
188 self.loadProjects() | |
189 self.state = PAUSED | |
190 self.current_project = None | |
191 | |
192 def initDB(self, database): | |
193 self.con = db.connect(database) | |
194 | |
195 def loadProjects(self): | |
196 cur = None | |
197 try: | |
198 cur = self.con.cursor() | |
199 cur.execute(LOAD_ACTIVE_PROJECTS) | |
200 self.projects = [Project(*row) for row in cur.fetchall()] | |
201 finally: | |
202 tolerantClose(cur) | |
203 | |
204 def shutdown(self): | |
205 self.con.close() | |
206 | |
207 def findProject(self, key): | |
208 key_lower = key.lower() | |
209 lower = None | |
210 | |
211 for p in self.projects: | |
212 if p.key == key: | |
213 return p | |
214 if p.key and p.key.lower() == key_lower: | |
215 lower = p | |
216 | |
217 return lower | |
218 | |
219 def findAnonymProject(self, num): | |
220 count = 0 | |
221 for p in self.projects: | |
222 if p.key is None: | |
223 if count == num: | |
224 return p | |
225 count += 1 | |
226 return None | |
227 | |
228 def renameAnonymProject(self, num, key, description): | |
229 project = self.findAnonymProject(num) | |
230 if project: | |
231 cur = None | |
232 try: | |
233 cur = self.con.cursor() | |
234 project.rename(cur, key, description) | |
235 finally: | |
236 tolerantClose(cur) | |
237 | |
238 def assignLogs(self, num, key): | |
239 anon = self.findAnonymProject(num) | |
240 if anon is None: return | |
241 project = self.findProject(key) | |
242 if project is None: return | |
243 cur = None | |
244 try: | |
245 cur = self.con.cursor() | |
246 project.assignLogs(cur, anon) | |
247 self.projects.remove(anon) | |
248 anon.delete(cur) | |
249 finally: | |
250 tolerantClose(cur) | |
251 | |
252 def isRunning(self): | |
253 return self.state in (RUNNING, RUNNING_ESC) | |
254 | |
255 def render(self, ofs=0): | |
256 ofs = render_header(ofs) | |
257 ml = max([len(p.desc and p.desc or "unknown") for p in self.projects]) | |
258 unknown = 0 | |
259 | |
260 if self.current_project and self.current_project.start_time: | |
261 current_delta = datetime.now() - self.current_project.start_time | |
262 current_time_str = "%s " % human_time(current_delta) | |
263 current_time_space = " " * len(current_time_str) | |
264 else: | |
265 current_time_str = "" | |
266 current_time_space = "" | |
267 | |
268 for project in self.projects: | |
269 is_current = project == self.current_project | |
270 pref = is_current and " -> " or " " | |
271 if project.key is None: | |
272 key = "^%d" % unknown | |
273 unknown += 1 | |
274 else: | |
275 key = " %s" % project.key | |
276 desc = project.desc is None and "unknown" or project.desc | |
277 stdscr.attron(curses.A_BOLD) | |
278 stdscr.addstr(ofs, 0, "%s%s" % (pref, key)) | |
279 stdscr.attroff(curses.A_BOLD) | |
280 stdscr.addstr(" %s" % desc) | |
281 | |
282 diff = ml - len(desc) + 1 | |
283 stdscr.addstr(" " * diff) | |
284 if is_current: stdscr.attron(curses.A_UNDERLINE) | |
285 | |
286 if is_current: | |
287 stdscr.addstr("%s(%s)" % ( | |
288 current_time_str, | |
289 human_time(project.total + current_delta))) | |
290 else: | |
291 stdscr.addstr("%s(%s)" % ( | |
292 current_time_space, | |
293 human_time(project.total))) | |
294 | |
295 if is_current: stdscr.attroff(curses.A_UNDERLINE) | |
296 ofs += 1 | |
297 | |
298 return ofs | |
299 | |
300 def writeLog(self, description = None): | |
301 if self.current_project is None: | |
302 return datetime.now() | |
303 cur = None | |
304 try: | |
305 cur = self.con.cursor() | |
306 now = self.current_project.writeLog(cur, description) | |
307 self.con.commit() | |
308 return now | |
309 finally: | |
310 tolerantClose(cur) | |
311 | |
312 def run(self): | |
313 global stdscr | |
314 | |
315 stdscr.erase() | |
316 ofs = self.render() | |
317 stdscr.refresh() | |
318 | |
319 while True: | |
320 c = stdscr.getch() | |
321 if c == -1: continue | |
322 | |
323 if self.state == PAUSED: | |
324 | |
325 if c == curses.KEY_DC: | |
326 stdscr.erase() | |
327 ofs = render_quit(self.render()) | |
328 stdscr.refresh() | |
329 self.state = PRE_EXIT | |
330 | |
331 elif c == curses.ascii.ESC: | |
332 self.state = PAUSED_ESC | |
333 | |
334 elif curses.ascii.isascii(c): | |
335 nproject = self.findProject(chr(c)) | |
336 if nproject is None: continue | |
337 self.current_project = nproject | |
338 nproject.start_time = datetime.now() | |
339 stdscr.erase() | |
340 ofs = self.render() | |
341 stdscr.refresh() | |
342 self.state = RUNNING | |
343 signal.signal(signal.SIGALRM, alarm_handler) | |
344 signal.alarm(1) | |
345 | |
346 elif self.state == RUNNING: | |
347 if c == curses.ascii.ESC: | |
348 self.state = RUNNING_ESC | |
349 | |
350 elif c == curses.ascii.NL: | |
351 signal.signal(signal.SIGALRM, signal.SIG_IGN) | |
352 self.state = PAUSED | |
353 stdscr.erase() | |
354 ofs = self.render() | |
355 old_cur = cursor_visible(1) | |
356 curses.echo() | |
357 stdscr.addstr(ofs + 1, 3, "Description: ") | |
358 description = stdscr.getstr() | |
359 curses.noecho() | |
360 cursor_visible(old_cur) | |
361 self.writeLog(description) | |
362 self.current_project = None | |
363 stdscr.erase() | |
364 ofs = self.render() | |
365 stdscr.refresh() | |
366 | |
367 elif curses.ascii.isascii(c): | |
368 nproject = self.findProject(chr(c)) | |
369 if nproject is None or nproject == self.current_project: | |
370 continue | |
371 nproject.start_time = self.writeLog() | |
372 self.current_project = nproject | |
373 stdscr.erase() | |
374 ofs = self.render() | |
375 stdscr.refresh() | |
376 | |
377 elif self.state == PAUSED_ESC: | |
378 if curses.ascii.isdigit(c): | |
379 pnum = c - ord('0') | |
380 nproject = self.findAnonymProject(pnum) | |
381 if nproject is None: | |
382 nproject = Project() | |
383 self.projects.append(nproject) | |
384 | |
385 nproject.start_time = self.writeLog() | |
386 self.current_project = nproject | |
387 self.state = RUNNING | |
388 stdscr.erase() | |
389 ofs = self.render() | |
390 stdscr.refresh() | |
391 signal.signal(signal.SIGALRM, alarm_handler) | |
392 signal.alarm(1) | |
393 elif curses.ascii.isalpha(c): | |
394 if c == ord('n'): | |
395 stdscr.erase() | |
396 ofs = self.render() | |
397 old_cur = cursor_visible(1) | |
398 curses.echo() | |
399 stdscr.addstr(ofs + 1, 3, "<num> <key> <description>: ") | |
400 stdscr.refresh() | |
401 description = stdscr.getstr() | |
402 curses.noecho() | |
403 cursor_visible(old_cur) | |
404 | |
405 description = description.strip() | |
406 if description: | |
407 num, key, description = SPACE.split(description, 2) | |
408 try: | |
409 num = int(num) | |
410 self.renameAnonymProject(num, key, description) | |
411 except ValueError: | |
412 pass | |
413 | |
414 stdscr.erase() | |
415 ofs = self.render() | |
416 stdscr.refresh() | |
417 self.state = PAUSED | |
418 | |
419 elif c == ord('a'): | |
420 stdscr.erase() | |
421 ofs = self.render() | |
422 old_cur = cursor_visible(1) | |
423 curses.echo() | |
424 stdscr.addstr(ofs + 1, 3, "<num> <key>: ") | |
425 stdscr.refresh() | |
426 key = stdscr.getstr() | |
427 curses.noecho() | |
428 cursor_visible(old_cur) | |
429 | |
430 key = key.strip() | |
431 if key: | |
432 num, key = SPACE.split(key, 1) | |
433 try: | |
434 num = int(num) | |
435 self.assignLogs(num, key) | |
436 except ValueError: | |
437 pass | |
438 | |
439 stdscr.erase() | |
440 ofs = self.render() | |
441 stdscr.refresh() | |
442 self.state = PAUSED | |
443 | |
444 else: | |
445 self.state = PAUSED | |
446 pass | |
447 else: | |
448 self.state = PAUSED | |
449 | |
450 elif self.state == RUNNING_ESC: | |
451 if curses.ascii.isdigit(c): | |
452 signal.signal(signal.SIGALRM, signal.SIG_IGN) | |
453 pnum = c - ord('0') | |
454 nproject = self.findAnonymProject(pnum) | |
455 if nproject is None: | |
456 nproject = Project() | |
457 self.projects.append(nproject) | |
458 | |
459 nproject.start_time = self.writeLog() | |
460 self.current_project = nproject | |
461 self.state = RUNNING | |
462 stdscr.erase() | |
463 ofs = self.render() | |
464 stdscr.refresh() | |
465 signal.signal(signal.SIGALRM, alarm_handler) | |
466 signal.alarm(1) | |
467 else: | |
468 self.state = RUNNING | |
469 | |
470 elif self.state == PRE_EXIT: | |
471 if c == curses.KEY_DC: | |
472 break | |
473 else: | |
474 stdscr.erase() | |
475 ofs = self.render() | |
476 stdscr.refresh() | |
477 self.state = PAUSED | |
478 | |
479 | |
480 | |
481 def alarm_handler(flag, frame): | |
482 global worklog | |
483 global stdscr | |
484 | |
485 stdscr.erase() | |
486 worklog.render() | |
487 stdscr.refresh() | |
488 if worklog.isRunning(): | |
489 signal.alarm(1) | |
490 | |
491 def exit_handler(flag, frame): | |
492 exit_code = 0 | |
493 global worklog | |
494 try: | |
495 worklog.shutdown() | |
496 except: | |
497 traceback.print_exc(file=sys.stderr) | |
498 exit_code = 1 | |
499 | |
500 restore_cursor() | |
501 curses.nocbreak() | |
502 stdscr.keypad(0) | |
503 curses.echo() | |
504 curses.endwin() | |
505 sys.exit(exit_code) | |
506 | |
507 def main(): | |
508 | |
509 database = len(sys.argv) < 2 and DEFAULT_DATABASE or sys.argv[1] | |
510 # TODO: create database file if it does not exist. | |
511 | |
512 global worklog | |
513 try: | |
514 worklog = Worklog(database) | |
515 except: | |
516 traceback.print_exc(file=sys.stderr) | |
517 sys.exit(1) | |
518 | |
519 global stdscr | |
520 stdscr = curses.initscr() | |
521 curses.noecho() | |
522 curses.cbreak() | |
523 stdscr.keypad(1) | |
524 cursor_visible(0) | |
525 | |
526 signal.signal(signal.SIGHUP, exit_handler) | |
527 signal.signal(signal.SIGINT, exit_handler) | |
528 signal.signal(signal.SIGQUIT, exit_handler) | |
529 signal.signal(signal.SIGTERM, exit_handler) | |
530 | |
531 try: | |
532 try: | |
533 worklog.run() | |
534 except: | |
535 traceback.print_exc(file=sys.stderr) | |
536 finally: | |
537 exit_handler(0, None) | |
538 | |
539 if __name__ == '__main__': | |
540 main() | |
541 | |
542 # vim:set ts=4 sw=4 si et sta sts=4 fenc=utf8: |