~ubuntu-branches/ubuntu/quantal/gtimelog/quantal

« back to all changes in this revision

Viewing changes to .pc/bug-1016212.patch/src/gtimelog/main.py

  • Committer: Package Import Robot
  • Author(s): Barry Warsaw
  • Date: 2012-08-13 13:30:21 UTC
  • Revision ID: package-import@ubuntu.com-20120813133021-d8zjlpi1niz4x3ih
Tags: 0.7.1-0ubuntu2
Apply upstream patch to work around gobject menu reference counting
bug.  (LP: #1016212)

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
#!/usr/bin/env python
 
2
"""An application for keeping track of your time."""
 
3
 
 
4
# Default to new-style classes.
 
5
__metaclass__ = type
 
6
 
 
7
import os
 
8
import re
 
9
import csv
 
10
import sys
 
11
import errno
 
12
import codecs
 
13
import signal
 
14
import urllib
 
15
import datetime
 
16
import optparse
 
17
import tempfile
 
18
import ConfigParser
 
19
 
 
20
from operator import itemgetter
 
21
 
 
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.
 
26
try:
 
27
    import gi
 
28
    toolkit = 'gi'
 
29
except ImportError:
 
30
    import pygtk
 
31
    toolkit = 'pygtk'
 
32
 
 
33
 
 
34
if toolkit == 'gi':
 
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.
 
40
    try:
 
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
 
48
 
 
49
    try:
 
50
        if gtk._version.startswith('2'):
 
51
            from gi.repository import AppIndicator
 
52
        else:
 
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
 
60
else:
 
61
    pygtk.require('2.0')
 
62
    import gobject
 
63
    import gtk
 
64
    from gtk import gdk as gdk
 
65
    import pango
 
66
 
 
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
 
71
 
 
72
    try:
 
73
        import appindicator
 
74
        new_app_indicator = appindicator.Indicator
 
75
        APPINDICATOR_CATEGORY = appindicator.CATEGORY_APPLICATION_STATUS
 
76
        APPINDICATOR_ACTIVE = appindicator.STATUS_ACTIVE
 
77
    except ImportError:
 
78
        # apt-get install python-appindicator on Ubuntu
 
79
        new_app_indicator = None
 
80
 
 
81
try:
 
82
    import dbus
 
83
    import dbus.service
 
84
    import dbus.mainloop.glib
 
85
except ImportError:
 
86
    dbus = None
 
87
 
 
88
from gtimelog import __version__
 
89
 
 
90
 
 
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'
 
97
 
 
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"
 
105
 
 
106
 
 
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
 
110
 
 
111
 
 
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)
 
115
 
 
116
 
 
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)
 
121
 
 
122
 
 
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)
 
127
 
 
128
 
 
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)
 
132
    if h and m:
 
133
        return '%d hour%s %d min' % (h, h != 1 and "s" or "", m)
 
134
    elif h:
 
135
        return '%d hour%s' % (h, h != 1 and "s" or "")
 
136
    else:
 
137
        return '%d min' % m
 
138
 
 
139
 
 
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)
 
143
    if not m:
 
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)
 
147
 
 
148
 
 
149
def parse_time(t):
 
150
    """Parse a time instance from 'HH:MM' formatted string."""
 
151
    m = re.match(r'^(\d+):(\d+)$', t)
 
152
    if not m:
 
153
        raise ValueError('bad time: ', t)
 
154
    hour, min = map(int, m.groups())
 
155
    return datetime.time(hour, min)
 
156
 
 
157
 
 
158
def virtual_day(dt, virtual_midnight):
 
159
    """Return the "virtual day" of a timestamp.
 
160
 
 
161
    Timestamps between midnight and "virtual midnight" (e.g. 2 am) are
 
162
    assigned to the previous "virtual day".
 
163
    """
 
164
    if dt.time() < virtual_midnight:     # assign to previous day
 
165
        return dt.date() - datetime.timedelta(1)
 
166
    return dt.date()
 
167
 
 
168
 
 
169
def different_days(dt1, dt2, virtual_midnight):
 
170
    """Check whether dt1 and dt2 are on different "virtual days".
 
171
 
 
172
    See virtual_day().
 
173
    """
 
174
    return virtual_day(dt1, virtual_midnight) != virtual_day(dt2,
 
175
                                                             virtual_midnight)
 
176
 
 
177
 
 
178
def first_of_month(date):
 
179
    """Return the first day of the month for a given date."""
 
180
    return date.replace(day=1)
 
181
 
 
182
 
 
183
def next_month(date):
 
184
    """Return the first day of the next month."""
 
185
    if date.month == 12:
 
186
        return datetime.date(date.year + 1, 1, 1)
 
187
    else:
 
188
        return datetime.date(date.year, date.month + 1, 1)
 
189
 
 
190
 
 
191
def uniq(l):
 
192
    """Return list with consecutive duplicates removed."""
 
193
    result = l[:1]
 
194
    for item in l[1:]:
 
195
        if item != result[-1]:
 
196
            result.append(item)
 
197
    return result
 
198
 
 
199
 
 
200
class TimeWindow(object):
 
201
    """A window into a time log.
 
202
 
 
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.
 
206
 
 
207
    self.items is a list of (timestamp, event_title) tuples.
 
208
 
 
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.
 
212
 
 
213
    The first event also creates a special "arrival" entry of zero duration.
 
214
 
 
215
    Entries that span virtual midnight boundaries are also converted to
 
216
    "arrival" entries at their end point.
 
217
 
 
218
    The earliest_timestamp attribute contains the first (which should be the
 
219
    oldest) timestamp in the file.
 
220
    """
 
221
 
 
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)
 
229
 
 
230
    def reread(self, callback=None):
 
231
        """Parse the time log file and update self.items.
 
232
 
 
233
        Also updates self.earliest_timestamp.
 
234
        """
 
235
        self.items = []
 
236
        self.earliest_timestamp = None
 
237
        try:
 
238
            # accept any file-like object
 
239
            # this is a hook for unit tests, really
 
240
            if hasattr(self.filename, 'read'):
 
241
                f = self.filename
 
242
                f.seek(0)
 
243
            else:
 
244
                f = codecs.open(self.filename, encoding='UTF-8')
 
245
        except IOError:
 
246
            return
 
247
        line = ''
 
248
        for line in f:
 
249
            if ': ' not in line:
 
250
                continue
 
251
            time, entry = line.split(': ', 1)
 
252
            try:
 
253
                time = parse_datetime(time)
 
254
            except ValueError:
 
255
                continue
 
256
            else:
 
257
                entry = entry.strip()
 
258
                if callback:
 
259
                    callback(entry)
 
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
 
269
        f.close()
 
270
 
 
271
    def last_time(self):
 
272
        """Return the time of the last event (or None if there are no events).
 
273
        """
 
274
        if not self.items:
 
275
            return None
 
276
        return self.items[-1][0]
 
277
 
 
278
    def all_entries(self):
 
279
        """Iterate over all entries.
 
280
 
 
281
        Yields (start, stop, duration, entry) tuples.  The first entry
 
282
        has a duration of 0.
 
283
        """
 
284
        stop = None
 
285
        for item in self.items:
 
286
            start = stop
 
287
            stop = item[0]
 
288
            entry = item[1]
 
289
            if start is None or different_days(start, stop,
 
290
                                               self.virtual_midnight):
 
291
                start = stop
 
292
            duration = stop - start
 
293
            yield start, stop, duration, entry
 
294
 
 
295
    def count_days(self):
 
296
        """Count days that have entries."""
 
297
        count = 0
 
298
        last = None
 
299
        for start, stop, duration, entry in self.all_entries():
 
300
            if last is None or different_days(last, start,
 
301
                                              self.virtual_midnight):
 
302
                last = start
 
303
                count += 1
 
304
        return count
 
305
 
 
306
    def last_entry(self):
 
307
        """Return the last entry (or None if there are no events).
 
308
 
 
309
        It is always true that
 
310
 
 
311
            self.last_entry() == list(self.all_entries())[-1]
 
312
 
 
313
        """
 
314
        if not self.items:
 
315
            return None
 
316
        stop = self.items[-1][0]
 
317
        entry = self.items[-1][1]
 
318
        if len(self.items) == 1:
 
319
            start = stop
 
320
        else:
 
321
            start = self.items[-2][0]
 
322
        if different_days(start, stop, self.virtual_midnight):
 
323
            start = stop
 
324
        duration = stop - start
 
325
        return start, stop, duration, entry
 
326
 
 
327
    def grouped_entries(self, skip_first=True):
 
328
        """Return consolidated entries (grouped by entry title).
 
329
 
 
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.
 
333
        """
 
334
        work = {}
 
335
        slack = {}
 
336
        for start, stop, duration, entry in self.all_entries():
 
337
            if skip_first:
 
338
                skip_first = False
 
339
                continue
 
340
            if '***' in entry:
 
341
                continue
 
342
            if '**' in entry:
 
343
                entries = slack
 
344
            else:
 
345
                entries = work
 
346
            if entry in 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)
 
351
        work = work.values()
 
352
        work.sort()
 
353
        slack = slack.values()
 
354
        slack.sort()
 
355
        return work, slack
 
356
 
 
357
    def categorized_work_entries(self, skip_first=True):
 
358
        """Return consolidated work entries grouped by category.
 
359
 
 
360
        Category is a string preceding the first ':' in the entry.
 
361
 
 
362
        Return two dicts:
 
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>.
 
368
        """
 
369
 
 
370
        work, slack = self.grouped_entries(skip_first=skip_first)
 
371
        entries = {}
 
372
        totals = {}
 
373
        for start, entry, duration in work:
 
374
            if ': ' in entry:
 
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
 
380
            else:
 
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
 
387
 
 
388
    def totals(self):
 
389
        """Calculate total time of work and slacking entries.
 
390
 
 
391
        Returns (total_work, total_slacking) tuple.
 
392
 
 
393
        Slacking entries are identified by finding two asterisks in the title.
 
394
 
 
395
        Assuming that
 
396
 
 
397
            total_work, total_slacking = self.totals()
 
398
            work, slacking = self.grouped_entries()
 
399
 
 
400
        It is always true that
 
401
 
 
402
            total_work = sum([duration for start, entry, duration in work])
 
403
            total_slacking = sum([duration
 
404
                                  for start, entry, duration in slacking])
 
405
 
 
406
        (that is, it would be true if sum could operate on timedeltas).
 
407
        """
 
408
        total_work = total_slacking = datetime.timedelta(0)
 
409
        for start, stop, duration, entry in self.all_entries():
 
410
            if '**' in entry:
 
411
                total_slacking += duration
 
412
            else:
 
413
                total_work += duration
 
414
        return total_work, total_slacking
 
415
 
 
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"
 
421
        try:
 
422
            import socket
 
423
            idhost = socket.getfqdn()
 
424
        except: # can it actually ever fail?
 
425
            idhost = 'localhost'
 
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('\\', '\\\\')
 
431
                                                  .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"
 
438
 
 
439
    def to_csv_complete(self, output, title_row=True):
 
440
        """Export work entries to a CSV file.
 
441
 
 
442
        The file has two columns: task title and time (in minutes).
 
443
        """
 
444
        writer = csv.writer(output)
 
445
        if title_row:
 
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
 
451
        work.sort()
 
452
        writer.writerows(work)
 
453
 
 
454
    def to_csv_daily(self, output, title_row=True):
 
455
        """Export daily work, slacking, and arrival times to a CSV file.
 
456
 
 
457
        The file has four columns: date, time from midnight til arrival at
 
458
        work, slacking, and work (in decimal hours).
 
459
        """
 
460
        writer = csv.writer(output)
 
461
        if title_row:
 
462
            writer.writerow(["date", "day-start (hours)",
 
463
                             "slacking (hours)", "work (hours)"])
 
464
 
 
465
        # sum timedeltas per date
 
466
        # timelog must be cronological for this to be dependable
 
467
 
 
468
        d0 = datetime.timedelta(0)
 
469
        days = {} # date -> [time_started, slacking, work]
 
470
        dmin = None
 
471
        for start, stop, duration, entry in self.all_entries():
 
472
            if dmin is None:
 
473
                dmin = start.date()
 
474
            day = days.setdefault(start.date(),
 
475
                                  [datetime.timedelta(minutes=start.minute,
 
476
                                                      hours=start.hour),
 
477
                                   d0, d0])
 
478
            if '**' in entry:
 
479
                day[1] += duration
 
480
            else:
 
481
                day[2] += duration
 
482
 
 
483
        if dmin:
 
484
            # fill in missing dates - aka. weekends
 
485
            dmax = start.date()
 
486
            while dmin <= dmax:
 
487
                days.setdefault(dmin, [d0, d0, d0])
 
488
                dmin += datetime.timedelta(days=1)
 
489
 
 
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()]
 
493
        items.sort()
 
494
        writer.writerows(items)
 
495
 
 
496
 
 
497
class Reports(object):
 
498
    """Generation of reports."""
 
499
 
 
500
    def __init__(self, window):
 
501
        self.window = window
 
502
 
 
503
    def _categorizing_report(self, output, email, who, subject, period_name,
 
504
                             estimated_column=False):
 
505
        """A report that displays entries by category.
 
506
 
 
507
        Writes a report template in RFC-822 format to output.
 
508
 
 
509
        The report looks like
 
510
        |                             time
 
511
        | Overhead:
 
512
        |   Status meeting              43
 
513
        |   Mail                      1:50
 
514
        | --------------------------------
 
515
        |                             2:33
 
516
        |
 
517
        | Compass:
 
518
        |   Compass: hotpatch         2:13
 
519
        |   Call with a client          30
 
520
        | --------------------------------
 
521
        |                             3:43
 
522
        |
 
523
        | No category:
 
524
        |   SAT roundup               1:00
 
525
        | --------------------------------
 
526
        |                             1:00
 
527
        |
 
528
        | Total work done this week: 6:26
 
529
        |
 
530
        | Categories by time spent:
 
531
        |
 
532
        | Compass       3:43
 
533
        | Overhead      2:33
 
534
        | No category   1:00
 
535
 
 
536
        """
 
537
        window = self.window
 
538
 
 
539
        print >> output, "To: %(email)s" % {'email': email}
 
540
        print >> output, "Subject: %s" % subject
 
541
        print >> output
 
542
        items = list(window.all_entries())
 
543
        if not items:
 
544
            print >> output, "No work done this %s." % period_name
 
545
            return
 
546
        print >> output, " " * 46,
 
547
        if estimated_column:
 
548
            print >> output, "estimated        actual"
 
549
        else:
 
550
            print >> output, "                   time"
 
551
 
 
552
        total_work, total_slacking = window.totals()
 
553
        entries, totals = window.categorized_work_entries()
 
554
        if entries:
 
555
            categories = entries.keys()
 
556
            categories.sort()
 
557
            if categories[0] == None:
 
558
                categories = categories[1:]
 
559
                categories.append('No category')
 
560
                e = entries.pop(None)
 
561
                entries['No category'] = e
 
562
                t = totals.pop(None)
 
563
                totals['No category'] = t
 
564
            for cat in categories:
 
565
                print >> output, '%s:' % cat
 
566
 
 
567
                work = [(entry, duration)
 
568
                        for start, entry, duration in entries[cat]]
 
569
                work.sort()
 
570
                for entry, duration in work:
 
571
                    if not duration:
 
572
                        continue # skip empty "arrival" entries
 
573
 
 
574
                    entry = entry[:1].upper() + entry[1:]
 
575
                    if estimated_column:
 
576
                        print >> output, (u"  %-46s  %-14s  %s" %
 
577
                                    (entry, '-', format_duration_short(duration)))
 
578
                    else:
 
579
                        print >> output, (u"  %-61s  %+5s" %
 
580
                                    (entry, format_duration_short(duration)))
 
581
 
 
582
                print >> output, '-' * 70
 
583
                print >> output, (u"%+70s" %
 
584
                                  format_duration_short(totals[cat]))
 
585
                print >> output
 
586
        print >> output, ("Total work done this %s: %s" %
 
587
                          (period_name, format_duration_short(total_work)))
 
588
 
 
589
        print >> output
 
590
 
 
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))
 
598
 
 
599
    def _report_categories(self, output, categories):
 
600
        """A helper method that lists time spent per category.
 
601
 
 
602
        Use this to add a section in a report looks similar to this:
 
603
 
 
604
        Administration:  2 hours 1 min
 
605
        Coding:          18 hours 45 min
 
606
        Learning:        3 hours
 
607
 
 
608
        category is a dict of entries (<category name>: <duration>).
 
609
        """
 
610
        print >> output
 
611
        print >> output, "By category:"
 
612
        print >> output
 
613
 
 
614
        items = categories.items()
 
615
        items.sort()
 
616
        for cat, duration in items:
 
617
            if not cat:
 
618
                continue
 
619
 
 
620
            print >> output, u"%-62s  %s" % (
 
621
                cat, format_duration_long(duration))
 
622
 
 
623
        if None in categories:
 
624
            print >> output, u"%-62s  %s" % (
 
625
                '(none)', format_duration_long(categories[None]))
 
626
        print >> output
 
627
 
 
628
    def _plain_report(self, output, email, who, subject, period_name,
 
629
                      estimated_column=False):
 
630
        """Format a report that does not categorize entries.
 
631
 
 
632
        Writes a report template in RFC-822 format to output.
 
633
        """
 
634
        window = self.window
 
635
 
 
636
        print >> output, "To: %(email)s" % {'email': email}
 
637
        print >> output, 'Subject: %s' % subject
 
638
        print >> output
 
639
        items = list(window.all_entries())
 
640
        if not items:
 
641
            print >> output, "No work done this %s." % period_name
 
642
            return
 
643
        print >> output, " " * 46,
 
644
        if estimated_column:
 
645
            print >> output, "estimated       actual"
 
646
        else:
 
647
            print >> output, "                time"
 
648
        work, slack = window.grouped_entries()
 
649
        total_work, total_slacking = window.totals()
 
650
        categories = {}
 
651
        if work:
 
652
            work = [(entry, duration) for start, entry, duration in work]
 
653
            work.sort()
 
654
            for entry, duration in work:
 
655
                if not duration:
 
656
                    continue # skip empty "arrival" entries
 
657
 
 
658
                if ': ' in entry:
 
659
                    cat, task = entry.split(': ', 1)
 
660
                    categories[cat] = categories.get(
 
661
                        cat, datetime.timedelta(0)) + duration
 
662
                else:
 
663
                    categories[None] = categories.get(
 
664
                        None, datetime.timedelta(0)) + duration
 
665
 
 
666
                entry = entry[:1].upper() + entry[1:]
 
667
                if estimated_column:
 
668
                    print >> output, (u"%-46s  %-14s  %s" %
 
669
                                (entry, '-', format_duration_long(duration)))
 
670
                else:
 
671
                    print >> output, (u"%-62s  %s" %
 
672
                                (entry, format_duration_long(duration)))
 
673
            print >> output
 
674
        print >> output, ("Total work done this %s: %s" %
 
675
                          (period_name, format_duration_long(total_work)))
 
676
 
 
677
        if categories:
 
678
            self._report_categories(output, categories)
 
679
 
 
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,
 
686
                                         period_name='week',
 
687
                                         estimated_column=estimated_column)
 
688
 
 
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,
 
695
                                         period_name='month',
 
696
                                         estimated_column=estimated_column)
 
697
 
 
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,
 
703
                                  period_name='week',
 
704
                                  estimated_column=estimated_column)
 
705
 
 
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,
 
711
                                  period_name='month',
 
712
                                  estimated_column=estimated_column)
 
713
 
 
714
    def daily_report(self, output, email, who):
 
715
        """Format a daily report.
 
716
 
 
717
        Writes a daily report template in RFC-822 format to output.
 
718
        """
 
719
        window = self.window
 
720
 
 
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})
 
731
        print >> output
 
732
        items = list(window.all_entries())
 
733
        if not items:
 
734
            print >> output, "No work done today."
 
735
            return
 
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'))
 
739
        print >> output
 
740
        work, slack = window.grouped_entries()
 
741
        total_work, total_slacking = window.totals()
 
742
        categories = {}
 
743
        if work:
 
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))
 
748
                if ': ' in entry:
 
749
                    cat, task = entry.split(': ', 1)
 
750
                    categories[cat] = categories.get(
 
751
                        cat, datetime.timedelta(0)) + duration
 
752
                else:
 
753
                    categories[None] = categories.get(
 
754
                        None, datetime.timedelta(0)) + duration
 
755
 
 
756
            print >> output
 
757
        print >> output, ("Total work done: %s" %
 
758
                          format_duration_long(total_work))
 
759
 
 
760
        if len(categories) > 0:
 
761
            self._report_categories(output, categories)
 
762
 
 
763
        print >> output, 'Slacking:\n'
 
764
 
 
765
        if slack:
 
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))
 
770
            print >> output
 
771
        print >> output, ("Time spent slacking: %s" %
 
772
                          format_duration_long(total_slacking))
 
773
 
 
774
 
 
775
class TimeLog(object):
 
776
    """Time log.
 
777
 
 
778
    A time log contains a time window for today, and can add new entries at
 
779
    the end.
 
780
    """
 
781
 
 
782
    def __init__(self, filename, virtual_midnight):
 
783
        self.filename = filename
 
784
        self.virtual_midnight = virtual_midnight
 
785
        self.reread()
 
786
 
 
787
    def reread(self):
 
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)
 
792
        self.history = []
 
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
 
797
 
 
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)
 
801
 
 
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())
 
808
 
 
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')
 
812
        if self.need_space:
 
813
            self.need_space = False
 
814
            print >> f
 
815
        print >> f, line
 
816
        f.close()
 
817
 
 
818
    def append(self, entry, now=None):
 
819
        """Append a new entry to the time log."""
 
820
        if not now:
 
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
 
825
            self.reread()
 
826
        self.window.items.append((now, entry))
 
827
        line = '%s: %s' % (now.strftime("%Y-%m-%d %H:%M"), entry)
 
828
        self.raw_append(line)
 
829
 
 
830
    def valid_time(self, time):
 
831
        if time > datetime.datetime.now():
 
832
            return False
 
833
        last = self.window.last_time()
 
834
        if last and time < last:
 
835
            return False
 
836
        return True
 
837
 
 
838
 
 
839
class TaskList(object):
 
840
    """Task list.
 
841
 
 
842
    You can have a list of common tasks in a text file that looks like this
 
843
 
 
844
        Arrived **
 
845
        Reading mail
 
846
        Project1: do some task
 
847
        Project2: do some other task
 
848
        Project1: do yet another task
 
849
 
 
850
    These tasks are grouped by their common prefix (separated with ':').
 
851
    Tasks without a ':' are grouped under "Other".
 
852
 
 
853
    A TaskList has an attribute 'groups' which is a list of tuples
 
854
    (group_name, list_of_group_items).
 
855
    """
 
856
 
 
857
    other_title = 'Other'
 
858
 
 
859
    loading_callback = None
 
860
    loaded_callback = None
 
861
    error_callback = None
 
862
 
 
863
    def __init__(self, filename):
 
864
        self.filename = filename
 
865
        self.load()
 
866
 
 
867
    def check_reload(self):
 
868
        """Look at the mtime of tasks.txt, and reload it if necessary.
 
869
 
 
870
        Returns True if the file was reloaded.
 
871
        """
 
872
        mtime = self.get_mtime()
 
873
        if mtime != self.last_mtime:
 
874
            self.load()
 
875
            return True
 
876
        else:
 
877
            return False
 
878
 
 
879
    def get_mtime(self):
 
880
        """Return the mtime of self.filename, or None if the file doesn't exist."""
 
881
        try:
 
882
            return os.stat(self.filename).st_mtime
 
883
        except OSError:
 
884
            return None
 
885
 
 
886
    def load(self):
 
887
        """Load task list from a file named self.filename."""
 
888
        groups = {}
 
889
        self.last_mtime = self.get_mtime()
 
890
        try:
 
891
            for line in file(self.filename):
 
892
                line = line.strip()
 
893
                if not line or line.startswith('#'):
 
894
                    continue
 
895
                if ':' in line:
 
896
                    group, task = [s.strip() for s in line.split(':', 1)]
 
897
                else:
 
898
                    group, task = self.other_title, line
 
899
                groups.setdefault(group, []).append(task)
 
900
        except IOError:
 
901
            pass # the file's not there, so what?
 
902
        self.groups = groups.items()
 
903
        self.groups.sort()
 
904
 
 
905
    def reload(self):
 
906
        """Reload the task list."""
 
907
        self.load()
 
908
 
 
909
 
 
910
class RemoteTaskList(TaskList):
 
911
    """Task list stored on a remote server.
 
912
 
 
913
    Keeps a cached copy of the list in a local file, so you can use it offline.
 
914
    """
 
915
 
 
916
    def __init__(self, url, cache_filename):
 
917
        self.url = url
 
918
        TaskList.__init__(self, cache_filename)
 
919
        self.first_time = True
 
920
 
 
921
    def check_reload(self):
 
922
        """Check whether the task list needs to be reloaded.
 
923
 
 
924
        Download the task list if this is the first time, and a cached copy is
 
925
        not found.
 
926
 
 
927
        Returns True if the file was reloaded.
 
928
        """
 
929
        if self.first_time:
 
930
            self.first_time = False
 
931
            if not os.path.exists(self.filename):
 
932
                self.download()
 
933
                return True
 
934
        return TaskList.check_reload(self)
 
935
 
 
936
    def download(self):
 
937
        """Download the task list from the server."""
 
938
        if self.loading_callback:
 
939
            self.loading_callback()
 
940
        try:
 
941
            urllib.urlretrieve(self.url, self.filename)
 
942
        except IOError:
 
943
            if self.error_callback:
 
944
                self.error_callback()
 
945
        self.load()
 
946
        if self.loaded_callback:
 
947
            self.loaded_callback()
 
948
 
 
949
    def reload(self):
 
950
        """Reload the task list."""
 
951
        self.download()
 
952
 
 
953
 
 
954
class Settings(object):
 
955
    """Configurable settings for GTimeLog."""
 
956
 
 
957
    # Insane defaults
 
958
    email = 'activity-list@example.com'
 
959
    name = 'Anonymous'
 
960
 
 
961
    editor = 'xdg-open'
 
962
    mailer = 'x-terminal-emulator -e "mutt -H %s"'
 
963
    spreadsheet = 'xdg-open %s'
 
964
    chronological = True
 
965
 
 
966
    enable_gtk_completion = True  # False enables gvim-style completion
 
967
 
 
968
    hours = 8
 
969
    virtual_midnight = datetime.time(2, 0)
 
970
 
 
971
    task_list_url = ''
 
972
    edit_task_list_cmd = ''
 
973
 
 
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
 
979
 
 
980
    report_style = 'plain'
 
981
 
 
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)
 
985
 
 
986
    def get_config_file(self):
 
987
        return os.path.join(self.get_config_dir(), 'gtimelogrc')
 
988
 
 
989
    def _config(self):
 
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))
 
1012
        return config
 
1013
 
 
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',
 
1024
                                                       'gtk-completion')
 
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')
 
1039
 
 
1040
    def save(self, filename):
 
1041
        config = self._config()
 
1042
        f = file(filename, 'w')
 
1043
        try:
 
1044
            config.write(f)
 
1045
        finally:
 
1046
            f.close()
 
1047
 
 
1048
 
 
1049
class IconChooser:
 
1050
 
 
1051
    @property
 
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.
 
1056
        if toolkit == 'gi':
 
1057
            style = gtk.MenuBar().get_style_context()
 
1058
            color = style.get_color(gtk.StateFlags.NORMAL)
 
1059
            value = (color.red + color.green + color.blue) / 3
 
1060
        else:
 
1061
            style = gtk.MenuBar().rc_get_style()
 
1062
            color = style.text[gtk.STATE_NORMAL]
 
1063
            value = color.value
 
1064
        if value >= 0.5:
 
1065
            return icon_file_bright
 
1066
        else:
 
1067
            return icon_file_dark
 
1068
 
 
1069
 
 
1070
class SimpleStatusIcon(IconChooser):
 
1071
    """Status icon for gtimelog in the notification area."""
 
1072
 
 
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.
 
1079
            return
 
1080
        self.icon = gtk_status_icon_new(self.icon_name)
 
1081
        self.last_tick = False
 
1082
        self.tick()
 
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
 
1092
 
 
1093
    def available(self):
 
1094
        """Is the icon supported by this system?
 
1095
 
 
1096
        SimpleStatusIcon needs PyGtk 2.10 or newer
 
1097
        """
 
1098
        return self.icon is not None
 
1099
 
 
1100
    def on_style_set(self, *args):
 
1101
        """The user chose a different theme."""
 
1102
        self.icon.set_from_file(self.icon_name)
 
1103
 
 
1104
    def on_activate(self, widget):
 
1105
        """The user clicked on the icon."""
 
1106
        self.gtimelog_window.toggle_visible()
 
1107
 
 
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)
 
1114
 
 
1115
    def entry_added(self, entry):
 
1116
        """An entry has been added."""
 
1117
        self.tick()
 
1118
 
 
1119
    def tick(self):
 
1120
        """Tick every second."""
 
1121
        self.icon.set_tooltip_text(self.tip())
 
1122
        return True
 
1123
 
 
1124
    def tip(self):
 
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))
 
1138
        return tip
 
1139
 
 
1140
 
 
1141
class AppIndicator(IconChooser):
 
1142
    """Ubuntu's application indicator for gtimelog."""
 
1143
 
 
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:
 
1149
            return
 
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
 
1159
 
 
1160
    def available(self):
 
1161
        """Is the icon supported by this system?
 
1162
 
 
1163
        AppIndicator needs python-appindicator
 
1164
        """
 
1165
        return self.indicator is not None
 
1166
 
 
1167
    def on_style_set(self, *args):
 
1168
        """The user chose a different theme."""
 
1169
        self.indicator.set_icon(self.icon_name)
 
1170
 
 
1171
 
 
1172
class OldTrayIcon(IconChooser):
 
1173
    """Old tray icon for gtimelog, shows a ticking clock.
 
1174
 
 
1175
    Uses the old and deprecated egg.trayicon module.
 
1176
    """
 
1177
 
 
1178
    def __init__(self, gtimelog_window):
 
1179
        self.gtimelog_window = gtimelog_window
 
1180
        self.timelog = gtimelog_window.timelog
 
1181
        self.trayicon = None
 
1182
        try:
 
1183
            import egg.trayicon
 
1184
        except ImportError:
 
1185
            # Nothing to do here, move along or install python-gnome2-extras
 
1186
            # which was later renamed to python-eggtrayicon.
 
1187
            return
 
1188
        self.eventbox = gtk.EventBox()
 
1189
        hbox = gtk.HBox()
 
1190
        self.icon = gtk.Image()
 
1191
        self.icon.set_from_file(self.icon_name)
 
1192
        hbox.add(self.icon)
 
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
 
1212
 
 
1213
    def available(self):
 
1214
        """Is the icon supported by this system?
 
1215
 
 
1216
        OldTrayIcon needs egg.trayicon, which is now deprecated and likely
 
1217
        no longer available in modern Linux distributions.
 
1218
        """
 
1219
        return self.trayicon is not None
 
1220
 
 
1221
    def on_style_set(self, *args):
 
1222
        """The user chose a different theme."""
 
1223
        self.icon.set_from_file(self.icon_name)
 
1224
 
 
1225
    def on_press(self, widget, event):
 
1226
        """A mouse button was pressed on the tray icon label."""
 
1227
        if event.button != 3:
 
1228
            return
 
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()
 
1235
        else:
 
1236
            self.gtimelog_window.tray_show.show()
 
1237
            self.gtimelog_window.tray_hide.hide()
 
1238
        widget.popup(None, None, None, event.button, event.time)
 
1239
 
 
1240
    def on_release(self, widget, event):
 
1241
        """A mouse button was released on the tray icon label."""
 
1242
        if event.button != 1:
 
1243
            return
 
1244
        self.gtimelog_window.toggle_visible()
 
1245
 
 
1246
    def entry_added(self, entry):
 
1247
        """An entry has been added."""
 
1248
        self.tick(force_update=True)
 
1249
 
 
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'))
 
1258
            else:
 
1259
                self.time_label.set_text(
 
1260
                    format_duration_short(now - last_time))
 
1261
        self.trayicon.set_tooltip_text(self.tip())
 
1262
        return True
 
1263
 
 
1264
    def tip(self):
 
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))
 
1278
        return tip
 
1279
 
 
1280
 
 
1281
class MainWindow:
 
1282
    """Main application window."""
 
1283
 
 
1284
    # Initial view mode.
 
1285
    chronological = True
 
1286
    show_tasks = True
 
1287
 
 
1288
    # URL to use for Help -> Online Documentation.
 
1289
    help_url = "http://mg.pov.lt/gtimelog"
 
1290
 
 
1291
    def __init__(self, timelog, settings, tasks):
 
1292
        """Create the main window."""
 
1293
        self.timelog = timelog
 
1294
        self.settings = settings
 
1295
        self.tasks = tasks
 
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.
 
1301
        self.lock = False
 
1302
        self.chronological = settings.chronological
 
1303
        self.entry_watchers = []
 
1304
        self._init_ui()
 
1305
 
 
1306
    def _init_ui(self):
 
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()
 
1375
        self.populate_log()
 
1376
        self.update_show_checkbox()
 
1377
        self.tick(True)
 
1378
        gobject.timeout_add(1000, self.tick)
 
1379
 
 
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)
 
1391
 
 
1392
    def w(self, text, tag=None):
 
1393
        """Write some text at the end of the log buffer."""
 
1394
        buffer = self.log_buffer
 
1395
        if tag:
 
1396
            buffer.insert_with_tags_by_name(buffer.get_end_iter(), text, tag)
 
1397
        else:
 
1398
            buffer.insert(buffer.get_end_iter(), text)
 
1399
 
 
1400
    def populate_log(self):
 
1401
        """Populate the log."""
 
1402
        self.lock = True
 
1403
        buffer = self.log_buffer
 
1404
        buffer.set_text('')
 
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)
 
1415
        else:
 
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)
 
1422
        self.add_footer()
 
1423
        self.scroll_to_end()
 
1424
        self.lock = False
 
1425
 
 
1426
    def delete_footer(self):
 
1427
        buffer = self.log_buffer
 
1428
        buffer.delete(
 
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
 
1432
 
 
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()
 
1441
 
 
1442
        self.w('\n')
 
1443
        self.w('Total work done: ')
 
1444
        self.w(format_duration(total_work), 'duration')
 
1445
        self.w(' (')
 
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
 
1450
            self.w(', ')
 
1451
            self.w(format_duration(per_diem), 'duration')
 
1452
            self.w(' per day')
 
1453
        self.w(')\n')
 
1454
        self.w('Total slacking: ')
 
1455
        self.w(format_duration(total_slacking), 'duration')
 
1456
        self.w(' (')
 
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
 
1461
            self.w(', ')
 
1462
            self.w(format_duration(per_diem), 'duration')
 
1463
            self.w(' per day')
 
1464
        self.w(')\n')
 
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')
 
1472
            self.w(' (till ')
 
1473
            self.w(time_to_leave.strftime('%H:%M'), 'time')
 
1474
            self.w(')')
 
1475
 
 
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' )
 
1481
            self.w('(')
 
1482
            if total > hours:
 
1483
                self.w(format_duration(total - hours), 'duration')
 
1484
                self.w(' overtime')
 
1485
            else:
 
1486
                self.w(format_duration(hours - total), 'duration')
 
1487
                self.w(' left')
 
1488
            self.w(')')
 
1489
 
 
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:
 
1494
            return 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
 
1500
        else:
 
1501
            total_time = total_work + current_task_time
 
1502
        return datetime.timedelta(hours=self.settings.hours) - total_time
 
1503
 
 
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)
 
1516
 
 
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)
 
1521
 
 
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)
 
1527
 
 
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:
 
1535
                    task = item
 
1536
                else:
 
1537
                    task = group_name + ': ' + item
 
1538
                self.task_store.append(t, [item, task])
 
1539
        self.task_list.expand_all()
 
1540
 
 
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:
 
1548
            return
 
1549
        seen = set()
 
1550
        self.completion_choices.clear()
 
1551
        for entry in self.history:
 
1552
            if entry not in seen:
 
1553
                seen.add(entry)
 
1554
                self.completion_choices.append([entry])
 
1555
 
 
1556
    def set_up_completion(self):
 
1557
        """Set up autocompletion."""
 
1558
        if not self.settings.enable_gtk_completion:
 
1559
            self.have_completion = False
 
1560
            return
 
1561
        self.have_completion = hasattr(gtk, 'EntryCompletion')
 
1562
        if not self.have_completion:
 
1563
            return
 
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)
 
1569
 
 
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:
 
1575
            return
 
1576
        if entry not in [row[0] for row in self.completion_choices]:
 
1577
            self.completion_choices.append([entry])
 
1578
 
 
1579
    def delete_event(self, widget, data=None):
 
1580
        """Try to close the window."""
 
1581
        if self.tray_icon:
 
1582
            self.on_hide_activate()
 
1583
            return True
 
1584
        else:
 
1585
            gtk.main_quit()
 
1586
            return False
 
1587
 
 
1588
    def close_about_dialog(self, widget):
 
1589
        """Ok clicked in the about dialog."""
 
1590
        self.about_dialog.hide()
 
1591
 
 
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()
 
1598
 
 
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()
 
1605
 
 
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
 
1611
 
 
1612
    ignore_on_toggle_visible = False
 
1613
 
 
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()
 
1618
 
 
1619
    def toggle_visible(self):
 
1620
        """Toggle main window visibility."""
 
1621
        if self.main_window.get_property('visible'):
 
1622
            self.on_hide_activate()
 
1623
        else:
 
1624
            self.on_show_activate()
 
1625
 
 
1626
    def on_quit_activate(self, widget):
 
1627
        """File -> Quit selected"""
 
1628
        gtk.main_quit()
 
1629
 
 
1630
    def on_about_activate(self, widget):
 
1631
        """Help -> About selected"""
 
1632
        self.about_dialog.show()
 
1633
 
 
1634
    def on_online_help_activate(self, widget):
 
1635
        """Help -> Online Documentation selected"""
 
1636
        import webbrowser
 
1637
        webbrowser.open(self.help_url)
 
1638
 
 
1639
    def on_chronological_activate(self, widget):
 
1640
        """View -> Chronological"""
 
1641
        self.chronological = True
 
1642
        self.populate_log()
 
1643
 
 
1644
    def on_grouped_activate(self, widget):
 
1645
        """View -> Grouped"""
 
1646
        self.chronological = False
 
1647
        self.populate_log()
 
1648
 
 
1649
    def on_daily_report_activate(self, widget):
 
1650
        """File -> Daily Report"""
 
1651
        reports = Reports(self.timelog.window)
 
1652
        self.mail(reports.daily_report)
 
1653
 
 
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)
 
1660
 
 
1661
    def on_previous_day_report_activate(self, widget):
 
1662
        """File -> Daily Report for a Previous Day"""
 
1663
        day = self.choose_date()
 
1664
        if day:
 
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)
 
1670
 
 
1671
    def choose_date(self):
 
1672
        """Pop up a calendar dialog.
 
1673
 
 
1674
        Returns either a datetime.date, or one.
 
1675
        """
 
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)
 
1679
        else:
 
1680
            day = None
 
1681
        self.calendar_dialog.hide()
 
1682
        return day
 
1683
 
 
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)
 
1687
 
 
1688
    def weekly_window(self, day=None):
 
1689
        if not day:
 
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)
 
1696
        return window
 
1697
 
 
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
 
1706
        else:
 
1707
            report = reports.weekly_report_plain
 
1708
        self.mail(report)
 
1709
 
 
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
 
1718
        else:
 
1719
            report = reports.weekly_report_plain
 
1720
        self.mail(report)
 
1721
 
 
1722
    def on_previous_week_report_activate(self, widget):
 
1723
        """File -> Weekly Report for a Previous Week"""
 
1724
        day = self.choose_date()
 
1725
        if day:
 
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
 
1731
            else:
 
1732
                report = reports.weekly_report_plain
 
1733
            self.mail(report)
 
1734
 
 
1735
    def monthly_window(self, day=None):
 
1736
        if not day:
 
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)
 
1745
        return window
 
1746
 
 
1747
    def on_previous_month_report_activate(self, widget):
 
1748
        """File -> Monthly Report for a Previous Month"""
 
1749
        day = self.choose_date()
 
1750
        if day:
 
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
 
1756
            else:
 
1757
                report = reports.monthly_report_plain
 
1758
            self.mail(report)
 
1759
 
 
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
 
1768
        else:
 
1769
            report = reports.monthly_report_plain
 
1770
        self.mail(report)
 
1771
 
 
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
 
1779
        else:
 
1780
            report = reports.monthly_report_plain
 
1781
        self.mail(report)
 
1782
 
 
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)
 
1789
 
 
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)
 
1796
 
 
1797
    def on_edit_timelog_activate(self, widget):
 
1798
        """File -> Edit timelog.txt"""
 
1799
        self.spawn(self.settings.editor, '"%s"' % self.timelog.filename)
 
1800
 
 
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?
 
1808
 
 
1809
    def spawn(self, command, arg=None):
 
1810
        """Spawn a process in background"""
 
1811
        # XXX shell-escape arg, please.
 
1812
        if arg is not None:
 
1813
            if '%s' in command:
 
1814
                command = command % arg
 
1815
            else:
 
1816
                command += ' ' + arg
 
1817
        os.system(command + " &")
 
1818
 
 
1819
    def on_reread_activate(self, widget):
 
1820
        """File -> Reread"""
 
1821
        self.timelog.reread()
 
1822
        self.set_up_history()
 
1823
        self.populate_log()
 
1824
        self.tick(True)
 
1825
 
 
1826
    def on_show_task_pane_toggled(self, event):
 
1827
        """View -> Tasks"""
 
1828
        if self.task_pane.get_property('visible'):
 
1829
            self.task_pane.hide()
 
1830
        else:
 
1831
            self.task_pane.show()
 
1832
 
 
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()
 
1836
 
 
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)
 
1842
        def grab_focus():
 
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)
 
1850
 
 
1851
    def task_list_button_press(self, menu, event):
 
1852
        if event.button == 3:
 
1853
            menu.popup(None, None, None, event.button, event.time)
 
1854
            return True
 
1855
        else:
 
1856
            return False
 
1857
 
 
1858
    def on_task_list_reload(self, event):
 
1859
        self.tasks.reload()
 
1860
        self.set_up_task_list()
 
1861
 
 
1862
    def on_task_list_edit(self, event):
 
1863
        self.spawn(self.settings.edit_task_list_cmd)
 
1864
 
 
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()
 
1872
 
 
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()
 
1877
 
 
1878
    def task_list_loaded(self):
 
1879
        if not self.task_list_loading_failed:
 
1880
            self.task_pane_info_label.hide()
 
1881
 
 
1882
    def task_entry_changed(self, widget):
 
1883
        """Reset history position when the task entry is changed."""
 
1884
        self.history_pos = 0
 
1885
 
 
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()
 
1890
            return True
 
1891
        if event.keyval == gdk.keyval_from_name('Prior'):
 
1892
            self._do_history(1)
 
1893
            return True
 
1894
        if event.keyval == gdk.keyval_from_name('Next'):
 
1895
            self._do_history(-1)
 
1896
            return True
 
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:
 
1900
            return False
 
1901
        if event.keyval == gdk.keyval_from_name('Up'):
 
1902
            self._do_history(1)
 
1903
            return True
 
1904
        if event.keyval == gdk.keyval_from_name('Down'):
 
1905
            self._do_history(-1)
 
1906
            return True
 
1907
        return False
 
1908
 
 
1909
    def _do_history(self, delta):
 
1910
        """Handle movement in history."""
 
1911
        if not self.history:
 
1912
            return
 
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)))
 
1919
        if new_pos == 0:
 
1920
            self.task_entry.set_text(self.history_undo)
 
1921
            self.task_entry.set_position(-1)
 
1922
        else:
 
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
 
1927
 
 
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')
 
1933
 
 
1934
        now = None
 
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)
 
1937
        if date_match:
 
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():]
 
1945
                else:
 
1946
                    now = None
 
1947
        if delta_match:
 
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():]
 
1953
            else:
 
1954
                now = None
 
1955
 
 
1956
        if not entry:
 
1957
            return
 
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())
 
1963
            self.add_footer()
 
1964
            self.scroll_to_end()
 
1965
        else:
 
1966
            self.populate_log()
 
1967
        self.task_entry.set_text('')
 
1968
        self.task_entry.grab_focus()
 
1969
        self.tick(True)
 
1970
        for watcher in self.entry_watchers:
 
1971
            watcher(entry)
 
1972
 
 
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.
 
1980
            return True
 
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'))
 
1985
        else:
 
1986
            self.time_label.set_text(format_duration(now - last_time))
 
1987
            # Update "time left to work"
 
1988
            if not self.lock:
 
1989
                self.delete_footer()
 
1990
                self.add_footer()
 
1991
        return True
 
1992
 
 
1993
 
 
1994
if dbus:
 
1995
    INTERFACE = 'lt.pov.mg.gtimelog.Service'
 
1996
    OBJECT_PATH = '/lt/pov/mg/gtimelog/Service'
 
1997
    SERVICE = 'lt.pov.mg.gtimelog.GTimeLog'
 
1998
 
 
1999
    class Service(dbus.service.Object):
 
2000
        """Our DBus service, used to communicate with the main instance."""
 
2001
 
 
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)
 
2006
 
 
2007
            self.main_window = main_window
 
2008
 
 
2009
        @dbus.service.method(INTERFACE)
 
2010
        def ToggleFocus(self):
 
2011
            self.main_window.toggle_visible()
 
2012
 
 
2013
        @dbus.service.method(INTERFACE)
 
2014
        def Present(self):
 
2015
            self.main_window.on_show_activate()
 
2016
 
 
2017
        @dbus.service.method(INTERFACE)
 
2018
        def Quit(self):
 
2019
            gtk.main_quit()
 
2020
 
 
2021
 
 
2022
def main():
 
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")
 
2035
 
 
2036
    opts, args = parser.parse_args()
 
2037
 
 
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()
 
2043
        return
 
2044
 
 
2045
    if opts.ignore_dbus:
 
2046
        global dbus
 
2047
        dbus = None
 
2048
 
 
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.
 
2052
    if dbus:
 
2053
        dbus.mainloop.glib.DBusGMainLoop(set_as_default=True)
 
2054
 
 
2055
        try:
 
2056
            session_bus = dbus.SessionBus()
 
2057
            dbus_service = session_bus.get_object(SERVICE, OBJECT_PATH)
 
2058
            if opts.replace:
 
2059
                print 'gtimelog: Telling the already-running instance to quit'
 
2060
                dbus_service.Quit()
 
2061
            elif opts.toggle:
 
2062
                dbus_service.ToggleFocus()
 
2063
                print 'gtimelog: Already running, toggling visibility'
 
2064
                sys.exit()
 
2065
            elif opts.tray:
 
2066
                print 'gtimelog: Already running, not doing anything'
 
2067
                sys.exit()
 
2068
            else:
 
2069
                dbus_service.Present()
 
2070
                print 'gtimelog: Already running, presenting main window'
 
2071
                sys.exit()
 
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
 
2075
                pass
 
2076
            else:
 
2077
                sys.exit('gtimelog: %s' % e)
 
2078
 
 
2079
    settings = Settings()
 
2080
    configdir = settings.get_config_dir()
 
2081
    try:
 
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
 
2087
            raise
 
2088
    settings_file = settings.get_config_file()
 
2089
    if not os.path.exists(settings_file):
 
2090
        settings.save(settings_file)
 
2091
    else:
 
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'))
 
2098
    else:
 
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]
 
2107
        else:
 
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
 
2114
                                 else opts.tray)
 
2115
                break # found one that works
 
2116
    if not start_in_tray:
 
2117
        main_window.on_show_activate()
 
2118
    if dbus:
 
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)
 
2123
    try:
 
2124
        gtk.main()
 
2125
    except KeyboardInterrupt:
 
2126
        pass
 
2127
 
 
2128
 
 
2129
if __name__ == '__main__':
 
2130
    main()