2
"""An application for keeping track of your time."""
4
# Default to new-style classes.
20
from operator import itemgetter
22
# Which Gnome toolkit should we use? Prior to 0.7, pygtk was the default with
23
# a fallback to gi (gobject introspection), except on Ubuntu where gi was
24
# forced. With 0.7, gi was made the default in upstream, so the Ubuntu
25
# specific patch isn't necessary.
35
from gi.repository import GObject as gobject
36
from gi.repository import Gdk as gdk
37
from gi.repository import Gtk as gtk
38
from gi.repository import Pango as pango
39
# These are hacks until we fully switch to GI.
41
PANGO_ALIGN_LEFT = pango.TabAlign.LEFT
42
except AttributeError:
43
# Backwards compatible for older Pango versions with broken GIR.
44
PANGO_ALIGN_LEFT = pango.TabAlign.TAB_LEFT
45
GTK_RESPONSE_OK = gtk.ResponseType.OK
46
gtk_status_icon_new = gtk.StatusIcon.new_from_file
47
pango_tabarray_new = pango.TabArray.new
50
if gtk._version.startswith('2'):
51
from gi.repository import AppIndicator
53
from gi.repository import AppIndicator3 as AppIndicator
54
new_app_indicator = AppIndicator.Indicator.new
55
APPINDICATOR_CATEGORY = (
56
AppIndicator.IndicatorCategory.APPLICATION_STATUS)
57
APPINDICATOR_ACTIVE = AppIndicator.IndicatorStatus.ACTIVE
58
except (ImportError, gi._gi.RepositoryError):
59
new_app_indicator = None
64
from gtk import gdk as gdk
67
PANGO_ALIGN_LEFT = pango.TAB_LEFT
68
GTK_RESPONSE_OK = gtk.RESPONSE_OK
69
gtk_status_icon_new = gtk.status_icon_new_from_file
70
pango_tabarray_new = pango.TabArray
74
new_app_indicator = appindicator.Indicator
75
APPINDICATOR_CATEGORY = appindicator.CATEGORY_APPLICATION_STATUS
76
APPINDICATOR_ACTIVE = appindicator.STATUS_ACTIVE
78
# apt-get install python-appindicator on Ubuntu
79
new_app_indicator = None
84
import dbus.mainloop.glib
88
from gtimelog import __version__
91
# This is to let people run GTimeLog without having to install it
92
resource_dir = os.path.dirname(os.path.realpath(__file__))
93
ui_file = os.path.join(resource_dir, "gtimelog.ui")
94
icon_file_bright = os.path.join(resource_dir, "gtimelog-small-bright.png")
95
icon_file_dark = os.path.join(resource_dir, "gtimelog-small.png")
96
default_home = '~/.gtimelog'
98
# This is for distribution packages
99
if not os.path.exists(ui_file):
100
ui_file = "/usr/share/gtimelog/gtimelog.ui"
101
if not os.path.exists(icon_file_dark):
102
icon_file_dark = "/usr/share/pixmaps/gtimelog-small.png"
103
if not os.path.exists(icon_file_bright):
104
icon_file_bright = "/usr/share/pixmaps/gtimelog-small-bright.png"
107
def as_minutes(duration):
108
"""Convert a datetime.timedelta to an integer number of minutes."""
109
return duration.days * 24 * 60 + duration.seconds // 60
112
def as_hours(duration):
113
"""Convert a datetime.timedelta to a float number of hours."""
114
return duration.days * 24.0 + duration.seconds / (60.0 * 60.0)
117
def format_duration(duration):
118
"""Format a datetime.timedelta with minute precision."""
119
h, m = divmod(as_minutes(duration), 60)
120
return '%d h %d min' % (h, m)
123
def format_duration_short(duration):
124
"""Format a datetime.timedelta with minute precision."""
125
h, m = divmod((duration.days * 24 * 60 + duration.seconds // 60), 60)
126
return '%d:%02d' % (h, m)
129
def format_duration_long(duration):
130
"""Format a datetime.timedelta with minute precision, long format."""
131
h, m = divmod((duration.days * 24 * 60 + duration.seconds // 60), 60)
133
return '%d hour%s %d min' % (h, h != 1 and "s" or "", m)
135
return '%d hour%s' % (h, h != 1 and "s" or "")
140
def parse_datetime(dt):
141
"""Parse a datetime instance from 'YYYY-MM-DD HH:MM' formatted string."""
142
m = re.match(r'^(\d+)-(\d+)-(\d+) (\d+):(\d+)$', dt)
144
raise ValueError('bad date time: ', dt)
145
year, month, day, hour, min = map(int, m.groups())
146
return datetime.datetime(year, month, day, hour, min)
150
"""Parse a time instance from 'HH:MM' formatted string."""
151
m = re.match(r'^(\d+):(\d+)$', t)
153
raise ValueError('bad time: ', t)
154
hour, min = map(int, m.groups())
155
return datetime.time(hour, min)
158
def virtual_day(dt, virtual_midnight):
159
"""Return the "virtual day" of a timestamp.
161
Timestamps between midnight and "virtual midnight" (e.g. 2 am) are
162
assigned to the previous "virtual day".
164
if dt.time() < virtual_midnight: # assign to previous day
165
return dt.date() - datetime.timedelta(1)
169
def different_days(dt1, dt2, virtual_midnight):
170
"""Check whether dt1 and dt2 are on different "virtual days".
174
return virtual_day(dt1, virtual_midnight) != virtual_day(dt2,
178
def first_of_month(date):
179
"""Return the first day of the month for a given date."""
180
return date.replace(day=1)
183
def next_month(date):
184
"""Return the first day of the next month."""
186
return datetime.date(date.year + 1, 1, 1)
188
return datetime.date(date.year, date.month + 1, 1)
192
"""Return list with consecutive duplicates removed."""
195
if item != result[-1]:
200
class TimeWindow(object):
201
"""A window into a time log.
203
Reads a time log file and remembers all events that took place between
204
min_timestamp and max_timestamp. Includes events that took place at
205
min_timestamp, but excludes events that took place at max_timestamp.
207
self.items is a list of (timestamp, event_title) tuples.
209
Time intervals between events within the time window form entries that have
210
a start time, a stop time, and a duration. Entry title is the title of the
211
event that occurred at the stop time.
213
The first event also creates a special "arrival" entry of zero duration.
215
Entries that span virtual midnight boundaries are also converted to
216
"arrival" entries at their end point.
218
The earliest_timestamp attribute contains the first (which should be the
219
oldest) timestamp in the file.
222
def __init__(self, filename, min_timestamp, max_timestamp,
223
virtual_midnight, callback=None):
224
self.filename = filename
225
self.min_timestamp = min_timestamp
226
self.max_timestamp = max_timestamp
227
self.virtual_midnight = virtual_midnight
228
self.reread(callback)
230
def reread(self, callback=None):
231
"""Parse the time log file and update self.items.
233
Also updates self.earliest_timestamp.
236
self.earliest_timestamp = None
238
# accept any file-like object
239
# this is a hook for unit tests, really
240
if hasattr(self.filename, 'read'):
244
f = codecs.open(self.filename, encoding='UTF-8')
251
time, entry = line.split(': ', 1)
253
time = parse_datetime(time)
257
entry = entry.strip()
260
if self.earliest_timestamp is None:
261
self.earliest_timestamp = time
262
if self.min_timestamp <= time < self.max_timestamp:
263
self.items.append((time, entry))
264
# The entries really should be already sorted in the file
265
# XXX: instead of quietly resorting them we should inform the user
266
# Note that we must preserve the relative order of entries with
267
# the same timestamp: https://bugs.launchpad.net/gtimelog/+bug/708825
268
self.items.sort(key=itemgetter(0)) # there's code that relies on them being sorted
272
"""Return the time of the last event (or None if there are no events).
276
return self.items[-1][0]
278
def all_entries(self):
279
"""Iterate over all entries.
281
Yields (start, stop, duration, entry) tuples. The first entry
285
for item in self.items:
289
if start is None or different_days(start, stop,
290
self.virtual_midnight):
292
duration = stop - start
293
yield start, stop, duration, entry
295
def count_days(self):
296
"""Count days that have entries."""
299
for start, stop, duration, entry in self.all_entries():
300
if last is None or different_days(last, start,
301
self.virtual_midnight):
306
def last_entry(self):
307
"""Return the last entry (or None if there are no events).
309
It is always true that
311
self.last_entry() == list(self.all_entries())[-1]
316
stop = self.items[-1][0]
317
entry = self.items[-1][1]
318
if len(self.items) == 1:
321
start = self.items[-2][0]
322
if different_days(start, stop, self.virtual_midnight):
324
duration = stop - start
325
return start, stop, duration, entry
327
def grouped_entries(self, skip_first=True):
328
"""Return consolidated entries (grouped by entry title).
330
Returns two list: work entries and slacking entries. Slacking
331
entries are identified by finding two asterisks in the title.
332
Entry lists are sorted, and contain (start, entry, duration) tuples.
336
for start, stop, duration, entry in self.all_entries():
347
old_start, old_entry, old_duration = entries[entry]
348
start = min(start, old_start)
349
duration += old_duration
350
entries[entry] = (start, entry, duration)
353
slack = slack.values()
357
def categorized_work_entries(self, skip_first=True):
358
"""Return consolidated work entries grouped by category.
360
Category is a string preceding the first ':' in the entry.
363
- {<category>: <entry list>}, where <category> is a category string
364
and <entry list> is a sorted list that contains tuples (start,
365
entry, duration); entry is stripped of its category prefix.
366
- {<category>: <total duration>}, where <total duration> is the
367
total duration of work in the <category>.
370
work, slack = self.grouped_entries(skip_first=skip_first)
373
for start, entry, duration in work:
375
cat, clipped_entry = entry.split(': ', 1)
376
entry_list = entries.get(cat, [])
377
entry_list.append((start, clipped_entry, duration))
378
entries[cat] = entry_list
379
totals[cat] = totals.get(cat, datetime.timedelta(0)) + duration
381
entry_list = entries.get(None, [])
382
entry_list.append((start, entry, duration))
383
entries[None] = entry_list
384
totals[None] = totals.get(
385
None, datetime.timedelta(0)) + duration
386
return entries, totals
389
"""Calculate total time of work and slacking entries.
391
Returns (total_work, total_slacking) tuple.
393
Slacking entries are identified by finding two asterisks in the title.
397
total_work, total_slacking = self.totals()
398
work, slacking = self.grouped_entries()
400
It is always true that
402
total_work = sum([duration for start, entry, duration in work])
403
total_slacking = sum([duration
404
for start, entry, duration in slacking])
406
(that is, it would be true if sum could operate on timedeltas).
408
total_work = total_slacking = datetime.timedelta(0)
409
for start, stop, duration, entry in self.all_entries():
411
total_slacking += duration
413
total_work += duration
414
return total_work, total_slacking
416
def icalendar(self, output):
417
"""Create an iCalendar file with activities."""
418
print >> output, "BEGIN:VCALENDAR"
419
print >> output, "PRODID:-//mg.pov.lt/NONSGML GTimeLog//EN"
420
print >> output, "VERSION:2.0"
423
idhost = socket.getfqdn()
424
except: # can it actually ever fail?
426
dtstamp = datetime.datetime.utcnow().strftime("%Y%m%dT%H%M%SZ")
427
for start, stop, duration, entry in self.all_entries():
428
print >> output, "BEGIN:VEVENT"
429
print >> output, "UID:%s@%s" % (hash((start, stop, entry)), idhost)
430
print >> output, "SUMMARY:%s" % (entry.replace('\\', '\\\\')
432
.replace(',', '\\,'))
433
print >> output, "DTSTART:%s" % start.strftime('%Y%m%dT%H%M%S')
434
print >> output, "DTEND:%s" % stop.strftime('%Y%m%dT%H%M%S')
435
print >> output, "DTSTAMP:%s" % dtstamp
436
print >> output, "END:VEVENT"
437
print >> output, "END:VCALENDAR"
439
def to_csv_complete(self, output, title_row=True):
440
"""Export work entries to a CSV file.
442
The file has two columns: task title and time (in minutes).
444
writer = csv.writer(output)
446
writer.writerow(["task", "time (minutes)"])
447
work, slack = self.grouped_entries()
448
work = [(entry.encode('UTF-8'), as_minutes(duration))
449
for start, entry, duration in work
450
if duration] # skip empty "arrival" entries
452
writer.writerows(work)
454
def to_csv_daily(self, output, title_row=True):
455
"""Export daily work, slacking, and arrival times to a CSV file.
457
The file has four columns: date, time from midnight til arrival at
458
work, slacking, and work (in decimal hours).
460
writer = csv.writer(output)
462
writer.writerow(["date", "day-start (hours)",
463
"slacking (hours)", "work (hours)"])
465
# sum timedeltas per date
466
# timelog must be cronological for this to be dependable
468
d0 = datetime.timedelta(0)
469
days = {} # date -> [time_started, slacking, work]
471
for start, stop, duration, entry in self.all_entries():
474
day = days.setdefault(start.date(),
475
[datetime.timedelta(minutes=start.minute,
484
# fill in missing dates - aka. weekends
487
days.setdefault(dmin, [d0, d0, d0])
488
dmin += datetime.timedelta(days=1)
490
# convert to hours, and a sortable list
491
items = [(day, as_hours(start), as_hours(slacking), as_hours(work))
492
for day, (start, slacking, work) in days.items()]
494
writer.writerows(items)
497
class Reports(object):
498
"""Generation of reports."""
500
def __init__(self, window):
503
def _categorizing_report(self, output, email, who, subject, period_name,
504
estimated_column=False):
505
"""A report that displays entries by category.
507
Writes a report template in RFC-822 format to output.
509
The report looks like
514
| --------------------------------
518
| Compass: hotpatch 2:13
519
| Call with a client 30
520
| --------------------------------
525
| --------------------------------
528
| Total work done this week: 6:26
530
| Categories by time spent:
539
print >> output, "To: %(email)s" % {'email': email}
540
print >> output, "Subject: %s" % subject
542
items = list(window.all_entries())
544
print >> output, "No work done this %s." % period_name
546
print >> output, " " * 46,
548
print >> output, "estimated actual"
550
print >> output, " time"
552
total_work, total_slacking = window.totals()
553
entries, totals = window.categorized_work_entries()
555
categories = entries.keys()
557
if categories[0] == None:
558
categories = categories[1:]
559
categories.append('No category')
560
e = entries.pop(None)
561
entries['No category'] = e
563
totals['No category'] = t
564
for cat in categories:
565
print >> output, '%s:' % cat
567
work = [(entry, duration)
568
for start, entry, duration in entries[cat]]
570
for entry, duration in work:
572
continue # skip empty "arrival" entries
574
entry = entry[:1].upper() + entry[1:]
576
print >> output, (u" %-46s %-14s %s" %
577
(entry, '-', format_duration_short(duration)))
579
print >> output, (u" %-61s %+5s" %
580
(entry, format_duration_short(duration)))
582
print >> output, '-' * 70
583
print >> output, (u"%+70s" %
584
format_duration_short(totals[cat]))
586
print >> output, ("Total work done this %s: %s" %
587
(period_name, format_duration_short(total_work)))
591
ordered_by_time = [(time, cat) for cat, time in totals.items()]
592
ordered_by_time.sort(reverse=True)
593
max_cat_length = max([len(cat) for cat in totals.keys()])
594
line_format = ' %-' + str(max_cat_length + 4) + 's %+5s'
595
print >> output, 'Categories by time spent:'
596
for time, cat in ordered_by_time:
597
print >> output, line_format % (cat, format_duration_short(time))
599
def _report_categories(self, output, categories):
600
"""A helper method that lists time spent per category.
602
Use this to add a section in a report looks similar to this:
604
Administration: 2 hours 1 min
605
Coding: 18 hours 45 min
608
category is a dict of entries (<category name>: <duration>).
611
print >> output, "By category:"
614
items = categories.items()
616
for cat, duration in items:
620
print >> output, u"%-62s %s" % (
621
cat, format_duration_long(duration))
623
if None in categories:
624
print >> output, u"%-62s %s" % (
625
'(none)', format_duration_long(categories[None]))
628
def _plain_report(self, output, email, who, subject, period_name,
629
estimated_column=False):
630
"""Format a report that does not categorize entries.
632
Writes a report template in RFC-822 format to output.
636
print >> output, "To: %(email)s" % {'email': email}
637
print >> output, 'Subject: %s' % subject
639
items = list(window.all_entries())
641
print >> output, "No work done this %s." % period_name
643
print >> output, " " * 46,
645
print >> output, "estimated actual"
647
print >> output, " time"
648
work, slack = window.grouped_entries()
649
total_work, total_slacking = window.totals()
652
work = [(entry, duration) for start, entry, duration in work]
654
for entry, duration in work:
656
continue # skip empty "arrival" entries
659
cat, task = entry.split(': ', 1)
660
categories[cat] = categories.get(
661
cat, datetime.timedelta(0)) + duration
663
categories[None] = categories.get(
664
None, datetime.timedelta(0)) + duration
666
entry = entry[:1].upper() + entry[1:]
668
print >> output, (u"%-46s %-14s %s" %
669
(entry, '-', format_duration_long(duration)))
671
print >> output, (u"%-62s %s" %
672
(entry, format_duration_long(duration)))
674
print >> output, ("Total work done this %s: %s" %
675
(period_name, format_duration_long(total_work)))
678
self._report_categories(output, categories)
680
def weekly_report_categorized(self, output, email, who,
681
estimated_column=False):
682
"""Format a weekly report with entries displayed under categories."""
683
week = self.window.min_timestamp.strftime('%V')
684
subject = 'Weekly report for %s (week %s)' % (who, week)
685
return self._categorizing_report(output, email, who, subject,
687
estimated_column=estimated_column)
689
def monthly_report_categorized(self, output, email, who,
690
estimated_column=False):
691
"""Format a monthly report with entries displayed under categories."""
692
month = self.window.min_timestamp.strftime('%Y/%m')
693
subject = 'Monthly report for %s (%s)' % (who, month)
694
return self._categorizing_report(output, email, who, subject,
696
estimated_column=estimated_column)
698
def weekly_report_plain(self, output, email, who, estimated_column=False):
699
"""Format a weekly report ."""
700
week = self.window.min_timestamp.strftime('%V')
701
subject = 'Weekly report for %s (week %s)' % (who, week)
702
return self._plain_report(output, email, who, subject,
704
estimated_column=estimated_column)
706
def monthly_report_plain(self, output, email, who, estimated_column=False):
707
"""Format a monthly report ."""
708
month = self.window.min_timestamp.strftime('%Y/%m')
709
subject = 'Monthly report for %s (%s)' % (who, month)
710
return self._plain_report(output, email, who, subject,
712
estimated_column=estimated_column)
714
def daily_report(self, output, email, who):
715
"""Format a daily report.
717
Writes a daily report template in RFC-822 format to output.
721
# Locale is set as a side effect of 'import gtk', so strftime('%a')
722
# would give us translated names
723
weekday_names = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
724
weekday = weekday_names[window.min_timestamp.weekday()]
725
week = window.min_timestamp.strftime('%V')
726
print >> output, "To: %(email)s" % {'email': email}
727
print >> output, ("Subject: %(date)s report for %(who)s"
728
" (%(weekday)s, week %(week)s)"
729
% {'date': window.min_timestamp.strftime('%Y-%m-%d'),
730
'weekday': weekday, 'week': week, 'who': who})
732
items = list(window.all_entries())
734
print >> output, "No work done today."
736
start, stop, duration, entry = items[0]
737
entry = entry[:1].upper() + entry[1:]
738
print >> output, "%s at %s" % (entry, start.strftime('%H:%M'))
740
work, slack = window.grouped_entries()
741
total_work, total_slacking = window.totals()
744
for start, entry, duration in work:
745
entry = entry[:1].upper() + entry[1:]
746
print >> output, u"%-62s %s" % (entry,
747
format_duration_long(duration))
749
cat, task = entry.split(': ', 1)
750
categories[cat] = categories.get(
751
cat, datetime.timedelta(0)) + duration
753
categories[None] = categories.get(
754
None, datetime.timedelta(0)) + duration
757
print >> output, ("Total work done: %s" %
758
format_duration_long(total_work))
760
if len(categories) > 0:
761
self._report_categories(output, categories)
763
print >> output, 'Slacking:\n'
766
for start, entry, duration in slack:
767
entry = entry[:1].upper() + entry[1:]
768
print >> output, u"%-62s %s" % (entry,
769
format_duration_long(duration))
771
print >> output, ("Time spent slacking: %s" %
772
format_duration_long(total_slacking))
775
class TimeLog(object):
778
A time log contains a time window for today, and can add new entries at
782
def __init__(self, filename, virtual_midnight):
783
self.filename = filename
784
self.virtual_midnight = virtual_midnight
788
"""Reload today's log."""
789
self.day = virtual_day(datetime.datetime.now(), self.virtual_midnight)
790
min = datetime.datetime.combine(self.day, self.virtual_midnight)
791
max = min + datetime.timedelta(1)
793
self.window = TimeWindow(self.filename, min, max,
794
self.virtual_midnight,
795
callback=self.history.append)
796
self.need_space = not self.window.items
798
def window_for(self, min, max):
799
"""Return a TimeWindow for a specified time interval."""
800
return TimeWindow(self.filename, min, max, self.virtual_midnight)
802
def whole_history(self):
803
"""Return a TimeWindow for the whole history."""
804
# XXX I don't like this solution. Better make the min/max filtering
805
# arguments optional in TimeWindow.reread
806
return self.window_for(self.window.earliest_timestamp,
807
datetime.datetime.now())
809
def raw_append(self, line):
810
"""Append a line to the time log file."""
811
f = codecs.open(self.filename, "a", encoding='UTF-8')
813
self.need_space = False
818
def append(self, entry, now=None):
819
"""Append a new entry to the time log."""
821
now = datetime.datetime.now().replace(second=0, microsecond=0)
822
last = self.window.last_time()
823
if last and different_days(now, last, self.virtual_midnight):
824
# next day: reset self.window
826
self.window.items.append((now, entry))
827
line = '%s: %s' % (now.strftime("%Y-%m-%d %H:%M"), entry)
828
self.raw_append(line)
830
def valid_time(self, time):
831
if time > datetime.datetime.now():
833
last = self.window.last_time()
834
if last and time < last:
839
class TaskList(object):
842
You can have a list of common tasks in a text file that looks like this
846
Project1: do some task
847
Project2: do some other task
848
Project1: do yet another task
850
These tasks are grouped by their common prefix (separated with ':').
851
Tasks without a ':' are grouped under "Other".
853
A TaskList has an attribute 'groups' which is a list of tuples
854
(group_name, list_of_group_items).
857
other_title = 'Other'
859
loading_callback = None
860
loaded_callback = None
861
error_callback = None
863
def __init__(self, filename):
864
self.filename = filename
867
def check_reload(self):
868
"""Look at the mtime of tasks.txt, and reload it if necessary.
870
Returns True if the file was reloaded.
872
mtime = self.get_mtime()
873
if mtime != self.last_mtime:
880
"""Return the mtime of self.filename, or None if the file doesn't exist."""
882
return os.stat(self.filename).st_mtime
887
"""Load task list from a file named self.filename."""
889
self.last_mtime = self.get_mtime()
891
for line in file(self.filename):
893
if not line or line.startswith('#'):
896
group, task = [s.strip() for s in line.split(':', 1)]
898
group, task = self.other_title, line
899
groups.setdefault(group, []).append(task)
901
pass # the file's not there, so what?
902
self.groups = groups.items()
906
"""Reload the task list."""
910
class RemoteTaskList(TaskList):
911
"""Task list stored on a remote server.
913
Keeps a cached copy of the list in a local file, so you can use it offline.
916
def __init__(self, url, cache_filename):
918
TaskList.__init__(self, cache_filename)
919
self.first_time = True
921
def check_reload(self):
922
"""Check whether the task list needs to be reloaded.
924
Download the task list if this is the first time, and a cached copy is
927
Returns True if the file was reloaded.
930
self.first_time = False
931
if not os.path.exists(self.filename):
934
return TaskList.check_reload(self)
937
"""Download the task list from the server."""
938
if self.loading_callback:
939
self.loading_callback()
941
urllib.urlretrieve(self.url, self.filename)
943
if self.error_callback:
944
self.error_callback()
946
if self.loaded_callback:
947
self.loaded_callback()
950
"""Reload the task list."""
954
class Settings(object):
955
"""Configurable settings for GTimeLog."""
958
email = 'activity-list@example.com'
962
mailer = 'x-terminal-emulator -e "mutt -H %s"'
963
spreadsheet = 'xdg-open %s'
966
enable_gtk_completion = True # False enables gvim-style completion
969
virtual_midnight = datetime.time(2, 0)
972
edit_task_list_cmd = ''
974
show_office_hours = True
975
show_tray_icon = True
976
prefer_app_indicator = True
977
prefer_old_tray_icon = False
978
start_in_tray = False
980
report_style = 'plain'
982
def get_config_dir(self):
983
envar_home = os.environ.get('GTIMELOG_HOME')
984
return os.path.expanduser(envar_home if envar_home else default_home)
986
def get_config_file(self):
987
return os.path.join(self.get_config_dir(), 'gtimelogrc')
990
config = ConfigParser.RawConfigParser()
991
config.add_section('gtimelog')
992
config.set('gtimelog', 'list-email', self.email)
993
config.set('gtimelog', 'name', self.name)
994
config.set('gtimelog', 'editor', self.editor)
995
config.set('gtimelog', 'mailer', self.mailer)
996
config.set('gtimelog', 'spreadsheet', self.spreadsheet)
997
config.set('gtimelog', 'chronological', str(self.chronological))
998
config.set('gtimelog', 'gtk-completion',
999
str(self.enable_gtk_completion))
1000
config.set('gtimelog', 'hours', str(self.hours))
1001
config.set('gtimelog', 'virtual_midnight',
1002
self.virtual_midnight.strftime('%H:%M'))
1003
config.set('gtimelog', 'task_list_url', self.task_list_url)
1004
config.set('gtimelog', 'edit_task_list_cmd', self.edit_task_list_cmd)
1005
config.set('gtimelog', 'show_office_hours',
1006
str(self.show_office_hours))
1007
config.set('gtimelog', 'show_tray_icon', str(self.show_tray_icon))
1008
config.set('gtimelog', 'prefer_app_indicator', str(self.prefer_app_indicator))
1009
config.set('gtimelog', 'prefer_old_tray_icon', str(self.prefer_old_tray_icon))
1010
config.set('gtimelog', 'report_style', str(self.report_style))
1011
config.set('gtimelog', 'start_in_tray', str(self.start_in_tray))
1014
def load(self, filename):
1015
config = self._config()
1016
config.read([filename])
1017
self.email = config.get('gtimelog', 'list-email')
1018
self.name = config.get('gtimelog', 'name')
1019
self.editor = config.get('gtimelog', 'editor')
1020
self.mailer = config.get('gtimelog', 'mailer')
1021
self.spreadsheet = config.get('gtimelog', 'spreadsheet')
1022
self.chronological = config.getboolean('gtimelog', 'chronological')
1023
self.enable_gtk_completion = config.getboolean('gtimelog',
1025
self.hours = config.getfloat('gtimelog', 'hours')
1026
self.virtual_midnight = parse_time(config.get('gtimelog',
1027
'virtual_midnight'))
1028
self.task_list_url = config.get('gtimelog', 'task_list_url')
1029
self.edit_task_list_cmd = config.get('gtimelog', 'edit_task_list_cmd')
1030
self.show_office_hours = config.getboolean('gtimelog',
1031
'show_office_hours')
1032
self.show_tray_icon = config.getboolean('gtimelog', 'show_tray_icon')
1033
self.prefer_app_indicator = config.getboolean('gtimelog',
1034
'prefer_app_indicator')
1035
self.prefer_old_tray_icon = config.getboolean('gtimelog',
1036
'prefer_old_tray_icon')
1037
self.report_style = config.get('gtimelog', 'report_style')
1038
self.start_in_tray = config.getboolean('gtimelog', 'start_in_tray')
1040
def save(self, filename):
1041
config = self._config()
1042
f = file(filename, 'w')
1052
def icon_name(self):
1053
# XXX assumes the panel's color matches a menu bar's color, which is
1054
# not necessarily the case! this logic works for, say,
1055
# Ambiance/Radiance, but it gets New Wave and Dark Room wrong.
1057
style = gtk.MenuBar().get_style_context()
1058
color = style.get_color(gtk.StateFlags.NORMAL)
1059
value = (color.red + color.green + color.blue) / 3
1061
style = gtk.MenuBar().rc_get_style()
1062
color = style.text[gtk.STATE_NORMAL]
1065
return icon_file_bright
1067
return icon_file_dark
1070
class SimpleStatusIcon(IconChooser):
1071
"""Status icon for gtimelog in the notification area."""
1073
def __init__(self, gtimelog_window):
1074
self.gtimelog_window = gtimelog_window
1075
self.timelog = gtimelog_window.timelog
1076
self.trayicon = None
1077
if not hasattr(gtk, 'StatusIcon'):
1078
# You must be using a very old PyGtk.
1080
self.icon = gtk_status_icon_new(self.icon_name)
1081
self.last_tick = False
1083
self.icon.connect('activate', self.on_activate)
1084
self.icon.connect('popup-menu', self.on_popup_menu)
1085
self.gtimelog_window.main_window.connect(
1086
'style-set', self.on_style_set) # Gtk+ 2
1087
self.gtimelog_window.main_window.connect(
1088
'style-updated', self.on_style_set) # Gtk+ 3
1089
gobject.timeout_add(1000, self.tick)
1090
self.gtimelog_window.entry_watchers.append(self.entry_added)
1091
self.gtimelog_window.tray_icon = self
1093
def available(self):
1094
"""Is the icon supported by this system?
1096
SimpleStatusIcon needs PyGtk 2.10 or newer
1098
return self.icon is not None
1100
def on_style_set(self, *args):
1101
"""The user chose a different theme."""
1102
self.icon.set_from_file(self.icon_name)
1104
def on_activate(self, widget):
1105
"""The user clicked on the icon."""
1106
self.gtimelog_window.toggle_visible()
1108
def on_popup_menu(self, widget, button, activate_time):
1109
"""The user clicked on the icon."""
1110
tray_icon_popup_menu = self.gtimelog_window.tray_icon_popup_menu
1111
tray_icon_popup_menu.popup(
1112
None, None, gtk.status_icon_position_menu,
1113
button, activate_time, self.icon)
1115
def entry_added(self, entry):
1116
"""An entry has been added."""
1120
"""Tick every second."""
1121
self.icon.set_tooltip_text(self.tip())
1125
"""Compute tooltip text."""
1126
current_task = self.gtimelog_window.task_entry.get_text()
1127
if not current_task:
1128
current_task = 'nothing'
1129
tip = 'GTimeLog: working on {0}'.format(current_task)
1130
total_work, total_slacking = self.timelog.window.totals()
1131
tip += '\nWork done today: {0}'.format(format_duration(total_work))
1132
time_left = self.gtimelog_window.time_left_at_work(total_work)
1133
if time_left is not None:
1134
if time_left < datetime.timedelta(0):
1135
time_left = datetime.timedelta(0)
1136
tip += '\nTime left at work: {0}'.format(
1137
format_duration(time_left))
1141
class AppIndicator(IconChooser):
1142
"""Ubuntu's application indicator for gtimelog."""
1144
def __init__(self, gtimelog_window):
1145
self.gtimelog_window = gtimelog_window
1146
self.timelog = gtimelog_window.timelog
1147
self.indicator = None
1148
if new_app_indicator is None:
1150
self.indicator = new_app_indicator(
1151
'gtimelog', self.icon_name, APPINDICATOR_CATEGORY)
1152
self.indicator.set_status(APPINDICATOR_ACTIVE)
1153
self.indicator.set_menu(gtimelog_window.app_indicator_menu)
1154
self.gtimelog_window.tray_icon = self
1155
self.gtimelog_window.main_window.connect(
1156
'style-set', self.on_style_set) # Gtk+ 2
1157
self.gtimelog_window.main_window.connect(
1158
'style-updated', self.on_style_set) # Gtk+ 3
1160
def available(self):
1161
"""Is the icon supported by this system?
1163
AppIndicator needs python-appindicator
1165
return self.indicator is not None
1167
def on_style_set(self, *args):
1168
"""The user chose a different theme."""
1169
self.indicator.set_icon(self.icon_name)
1172
class OldTrayIcon(IconChooser):
1173
"""Old tray icon for gtimelog, shows a ticking clock.
1175
Uses the old and deprecated egg.trayicon module.
1178
def __init__(self, gtimelog_window):
1179
self.gtimelog_window = gtimelog_window
1180
self.timelog = gtimelog_window.timelog
1181
self.trayicon = None
1185
# Nothing to do here, move along or install python-gnome2-extras
1186
# which was later renamed to python-eggtrayicon.
1188
self.eventbox = gtk.EventBox()
1190
self.icon = gtk.Image()
1191
self.icon.set_from_file(self.icon_name)
1193
self.time_label = gtk.Label()
1194
hbox.add(self.time_label)
1195
self.eventbox.add(hbox)
1196
self.trayicon = egg.trayicon.TrayIcon('GTimeLog')
1197
self.trayicon.add(self.eventbox)
1198
self.last_tick = False
1199
self.tick(force_update=True)
1200
self.trayicon.show_all()
1201
self.gtimelog_window.main_window.connect(
1202
'style-set', self.on_style_set) # Gtk+ 2
1203
self.gtimelog_window.main_window.connect(
1204
'style-updated', self.on_style_set) # Gtk+ 3
1205
tray_icon_popup_menu = gtimelog_window.tray_icon_popup_menu
1206
self.eventbox.connect_object(
1207
'button-press-event', self.on_press, tray_icon_popup_menu)
1208
self.eventbox.connect('button-release-event', self.on_release)
1209
gobject.timeout_add(1000, self.tick)
1210
self.gtimelog_window.entry_watchers.append(self.entry_added)
1211
self.gtimelog_window.tray_icon = self
1213
def available(self):
1214
"""Is the icon supported by this system?
1216
OldTrayIcon needs egg.trayicon, which is now deprecated and likely
1217
no longer available in modern Linux distributions.
1219
return self.trayicon is not None
1221
def on_style_set(self, *args):
1222
"""The user chose a different theme."""
1223
self.icon.set_from_file(self.icon_name)
1225
def on_press(self, widget, event):
1226
"""A mouse button was pressed on the tray icon label."""
1227
if event.button != 3:
1229
main_window = self.gtimelog_window.main_window
1230
# This should be unnecessary, as we now show/hide menu items
1231
# immediatelly after showing/hiding the main window.
1232
if main_window.get_property('visible'):
1233
self.gtimelog_window.tray_show.hide()
1234
self.gtimelog_window.tray_hide.show()
1236
self.gtimelog_window.tray_show.show()
1237
self.gtimelog_window.tray_hide.hide()
1238
widget.popup(None, None, None, event.button, event.time)
1240
def on_release(self, widget, event):
1241
"""A mouse button was released on the tray icon label."""
1242
if event.button != 1:
1244
self.gtimelog_window.toggle_visible()
1246
def entry_added(self, entry):
1247
"""An entry has been added."""
1248
self.tick(force_update=True)
1250
def tick(self, force_update=False):
1251
"""Tick every second."""
1252
now = datetime.datetime.now().replace(second=0, microsecond=0)
1253
if now != self.last_tick or force_update: # Do not eat CPU too much
1254
self.last_tick = now
1255
last_time = self.timelog.window.last_time()
1256
if last_time is None:
1257
self.time_label.set_text(now.strftime('%H:%M'))
1259
self.time_label.set_text(
1260
format_duration_short(now - last_time))
1261
self.trayicon.set_tooltip_text(self.tip())
1265
"""Compute tooltip text."""
1266
current_task = self.gtimelog_window.task_entry.get_text()
1267
if not current_task:
1268
current_task = 'nothing'
1269
tip = 'GTimeLog: working on {0}'.format(current_task)
1270
total_work, total_slacking = self.timelog.window.totals()
1271
tip += '\nWork done today: {0}'.format(format_duration(total_work))
1272
time_left = self.gtimelog_window.time_left_at_work(total_work)
1273
if time_left is not None:
1274
if time_left < datetime.timedelta(0):
1275
time_left = datetime.timedelta(0)
1276
tip += '\nTime left at work: {0}'.format(
1277
format_duration(time_left))
1282
"""Main application window."""
1284
# Initial view mode.
1285
chronological = True
1288
# URL to use for Help -> Online Documentation.
1289
help_url = "http://mg.pov.lt/gtimelog"
1291
def __init__(self, timelog, settings, tasks):
1292
"""Create the main window."""
1293
self.timelog = timelog
1294
self.settings = settings
1296
self.tray_icon = None
1297
self.last_tick = None
1298
self.footer_mark = None
1299
# Try to prevent timer routines mucking with the buffer while we're
1300
# mucking with the buffer. Not sure if it is necessary.
1302
self.chronological = settings.chronological
1303
self.entry_watchers = []
1307
"""Initialize the user interface."""
1308
builder = gtk.Builder()
1309
builder.add_from_file(ui_file)
1310
# Set initial state of menu items *before* we hook up signals
1311
chronological_menu_item = builder.get_object('chronological')
1312
chronological_menu_item.set_active(self.chronological)
1313
show_task_pane_item = builder.get_object('show_task_pane')
1314
show_task_pane_item.set_active(self.show_tasks)
1315
# Now hook up signals.
1316
builder.connect_signals(self)
1317
# Store references to UI elements we're going to need later
1318
self.app_indicator_menu = builder.get_object('app_indicator_menu')
1319
self.appind_show = builder.get_object('appind_show')
1320
self.tray_icon_popup_menu = builder.get_object('tray_icon_popup_menu')
1321
self.tray_show = builder.get_object('tray_show')
1322
self.tray_hide = builder.get_object('tray_hide')
1323
self.tray_show.hide()
1324
self.about_dialog = builder.get_object('about_dialog')
1325
self.about_dialog_ok_btn = builder.get_object('ok_button')
1326
self.about_dialog_ok_btn.connect('clicked', self.close_about_dialog)
1327
self.about_text = builder.get_object('about_text')
1328
self.about_text.set_markup(
1329
self.about_text.get_label() % dict(version=__version__))
1330
self.calendar_dialog = builder.get_object('calendar_dialog')
1331
self.calendar = builder.get_object('calendar')
1332
self.calendar.connect(
1333
'day_selected_double_click',
1334
self.on_calendar_day_selected_double_click)
1335
self.main_window = builder.get_object('main_window')
1336
self.main_window.connect('delete_event', self.delete_event)
1337
self.log_view = builder.get_object('log_view')
1338
self.set_up_log_view_columns()
1339
self.task_pane = builder.get_object('task_list_pane')
1340
if not self.show_tasks:
1341
self.task_pane.hide()
1342
self.task_pane_info_label = builder.get_object('task_pane_info_label')
1343
self.tasks.loading_callback = self.task_list_loading
1344
self.tasks.loaded_callback = self.task_list_loaded
1345
self.tasks.error_callback = self.task_list_error
1346
self.task_list = builder.get_object('task_list')
1347
self.task_store = gtk.TreeStore(str, str)
1348
self.task_list.set_model(self.task_store)
1349
column = gtk.TreeViewColumn('Task', gtk.CellRendererText(), text=0)
1350
self.task_list.append_column(column)
1351
self.task_list.connect('row_activated', self.task_list_row_activated)
1352
self.task_list_popup_menu = builder.get_object('task_list_popup_menu')
1353
self.task_list.connect_object(
1354
'button_press_event',
1355
self.task_list_button_press,
1356
self.task_list_popup_menu)
1357
task_list_edit_menu_item = builder.get_object('task_list_edit')
1358
if not self.settings.edit_task_list_cmd:
1359
task_list_edit_menu_item.set_sensitive(False)
1360
self.time_label = builder.get_object('time_label')
1361
self.task_entry = builder.get_object('task_entry')
1362
self.task_entry.connect('changed', self.task_entry_changed)
1363
self.task_entry.connect('key_press_event', self.task_entry_key_press)
1364
self.add_button = builder.get_object('add_button')
1365
self.add_button.connect('clicked', self.add_entry)
1366
buffer = self.log_view.get_buffer()
1367
self.log_buffer = buffer
1368
buffer.create_tag('today', foreground='blue')
1369
buffer.create_tag('duration', foreground='red')
1370
buffer.create_tag('time', foreground='green')
1371
buffer.create_tag('slacking', foreground='gray')
1372
self.set_up_task_list()
1373
self.set_up_completion()
1374
self.set_up_history()
1376
self.update_show_checkbox()
1378
gobject.timeout_add(1000, self.tick)
1380
def set_up_log_view_columns(self):
1381
"""Set up tab stops in the log view."""
1382
# we can't get a Pango context for unrealized widgets
1383
if not self.log_view.get_realized():
1384
self.log_view.realize()
1385
pango_context = self.log_view.get_pango_context()
1386
em = pango_context.get_font_description().get_size()
1387
tabs = pango_tabarray_new(2, False)
1388
tabs.set_tab(0, PANGO_ALIGN_LEFT, 9 * em)
1389
tabs.set_tab(1, PANGO_ALIGN_LEFT, 12 * em)
1390
self.log_view.set_tabs(tabs)
1392
def w(self, text, tag=None):
1393
"""Write some text at the end of the log buffer."""
1394
buffer = self.log_buffer
1396
buffer.insert_with_tags_by_name(buffer.get_end_iter(), text, tag)
1398
buffer.insert(buffer.get_end_iter(), text)
1400
def populate_log(self):
1401
"""Populate the log."""
1403
buffer = self.log_buffer
1405
if self.footer_mark is not None:
1406
buffer.delete_mark(self.footer_mark)
1407
self.footer_mark = None
1408
today = virtual_day(
1409
datetime.datetime.now(), self.timelog.virtual_midnight)
1410
today = today.strftime('%A, %Y-%m-%d (week %V)')
1411
self.w(today + '\n\n', 'today')
1412
if self.chronological:
1413
for item in self.timelog.window.all_entries():
1414
self.write_item(item)
1416
work, slack = self.timelog.window.grouped_entries()
1417
for start, entry, duration in work + slack:
1418
self.write_group(entry, duration)
1419
where = buffer.get_end_iter()
1420
where.backward_cursor_position()
1421
buffer.place_cursor(where)
1423
self.scroll_to_end()
1426
def delete_footer(self):
1427
buffer = self.log_buffer
1429
buffer.get_iter_at_mark(self.footer_mark), buffer.get_end_iter())
1430
buffer.delete_mark(self.footer_mark)
1431
self.footer_mark = None
1433
def add_footer(self):
1434
buffer = self.log_buffer
1435
self.footer_mark = buffer.create_mark(
1436
'footer', buffer.get_end_iter(), True)
1437
total_work, total_slacking = self.timelog.window.totals()
1438
weekly_window = self.weekly_window()
1439
week_total_work, week_total_slacking = weekly_window.totals()
1440
work_days_this_week = weekly_window.count_days()
1443
self.w('Total work done: ')
1444
self.w(format_duration(total_work), 'duration')
1446
self.w(format_duration(week_total_work), 'duration')
1447
self.w(' this week')
1448
if work_days_this_week:
1449
per_diem = week_total_work / work_days_this_week
1451
self.w(format_duration(per_diem), 'duration')
1454
self.w('Total slacking: ')
1455
self.w(format_duration(total_slacking), 'duration')
1457
self.w(format_duration(week_total_slacking), 'duration')
1458
self.w(' this week')
1459
if work_days_this_week:
1460
per_diem = week_total_slacking / work_days_this_week
1462
self.w(format_duration(per_diem), 'duration')
1465
time_left = self.time_left_at_work(total_work)
1466
if time_left is not None:
1467
time_to_leave = datetime.datetime.now() + time_left
1468
if time_left < datetime.timedelta(0):
1469
time_left = datetime.timedelta(0)
1470
self.w('Time left at work: ')
1471
self.w(format_duration(time_left), 'duration')
1473
self.w(time_to_leave.strftime('%H:%M'), 'time')
1476
if self.settings.show_office_hours:
1477
self.w('\nAt office today: ')
1478
hours = datetime.timedelta(hours=self.settings.hours)
1479
total = total_slacking + total_work
1480
self.w("%s " % format_duration(total), 'duration' )
1483
self.w(format_duration(total - hours), 'duration')
1486
self.w(format_duration(hours - total), 'duration')
1490
def time_left_at_work(self, total_work):
1491
"""Calculate time left to work."""
1492
last_time = self.timelog.window.last_time()
1493
if last_time is None:
1495
now = datetime.datetime.now()
1496
current_task = self.task_entry.get_text()
1497
current_task_time = now - last_time
1498
if '**' in current_task:
1499
total_time = total_work
1501
total_time = total_work + current_task_time
1502
return datetime.timedelta(hours=self.settings.hours) - total_time
1504
def write_item(self, item):
1505
buffer = self.log_buffer
1506
start, stop, duration, entry = item
1507
self.w(format_duration(duration), 'duration')
1508
period = '\t({0}-{1})\t'.format(
1509
start.strftime('%H:%M'), stop.strftime('%H:%M'))
1510
self.w(period, 'time')
1511
tag = ('slacking' if '**' in entry else None)
1512
self.w(entry + '\n', tag)
1513
where = buffer.get_end_iter()
1514
where.backward_cursor_position()
1515
buffer.place_cursor(where)
1517
def write_group(self, entry, duration):
1518
self.w(format_duration(duration), 'duration')
1519
tag = ('slacking' if '**' in entry else None)
1520
self.w('\t' + entry + '\n', tag)
1522
def scroll_to_end(self):
1523
buffer = self.log_view.get_buffer()
1524
end_mark = buffer.create_mark('end', buffer.get_end_iter())
1525
self.log_view.scroll_to_mark(end_mark, 0, False, 0, 0)
1526
buffer.delete_mark(end_mark)
1528
def set_up_task_list(self):
1529
"""Set up the task list pane."""
1530
self.task_store.clear()
1531
for group_name, group_items in self.tasks.groups:
1532
t = self.task_store.append(None, [group_name, group_name + ': '])
1533
for item in group_items:
1534
if group_name == self.tasks.other_title:
1537
task = group_name + ': ' + item
1538
self.task_store.append(t, [item, task])
1539
self.task_list.expand_all()
1541
def set_up_history(self):
1542
"""Set up history."""
1543
self.history = self.timelog.history
1544
self.filtered_history = []
1545
self.history_pos = 0
1546
self.history_undo = ''
1547
if not self.have_completion:
1550
self.completion_choices.clear()
1551
for entry in self.history:
1552
if entry not in seen:
1554
self.completion_choices.append([entry])
1556
def set_up_completion(self):
1557
"""Set up autocompletion."""
1558
if not self.settings.enable_gtk_completion:
1559
self.have_completion = False
1561
self.have_completion = hasattr(gtk, 'EntryCompletion')
1562
if not self.have_completion:
1564
self.completion_choices = gtk.ListStore(str)
1565
completion = gtk.EntryCompletion()
1566
completion.set_model(self.completion_choices)
1567
completion.set_text_column(0)
1568
self.task_entry.set_completion(completion)
1570
def add_history(self, entry):
1571
"""Add an entry to history."""
1572
self.history.append(entry)
1573
self.history_pos = 0
1574
if not self.have_completion:
1576
if entry not in [row[0] for row in self.completion_choices]:
1577
self.completion_choices.append([entry])
1579
def delete_event(self, widget, data=None):
1580
"""Try to close the window."""
1582
self.on_hide_activate()
1588
def close_about_dialog(self, widget):
1589
"""Ok clicked in the about dialog."""
1590
self.about_dialog.hide()
1592
def on_show_activate(self, widget=None):
1593
"""Tray icon menu -> Show selected"""
1594
self.main_window.present()
1595
self.tray_show.hide()
1596
self.tray_hide.show()
1597
self.update_show_checkbox()
1599
def on_hide_activate(self, widget=None):
1600
"""Tray icon menu -> Hide selected"""
1601
self.main_window.hide()
1602
self.tray_hide.hide()
1603
self.tray_show.show()
1604
self.update_show_checkbox()
1606
def update_show_checkbox(self):
1607
self.ignore_on_toggle_visible = True
1608
# This next line triggers both 'activate' and 'toggled' signals.
1609
self.appind_show.set_active(self.main_window.get_property('visible'))
1610
self.ignore_on_toggle_visible = False
1612
ignore_on_toggle_visible = False
1614
def on_toggle_visible(self, widget=None):
1615
"""Application indicator menu -> Show GTimeLog"""
1616
if not self.ignore_on_toggle_visible:
1617
self.toggle_visible()
1619
def toggle_visible(self):
1620
"""Toggle main window visibility."""
1621
if self.main_window.get_property('visible'):
1622
self.on_hide_activate()
1624
self.on_show_activate()
1626
def on_quit_activate(self, widget):
1627
"""File -> Quit selected"""
1630
def on_about_activate(self, widget):
1631
"""Help -> About selected"""
1632
self.about_dialog.show()
1634
def on_online_help_activate(self, widget):
1635
"""Help -> Online Documentation selected"""
1637
webbrowser.open(self.help_url)
1639
def on_chronological_activate(self, widget):
1640
"""View -> Chronological"""
1641
self.chronological = True
1644
def on_grouped_activate(self, widget):
1645
"""View -> Grouped"""
1646
self.chronological = False
1649
def on_daily_report_activate(self, widget):
1650
"""File -> Daily Report"""
1651
reports = Reports(self.timelog.window)
1652
self.mail(reports.daily_report)
1654
def on_yesterdays_report_activate(self, widget):
1655
"""File -> Daily Report for Yesterday"""
1656
max = self.timelog.window.min_timestamp
1657
min = max - datetime.timedelta(1)
1658
reports = Reports(self.timelog.window_for(min, max))
1659
self.mail(reports.daily_report)
1661
def on_previous_day_report_activate(self, widget):
1662
"""File -> Daily Report for a Previous Day"""
1663
day = self.choose_date()
1665
min = datetime.datetime.combine(
1666
day, self.timelog.virtual_midnight)
1667
max = min + datetime.timedelta(1)
1668
reports = Reports(self.timelog.window_for(min, max))
1669
self.mail(reports.daily_report)
1671
def choose_date(self):
1672
"""Pop up a calendar dialog.
1674
Returns either a datetime.date, or one.
1676
if self.calendar_dialog.run() == GTK_RESPONSE_OK:
1677
y, m1, d = self.calendar.get_date()
1678
day = datetime.date(y, m1+1, d)
1681
self.calendar_dialog.hide()
1684
def on_calendar_day_selected_double_click(self, widget):
1685
"""Double-click on a calendar day: close the dialog."""
1686
self.calendar_dialog.response(GTK_RESPONSE_OK)
1688
def weekly_window(self, day=None):
1690
day = self.timelog.day
1691
monday = day - datetime.timedelta(day.weekday())
1692
min = datetime.datetime.combine(monday,
1693
self.timelog.virtual_midnight)
1694
max = min + datetime.timedelta(7)
1695
window = self.timelog.window_for(min, max)
1698
def on_weekly_report_activate(self, widget):
1699
"""File -> Weekly Report"""
1700
day = self.timelog.day
1701
reports = Reports(self.weekly_window(day=day))
1702
if self.settings.report_style == 'plain':
1703
report = reports.weekly_report_plain
1704
elif self.settings.report_style == 'categorized':
1705
report = reports.weekly_report_categorized
1707
report = reports.weekly_report_plain
1710
def on_last_weeks_report_activate(self, widget):
1711
"""File -> Weekly Report for Last Week"""
1712
day = self.timelog.day - datetime.timedelta(7)
1713
reports = Reports(self.weekly_window(day=day))
1714
if self.settings.report_style == 'plain':
1715
report = reports.weekly_report_plain
1716
elif self.settings.report_style == 'categorized':
1717
report = reports.weekly_report_categorized
1719
report = reports.weekly_report_plain
1722
def on_previous_week_report_activate(self, widget):
1723
"""File -> Weekly Report for a Previous Week"""
1724
day = self.choose_date()
1726
reports = Reports(self.weekly_window(day=day))
1727
if self.settings.report_style == 'plain':
1728
report = reports.weekly_report_plain
1729
elif self.settings.report_style == 'categorized':
1730
report = reports.weekly_report_categorized
1732
report = reports.weekly_report_plain
1735
def monthly_window(self, day=None):
1737
day = self.timelog.day
1738
first_of_this_month = first_of_month(day)
1739
first_of_next_month = next_month(day)
1740
min = datetime.datetime.combine(
1741
first_of_this_month, self.timelog.virtual_midnight)
1742
max = datetime.datetime.combine(
1743
first_of_next_month, self.timelog.virtual_midnight)
1744
window = self.timelog.window_for(min, max)
1747
def on_previous_month_report_activate(self, widget):
1748
"""File -> Monthly Report for a Previous Month"""
1749
day = self.choose_date()
1751
reports = Reports(self.monthly_window(day=day))
1752
if self.settings.report_style == 'plain':
1753
report = reports.monthly_report_plain
1754
elif self.settings.report_style == 'categorized':
1755
report = reports.monthly_report_categorized
1757
report = reports.monthly_report_plain
1760
def on_last_month_report_activate(self, widget):
1761
"""File -> Monthly Report for Last Month"""
1762
day = self.timelog.day - datetime.timedelta(self.timelog.day.day)
1763
reports = Reports(self.monthly_window(day=day))
1764
if self.settings.report_style == 'plain':
1765
report = reports.monthly_report_plain
1766
elif self.settings.report_style == 'categorized':
1767
report = reports.monthly_report_categorized
1769
report = reports.monthly_report_plain
1772
def on_monthly_report_activate(self, widget):
1773
"""File -> Monthly Report"""
1774
reports = Reports(self.monthly_window())
1775
if self.settings.report_style == 'plain':
1776
report = reports.monthly_report_plain
1777
elif self.settings.report_style == 'categorized':
1778
report = reports.monthly_report_categorized
1780
report = reports.monthly_report_plain
1783
def on_open_complete_spreadsheet_activate(self, widget):
1784
"""Report -> Complete Report in Spreadsheet"""
1785
tempfn = tempfile.mktemp(suffix='gtimelog.csv') # XXX unsafe!
1786
with open(tempfn, 'w') as f:
1787
self.timelog.whole_history().to_csv_complete(f)
1788
self.spawn(self.settings.spreadsheet, tempfn)
1790
def on_open_slack_spreadsheet_activate(self, widget):
1791
"""Report -> Work/_Slacking stats in Spreadsheet"""
1792
tempfn = tempfile.mktemp(suffix='gtimelog.csv') # XXX unsafe!
1793
with open(tempfn, 'w') as f:
1794
self.timelog.whole_history().to_csv_daily(f)
1795
self.spawn(self.settings.spreadsheet, tempfn)
1797
def on_edit_timelog_activate(self, widget):
1798
"""File -> Edit timelog.txt"""
1799
self.spawn(self.settings.editor, '"%s"' % self.timelog.filename)
1801
def mail(self, write_draft):
1802
"""Send an email."""
1803
draftfn = tempfile.mktemp(suffix='gtimelog') # XXX unsafe!
1804
with codecs.open(draftfn, 'w', encoding='UTF-8') as draft:
1805
write_draft(draft, self.settings.email, self.settings.name)
1806
self.spawn(self.settings.mailer, draftfn)
1807
# XXX rm draftfn when done -- but how?
1809
def spawn(self, command, arg=None):
1810
"""Spawn a process in background"""
1811
# XXX shell-escape arg, please.
1814
command = command % arg
1816
command += ' ' + arg
1817
os.system(command + " &")
1819
def on_reread_activate(self, widget):
1820
"""File -> Reread"""
1821
self.timelog.reread()
1822
self.set_up_history()
1826
def on_show_task_pane_toggled(self, event):
1828
if self.task_pane.get_property('visible'):
1829
self.task_pane.hide()
1831
self.task_pane.show()
1833
def on_task_pane_close_button_activate(self, event, data=None):
1834
"""The close button next to the task pane title"""
1835
self.task_pane.hide()
1837
def task_list_row_activated(self, treeview, path, view_column):
1838
"""A task was selected in the task pane -- put it to the entry."""
1839
model = treeview.get_model()
1840
task = model[path][1]
1841
self.task_entry.set_text(task)
1843
self.task_entry.grab_focus()
1844
self.task_entry.set_position(-1)
1845
# There's a race here: sometimes the GDK_2BUTTON_PRESS signal is
1846
# handled _after_ row-activated, which makes the tree control steal
1847
# the focus back from the task entry. To avoid this, wait until all
1848
# the events have been handled.
1849
gobject.idle_add(grab_focus)
1851
def task_list_button_press(self, menu, event):
1852
if event.button == 3:
1853
menu.popup(None, None, None, event.button, event.time)
1858
def on_task_list_reload(self, event):
1860
self.set_up_task_list()
1862
def on_task_list_edit(self, event):
1863
self.spawn(self.settings.edit_task_list_cmd)
1865
def task_list_loading(self):
1866
self.task_list_loading_failed = False
1867
self.task_pane_info_label.set_text('Loading...')
1868
self.task_pane_info_label.show()
1869
# let the ui update become visible
1870
while gtk.events_pending():
1871
gtk.main_iteration()
1873
def task_list_error(self):
1874
self.task_list_loading_failed = True
1875
self.task_pane_info_label.set_text('Could not get task list.')
1876
self.task_pane_info_label.show()
1878
def task_list_loaded(self):
1879
if not self.task_list_loading_failed:
1880
self.task_pane_info_label.hide()
1882
def task_entry_changed(self, widget):
1883
"""Reset history position when the task entry is changed."""
1884
self.history_pos = 0
1886
def task_entry_key_press(self, widget, event):
1887
"""Handle key presses in task entry."""
1888
if event.keyval == gdk.keyval_from_name('Escape') and self.tray_icon:
1889
self.on_hide_activate()
1891
if event.keyval == gdk.keyval_from_name('Prior'):
1894
if event.keyval == gdk.keyval_from_name('Next'):
1895
self._do_history(-1)
1897
# XXX This interferes with the completion box. How do I determine
1898
# whether the completion box is visible or not?
1899
if self.have_completion:
1901
if event.keyval == gdk.keyval_from_name('Up'):
1904
if event.keyval == gdk.keyval_from_name('Down'):
1905
self._do_history(-1)
1909
def _do_history(self, delta):
1910
"""Handle movement in history."""
1911
if not self.history:
1913
if self.history_pos == 0:
1914
self.history_undo = self.task_entry.get_text()
1915
self.filtered_history = uniq([
1916
l for l in self.history if l.startswith(self.history_undo)])
1917
history = self.filtered_history
1918
new_pos = max(0, min(self.history_pos + delta, len(history)))
1920
self.task_entry.set_text(self.history_undo)
1921
self.task_entry.set_position(-1)
1923
self.task_entry.set_text(history[-new_pos])
1924
self.task_entry.select_region(0, -1)
1925
# Do this after task_entry_changed reset history_pos to 0
1926
self.history_pos = new_pos
1928
def add_entry(self, widget, data=None):
1929
"""Add the task entry to the log."""
1930
entry = self.task_entry.get_text()
1931
if not isinstance(entry, unicode):
1932
entry = unicode(entry, 'UTF-8')
1935
date_match = re.match(r'(\d\d):(\d\d)\s+', entry)
1936
delta_match = re.match(r'-([1-9]\d?|1\d\d)\s+', entry)
1938
h = int(date_match.group(1))
1939
m = int(date_match.group(2))
1940
if 0 <= h < 24 and 0 <= m <= 60:
1941
now = datetime.datetime.now()
1942
now = now.replace(hour=h, minute=m, second=0, microsecond=0)
1943
if self.timelog.valid_time(now):
1944
entry = entry[date_match.end():]
1948
seconds = int(delta_match.group()) * 60
1949
now = datetime.datetime.now().replace(second=0, microsecond=0)
1950
now += datetime.timedelta(seconds=seconds)
1951
if self.timelog.valid_time(now):
1952
entry = entry[delta_match.end():]
1958
self.add_history(entry)
1959
self.timelog.append(entry, now)
1960
if self.chronological:
1961
self.delete_footer()
1962
self.write_item(self.timelog.window.last_entry())
1964
self.scroll_to_end()
1967
self.task_entry.set_text('')
1968
self.task_entry.grab_focus()
1970
for watcher in self.entry_watchers:
1973
def tick(self, force_update=False):
1974
"""Tick every second."""
1975
if self.tasks.check_reload():
1976
self.set_up_task_list()
1977
now = datetime.datetime.now().replace(second=0, microsecond=0)
1978
if now == self.last_tick and not force_update:
1979
# Do not eat CPU unnecessarily.
1981
self.last_tick = now
1982
last_time = self.timelog.window.last_time()
1983
if last_time is None:
1984
self.time_label.set_text(now.strftime('%H:%M'))
1986
self.time_label.set_text(format_duration(now - last_time))
1987
# Update "time left to work"
1989
self.delete_footer()
1995
INTERFACE = 'lt.pov.mg.gtimelog.Service'
1996
OBJECT_PATH = '/lt/pov/mg/gtimelog/Service'
1997
SERVICE = 'lt.pov.mg.gtimelog.GTimeLog'
1999
class Service(dbus.service.Object):
2000
"""Our DBus service, used to communicate with the main instance."""
2002
def __init__(self, main_window):
2003
session_bus = dbus.SessionBus()
2004
connection = dbus.service.BusName(SERVICE, session_bus)
2005
dbus.service.Object.__init__(self, connection, OBJECT_PATH)
2007
self.main_window = main_window
2009
@dbus.service.method(INTERFACE)
2010
def ToggleFocus(self):
2011
self.main_window.toggle_visible()
2013
@dbus.service.method(INTERFACE)
2015
self.main_window.on_show_activate()
2017
@dbus.service.method(INTERFACE)
2023
"""Run the program."""
2024
parser = optparse.OptionParser(usage='%prog [options]')
2025
parser.add_option('--sample-config', action='store_true',
2026
help="write a sample configuration file to 'gtimelogrc.sample'")
2027
parser.add_option('--ignore-dbus', action='store_true',
2028
help="do not check if GTimeLog is already running")
2029
parser.add_option('--replace', action='store_true',
2030
help="replace the already running GTimeLog instance")
2031
parser.add_option('--toggle', action='store_true',
2032
help="show/hide the GTimeLog window if already running")
2033
parser.add_option('--tray', action='store_true',
2034
help="start minimized")
2036
opts, args = parser.parse_args()
2038
if opts.sample_config:
2039
settings = Settings()
2040
settings.save("gtimelogrc.sample")
2041
print "Sample configuration file written to gtimelogrc.sample"
2042
print "Edit it and save as %s" % settings.get_config_file()
2045
if opts.ignore_dbus:
2049
# Let's check if there is already an instance of GTimeLog running
2050
# and if it is make it present itself or when it is already presented
2051
# hide it and then quit.
2053
dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
2056
session_bus = dbus.SessionBus()
2057
dbus_service = session_bus.get_object(SERVICE, OBJECT_PATH)
2059
print 'gtimelog: Telling the already-running instance to quit'
2062
dbus_service.ToggleFocus()
2063
print 'gtimelog: Already running, toggling visibility'
2066
print 'gtimelog: Already running, not doing anything'
2069
dbus_service.Present()
2070
print 'gtimelog: Already running, presenting main window'
2072
except dbus.DBusException, e:
2073
if e.get_dbus_name() == 'org.freedesktop.DBus.Error.ServiceUnknown':
2074
# gtimelog is not running: that's fine and not an error at all
2077
sys.exit('gtimelog: %s' % e)
2079
settings = Settings()
2080
configdir = settings.get_config_dir()
2082
# Create it if it doesn't exist.
2083
os.makedirs(configdir)
2084
except OSError as error:
2085
if error.errno != errno.EEXIST:
2086
# XXX: not the most friendly way of error reporting for a GUI app
2088
settings_file = settings.get_config_file()
2089
if not os.path.exists(settings_file):
2090
settings.save(settings_file)
2092
settings.load(settings_file)
2093
timelog = TimeLog(os.path.join(configdir, 'timelog.txt'),
2094
settings.virtual_midnight)
2095
if settings.task_list_url:
2096
tasks = RemoteTaskList(settings.task_list_url,
2097
os.path.join(configdir, 'remote-tasks.txt'))
2099
tasks = TaskList(os.path.join(configdir, 'tasks.txt'))
2100
main_window = MainWindow(timelog, settings, tasks)
2101
start_in_tray = False
2102
if settings.show_tray_icon:
2103
if settings.prefer_app_indicator:
2104
icons = [AppIndicator, SimpleStatusIcon, OldTrayIcon]
2105
elif settings.prefer_old_tray_icon:
2106
icons = [OldTrayIcon, SimpleStatusIcon, AppIndicator]
2108
icons = [SimpleStatusIcon, OldTrayIcon, AppIndicator]
2109
for icon_class in icons:
2110
tray_icon = icon_class(main_window)
2111
if tray_icon.available():
2112
start_in_tray = (settings.start_in_tray
2113
if settings.start_in_tray
2115
break # found one that works
2116
if not start_in_tray:
2117
main_window.on_show_activate()
2119
service = Service(main_window)
2120
# This is needed to make ^C terminate gtimelog when we're using
2121
# gobject-introspection.
2122
signal.signal(signal.SIGINT, signal.SIG_DFL)
2125
except KeyboardInterrupt:
2129
if __name__ == '__main__':