3
A Gtk+ application for keeping track of time.
5
$Id: gtimelog.py 85 2007-11-10 17:23:00Z mg $
26
# This is to let people run GTimeLog without having to install it
27
resource_dir = os.path.dirname(os.path.realpath(__file__))
28
ui_file = os.path.join(resource_dir, "gtimelog.glade")
29
icon_file = os.path.join(resource_dir, "gtimelog-small.png")
31
# This is for distribution packages
32
if not os.path.exists(ui_file):
33
ui_file = "/usr/share/gtimelog/gtimelog.glade"
34
if not os.path.exists(icon_file):
35
icon_file = "/usr/share/pixmaps/gtimelog-small.png"
38
def as_minutes(duration):
39
"""Convert a datetime.timedelta to an integer number of minutes."""
40
return duration.days * 24 * 60 + duration.seconds // 60
43
def format_duration(duration):
44
"""Format a datetime.timedelta with minute precision."""
45
h, m = divmod(as_minutes(duration), 60)
46
return '%d h %d min' % (h, m)
49
def format_duration_short(duration):
50
"""Format a datetime.timedelta with minute precision."""
51
h, m = divmod((duration.days * 24 * 60 + duration.seconds // 60), 60)
52
return '%d:%02d' % (h, m)
55
def format_duration_long(duration):
56
"""Format a datetime.timedelta with minute precision, long format."""
57
h, m = divmod((duration.days * 24 * 60 + duration.seconds // 60), 60)
59
return '%d hour%s %d min' % (h, h != 1 and "s" or "", m)
61
return '%d hour%s' % (h, h != 1 and "s" or "")
66
def parse_datetime(dt):
67
"""Parse a datetime instance from 'YYYY-MM-DD HH:MM' formatted string."""
68
m = re.match(r'^(\d+)-(\d+)-(\d+) (\d+):(\d+)$', dt)
70
raise ValueError('bad date time: ', dt)
71
year, month, day, hour, min = map(int, m.groups())
72
return datetime.datetime(year, month, day, hour, min)
76
"""Parse a time instance from 'HH:MM' formatted string."""
77
m = re.match(r'^(\d+):(\d+)$', t)
79
raise ValueError('bad time: ', t)
80
hour, min = map(int, m.groups())
81
return datetime.time(hour, min)
84
def virtual_day(dt, virtual_midnight):
85
"""Return the "virtual day" of a timestamp.
87
Timestamps between midnight and "virtual midnight" (e.g. 2 am) are
88
assigned to the previous "virtual day".
90
if dt.time() < virtual_midnight: # assign to previous day
91
return dt.date() - datetime.timedelta(1)
95
def different_days(dt1, dt2, virtual_midnight):
96
"""Check whether dt1 and dt2 are on different "virtual days".
100
return virtual_day(dt1, virtual_midnight) != virtual_day(dt2,
104
def first_of_month(date):
105
"""Return the first day of the month for a given date."""
106
return date.replace(day=1)
109
def next_month(date):
110
"""Return the first day of the next month."""
112
return datetime.date(date.year + 1, 1, 1)
114
return datetime.date(date.year, date.month + 1, 1)
118
"""Return list with consecutive duplicates removed."""
121
if item != result[-1]:
126
class TimeWindow(object):
127
"""A window into a time log.
129
Reads a time log file and remembers all events that took place between
130
min_timestamp and max_timestamp. Includes events that took place at
131
min_timestamp, but excludes events that took place at max_timestamp.
133
self.items is a list of (timestamp, event_title) tuples.
135
Time intervals between events within the time window form entries that have
136
a start time, a stop time, and a duration. Entry title is the title of the
137
event that occurred at the stop time.
139
The first event also creates a special "arrival" entry of zero duration.
141
Entries that span virtual midnight boundaries are also converted to
142
"arrival" entries at their end point.
144
The earliest_timestamp attribute contains the first (which should be the
145
oldest) timestamp in the file.
148
def __init__(self, filename, min_timestamp, max_timestamp,
149
virtual_midnight, callback=None):
150
self.filename = filename
151
self.min_timestamp = min_timestamp
152
self.max_timestamp = max_timestamp
153
self.virtual_midnight = virtual_midnight
154
self.reread(callback)
156
def reread(self, callback=None):
157
"""Parse the time log file and update self.items.
159
Also updates self.earliest_timestamp.
162
self.earliest_timestamp = None
164
f = open(self.filename)
171
time, entry = line.split(': ', 1)
173
time = parse_datetime(time)
177
entry = entry.strip()
180
if self.earliest_timestamp is None:
181
self.earliest_timestamp = time
182
if self.min_timestamp <= time < self.max_timestamp:
183
self.items.append((time, entry))
187
"""Return the time of the last event (or None if there are no events).
191
return self.items[-1][0]
193
def all_entries(self):
194
"""Iterate over all entries.
196
Yields (start, stop, duration, entry) tuples. The first entry
200
for item in self.items:
204
if start is None or different_days(start, stop,
205
self.virtual_midnight):
207
duration = stop - start
208
yield start, stop, duration, entry
210
def count_days(self):
211
"""Count days that have entries."""
214
for start, stop, duration, entry in self.all_entries():
215
if last is None or different_days(last, start,
216
self.virtual_midnight):
221
def last_entry(self):
222
"""Return the last entry (or None if there are no events).
224
It is always true that
226
self.last_entry() == list(self.all_entries())[-1]
231
stop = self.items[-1][0]
232
entry = self.items[-1][1]
233
if len(self.items) == 1:
236
start = self.items[-2][0]
237
if different_days(start, stop, self.virtual_midnight):
239
duration = stop - start
240
return start, stop, duration, entry
242
def grouped_entries(self, skip_first=True):
243
"""Return consolidated entries (grouped by entry title).
245
Returns two list: work entries and slacking entries. Slacking
246
entries are identified by finding two asterisks in the title.
247
Entry lists are sorted, and contain (start, entry, duration) tuples.
251
for start, stop, duration, entry in self.all_entries():
260
old_start, old_entry, old_duration = entries[entry]
261
start = min(start, old_start)
262
duration += old_duration
263
entries[entry] = (start, entry, duration)
266
slack = slack.values()
271
"""Calculate total time of work and slacking entries.
273
Returns (total_work, total_slacking) tuple.
275
Slacking entries are identified by finding two asterisks in the title.
279
total_work, total_slacking = self.totals()
280
work, slacking = self.grouped_entries()
282
It is always true that
284
total_work = sum([duration for start, entry, duration in work])
285
total_slacking = sum([duration
286
for start, entry, duration in slacking])
288
(that is, it would be true if sum could operate on timedeltas).
290
total_work = total_slacking = datetime.timedelta(0)
291
for start, stop, duration, entry in self.all_entries():
293
total_slacking += duration
295
total_work += duration
296
return total_work, total_slacking
298
def icalendar(self, output):
299
"""Create an iCalendar file with activities."""
300
print >> output, "BEGIN:VCALENDAR"
301
print >> output, "PRODID:-//mg.pov.lt/NONSGML GTimeLog//EN"
302
print >> output, "VERSION:2.0"
305
idhost = socket.getfqdn()
306
except: # can it actually ever fail?
308
dtstamp = datetime.datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
309
for start, stop, duration, entry in self.all_entries():
310
print >> output, "BEGIN:VEVENT"
311
print >> output, "UID:%s@%s" % (hash((start, stop, entry)), idhost)
312
print >> output, "SUMMARY:%s" % (entry.replace('\\', '\\\\')
314
.replace(',', '\\,'))
315
print >> output, "DTSTART:%s" % start.strftime('%Y%m%dT%H%M%S')
316
print >> output, "DTEND:%s" % stop.strftime('%Y%m%dT%H%M%S')
317
print >> output, "DTSTAMP:%s" % dtstamp
318
print >> output, "END:VEVENT"
319
print >> output, "END:VCALENDAR"
321
def to_csv(self, output, title_row=True):
322
"""Export work entries to a CSV file.
324
The file has two columns: task title and time (in minutes).
326
writer = csv.writer(output)
328
writer.writerow(["task", "time (minutes)"])
329
work, slack = self.grouped_entries()
330
work = [(entry, as_minutes(duration))
331
for start, entry, duration in work
332
if duration] # skip empty "arrival" entries
334
writer.writerows(work)
336
def daily_report(self, output, email, who):
337
"""Format a daily report.
339
Writes a daily report template in RFC-822 format to output.
341
# Locale is set as a side effect of 'import gtk', so strftime('%a')
342
# would give us translated names
343
weekday_names = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
344
weekday = weekday_names[self.min_timestamp.weekday()]
345
week = self.min_timestamp.strftime('%V')
346
print >> output, "To: %(email)s" % {'email': email}
347
print >> output, ("Subject: %(date)s report for %(who)s"
348
" (%(weekday)s, week %(week)s)"
349
% {'date': self.min_timestamp.strftime('%Y-%m-%d'),
350
'weekday': weekday, 'week': week, 'who': who})
352
items = list(self.all_entries())
354
print >> output, "No work done today."
356
start, stop, duration, entry = items[0]
357
entry = entry[:1].upper() + entry[1:]
358
print >> output, "%s at %s" % (entry, start.strftime('%H:%M'))
360
work, slack = self.grouped_entries()
361
total_work, total_slacking = self.totals()
363
for start, entry, duration in work:
364
entry = entry[:1].upper() + entry[1:]
365
print >> output, u"%-62s %s" % (entry,
366
format_duration_long(duration))
368
print >> output, ("Total work done: %s" %
369
format_duration_long(total_work))
372
for start, entry, duration in slack:
373
entry = entry[:1].upper() + entry[1:]
374
print >> output, u"%-62s %s" % (entry,
375
format_duration_long(duration))
377
print >> output, ("Time spent slacking: %s" %
378
format_duration_long(total_slacking))
380
def weekly_report(self, output, email, who, estimated_column=False):
381
"""Format a weekly report.
383
Writes a weekly report template in RFC-822 format to output.
385
week = self.min_timestamp.strftime('%V')
386
print >> output, "To: %(email)s" % {'email': email}
387
print >> output, "Subject: Weekly report for %s (week %s)" % (who,
390
items = list(self.all_entries())
392
print >> output, "No work done this week."
394
print >> output, " " * 46,
396
print >> output, "estimated actual"
398
print >> output, " time"
399
work, slack = self.grouped_entries()
400
total_work, total_slacking = self.totals()
402
work = [(entry, duration) for start, entry, duration in work]
404
for entry, duration in work:
406
continue # skip empty "arrival" entries
407
entry = entry[:1].upper() + entry[1:]
409
print >> output, (u"%-46s %-14s %s" %
410
(entry, '-', format_duration_long(duration)))
412
print >> output, (u"%-62s %s" %
413
(entry, format_duration_long(duration)))
415
print >> output, ("Total work done this week: %s" %
416
format_duration_long(total_work))
418
def monthly_report(self, output, email, who):
419
"""Format a monthly report.
421
Writes a monthly report template in RFC-822 format to output.
424
month = self.min_timestamp.strftime('%Y/%m')
425
print >> output, "To: %(email)s" % {'email': email}
426
print >> output, "Subject: Monthly report for %s (%s)" % (who, month)
429
items = list(self.all_entries())
431
print >> output, "No work done this month."
434
print >> output, " " * 46
436
work, slack = self.grouped_entries()
437
total_work, total_slacking = self.totals()
441
work = [(entry, duration) for start, entry, duration in work]
443
for entry, duration in work:
445
continue # skip empty "arrival" entries
448
cat, task = entry.split(': ', 1)
449
categories[cat] = categories.get(
450
cat, datetime.timedelta(0)) + duration
452
categories[None] = categories.get(
453
None, datetime.timedelta(0)) + duration
455
entry = entry[:1].upper() + entry[1:]
456
print >> output, (u"%-62s %s" %
457
(entry, format_duration_long(duration)))
460
print >> output, ("Total work done this month: %s" %
461
format_duration_long(total_work))
465
print >> output, "By category:"
468
items = categories.items()
470
for cat, duration in items:
474
print >> output, u"%-62s %s" % (
475
cat, format_duration_long(duration))
477
if None in categories:
478
print >> output, u"%-62s %s" % (
479
'(none)', format_duration_long(categories[None]))
483
class TimeLog(object):
486
A time log contains a time window for today, and can add new entries at
490
def __init__(self, filename, virtual_midnight):
491
self.filename = filename
492
self.virtual_midnight = virtual_midnight
496
"""Reload today's log."""
497
self.day = virtual_day(datetime.datetime.now(), self.virtual_midnight)
498
min = datetime.datetime.combine(self.day, self.virtual_midnight)
499
max = min + datetime.timedelta(1)
501
self.window = TimeWindow(self.filename, min, max,
502
self.virtual_midnight,
503
callback=self.history.append)
504
self.need_space = not self.window.items
506
def window_for(self, min, max):
507
"""Return a TimeWindow for a specified time interval."""
508
return TimeWindow(self.filename, min, max, self.virtual_midnight)
510
def whole_history(self):
511
"""Return a TimeWindow for the whole history."""
512
# XXX I don't like this solution. Better make the min/max filtering
513
# arguments optional in TimeWindow.reread
514
return self.window_for(self.window.earliest_timestamp,
515
datetime.datetime.now())
517
def raw_append(self, line):
518
"""Append a line to the time log file."""
519
f = open(self.filename, "a")
521
self.need_space = False
526
def append(self, entry):
527
"""Append a new entry to the time log."""
528
now = datetime.datetime.now().replace(second=0, microsecond=0)
529
last = self.window.last_time()
530
if last and different_days(now, last, self.virtual_midnight):
531
# next day: reset self.window
533
self.window.items.append((now, entry))
534
line = '%s: %s' % (now.strftime("%Y-%m-%d %H:%M"), entry)
535
self.raw_append(line)
538
class TaskList(object):
541
You can have a list of common tasks in a text file that looks like this
545
Project1: do some task
546
Project2: do some other task
547
Project1: do yet another task
549
These tasks are grouped by their common prefix (separated with ':').
550
Tasks without a ':' are grouped under "Other".
552
A TaskList has an attribute 'groups' which is a list of tuples
553
(group_name, list_of_group_items).
556
other_title = 'Other'
558
loading_callback = None
559
loaded_callback = None
560
error_callback = None
562
def __init__(self, filename):
563
self.filename = filename
566
def check_reload(self):
567
"""Look at the mtime of tasks.txt, and reload it if necessary.
569
Returns True if the file was reloaded.
571
mtime = self.get_mtime()
572
if mtime != self.last_mtime:
579
"""Return the mtime of self.filename, or None if the file doesn't exist."""
581
return os.stat(self.filename).st_mtime
586
"""Load task list from a file named self.filename."""
588
self.last_mtime = self.get_mtime()
590
for line in file(self.filename):
592
if not line or line.startswith('#'):
595
group, task = [s.strip() for s in line.split(':', 1)]
597
group, task = self.other_title, line
598
groups.setdefault(group, []).append(task)
600
pass # the file's not there, so what?
601
self.groups = groups.items()
605
"""Reload the task list."""
609
class RemoteTaskList(TaskList):
610
"""Task list stored on a remote server.
612
Keeps a cached copy of the list in a local file, so you can use it offline.
615
def __init__(self, url, cache_filename):
617
TaskList.__init__(self, cache_filename)
618
self.first_time = True
620
def check_reload(self):
621
"""Check whether the task list needs to be reloaded.
623
Download the task list if this is the first time, and a cached copy is
626
Returns True if the file was reloaded.
629
self.first_time = False
630
if not os.path.exists(self.filename):
633
return TaskList.check_reload(self)
636
"""Download the task list from the server."""
637
if self.loading_callback:
638
self.loading_callback()
640
urllib.urlretrieve(self.url, self.filename)
642
if self.error_callback:
643
self.error_callback()
645
if self.loaded_callback:
646
self.loaded_callback()
649
"""Reload the task list."""
653
class Settings(object):
654
"""Configurable settings for GTimeLog."""
657
email = 'activity-list@example.com'
661
mailer = 'x-terminal-emulator -e mutt -H %s'
662
spreadsheet = 'oocalc %s'
664
enable_gtk_completion = True # False enables gvim-style completion
667
virtual_midnight = datetime.time(2, 0)
670
edit_task_list_cmd = ''
673
config = ConfigParser.RawConfigParser()
674
config.add_section('gtimelog')
675
config.set('gtimelog', 'list-email', self.email)
676
config.set('gtimelog', 'name', self.name)
677
config.set('gtimelog', 'editor', self.editor)
678
config.set('gtimelog', 'mailer', self.mailer)
679
config.set('gtimelog', 'spreadsheet', self.spreadsheet)
680
config.set('gtimelog', 'gtk-completion',
681
str(self.enable_gtk_completion))
682
config.set('gtimelog', 'hours', str(self.hours))
683
config.set('gtimelog', 'virtual_midnight',
684
self.virtual_midnight.strftime('%H:%M'))
685
config.set('gtimelog', 'task_list_url', self.task_list_url)
686
config.set('gtimelog', 'edit_task_list_cmd', self.edit_task_list_cmd)
689
def load(self, filename):
690
config = self._config()
691
config.read([filename])
692
self.email = config.get('gtimelog', 'list-email')
693
self.name = config.get('gtimelog', 'name')
694
self.editor = config.get('gtimelog', 'editor')
695
self.mailer = config.get('gtimelog', 'mailer')
696
self.spreadsheet = config.get('gtimelog', 'spreadsheet')
697
self.enable_gtk_completion = config.getboolean('gtimelog',
699
self.hours = config.getfloat('gtimelog', 'hours')
700
self.virtual_midnight = parse_time(config.get('gtimelog',
702
self.task_list_url = config.get('gtimelog', 'task_list_url')
703
self.edit_task_list_cmd = config.get('gtimelog', 'edit_task_list_cmd')
705
def save(self, filename):
706
config = self._config()
707
f = file(filename, 'w')
714
class TrayIcon(object):
715
"""Tray icon for gtimelog."""
717
def __init__(self, gtimelog_window):
718
self.gtimelog_window = gtimelog_window
719
self.timelog = gtimelog_window.timelog
724
return # nothing to do here, move along
725
# or install python-gnome2-extras
726
self.tooltips = gtk.Tooltips()
727
self.eventbox = gtk.EventBox()
730
icon.set_from_file(icon_file)
732
self.time_label = gtk.Label()
733
hbox.add(self.time_label)
734
self.eventbox.add(hbox)
735
self.trayicon = egg.trayicon.TrayIcon("GTimeLog")
736
self.trayicon.add(self.eventbox)
737
self.last_tick = False
738
self.tick(force_update=True)
739
self.trayicon.show_all()
740
tray_icon_popup_menu = gtimelog_window.tray_icon_popup_menu
741
self.eventbox.connect_object("button-press-event", self.on_press,
742
tray_icon_popup_menu)
743
self.eventbox.connect("button-release-event", self.on_release)
744
gobject.timeout_add(1000, self.tick)
745
self.gtimelog_window.entry_watchers.append(self.entry_added)
747
def on_press(self, widget, event):
748
"""A mouse button was pressed on the tray icon label."""
749
if event.button != 3:
751
main_window = self.gtimelog_window.main_window
752
if main_window.get_property("visible"):
753
self.gtimelog_window.tray_show.hide()
754
self.gtimelog_window.tray_hide.show()
756
self.gtimelog_window.tray_show.show()
757
self.gtimelog_window.tray_hide.hide()
758
widget.popup(None, None, None, event.button, event.time)
760
def on_release(self, widget, event):
761
"""A mouse button was released on the tray icon label."""
762
if event.button != 1:
764
main_window = self.gtimelog_window.main_window
765
if main_window.get_property("visible"):
768
main_window.present()
770
def entry_added(self, entry):
771
"""An entry has been added."""
772
self.tick(force_update=True)
774
def tick(self, force_update=False):
775
"""Tick every second."""
776
now = datetime.datetime.now().replace(second=0, microsecond=0)
777
if now != self.last_tick or force_update: # Do not eat CPU too much
779
last_time = self.timelog.window.last_time()
780
if last_time is None:
781
self.time_label.set_text(now.strftime("%H:%M"))
783
self.time_label.set_text(format_duration_short(now - last_time))
784
self.tooltips.set_tip(self.trayicon, self.tip())
788
"""Compute tooltip text."""
789
current_task = self.gtimelog_window.task_entry.get_text()
791
current_task = "nothing"
792
tip = "GTimeLog: working on %s" % current_task
793
total_work, total_slacking = self.timelog.window.totals()
794
tip += "\nWork done today: %s" % format_duration(total_work)
795
time_left = self.gtimelog_window.time_left_at_work(total_work)
796
if time_left is not None:
797
if time_left < datetime.timedelta(0):
798
time_left = datetime.timedelta(0)
799
tip += "\nTime left at work: %s" % format_duration(time_left)
803
class MainWindow(object):
804
"""Main application window."""
809
# Try to prevent timer routines mucking with the buffer while we're
810
# mucking with the buffer. Not sure if it is necessary.
813
def __init__(self, timelog, settings, tasks):
814
"""Create the main window."""
815
self.timelog = timelog
816
self.settings = settings
818
self.last_tick = None
819
self.entry_watchers = []
820
tree = gtk.glade.XML(ui_file)
821
tree.signal_autoconnect(self)
822
self.tray_icon_popup_menu = tree.get_widget("tray_icon_popup_menu")
823
self.tray_show = tree.get_widget("tray_show")
824
self.tray_hide = tree.get_widget("tray_hide")
825
self.about_dialog = tree.get_widget("about_dialog")
826
self.about_dialog_ok_btn = tree.get_widget("ok_button")
827
self.about_dialog_ok_btn.connect("clicked", self.close_about_dialog)
828
self.calendar_dialog = tree.get_widget("calendar_dialog")
829
self.calendar = tree.get_widget("calendar")
830
self.calendar.connect("day_selected_double_click",
831
self.on_calendar_day_selected_double_click)
832
self.main_window = tree.get_widget("main_window")
833
self.main_window.connect("delete_event", self.delete_event)
834
self.log_view = tree.get_widget("log_view")
835
self.set_up_log_view_columns()
836
self.task_pane_info_label = tree.get_widget("task_pane_info_label")
837
tasks.loading_callback = self.task_list_loading
838
tasks.loaded_callback = self.task_list_loaded
839
tasks.error_callback = self.task_list_error
840
self.task_list = tree.get_widget("task_list")
841
self.task_store = gtk.TreeStore(str, str)
842
self.task_list.set_model(self.task_store)
843
column = gtk.TreeViewColumn("Task", gtk.CellRendererText(), text=0)
844
self.task_list.append_column(column)
845
self.task_list.connect("row_activated", self.task_list_row_activated)
846
self.task_list_popup_menu = tree.get_widget("task_list_popup_menu")
847
self.task_list.connect_object("button_press_event",
848
self.task_list_button_press,
849
self.task_list_popup_menu)
850
task_list_edit_menu_item = tree.get_widget("task_list_edit")
851
if not self.settings.edit_task_list_cmd:
852
task_list_edit_menu_item.set_sensitive(False)
853
self.time_label = tree.get_widget("time_label")
854
self.task_entry = tree.get_widget("task_entry")
855
self.task_entry.connect("changed", self.task_entry_changed)
856
self.task_entry.connect("key_press_event", self.task_entry_key_press)
857
self.add_button = tree.get_widget("add_button")
858
self.add_button.connect("clicked", self.add_entry)
859
buffer = self.log_view.get_buffer()
860
self.log_buffer = buffer
861
buffer.create_tag('today', foreground='blue')
862
buffer.create_tag('duration', foreground='red')
863
buffer.create_tag('time', foreground='green')
864
buffer.create_tag('slacking', foreground='gray')
865
self.set_up_task_list()
866
self.set_up_completion()
867
self.set_up_history()
870
gobject.timeout_add(1000, self.tick)
872
def set_up_log_view_columns(self):
873
"""Set up tab stops in the log view."""
874
pango_context = self.log_view.get_pango_context()
875
em = pango_context.get_font_description().get_size()
876
tabs = pango.TabArray(2, False)
877
tabs.set_tab(0, pango.TAB_LEFT, 9 * em)
878
tabs.set_tab(1, pango.TAB_LEFT, 12 * em)
879
self.log_view.set_tabs(tabs)
881
def w(self, text, tag=None):
882
"""Write some text at the end of the log buffer."""
883
buffer = self.log_buffer
885
buffer.insert_with_tags_by_name(buffer.get_end_iter(), text, tag)
887
buffer.insert(buffer.get_end_iter(), text)
889
def populate_log(self):
890
"""Populate the log."""
892
buffer = self.log_buffer
894
if self.footer_mark is not None:
895
buffer.delete_mark(self.footer_mark)
896
self.footer_mark = None
897
today = virtual_day(datetime.datetime.now(),
898
self.timelog.virtual_midnight)
899
today = today.strftime('%A, %Y-%m-%d (week %V)')
900
self.w(today + '\n\n', 'today')
901
if self.chronological:
902
for item in self.timelog.window.all_entries():
903
self.write_item(item)
905
work, slack = self.timelog.window.grouped_entries()
906
for start, entry, duration in work + slack:
907
self.write_group(entry, duration)
908
where = buffer.get_end_iter()
909
where.backward_cursor_position()
910
buffer.place_cursor(where)
915
def delete_footer(self):
916
buffer = self.log_buffer
917
buffer.delete(buffer.get_iter_at_mark(self.footer_mark),
918
buffer.get_end_iter())
919
buffer.delete_mark(self.footer_mark)
920
self.footer_mark = None
922
def add_footer(self):
923
buffer = self.log_buffer
924
self.footer_mark = buffer.create_mark('footer', buffer.get_end_iter(),
926
total_work, total_slacking = self.timelog.window.totals()
927
weekly_window = self.weekly_window()
928
week_total_work, week_total_slacking = weekly_window.totals()
929
work_days_this_week = weekly_window.count_days()
932
self.w('Total work done: ')
933
self.w(format_duration(total_work), 'duration')
935
self.w(format_duration(week_total_work), 'duration')
937
if work_days_this_week:
938
per_diem = week_total_work / work_days_this_week
940
self.w(format_duration(per_diem), 'duration')
943
self.w('Total slacking: ')
944
self.w(format_duration(total_slacking), 'duration')
946
self.w(format_duration(week_total_slacking), 'duration')
948
if work_days_this_week:
949
per_diem = week_total_slacking / work_days_this_week
951
self.w(format_duration(per_diem), 'duration')
954
time_left = self.time_left_at_work(total_work)
955
if time_left is not None:
956
time_to_leave = datetime.datetime.now() + time_left
957
if time_left < datetime.timedelta(0):
958
time_left = datetime.timedelta(0)
959
self.w('Time left at work: ')
960
self.w(format_duration(time_left), 'duration')
962
self.w(time_to_leave.strftime('%H:%M'), 'time')
965
def time_left_at_work(self, total_work):
966
"""Calculate time left to work."""
967
last_time = self.timelog.window.last_time()
968
if last_time is None:
970
now = datetime.datetime.now()
971
current_task = self.task_entry.get_text()
972
current_task_time = now - last_time
973
if '**' in current_task:
974
total_time = total_work
976
total_time = total_work + current_task_time
977
return datetime.timedelta(hours=self.settings.hours) - total_time
979
def write_item(self, item):
980
buffer = self.log_buffer
981
start, stop, duration, entry = item
982
self.w(format_duration(duration), 'duration')
983
period = '\t(%s-%s)\t' % (start.strftime('%H:%M'),
984
stop.strftime('%H:%M'))
985
self.w(period, 'time')
986
tag = '**' in entry and 'slacking' or None
987
self.w(entry + '\n', tag)
988
where = buffer.get_end_iter()
989
where.backward_cursor_position()
990
buffer.place_cursor(where)
992
def write_group(self, entry, duration):
993
self.w(format_duration(duration), 'duration')
994
tag = '**' in entry and 'slacking' or None
995
self.w('\t' + entry + '\n', tag)
997
def scroll_to_end(self):
998
buffer = self.log_view.get_buffer()
999
end_mark = buffer.create_mark('end', buffer.get_end_iter())
1000
self.log_view.scroll_to_mark(end_mark, 0)
1001
buffer.delete_mark(end_mark)
1003
def set_up_task_list(self):
1004
"""Set up the task list pane."""
1005
self.task_store.clear()
1006
for group_name, group_items in self.tasks.groups:
1007
t = self.task_store.append(None, [group_name, group_name + ': '])
1008
for item in group_items:
1009
if group_name == self.tasks.other_title:
1012
task = group_name + ': ' + item
1013
self.task_store.append(t, [item, task])
1014
self.task_list.expand_all()
1016
def set_up_history(self):
1017
"""Set up history."""
1018
self.history = self.timelog.history
1019
self.filtered_history = []
1020
self.history_pos = 0
1021
self.history_undo = ''
1022
if not self.have_completion:
1025
for entry in self.history:
1026
if entry not in seen:
1028
self.completion_choices.append([entry])
1030
def set_up_completion(self):
1031
"""Set up autocompletion."""
1032
if not self.settings.enable_gtk_completion:
1033
self.have_completion = False
1035
self.have_completion = hasattr(gtk, 'EntryCompletion')
1036
if not self.have_completion:
1038
self.completion_choices = gtk.ListStore(str)
1039
completion = gtk.EntryCompletion()
1040
completion.set_model(self.completion_choices)
1041
completion.set_text_column(0)
1042
self.task_entry.set_completion(completion)
1044
def add_history(self, entry):
1045
"""Add an entry to history."""
1046
self.history.append(entry)
1047
self.history_pos = 0
1048
if not self.have_completion:
1050
if entry not in [row[0] for row in self.completion_choices]:
1051
self.completion_choices.append([entry])
1053
def delete_event(self, widget, data=None):
1054
"""Try to close the window."""
1058
def close_about_dialog(self, widget):
1059
"""Ok clicked in the about dialog."""
1060
self.about_dialog.hide()
1062
def on_show_activate(self, widget):
1063
"""Tray icon menu -> Show selected"""
1064
self.main_window.present()
1066
def on_hide_activate(self, widget):
1067
"""Tray icon menu -> Hide selected"""
1068
self.main_window.hide()
1070
def on_quit_activate(self, widget):
1071
"""File -> Quit selected"""
1074
def on_about_activate(self, widget):
1075
"""Help -> About selected"""
1076
self.about_dialog.show()
1078
def on_chronological_activate(self, widget):
1079
"""View -> Chronological"""
1080
self.chronological = True
1083
def on_grouped_activate(self, widget):
1084
"""View -> Grouped"""
1085
self.chronological = False
1088
def on_daily_report_activate(self, widget):
1089
"""File -> Daily Report"""
1090
window = self.timelog.window
1091
self.mail(window.daily_report)
1093
def on_yesterdays_report_activate(self, widget):
1094
"""File -> Daily Report for Yesterday"""
1095
max = self.timelog.window.min_timestamp
1096
min = max - datetime.timedelta(1)
1097
window = self.timelog.window_for(min, max)
1098
self.mail(window.daily_report)
1100
def on_previous_day_report_activate(self, widget):
1101
"""File -> Daily Report for a Previous Day"""
1102
day = self.choose_date()
1104
min = datetime.datetime.combine(day,
1105
self.timelog.virtual_midnight)
1106
max = min + datetime.timedelta(1)
1107
window = self.timelog.window_for(min, max)
1108
self.mail(window.daily_report)
1110
def choose_date(self):
1111
"""Pop up a calendar dialog.
1113
Returns either a datetime.date, or one.
1115
if self.calendar_dialog.run() == gtk.RESPONSE_OK:
1116
y, m1, d = self.calendar.get_date()
1117
day = datetime.date(y, m1+1, d)
1120
self.calendar_dialog.hide()
1123
def on_calendar_day_selected_double_click(self, widget):
1124
"""Double-click on a calendar day: close the dialog."""
1125
self.calendar_dialog.response(gtk.RESPONSE_OK)
1127
def weekly_window(self, day=None):
1129
day = self.timelog.day
1130
monday = day - datetime.timedelta(day.weekday())
1131
min = datetime.datetime.combine(monday,
1132
self.timelog.virtual_midnight)
1133
max = min + datetime.timedelta(7)
1134
window = self.timelog.window_for(min, max)
1137
def on_weekly_report_activate(self, widget):
1138
"""File -> Weekly Report"""
1139
window = self.weekly_window()
1140
self.mail(window.weekly_report)
1142
def on_last_weeks_report_activate(self, widget):
1143
"""File -> Weekly Report for Last Week"""
1144
day = self.timelog.day - datetime.timedelta(7)
1145
window = self.weekly_window(day=day)
1146
self.mail(window.weekly_report)
1148
def on_previous_week_report_activate(self, widget):
1149
"""File -> Weekly Report for a Previous Week"""
1150
day = self.choose_date()
1152
window = self.weekly_window(day=day)
1153
self.mail(window.weekly_report)
1155
def monthly_window(self, day=None):
1157
day = self.timelog.day
1158
first_of_this_month = first_of_month(day)
1159
first_of_next_month = next_month(day)
1160
min = datetime.datetime.combine(first_of_this_month,
1161
self.timelog.virtual_midnight)
1162
max = datetime.datetime.combine(first_of_next_month,
1163
self.timelog.virtual_midnight)
1164
window = self.timelog.window_for(min, max)
1167
def on_previous_month_report_activate(self, widget):
1168
"""File -> Monthly Report for a Previous Month"""
1169
day = self.choose_date()
1171
window = self.monthly_window(day=day)
1172
self.mail(window.monthly_report)
1174
def on_last_month_report_activate(self, widget):
1175
"""File -> Monthly Report for Last Month"""
1176
day = self.timelog.day - datetime.timedelta(self.timelog.day.day)
1177
window = self.monthly_window(day=day)
1178
self.mail(window.monthly_report)
1180
def on_monthly_report_activate(self, widget):
1181
"""File -> Monthly Report"""
1182
window = self.monthly_window()
1183
self.mail(window.monthly_report)
1185
def on_open_in_spreadsheet_activate(self, widget):
1186
"""Report -> Open in Spreadsheet"""
1187
tempfn = tempfile.mktemp(suffix='gtimelog.csv') # XXX unsafe!
1188
f = open(tempfn, 'w')
1189
self.timelog.whole_history().to_csv(f)
1191
self.spawn(self.settings.spreadsheet, tempfn)
1193
def on_edit_timelog_activate(self, widget):
1194
"""File -> Edit timelog.txt"""
1195
self.spawn(self.settings.editor, self.timelog.filename)
1197
def mail(self, write_draft):
1198
"""Send an email."""
1199
draftfn = tempfile.mktemp(suffix='gtimelog') # XXX unsafe!
1200
draft = open(draftfn, 'w')
1201
write_draft(draft, self.settings.email, self.settings.name)
1203
self.spawn(self.settings.mailer, draftfn)
1204
# XXX rm draftfn when done -- but how?
1206
def spawn(self, command, arg=None):
1207
"""Spawn a process in background"""
1208
# XXX shell-escape arg, please.
1211
command = command % arg
1213
command += ' ' + arg
1214
os.system(command + " &")
1216
def on_reread_activate(self, widget):
1217
"""File -> Reread"""
1218
self.timelog.reread()
1219
self.set_up_history()
1223
def task_list_row_activated(self, treeview, path, view_column):
1224
"""A task was selected in the task pane -- put it to the entry."""
1225
model = treeview.get_model()
1226
task = model[path][1]
1227
self.task_entry.set_text(task)
1228
self.task_entry.grab_focus()
1229
self.task_entry.set_position(-1)
1230
# XXX: how does this integrate with history?
1232
def task_list_button_press(self, menu, event):
1233
if event.button == 3:
1234
menu.popup(None, None, None, event.button, event.time)
1239
def on_task_list_reload(self, event):
1241
self.set_up_task_list()
1243
def on_task_list_edit(self, event):
1244
self.spawn(self.settings.edit_task_list_cmd)
1246
def task_list_loading(self):
1247
self.task_list_loading_failed = False
1248
self.task_pane_info_label.set_text("Loading...")
1249
self.task_pane_info_label.show()
1250
# let the ui update become visible
1251
while gtk.events_pending():
1252
gtk.main_iteration()
1254
def task_list_error(self):
1255
self.task_list_loading_failed = True
1256
self.task_pane_info_label.set_text("Could not get task list.")
1257
self.task_pane_info_label.show()
1259
def task_list_loaded(self):
1260
if not self.task_list_loading_failed:
1261
self.task_pane_info_label.hide()
1263
def task_entry_changed(self, widget):
1264
"""Reset history position when the task entry is changed."""
1265
self.history_pos = 0
1267
def task_entry_key_press(self, widget, event):
1268
"""Handle key presses in task entry."""
1269
if event.keyval == gtk.gdk.keyval_from_name('Prior'):
1272
if event.keyval == gtk.gdk.keyval_from_name('Next'):
1273
self._do_history(-1)
1275
# XXX This interferes with the completion box. How do I determine
1276
# whether the completion box is visible or not?
1277
if self.have_completion:
1279
if event.keyval == gtk.gdk.keyval_from_name('Up'):
1282
if event.keyval == gtk.gdk.keyval_from_name('Down'):
1283
self._do_history(-1)
1287
def _do_history(self, delta):
1288
"""Handle movement in history."""
1289
if not self.history:
1291
if self.history_pos == 0:
1292
self.history_undo = self.task_entry.get_text()
1293
self.filtered_history = uniq([l for l in self.history
1294
if l.startswith(self.history_undo)])
1295
history = self.filtered_history
1296
new_pos = max(0, min(self.history_pos + delta, len(history)))
1298
self.task_entry.set_text(self.history_undo)
1299
self.task_entry.set_position(-1)
1301
self.task_entry.set_text(history[-new_pos])
1302
self.task_entry.select_region(0, -1)
1303
# Do this after task_entry_changed reset history_pos to 0
1304
self.history_pos = new_pos
1306
def add_entry(self, widget, data=None):
1307
"""Add the task entry to the log."""
1308
entry = self.task_entry.get_text()
1311
self.add_history(entry)
1312
self.timelog.append(entry)
1313
if self.chronological:
1314
self.delete_footer()
1315
self.write_item(self.timelog.window.last_entry())
1317
self.scroll_to_end()
1320
self.task_entry.set_text("")
1321
self.task_entry.grab_focus()
1323
for watcher in self.entry_watchers:
1326
def tick(self, force_update=False):
1327
"""Tick every second."""
1328
if self.tasks.check_reload():
1329
self.set_up_task_list()
1330
now = datetime.datetime.now().replace(second=0, microsecond=0)
1331
if now == self.last_tick and not force_update:
1332
# Do not eat CPU unnecessarily
1334
self.last_tick = now
1335
last_time = self.timelog.window.last_time()
1336
if last_time is None:
1337
self.time_label.set_text(now.strftime("%H:%M"))
1339
self.time_label.set_text(format_duration(now - last_time))
1340
# Update "time left to work"
1342
self.delete_footer()
1348
"""Run the program."""
1349
if len(sys.argv) > 1 and sys.argv[1] == '--sample-config':
1350
settings = Settings()
1351
settings.save("gtimelogrc.sample")
1352
print "Sample configuration file written to gtimelogrc.sample"
1355
configdir = os.path.expanduser('~/.gtimelog')
1357
os.makedirs(configdir) # create it if it doesn't exist
1360
settings = Settings()
1361
settings_file = os.path.join(configdir, 'gtimelogrc')
1362
if not os.path.exists(settings_file):
1363
settings.save(settings_file)
1365
settings.load(settings_file)
1366
timelog = TimeLog(os.path.join(configdir, 'timelog.txt'),
1367
settings.virtual_midnight)
1368
if settings.task_list_url:
1369
tasks = RemoteTaskList(settings.task_list_url,
1370
os.path.join(configdir, 'remote-tasks.txt'))
1372
tasks = TaskList(os.path.join(configdir, 'tasks.txt'))
1373
main_window = MainWindow(timelog, settings, tasks)
1374
tray_icon = TrayIcon(main_window)
1377
except KeyboardInterrupt:
1380
if __name__ == '__main__':