forked from AuHau/toggl-cli
-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathtoggl.py
executable file
·501 lines (415 loc) · 15.7 KB
/
toggl.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
#!/usr/bin/python
"""
toggl.py
Created by Robert Adams on 2012-04-19.
Last modified: 2014-09-14
Copyright (c) 2012 D. Robert Adams. All rights reserved.
Modified for toggl API v8 by Beau Raines
"""
#############################################################################
### Configuration Section ###
###
# How do you log into toggl.com?
AUTH = ('', '')
# Do you want to ignore starting times by default?
IGNORE_START_TIMES = False
# Command to visit toggl.com
VISIT_WWW_COMMAND = "open http://www.toggl.com"
### ###
### End of Configuration Section ###
#############################################################################
import datetime
import iso8601
import json
import optparse
import os
import pytz
import requests
import sys
import time
import urllib
import ConfigParser
from dateutil.parser import *
TOGGL_URL = "https://www.toggl.com/api/v8"
def add_time_entry(args):
"""
Creates a completed time entry.
args should be: ENTRY [@PROJECT] DURATION
"""
# Make sure we have an entry description.
if len(args) < 2:
global parser
parser.print_help()
return 1
entry = args[0]
args = args[1:] # strip of the entry
# See if we have a @project.
if len(args) == 2:
project_name = find_project(args[0][1:])
args = args[1:] # strip off the project
# Get the duration.
duration = parse_duration(args[0])
# Create the JSON object, or die trying.
data = create_time_entry_json(entry, project_name, duration)
if data == None:
return 1
if options.verbose:
print json.dumps(data)
# Send the data.
headers = {'content-type': 'application/json'}
r = requests.post("%s/time_entries" % TOGGL_URL, auth=AUTH,
data=json.dumps(data), headers=headers)
r.raise_for_status() # raise exception on error
return 0
def create_time_entry_json(description, project_name=None, duration=0):
"""Creates a basic time entry JSON from the given arguments
project_name should not have the '@' prefix.
duration should be an integer seconds.
"""
# See if we have a @project.
project_id = None
if project_name != None:
# Look up the project from toggl to get the id.
projects = get_projects()
for project in projects:
if project['name'] == project_name:
project_id = project['id']
break
if project_id == None:
print >> sys.stderr, "Project not found '%s'" % project_name
return None
# If duration is 0, then we calculate the number of seconds since the
# epoch.
if duration == 0:
duration = 0-time.time()
# Create JSON object to send to toggl.
data = { 'time_entry' : \
{ 'duration' : duration,
'billable' : False,
'start' : datetime.datetime.utcnow().isoformat(),
'description' : description,
'created_with' : 'toggl-cli',
'ignore_start_and_stop' : options.ignore_start_and_stop
}
}
if project_id != None:
data['time_entry']['pid'] = project_id
return data
def elapsed_time(seconds, suffixes=['y','w','d','h','m','s'], add_s=False, separator=' '):
"""
Takes an amount of seconds and turns it into a human-readable amount of time.
From http://snipplr.com/view.php?codeview&id=5713
"""
# the formatted time string to be returned
time = []
# the pieces of time to iterate over (days, hours, minutes, etc)
# - the first piece in each tuple is the suffix (d, h, w)
# - the second piece is the length in seconds (a day is 60s * 60m * 24h)
parts = [(suffixes[0], 60 * 60 * 24 * 7 * 52),
(suffixes[1], 60 * 60 * 24 * 7),
(suffixes[2], 60 * 60 * 24),
(suffixes[3], 60 * 60),
(suffixes[4], 60),
(suffixes[5], 1)]
# for each time piece, grab the value and remaining seconds, and add it to
# the time string
for suffix, length in parts:
value = seconds / length
if value > 0:
seconds = seconds % length
time.append('%s%s' % (str(value),
(suffix, (suffix, suffix + 's')[value > 1])[add_s]))
if seconds < 1:
break
return separator.join(time)
def get_current_time_entry():
"""Returns the current time entry JSON object, or None."""
response = get_time_entry_data()
for entry in response:
if int(entry['duration']) < 0:
return entry
return None
def get_projects():
"""Fetches the projects as JSON objects."""
# Look up default workspace
user = get_user()
wid = user['data']['default_wid']
url = "%s/workspaces/%s/projects" % (TOGGL_URL,wid)
global options
if options.verbose:
print url
r = requests.get(url, auth=AUTH)
r.raise_for_status() # raise exception on error
return json.loads(r.text)
def get_user():
"""Fetches the user as JSON objects."""
url = "%s/me" % (TOGGL_URL)
global options
if options.verbose:
print url
r = requests.get(url, auth=AUTH)
r.raise_for_status() # raise exception on error
return json.loads(r.text)
def get_time_entry_data():
"""Fetches time entry data and returns it as a Python array."""
# Construct the start and end dates.
#Toggl can accept these in local tz, but must be IS08601 formatted
tz = pytz.timezone(toggl_cfg.get('options', 'timezone'))
today = datetime.datetime.now(tz)
today_at_midnight = today.replace(hour=23, minute=59, second=59, microsecond = 0)
today_at_midnight = today_at_midnight.isoformat('T')
yesterday = today - datetime.timedelta(days=1)
yesterday_at_midnight = datetime.datetime(yesterday.year, yesterday.month, yesterday.day, 0, 0, 0)
yesterday_at_midnight = tz.localize(yesterday_at_midnight)
yesterday_at_midnight = yesterday_at_midnight.isoformat('T')
# Fetch the data or die trying.
url = "%s/time_entries?start_date=%s&end_date=%s" % \
(TOGGL_URL, urllib.quote(str(yesterday_at_midnight)), urllib.quote(str(today_at_midnight)))
global options
if options.verbose:
print url
r = requests.get(url, auth=AUTH)
r.raise_for_status() # raise exception on error
return json.loads(r.text)
def list_current_time_entry():
"""Shows what the user is currently working on (duration is negative)."""
entry = get_current_time_entry()
if entry != None:
print_time_entry(entry)
else:
print "You're not working on anything right now."
return 0
def list_projects():
"""List all projects."""
response = get_projects()
for project in response:
print "@%s" % (project['name'])
return 0
def find_project(proj):
"""Find a project given the unique prefix of the name"""
response = get_projects()
for project in response:
if project['name'].startswith(proj):
return project['name']
print "Could not find project!"
sys.exit(1)
def find_project_by_id(id):
"""Find a project given the project id"""
response = get_projects()
for project in response:
if project['id'] ==id:
return project['name']
print "Could not find project!"
return None
def list_time_entries():
"""Lists all of the time entries from yesterday and today along with
the amount of time devoted to each.
"""
# Get an array of objects of recent time data.
response = get_time_entry_data()
# Sort the time entries into buckets based on "Month Day" of the entry.
days = { }
tz = pytz.timezone(toggl_cfg.get('options', 'timezone'))
for entry in response:
start_time = iso8601.parse_date(entry['start']).astimezone(tz).strftime("%b %d")
if start_time not in days:
days[start_time] = []
days[start_time].append(entry)
# For each day, print the entries, then sum the times.
for date in sorted(days.keys()):
print date
duration = 0
for entry in days[date]:
print " ",
duration += print_time_entry(entry)
print " (%s)" % elapsed_time(int(duration))
return 0
def parse_duration(str):
"""Parses a string of the form [[Hours:]Minutes:]Seconds and returns
the total time in seconds as an integer.
"""
elements = str.split(':')
duration = 0
if len(elements) == 3:
duration += int(elements[0]) * 3600
elements = elements[1:]
if len(elements) == 2:
duration += int(elements[0]) * 60
elements = elements[1:]
duration += int(elements[0])
return duration
def print_time_entry(entry):
"""Utility function to print a time entry object and returns the
integer duration for this entry."""
# If the duration is negative, the entry is currently running so we
# have to calculate the duration by adding the current time.
is_running = ''
e_time = 0
if entry['duration'] > 0:
e_time = int(entry['duration'])
else:
is_running = '* '
e_time = time.time() + int(entry['duration'])
e_time_str = " %s" % elapsed_time(int(e_time), separator='')
# Get the project name (if one exists).
project_name = ''
if 'pid' in entry:
#project_name = " @%s" % entry['project']['name']
# This needs to look up the project by ID
project_name = find_project_by_id(entry['pid'])
project_name = " @%s" % project_name
else:
project_name = " No project"
if options.verbose:
print "%s%s%s%s [%s]" % (is_running, entry['description'], project_name, e_time_str, entry['id'])
else:
print "%s%s%s%s" % (is_running, entry['description'], project_name, e_time_str)
return e_time
def delete_time_entry(args):
if len(args) == 0:
global parser
parser.print_help()
return 1
entry_id = args[0]
response = get_time_entry_data()
for entry in response:
if str(entry['id']) == entry_id:
print "Deleting entry " + entry_id
headers = {'content-type': 'application/json'}
r = requests.delete("%s/time_entries/%s" % (TOGGL_URL, entry_id), auth=AUTH,
data=None, headers=headers)
r.raise_for_status() # raise exception on error
return 0
def start_time_entry(args):
"""
Starts a new time entry.
args should be: ENTRY [@PROJECT]
"""
global toggl_cfg
# Make sure we have an entry description.
if len(args) == 0:
global parser
parser.print_help()
return 1
entry = args[0]
args = args[1:] # strip off the entry description
# See if we have a @project.
project_name = None
if len(args) >= 1 and args[0][0] == '@':
project_name = find_project(args[0][1:])
args = args[1:] # strip off the project
# Create JSON object to send to toggl.
data = create_time_entry_json(entry, project_name, 0)
if len(args) == 1:
tz = pytz.timezone(toggl_cfg.get('options', 'timezone'))
st = tz.localize(parse(args[0]))
data['time_entry']['start'] = st.astimezone(pytz.utc).isoformat()
if options.verbose:
print json.dumps(data)
headers = {'content-type': 'application/json'}
r = requests.post("%s/time_entries/start" % TOGGL_URL, auth=AUTH,
data=json.dumps(data), headers=headers)
r.raise_for_status() # raise exception on error
return 0
def stop_time_entry(args=None):
"""Stops the current time entry (duration is negative)."""
global toggl_cfg
entry = get_current_time_entry()
if entry != None:
# Get the start time from the entry, converted to UTC.
start_time = iso8601.parse_date(entry['start']).astimezone(pytz.utc)
if args != None and len(args) == 1:
tz = pytz.timezone(toggl_cfg.get('options', 'timezone'))
stop_time = tz.localize(parse(args[0])).astimezone(pytz.utc)
else:
# Get stop time(now) in UTC.
stop_time = datetime.datetime.now(pytz.utc)
# Create the payload.
data = { 'time_entry' : entry }
data['time_entry']['stop'] = stop_time.isoformat()
data['time_entry']['duration'] = (stop_time - start_time).seconds
url = "%s/time_entries/%d" % (TOGGL_URL, entry['id'])
global options
if options.verbose:
print url
headers = {'content-type': 'application/json'}
r = requests.put(url, auth=AUTH, data=json.dumps(data), headers=headers)
r.raise_for_status() # raise exception on error
else:
print >> sys.stderr, "You're not working on anything right now."
return 1
return 0
def visit_web():
os.system(VISIT_WWW_COMMAND)
def create_default_cfg():
cfg = ConfigParser.RawConfigParser()
cfg.add_section('auth')
cfg.set('auth', 'username', '[email protected]')
cfg.set('auth', 'password', 'secretpasswd')
cfg.add_section('options')
cfg.set('options', 'ignore_start_times', 'False')
cfg.set('options', 'timezone', 'UTC')
with open(os.path.expanduser('~/.togglrc'), 'w') as cfgfile:
cfg.write(cfgfile)
os.chmod(os.path.expanduser('~/.togglrc'), 0600)
def main(argv=None):
"""Program entry point."""
global toggl_cfg
toggl_cfg = ConfigParser.ConfigParser()
if toggl_cfg.read(os.path.expanduser('~/.togglrc')) == []:
create_default_cfg()
print "Missing ~/.togglrc. A default has been created for editing."
return 1
global AUTH, IGNORE_START_TIMES
AUTH = (toggl_cfg.get('auth', 'username').strip(), toggl_cfg.get('auth', 'password').strip())
IGNORE_START_TIMES = toggl_cfg.getboolean('options', 'ignore_start_times')
# Override the option parser epilog formatting rule.
# See http://stackoverflow.com/questions/1857346/python-optparse-how-to-include-additional-info-in-usage-output
optparse.OptionParser.format_epilog = lambda self, formatter: self.epilog
global parser, options
parser = optparse.OptionParser(usage="Usage: %prog [OPTIONS] [ACTION]", \
epilog="\nActions:\n"
" add ENTRY [@PROJECT] DURATION\t\tcreates a completed time entry\n"
" ls\t\t\t\t\tlist recent time entries\n"
" rm ID\t\t\t\t\tdelete a time entry by id\n"
" now\t\t\t\t\tprint what you're working on now\n"
" projects\t\t\t\tlists all projects\n"
" start ENTRY [@PROJECT] [DATETIME]\tstarts a new entry\n"
" stop [DATETIME]\t\t\tstops the current entry\n"
" www\t\t\t\t\tvisits toggl.com\n"
"\n"
" DURATION = [[Hours:]Minutes:]Seconds\n")
parser.add_option("-v", "--verbose",
action="store_true", dest="verbose", default=False,
help="print debugging output")
parser.add_option("-i", "--ignore",
action="store_true", dest="ignore_start_and_stop", default=IGNORE_START_TIMES,
help="ignore starting and ending times")
parser.add_option("-n", "--no_ignore",
action="store_false", dest="ignore_start_and_stop", default=IGNORE_START_TIMES,
help="don't ignore starting and ending times")
(options, args) = parser.parse_args()
if len(args) == 0 or args[0] == "ls":
return list_time_entries()
elif args[0] == "add":
return add_time_entry(args[1:])
elif args[0] == "now":
return list_current_time_entry()
elif args[0] == "projects":
return list_projects()
elif args[0] == "start":
return start_time_entry(args[1:])
elif args[0] == "stop":
if len(args) > 1:
return stop_time_entry(args[1:])
else:
return stop_time_entry()
elif args[0] == "www":
return visit_web()
elif args[0] == "rm":
return delete_time_entry(args[1:])
else:
parser.print_help()
return 1
if __name__ == "__main__":
sys.exit(main())