~menesis/ubuntu/maverick/gtimelog/maverick

« back to all changes in this revision

Viewing changes to gtimelog.py

  • Committer: Bazaar Package Importer
  • Author(s): Barry Warsaw
  • Date: 2010-09-10 10:12:03 UTC
  • mfrom: (1.1.3 upstream)
  • Revision ID: james.westby@ubuntu.com-20100910101203-r7epgzni3537hkxw
Tags: 0.4.0-0ubuntu1
New packaging for new upstream.

Show diffs side-by-side

added added

removed removed

Lines of Context:
1
 
#!/usr/bin/python
2
 
"""
3
 
A Gtk+ application for keeping track of time.
4
 
 
5
 
$Id: gtimelog.py 85 2007-11-10 17:23:00Z mg $
6
 
"""
7
 
 
8
 
import re
9
 
import os
10
 
import csv
11
 
import sys
12
 
import sets
13
 
import urllib
14
 
import datetime
15
 
import tempfile
16
 
import ConfigParser
17
 
 
18
 
import pygtk
19
 
pygtk.require('2.0')
20
 
import gobject
21
 
import gtk
22
 
import gtk.glade
23
 
import pango
24
 
 
25
 
 
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")
30
 
 
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"
36
 
 
37
 
 
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
41
 
 
42
 
 
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)
47
 
 
48
 
 
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)
53
 
 
54
 
 
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)
58
 
    if h and m:
59
 
        return '%d hour%s %d min' % (h, h != 1 and "s" or "", m)
60
 
    elif h:
61
 
        return '%d hour%s' % (h, h != 1 and "s" or "")
62
 
    else:
63
 
        return '%d min' % m
64
 
 
65
 
 
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)
69
 
    if not m:
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)
73
 
 
74
 
 
75
 
def parse_time(t):
76
 
    """Parse a time instance from 'HH:MM' formatted string."""
77
 
    m = re.match(r'^(\d+):(\d+)$', t)
78
 
    if not m:
79
 
        raise ValueError('bad time: ', t)
80
 
    hour, min = map(int, m.groups())
81
 
    return datetime.time(hour, min)
82
 
 
83
 
 
84
 
def virtual_day(dt, virtual_midnight):
85
 
    """Return the "virtual day" of a timestamp.
86
 
 
87
 
    Timestamps between midnight and "virtual midnight" (e.g. 2 am) are
88
 
    assigned to the previous "virtual day".
89
 
    """
90
 
    if dt.time() < virtual_midnight:     # assign to previous day
91
 
        return dt.date() - datetime.timedelta(1)
92
 
    return dt.date()
93
 
 
94
 
 
95
 
def different_days(dt1, dt2, virtual_midnight):
96
 
    """Check whether dt1 and dt2 are on different "virtual days".
97
 
 
98
 
    See virtual_day().
99
 
    """
100
 
    return virtual_day(dt1, virtual_midnight) != virtual_day(dt2,
101
 
                                                             virtual_midnight)
102
 
 
103
 
 
104
 
def first_of_month(date):
105
 
    """Return the first day of the month for a given date."""
106
 
    return date.replace(day=1)
107
 
 
108
 
 
109
 
def next_month(date):
110
 
    """Return the first day of the next month."""
111
 
    if date.month == 12:
112
 
        return datetime.date(date.year + 1, 1, 1)
113
 
    else:
114
 
        return datetime.date(date.year, date.month + 1, 1)
115
 
 
116
 
 
117
 
def uniq(l):
118
 
    """Return list with consecutive duplicates removed."""
119
 
    result = l[:1]
120
 
    for item in l[1:]:
121
 
        if item != result[-1]:
122
 
            result.append(item)
123
 
    return result
124
 
 
125
 
 
126
 
class TimeWindow(object):
127
 
    """A window into a time log.
128
 
 
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.
132
 
 
133
 
    self.items is a list of (timestamp, event_title) tuples.
134
 
 
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.
138
 
 
139
 
    The first event also creates a special "arrival" entry of zero duration.
140
 
 
141
 
    Entries that span virtual midnight boundaries are also converted to
142
 
    "arrival" entries at their end point.
143
 
 
144
 
    The earliest_timestamp attribute contains the first (which should be the
145
 
    oldest) timestamp in the file.
146
 
    """
147
 
 
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)
155
 
 
156
 
    def reread(self, callback=None):
157
 
        """Parse the time log file and update self.items.
158
 
 
159
 
        Also updates self.earliest_timestamp.
160
 
        """
161
 
        self.items = []
162
 
        self.earliest_timestamp = None
163
 
        try:
164
 
            f = open(self.filename)
165
 
        except IOError:
166
 
            return
167
 
        line = ''
168
 
        for line in f:
169
 
            if ': ' not in line:
170
 
                continue
171
 
            time, entry = line.split(': ', 1)
172
 
            try:
173
 
                time = parse_datetime(time)
174
 
            except ValueError:
175
 
                continue
176
 
            else:
177
 
                entry = entry.strip()
178
 
                if callback:
179
 
                    callback(entry)
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))
184
 
        f.close()
185
 
 
186
 
    def last_time(self):
187
 
        """Return the time of the last event (or None if there are no events).
188
 
        """
189
 
        if not self.items:
190
 
            return None
191
 
        return self.items[-1][0]
192
 
 
193
 
    def all_entries(self):
194
 
        """Iterate over all entries.
195
 
 
196
 
        Yields (start, stop, duration, entry) tuples.  The first entry
197
 
        has a duration of 0.
198
 
        """
199
 
        stop = None
200
 
        for item in self.items:
201
 
            start = stop
202
 
            stop = item[0]
203
 
            entry = item[1]
204
 
            if start is None or different_days(start, stop,
205
 
                                               self.virtual_midnight):
206
 
                start = stop
207
 
            duration = stop - start
208
 
            yield start, stop, duration, entry
209
 
 
210
 
    def count_days(self):
211
 
        """Count days that have entries."""
212
 
        count = 0
213
 
        last = None
214
 
        for start, stop, duration, entry in self.all_entries():
215
 
            if last is None or different_days(last, start,
216
 
                                              self.virtual_midnight):
217
 
                last = start
218
 
                count += 1
219
 
        return count
220
 
 
221
 
    def last_entry(self):
222
 
        """Return the last entry (or None if there are no events).
223
 
 
224
 
        It is always true that
225
 
 
226
 
            self.last_entry() == list(self.all_entries())[-1]
227
 
 
228
 
        """
229
 
        if not self.items:
230
 
            return None
231
 
        stop = self.items[-1][0]
232
 
        entry = self.items[-1][1]
233
 
        if len(self.items) == 1:
234
 
            start = stop
235
 
        else:
236
 
            start = self.items[-2][0]
237
 
        if different_days(start, stop, self.virtual_midnight):
238
 
            start = stop
239
 
        duration = stop - start
240
 
        return start, stop, duration, entry
241
 
 
242
 
    def grouped_entries(self, skip_first=True):
243
 
        """Return consolidated entries (grouped by entry title).
244
 
 
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.
248
 
        """
249
 
        work = {}
250
 
        slack = {}
251
 
        for start, stop, duration, entry in self.all_entries():
252
 
            if skip_first:
253
 
                skip_first = False
254
 
                continue
255
 
            if '**' in entry:
256
 
                entries = slack
257
 
            else:
258
 
                entries = work
259
 
            if entry in 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)
264
 
        work = work.values()
265
 
        work.sort()
266
 
        slack = slack.values()
267
 
        slack.sort()
268
 
        return work, slack
269
 
 
270
 
    def totals(self):
271
 
        """Calculate total time of work and slacking entries.
272
 
 
273
 
        Returns (total_work, total_slacking) tuple.
274
 
 
275
 
        Slacking entries are identified by finding two asterisks in the title.
276
 
 
277
 
        Assuming that
278
 
 
279
 
            total_work, total_slacking = self.totals()
280
 
            work, slacking = self.grouped_entries()
281
 
 
282
 
        It is always true that
283
 
 
284
 
            total_work = sum([duration for start, entry, duration in work])
285
 
            total_slacking = sum([duration
286
 
                                  for start, entry, duration in slacking])
287
 
 
288
 
        (that is, it would be true if sum could operate on timedeltas).
289
 
        """
290
 
        total_work = total_slacking = datetime.timedelta(0)
291
 
        for start, stop, duration, entry in self.all_entries():
292
 
            if '**' in entry:
293
 
                total_slacking += duration
294
 
            else:
295
 
                total_work += duration
296
 
        return total_work, total_slacking
297
 
 
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"
303
 
        try:
304
 
            import socket
305
 
            idhost = socket.getfqdn()
306
 
        except: # can it actually ever fail?
307
 
            idhost = 'localhost'
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('\\', '\\\\')
313
 
                                                  .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"
320
 
 
321
 
    def to_csv(self, output, title_row=True):
322
 
        """Export work entries to a CSV file.
323
 
 
324
 
        The file has two columns: task title and time (in minutes).
325
 
        """
326
 
        writer = csv.writer(output)
327
 
        if title_row:
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
333
 
        work.sort()
334
 
        writer.writerows(work)
335
 
 
336
 
    def daily_report(self, output, email, who):
337
 
        """Format a daily report.
338
 
 
339
 
        Writes a daily report template in RFC-822 format to output.
340
 
        """
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})
351
 
        print >> output
352
 
        items = list(self.all_entries())
353
 
        if not items:
354
 
            print >> output, "No work done today."
355
 
            return
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'))
359
 
        print >> output
360
 
        work, slack = self.grouped_entries()
361
 
        total_work, total_slacking = self.totals()
362
 
        if work:
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))
367
 
            print >> output
368
 
        print >> output, ("Total work done: %s" %
369
 
                          format_duration_long(total_work))
370
 
        print >> output
371
 
        if slack:
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))
376
 
            print >> output
377
 
        print >> output, ("Time spent slacking: %s" %
378
 
                          format_duration_long(total_slacking))
379
 
 
380
 
    def weekly_report(self, output, email, who, estimated_column=False):
381
 
        """Format a weekly report.
382
 
 
383
 
        Writes a weekly report template in RFC-822 format to output.
384
 
        """
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,
388
 
                                                                      week)
389
 
        print >> output
390
 
        items = list(self.all_entries())
391
 
        if not items:
392
 
            print >> output, "No work done this week."
393
 
            return
394
 
        print >> output, " " * 46,
395
 
        if estimated_column:
396
 
            print >> output, "estimated       actual"
397
 
        else:
398
 
            print >> output, "                time"
399
 
        work, slack = self.grouped_entries()
400
 
        total_work, total_slacking = self.totals()
401
 
        if work:
402
 
            work = [(entry, duration) for start, entry, duration in work]
403
 
            work.sort()
404
 
            for entry, duration in work:
405
 
                if not duration:
406
 
                    continue # skip empty "arrival" entries
407
 
                entry = entry[:1].upper() + entry[1:]
408
 
                if estimated_column:
409
 
                    print >> output, (u"%-46s  %-14s  %s" %
410
 
                                (entry, '-', format_duration_long(duration)))
411
 
                else:
412
 
                    print >> output, (u"%-62s  %s" %
413
 
                                (entry, format_duration_long(duration)))
414
 
            print >> output
415
 
        print >> output, ("Total work done this week: %s" %
416
 
                          format_duration_long(total_work))
417
 
 
418
 
    def monthly_report(self, output, email, who):
419
 
        """Format a monthly report.
420
 
 
421
 
        Writes a monthly report template in RFC-822 format to output.
422
 
        """
423
 
 
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)
427
 
        print >> output
428
 
 
429
 
        items = list(self.all_entries())
430
 
        if not items:
431
 
            print >> output, "No work done this month."
432
 
            return
433
 
 
434
 
        print >> output, " " * 46
435
 
 
436
 
        work, slack = self.grouped_entries()
437
 
        total_work, total_slacking = self.totals()
438
 
        categories = {}
439
 
 
440
 
        if work:
441
 
            work = [(entry, duration) for start, entry, duration in work]
442
 
            work.sort()
443
 
            for entry, duration in work:
444
 
                if not duration:
445
 
                    continue # skip empty "arrival" entries
446
 
 
447
 
                if ': ' in entry:
448
 
                    cat, task = entry.split(': ', 1)
449
 
                    categories[cat] = categories.get(
450
 
                        cat, datetime.timedelta(0)) + duration
451
 
                else:
452
 
                    categories[None] = categories.get(
453
 
                        None, datetime.timedelta(0)) + duration
454
 
 
455
 
                entry = entry[:1].upper() + entry[1:]
456
 
                print >> output, (u"%-62s  %s" %
457
 
                    (entry, format_duration_long(duration)))
458
 
            print >> output
459
 
 
460
 
        print >> output, ("Total work done this month: %s" %
461
 
                          format_duration_long(total_work))
462
 
 
463
 
        if categories:
464
 
            print >> output
465
 
            print >> output, "By category:"
466
 
            print >> output
467
 
 
468
 
            items = categories.items()
469
 
            items.sort()
470
 
            for cat, duration in items:
471
 
                if not cat:
472
 
                    continue
473
 
 
474
 
                print >> output, u"%-62s  %s" % (
475
 
                    cat, format_duration_long(duration))
476
 
 
477
 
            if None in categories:
478
 
                print >> output, u"%-62s  %s" % (
479
 
                    '(none)', format_duration_long(categories[None]))
480
 
            print >> output
481
 
 
482
 
 
483
 
class TimeLog(object):
484
 
    """Time log.
485
 
 
486
 
    A time log contains a time window for today, and can add new entries at
487
 
    the end.
488
 
    """
489
 
 
490
 
    def __init__(self, filename, virtual_midnight):
491
 
        self.filename = filename
492
 
        self.virtual_midnight = virtual_midnight
493
 
        self.reread()
494
 
 
495
 
    def reread(self):
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)
500
 
        self.history = []
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
505
 
 
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)
509
 
 
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())
516
 
 
517
 
    def raw_append(self, line):
518
 
        """Append a line to the time log file."""
519
 
        f = open(self.filename, "a")
520
 
        if self.need_space:
521
 
            self.need_space = False
522
 
            print >> f
523
 
        print >> f, line
524
 
        f.close()
525
 
 
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
532
 
            self.reread()
533
 
        self.window.items.append((now, entry))
534
 
        line = '%s: %s' % (now.strftime("%Y-%m-%d %H:%M"), entry)
535
 
        self.raw_append(line)
536
 
 
537
 
 
538
 
class TaskList(object):
539
 
    """Task list.
540
 
 
541
 
    You can have a list of common tasks in a text file that looks like this
542
 
 
543
 
        Arrived **
544
 
        Reading mail
545
 
        Project1: do some task
546
 
        Project2: do some other task
547
 
        Project1: do yet another task
548
 
 
549
 
    These tasks are grouped by their common prefix (separated with ':').
550
 
    Tasks without a ':' are grouped under "Other".
551
 
 
552
 
    A TaskList has an attribute 'groups' which is a list of tuples
553
 
    (group_name, list_of_group_items).
554
 
    """
555
 
 
556
 
    other_title = 'Other'
557
 
 
558
 
    loading_callback = None
559
 
    loaded_callback = None
560
 
    error_callback = None
561
 
 
562
 
    def __init__(self, filename):
563
 
        self.filename = filename
564
 
        self.load()
565
 
 
566
 
    def check_reload(self):
567
 
        """Look at the mtime of tasks.txt, and reload it if necessary.
568
 
 
569
 
        Returns True if the file was reloaded.
570
 
        """
571
 
        mtime = self.get_mtime()
572
 
        if mtime != self.last_mtime:
573
 
            self.load()
574
 
            return True
575
 
        else:
576
 
            return False
577
 
 
578
 
    def get_mtime(self):
579
 
        """Return the mtime of self.filename, or None if the file doesn't exist."""
580
 
        try:
581
 
            return os.stat(self.filename).st_mtime
582
 
        except OSError:
583
 
            return None
584
 
 
585
 
    def load(self):
586
 
        """Load task list from a file named self.filename."""
587
 
        groups = {}
588
 
        self.last_mtime = self.get_mtime()
589
 
        try:
590
 
            for line in file(self.filename):
591
 
                line = line.strip()
592
 
                if not line or line.startswith('#'):
593
 
                    continue
594
 
                if ':' in line:
595
 
                    group, task = [s.strip() for s in line.split(':', 1)]
596
 
                else:
597
 
                    group, task = self.other_title, line
598
 
                groups.setdefault(group, []).append(task)
599
 
        except IOError:
600
 
            pass # the file's not there, so what?
601
 
        self.groups = groups.items()
602
 
        self.groups.sort()
603
 
 
604
 
    def reload(self):
605
 
        """Reload the task list."""
606
 
        self.load()
607
 
 
608
 
 
609
 
class RemoteTaskList(TaskList):
610
 
    """Task list stored on a remote server.
611
 
 
612
 
    Keeps a cached copy of the list in a local file, so you can use it offline.
613
 
    """
614
 
 
615
 
    def __init__(self, url, cache_filename):
616
 
        self.url = url
617
 
        TaskList.__init__(self, cache_filename)
618
 
        self.first_time = True
619
 
 
620
 
    def check_reload(self):
621
 
        """Check whether the task list needs to be reloaded.
622
 
 
623
 
        Download the task list if this is the first time, and a cached copy is
624
 
        not found.
625
 
 
626
 
        Returns True if the file was reloaded.
627
 
        """
628
 
        if self.first_time:
629
 
            self.first_time = False
630
 
            if not os.path.exists(self.filename):
631
 
                self.download()
632
 
                return True
633
 
        return TaskList.check_reload(self)
634
 
 
635
 
    def download(self):
636
 
        """Download the task list from the server."""
637
 
        if self.loading_callback:
638
 
            self.loading_callback()
639
 
        try:
640
 
            urllib.urlretrieve(self.url, self.filename)
641
 
        except IOError:
642
 
            if self.error_callback:
643
 
                self.error_callback()
644
 
        self.load()
645
 
        if self.loaded_callback:
646
 
            self.loaded_callback()
647
 
 
648
 
    def reload(self):
649
 
        """Reload the task list."""
650
 
        self.download()
651
 
 
652
 
 
653
 
class Settings(object):
654
 
    """Configurable settings for GTimeLog."""
655
 
 
656
 
    # Insane defaults
657
 
    email = 'activity-list@example.com'
658
 
    name = 'Anonymous'
659
 
 
660
 
    editor = 'gvim'
661
 
    mailer = 'x-terminal-emulator -e mutt -H %s'
662
 
    spreadsheet = 'oocalc %s'
663
 
 
664
 
    enable_gtk_completion = True  # False enables gvim-style completion
665
 
 
666
 
    hours = 8
667
 
    virtual_midnight = datetime.time(2, 0)
668
 
 
669
 
    task_list_url = ''
670
 
    edit_task_list_cmd = ''
671
 
 
672
 
    def _config(self):
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)
687
 
        return config
688
 
 
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',
698
 
                                                       'gtk-completion')
699
 
        self.hours = config.getfloat('gtimelog', 'hours')
700
 
        self.virtual_midnight = parse_time(config.get('gtimelog',
701
 
                                                      'virtual_midnight'))
702
 
        self.task_list_url = config.get('gtimelog', 'task_list_url')
703
 
        self.edit_task_list_cmd = config.get('gtimelog', 'edit_task_list_cmd')
704
 
 
705
 
    def save(self, filename):
706
 
        config = self._config()
707
 
        f = file(filename, 'w')
708
 
        try:
709
 
            config.write(f)
710
 
        finally:
711
 
            f.close()
712
 
 
713
 
 
714
 
class TrayIcon(object):
715
 
    """Tray icon for gtimelog."""
716
 
 
717
 
    def __init__(self, gtimelog_window):
718
 
        self.gtimelog_window = gtimelog_window
719
 
        self.timelog = gtimelog_window.timelog
720
 
        self.trayicon = None
721
 
        try:
722
 
            import egg.trayicon
723
 
        except ImportError:
724
 
            return # nothing to do here, move along
725
 
                   # or install python-gnome2-extras
726
 
        self.tooltips = gtk.Tooltips()
727
 
        self.eventbox = gtk.EventBox()
728
 
        hbox = gtk.HBox()
729
 
        icon = gtk.Image()
730
 
        icon.set_from_file(icon_file)
731
 
        hbox.add(icon)
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)
746
 
 
747
 
    def on_press(self, widget, event):
748
 
        """A mouse button was pressed on the tray icon label."""
749
 
        if event.button != 3:
750
 
            return
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()
755
 
        else:
756
 
            self.gtimelog_window.tray_show.show()
757
 
            self.gtimelog_window.tray_hide.hide()
758
 
        widget.popup(None, None, None, event.button, event.time)
759
 
 
760
 
    def on_release(self, widget, event):
761
 
        """A mouse button was released on the tray icon label."""
762
 
        if event.button != 1:
763
 
            return
764
 
        main_window = self.gtimelog_window.main_window
765
 
        if main_window.get_property("visible"):
766
 
           main_window.hide()
767
 
        else:
768
 
           main_window.present()
769
 
 
770
 
    def entry_added(self, entry):
771
 
        """An entry has been added."""
772
 
        self.tick(force_update=True)
773
 
 
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
778
 
            self.last_tick = now
779
 
            last_time = self.timelog.window.last_time()
780
 
            if last_time is None:
781
 
                self.time_label.set_text(now.strftime("%H:%M"))
782
 
            else:
783
 
                self.time_label.set_text(format_duration_short(now - last_time))
784
 
        self.tooltips.set_tip(self.trayicon, self.tip())
785
 
        return True
786
 
 
787
 
    def tip(self):
788
 
        """Compute tooltip text."""
789
 
        current_task = self.gtimelog_window.task_entry.get_text()
790
 
        if not current_task: 
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)
800
 
        return tip
801
 
 
802
 
 
803
 
class MainWindow(object):
804
 
    """Main application window."""
805
 
 
806
 
    chronological = True
807
 
    footer_mark = None
808
 
 
809
 
    # Try to prevent timer routines mucking with the buffer while we're
810
 
    # mucking with the buffer.  Not sure if it is necessary.
811
 
    lock = False
812
 
 
813
 
    def __init__(self, timelog, settings, tasks):
814
 
        """Create the main window."""
815
 
        self.timelog = timelog
816
 
        self.settings = settings
817
 
        self.tasks = tasks
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()
868
 
        self.populate_log()
869
 
        self.tick(True)
870
 
        gobject.timeout_add(1000, self.tick)
871
 
 
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)
880
 
 
881
 
    def w(self, text, tag=None):
882
 
        """Write some text at the end of the log buffer."""
883
 
        buffer = self.log_buffer
884
 
        if tag:
885
 
            buffer.insert_with_tags_by_name(buffer.get_end_iter(), text, tag)
886
 
        else:
887
 
            buffer.insert(buffer.get_end_iter(), text)
888
 
 
889
 
    def populate_log(self):
890
 
        """Populate the log."""
891
 
        self.lock = True
892
 
        buffer = self.log_buffer
893
 
        buffer.set_text("")
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)
904
 
        else:
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)
911
 
        self.add_footer()
912
 
        self.scroll_to_end()
913
 
        self.lock = False
914
 
 
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
921
 
 
922
 
    def add_footer(self):
923
 
        buffer = self.log_buffer
924
 
        self.footer_mark = buffer.create_mark('footer', buffer.get_end_iter(),
925
 
                                              True)
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()
930
 
 
931
 
        self.w('\n')
932
 
        self.w('Total work done: ')
933
 
        self.w(format_duration(total_work), 'duration')
934
 
        self.w(' (')
935
 
        self.w(format_duration(week_total_work), 'duration')
936
 
        self.w(' this week')
937
 
        if work_days_this_week:
938
 
            per_diem = week_total_work / work_days_this_week
939
 
            self.w(', ')
940
 
            self.w(format_duration(per_diem), 'duration')
941
 
            self.w(' per day')
942
 
        self.w(')\n')
943
 
        self.w('Total slacking: ')
944
 
        self.w(format_duration(total_slacking), 'duration')
945
 
        self.w(' (')
946
 
        self.w(format_duration(week_total_slacking), 'duration')
947
 
        self.w(' this week')
948
 
        if work_days_this_week:
949
 
            per_diem = week_total_slacking / work_days_this_week
950
 
            self.w(', ')
951
 
            self.w(format_duration(per_diem), 'duration')
952
 
            self.w(' per day')
953
 
        self.w(')\n')
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')
961
 
            self.w(' (till ')
962
 
            self.w(time_to_leave.strftime('%H:%M'), 'time')
963
 
            self.w(')')
964
 
 
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:
969
 
            return 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
975
 
        else:
976
 
            total_time = total_work + current_task_time
977
 
        return datetime.timedelta(hours=self.settings.hours) - total_time
978
 
 
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)
991
 
 
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)
996
 
 
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)
1002
 
 
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:
1010
 
                    task = item
1011
 
                else:
1012
 
                    task = group_name + ': ' + item
1013
 
                self.task_store.append(t, [item, task])
1014
 
        self.task_list.expand_all()
1015
 
 
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:
1023
 
            return
1024
 
        seen = sets.Set()
1025
 
        for entry in self.history:
1026
 
            if entry not in seen:
1027
 
                seen.add(entry)
1028
 
                self.completion_choices.append([entry])
1029
 
 
1030
 
    def set_up_completion(self):
1031
 
        """Set up autocompletion."""
1032
 
        if not self.settings.enable_gtk_completion:
1033
 
            self.have_completion = False
1034
 
            return
1035
 
        self.have_completion = hasattr(gtk, 'EntryCompletion')
1036
 
        if not self.have_completion:
1037
 
            return
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)
1043
 
 
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:
1049
 
            return
1050
 
        if entry not in [row[0] for row in self.completion_choices]:
1051
 
            self.completion_choices.append([entry])
1052
 
 
1053
 
    def delete_event(self, widget, data=None):
1054
 
        """Try to close the window."""
1055
 
        gtk.main_quit()
1056
 
        return False
1057
 
 
1058
 
    def close_about_dialog(self, widget):
1059
 
        """Ok clicked in the about dialog."""
1060
 
        self.about_dialog.hide()
1061
 
 
1062
 
    def on_show_activate(self, widget):
1063
 
        """Tray icon menu -> Show selected"""
1064
 
        self.main_window.present()
1065
 
 
1066
 
    def on_hide_activate(self, widget):
1067
 
        """Tray icon menu -> Hide selected"""
1068
 
        self.main_window.hide()
1069
 
 
1070
 
    def on_quit_activate(self, widget):
1071
 
        """File -> Quit selected"""
1072
 
        gtk.main_quit()
1073
 
 
1074
 
    def on_about_activate(self, widget):
1075
 
        """Help -> About selected"""
1076
 
        self.about_dialog.show()
1077
 
 
1078
 
    def on_chronological_activate(self, widget):
1079
 
        """View -> Chronological"""
1080
 
        self.chronological = True
1081
 
        self.populate_log()
1082
 
 
1083
 
    def on_grouped_activate(self, widget):
1084
 
        """View -> Grouped"""
1085
 
        self.chronological = False
1086
 
        self.populate_log()
1087
 
 
1088
 
    def on_daily_report_activate(self, widget):
1089
 
        """File -> Daily Report"""
1090
 
        window = self.timelog.window
1091
 
        self.mail(window.daily_report)
1092
 
 
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)
1099
 
 
1100
 
    def on_previous_day_report_activate(self, widget):
1101
 
        """File -> Daily Report for a Previous Day"""
1102
 
        day = self.choose_date()
1103
 
        if day:
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)
1109
 
 
1110
 
    def choose_date(self):
1111
 
        """Pop up a calendar dialog.
1112
 
 
1113
 
        Returns either a datetime.date, or one.
1114
 
        """
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)
1118
 
        else:
1119
 
            day = None
1120
 
        self.calendar_dialog.hide()
1121
 
        return day
1122
 
 
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)
1126
 
 
1127
 
    def weekly_window(self, day=None):
1128
 
        if not day:
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)
1135
 
        return window
1136
 
 
1137
 
    def on_weekly_report_activate(self, widget):
1138
 
        """File -> Weekly Report"""
1139
 
        window = self.weekly_window()
1140
 
        self.mail(window.weekly_report)
1141
 
 
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)
1147
 
 
1148
 
    def on_previous_week_report_activate(self, widget):
1149
 
        """File -> Weekly Report for a Previous Week"""
1150
 
        day = self.choose_date()
1151
 
        if day:
1152
 
            window = self.weekly_window(day=day)
1153
 
            self.mail(window.weekly_report)
1154
 
 
1155
 
    def monthly_window(self, day=None):
1156
 
        if not day:
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)
1165
 
        return window
1166
 
 
1167
 
    def on_previous_month_report_activate(self, widget):
1168
 
        """File -> Monthly Report for a Previous Month"""
1169
 
        day = self.choose_date()
1170
 
        if day:
1171
 
            window = self.monthly_window(day=day)
1172
 
            self.mail(window.monthly_report)
1173
 
 
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)
1179
 
 
1180
 
    def on_monthly_report_activate(self, widget):
1181
 
        """File -> Monthly Report"""
1182
 
        window = self.monthly_window()
1183
 
        self.mail(window.monthly_report)
1184
 
 
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)
1190
 
        f.close()
1191
 
        self.spawn(self.settings.spreadsheet, tempfn)
1192
 
 
1193
 
    def on_edit_timelog_activate(self, widget):
1194
 
        """File -> Edit timelog.txt"""
1195
 
        self.spawn(self.settings.editor, self.timelog.filename)
1196
 
 
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)
1202
 
        draft.close()
1203
 
        self.spawn(self.settings.mailer, draftfn)
1204
 
        # XXX rm draftfn when done -- but how?
1205
 
 
1206
 
    def spawn(self, command, arg=None):
1207
 
        """Spawn a process in background"""
1208
 
        # XXX shell-escape arg, please.
1209
 
        if arg is not None:
1210
 
            if '%s' in command:
1211
 
                command = command % arg
1212
 
            else:
1213
 
                command += ' ' + arg
1214
 
        os.system(command + " &")
1215
 
 
1216
 
    def on_reread_activate(self, widget):
1217
 
        """File -> Reread"""
1218
 
        self.timelog.reread()
1219
 
        self.set_up_history()
1220
 
        self.populate_log()
1221
 
        self.tick(True)
1222
 
 
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?
1231
 
 
1232
 
    def task_list_button_press(self, menu, event):
1233
 
        if event.button == 3:
1234
 
            menu.popup(None, None, None, event.button, event.time)
1235
 
            return True
1236
 
        else:
1237
 
            return False
1238
 
 
1239
 
    def on_task_list_reload(self, event):
1240
 
        self.tasks.reload()
1241
 
        self.set_up_task_list()
1242
 
 
1243
 
    def on_task_list_edit(self, event):
1244
 
        self.spawn(self.settings.edit_task_list_cmd)
1245
 
 
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()
1253
 
 
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()
1258
 
 
1259
 
    def task_list_loaded(self):
1260
 
        if not self.task_list_loading_failed:
1261
 
            self.task_pane_info_label.hide()
1262
 
 
1263
 
    def task_entry_changed(self, widget):
1264
 
        """Reset history position when the task entry is changed."""
1265
 
        self.history_pos = 0
1266
 
 
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'):
1270
 
            self._do_history(1)
1271
 
            return True
1272
 
        if event.keyval == gtk.gdk.keyval_from_name('Next'):
1273
 
            self._do_history(-1)
1274
 
            return True
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:
1278
 
            return False
1279
 
        if event.keyval == gtk.gdk.keyval_from_name('Up'):
1280
 
            self._do_history(1)
1281
 
            return True
1282
 
        if event.keyval == gtk.gdk.keyval_from_name('Down'):
1283
 
            self._do_history(-1)
1284
 
            return True
1285
 
        return False
1286
 
 
1287
 
    def _do_history(self, delta):
1288
 
        """Handle movement in history."""
1289
 
        if not self.history:
1290
 
            return
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)))
1297
 
        if new_pos == 0:
1298
 
            self.task_entry.set_text(self.history_undo)
1299
 
            self.task_entry.set_position(-1)
1300
 
        else:
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
1305
 
 
1306
 
    def add_entry(self, widget, data=None):
1307
 
        """Add the task entry to the log."""
1308
 
        entry = self.task_entry.get_text()
1309
 
        if not entry:
1310
 
            return
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())
1316
 
            self.add_footer()
1317
 
            self.scroll_to_end()
1318
 
        else:
1319
 
            self.populate_log()
1320
 
        self.task_entry.set_text("")
1321
 
        self.task_entry.grab_focus()
1322
 
        self.tick(True)
1323
 
        for watcher in self.entry_watchers:
1324
 
            watcher(entry)
1325
 
 
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
1333
 
            return True
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"))
1338
 
        else:
1339
 
            self.time_label.set_text(format_duration(now - last_time))
1340
 
            # Update "time left to work"
1341
 
            if not self.lock:
1342
 
                self.delete_footer()
1343
 
                self.add_footer()
1344
 
        return True
1345
 
 
1346
 
 
1347
 
def main():
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"
1353
 
        return
1354
 
 
1355
 
    configdir = os.path.expanduser('~/.gtimelog')
1356
 
    try:
1357
 
        os.makedirs(configdir) # create it if it doesn't exist
1358
 
    except OSError:
1359
 
        pass
1360
 
    settings = Settings()
1361
 
    settings_file = os.path.join(configdir, 'gtimelogrc') 
1362
 
    if not os.path.exists(settings_file):
1363
 
        settings.save(settings_file)
1364
 
    else:
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'))
1371
 
    else:
1372
 
        tasks = TaskList(os.path.join(configdir, 'tasks.txt'))
1373
 
    main_window = MainWindow(timelog, settings, tasks)
1374
 
    tray_icon = TrayIcon(main_window)
1375
 
    try:
1376
 
        gtk.main()
1377
 
    except KeyboardInterrupt:
1378
 
        pass
1379
 
 
1380
 
if __name__ == '__main__':
1381
 
    main()