diff --git a/ti/__init__.py b/ti/__init__.py index 89f00bf..473d2ec 100755 --- a/ti/__init__.py +++ b/ti/__init__.py @@ -1,3 +1,4 @@ +#!/usr/bin/env python # coding: utf-8 """ @@ -10,7 +11,7 @@ ti (s|status) ti (t|tag) ... ti (n|note) ... - ti (l|log) [today] + ti (l|log) [today|yyyy-mm-dd] ti (e|edit) ti (i|interrupt) ti --no-color @@ -35,12 +36,14 @@ import subprocess import sys import tempfile +import argparse from datetime import datetime, timedelta from collections import defaultdict from os import path - import yaml -from colorama import Fore + + +NOW = datetime.now().replace(second=0, microsecond=0) class TIError(Exception): @@ -67,10 +70,6 @@ class BadTime(TIError): """Time string can't be parsed.""" -class BadArguments(TIError): - """The command line arguments passed are not valid.""" - - class JsonStore(object): def __init__(self, filename): @@ -92,85 +91,39 @@ def dump(self, data): json.dump(data, f, separators=(',', ': '), indent=2) -def red(str): - if use_color: - return Fore.RED + str + Fore.RESET - else: - return str - - -def green(str): - if use_color: - return Fore.GREEN + str + Fore.RESET - else: - return str - - -def yellow(str): - if use_color: - return Fore.YELLOW + str + Fore.RESET - else: - return str - - -def blue(str): - if use_color: - return Fore.BLUE + str + Fore.RESET - else: - return str - - -color_regex = re.compile("(\x9B|\x1B\\[)[0-?]*[ -\/]*[@-~]") - - -def strip_color(str): - """Strip color from string.""" - return color_regex.sub("", str) - - -def len_color(str): - """Compute how long the color escape sequences in the string are.""" - return len(str) - len(strip_color(str)) - - -def ljust_with_color(str, n): - """ljust string that might contain color.""" - return str.ljust(n + len_color(str)) - - def action_on(name, time): - data = store.load() + data = STORE.load() work = data['work'] - if work and 'end' not in work[-1]: raise AlreadyOn("You are already working on %s. Stop it or use a " - "different sheet." % (yellow(work[-1]['name']),)) + "different sheet." % (work[-1]['name'],)) entry = { 'name': name, - 'start': time, + 'start': NOW.strftime("%Y-%m-%d") + " " + time, } work.append(entry) - store.dump(data) + STORE.dump(data) - print('Start working on ' + green(name) + '.') + print('Start working on ' + name + '.') def action_fin(time, back_from_interrupt=True): ensure_working() - data = store.load() + data = STORE.load() current = data['work'][-1] - current['end'] = time - store.dump(data) - print('So you stopped working on ' + red(current['name']) + '.') + current['end'] = NOW.strftime("%Y-%m-%d") + " " + time + STORE.dump(data) + print('So you stopped working on ' + current['name'] + '.') if back_from_interrupt and len(data['interrupt_stack']) > 0: - name = data['interrupt_stack'].pop()['name'] - store.dump(data) - action_on(name, time) + entry = data['interrupt_stack'].pop() + STORE.dump(data) + action_on(entry["name"], time) + action_tag(entry["tags"]) if len(data['interrupt_stack']) > 0: print('You are now %d deep in interrupts.' % len(data['interrupt_stack'])) @@ -183,23 +136,23 @@ def action_interrupt(name, time): action_fin(time, back_from_interrupt=False) - data = store.load() + data = STORE.load() if 'interrupt_stack' not in data: data['interrupt_stack'] = [] interrupt_stack = data['interrupt_stack'] interrupted = data['work'][-1] interrupt_stack.append(interrupted) - store.dump(data) + STORE.dump(data) - action_on('interrupt: ' + green(name), time) + action_on(name, time) print('You are now %d deep in interrupts.' % len(interrupt_stack)) def action_note(content): ensure_working() - data = store.load() + data = STORE.load() current = data['work'][-1] if 'notes' not in current: @@ -207,22 +160,23 @@ def action_note(content): else: current['notes'].append(content) - store.dump(data) + STORE.dump(data) - print('Yep, noted to ' + yellow(current['name']) + '.') + print('Yep, noted to ' + current['name'] + '.') def action_tag(tags): ensure_working() - data = store.load() + data = STORE.load() current = data['work'][-1] - current['tags'] = set(current.get('tags') or []) - current['tags'].update(tags) + + for tag in tags: + current['tags'].add(tag) current['tags'] = list(current['tags']) - store.dump(data) + STORE.dump(data) tag_count = len(tags) print("Okay, tagged current work with %d tag%s." @@ -232,64 +186,73 @@ def action_tag(tags): def action_status(): ensure_working() - data = store.load() + data = STORE.load() current = data['work'][-1] start_time = parse_isotime(current['start']) - diff = timegap(start_time, datetime.utcnow()) - print('You have been working on {0} for {1}.'.format( - green(current['name']), diff)) + print('You have been working on {0} since {1}.'.format( + current['name'], start_time.strftime("%H:%M"))) -def action_log(period): - data = store.load() +def action_log(startdate, enddate): + data = STORE.load() work = data['work'] + data['interrupt_stack'] - log = defaultdict(lambda: {'delta': timedelta()}) current = None - + tagList = defaultdict(lambda: {'delta': timedelta(), 'items': {}}) for item in work: start_time = parse_isotime(item['start']) - if 'end' in item: - log[item['name']]['delta'] += ( - parse_isotime(item['end']) - start_time) + if "end" not in item: + end_time = NOW + current = item["name"] else: - log[item['name']]['delta'] += datetime.utcnow() - start_time - current = item['name'] - + end_time = parse_isotime(item['end']) + if "tags" not in item: + item["tags"] = ["_noTag_"] + if (end_time.date() >= startdate.date() + and start_time.date() <= enddate.date()): + delta = end_time - start_time + for tag in item["tags"]: + tagList[tag]['delta'] += delta + if item['name'] in tagList[tag]['items'].keys(): + tagList[tag]['items'][item['name']] += delta + else: + tagList[tag]['items'][item['name']] = delta name_col_len = 0 - - for name, item in log.items(): - name_col_len = max(name_col_len, len(strip_color(name))) - - secs = item['delta'].total_seconds() - tmsg = [] - - if secs > 3600: - hours = int(secs // 3600) - secs -= hours * 3600 - tmsg.append(str(hours) + ' hour' + ('s' if hours > 1 else '')) - - if secs > 60: - mins = int(secs // 60) - secs -= mins * 60 - tmsg.append(str(mins) + ' minute' + ('s' if mins > 1 else '')) - - if secs: - tmsg.append(str(secs) + ' second' + ('s' if secs > 1 else '')) - - log[name]['tmsg'] = ', '.join(tmsg)[::-1].replace(',', '& ', 1)[::-1] - - for name, item in sorted(log.items(), key=(lambda x: x[0]), reverse=True): - print(ljust_with_color(name, name_col_len), ' ∙∙ ', item['tmsg'], - end=' ← working\n' if current == name else '\n') + for tag, items in sorted(tagList.items(), key=(lambda x: x[0])): + print(tag.ljust(max(name_col_len, len(tag))), + "--", + time_to_string(items["delta"])) + for name, delta in sorted(items["items"].items(), + key=(lambda x: x[0]), + reverse=True): + print("\t", name.ljust(max(name_col_len, len(name))), + ' ∙∙ ', + time_to_string(delta), + end=' ← working\n' if current == name else '\n') + + +def time_to_string(time): + secs = time.total_seconds() + tmsg = [] + + if secs > 3600: + hours = int(secs // 3600) + secs -= hours * 3600 + tmsg.append(str(hours) + ' hour' + ('s' if hours > 1 else '')) + + if secs > 60: + mins = int(secs // 60) + secs -= mins * 60 + tmsg.append(str(mins) + ' minute' + ('s' if mins > 1 else '')) + return ', '.join(tmsg)[::-1].replace(',', '& ', 1)[::-1] def action_edit(): if "EDITOR" not in os.environ: raise NoEditor("Please set the 'EDITOR' environment variable") - data = store.load() + data = STORE.load() yml = yaml.safe_dump(data, default_flow_style=False, allow_unicode=True) cmd = os.getenv('EDITOR') @@ -307,14 +270,15 @@ def action_edit(): try: data = yaml.load(yml) - except: + except (yaml.scanner.ScannerError, + yaml.parser.ParserError): raise InvalidYAML("Oops, that YAML doesn't appear to be valid!") - store.dump(data) + STORE.dump(data) def is_working(): - data = store.load() + data = STORE.load() return data.get('work') and 'end' not in data['work'][-1] @@ -328,154 +292,114 @@ def ensure_working(): def to_datetime(timestr): - return parse_engtime(timestr).isoformat() + 'Z' + return parse_engtime(timestr).strftime('%Y-%m-%d %H:%M') def parse_engtime(timestr): - now = datetime.utcnow() if not timestr or timestr.strip() == 'now': - return now - - match = re.match(r'(\d+|a) \s* (s|secs?|seconds?) \s+ ago $', - timestr, re.X) - if match is not None: - n = match.group(1) - seconds = 1 if n == 'a' else int(n) - return now - timedelta(seconds=seconds) + return NOW match = re.match(r'(\d+|a) \s* (mins?|minutes?) \s+ ago $', timestr, re.X) if match is not None: n = match.group(1) minutes = 1 if n == 'a' else int(n) - return now - timedelta(minutes=minutes) + return NOW - timedelta(minutes=minutes) match = re.match(r'(\d+|a|an) \s* (hrs?|hours?) \s+ ago $', timestr, re.X) if match is not None: n = match.group(1) hours = 1 if n in ['a', 'an'] else int(n) - return now - timedelta(hours=hours) + return NOW - timedelta(hours=hours) raise BadTime("Don't understand the time %r" % (timestr,)) def parse_isotime(isotime): - return datetime.strptime(isotime, '%Y-%m-%dT%H:%M:%S.%fZ') - - -def timegap(start_time, end_time): - diff = end_time - start_time - - mins = diff.total_seconds() // 60 - - if mins == 0: - return 'less than a minute' - elif mins == 1: - return 'a minute' - elif mins < 44: - return '{} minutes'.format(mins) - elif mins < 89: - return 'about an hour' - elif mins < 1439: - return 'about {} hours'.format(mins // 60) - elif mins < 2519: - return 'about a day' - elif mins < 43199: - return 'about {} days'.format(mins // 1440) - elif mins < 86399: - return 'about a month' - elif mins < 525599: - return 'about {} months'.format(mins // 43200) - else: - return 'more than a year' - - -def parse_args(argv=sys.argv): - global use_color - - if '--no-color' in argv: - use_color = False - argv.remove('--no-color') - - # prog = argv[0] - if len(argv) == 1: - raise BadArguments("You must specify a command.") - - head = argv[1] - tail = argv[2:] - - if head in ['-h', '--help', 'h', 'help']: - raise BadArguments() + return datetime.strptime(isotime, '%Y-%m-%d %H:%M') - elif head in ['e', 'edit']: - fn = action_edit - args = {} - elif head in ['o', 'on']: - if not tail: - raise BadArguments("Need the name of whatever you are working on.") - - fn = action_on - args = { - 'name': tail[0], - 'time': to_datetime(' '.join(tail[1:])), - } - - elif head in ['f', 'fin']: - fn = action_fin - args = {'time': to_datetime(' '.join(tail))} - - elif head in ['s', 'status']: - fn = action_status - args = {} - - elif head in ['l', 'log']: - fn = action_log - args = {'period': tail[0] if tail else None} - - elif head in ['t', 'tag']: - if not tail: - raise BadArguments("Please provide at least one tag to add.") - - fn = action_tag - args = {'tags': tail} - - elif head in ['n', 'note']: - if not tail: - raise BadArguments("Please provide some text to be noted.") - - fn = action_note - args = {'content': ' '.join(tail)} - - elif head in ['i', 'interrupt']: - if not tail: - raise BadArguments("Need the name of whatever you are working on.") - - fn = action_interrupt - args = { - 'name': tail[0], - 'time': to_datetime(' '.join(tail[1:])), - } +def validate_time(s): + try: + return datetime.strptime(s, "%H:%M") + except ValueError: + msg = "Not a valid time hh:mm: '{0}'.".format(s) + raise argparse.ArgumentTypeError(msg) - else: - raise BadArguments("I don't understand %r" % (head,)) - return fn, args +def validate_date(s): + try: + return datetime.strptime(s, "%Y-%m-%d") + except ValueError: + msg = "Not a valid date yyyy-mm-dd: '{0}'. U".format(s) + raise argparse.ArgumentTypeError(msg) def main(): + parser = argparse.ArgumentParser(description="ti is a simple and " + + "extensible time tracker for the " + + "command line.", prog="ti") + group = parser.add_mutually_exclusive_group() + group.add_argument("-o", "--on", action="store", + help="start an action to work on") + group.add_argument("-f", "--fin", action="store_true", + help="end an action") + group.add_argument("-s", "--status", action="store_true", + help="show status", default=False) + group.add_argument("-e", "--edit", action="store_true", + help="edit saved tasks", default=False) + group.add_argument("-i", "--interrupt", action="store", + help="interrupt the current task with a new task") + + group.add_argument("-l", "--log", action="store_true", + help="show log", default=False) + parser.add_argument("--at", action="store", help="start or stop actions at" + + "special time", + default=datetime.now().strftime("%H:%M"), + type=validate_time) + parser.add_argument("--start", action="store", + help="show log from", + default=datetime.now().strftime("%Y-%m-%d"), + type=validate_date) + parser.add_argument("--end", action="store", + help="show log from", + default=datetime.now().strftime("%Y-%m-%d"), + type=validate_date) + parser.add_argument("-t", "--tag", action="store", + help="add commasaperated tags to current task") + parser.add_argument("--note", action="store", + help="add a note to the current task") + try: - fn, args = parse_args() - fn(**args) + arguments = parser.parse_args() + if arguments.on: + action_on(arguments.on, str(arguments.at.hour) + ":" + + str(arguments.at.minute)) + if arguments.interrupt: + action_interrupt(arguments.interrupt, str(arguments.at.hour) + + ":" + str(arguments.at.minute)) + if arguments.log: + action_log(arguments.start, arguments.end) + if arguments.fin: + action_fin(str(arguments.at.hour) + ":" + str(arguments.at.minute)) + if arguments.tag: + action_tag(str(arguments.tag).split(",")) + if arguments.status: + action_status() + if arguments.edit: + action_edit() + if arguments.note: + action_note(arguments.note) + except TIError as e: msg = str(e) if len(str(e)) > 0 else __doc__ print(msg, file=sys.stderr) + parser.print_help() sys.exit(1) -store = JsonStore(os.getenv('SHEET_FILE', None) or +STORE = JsonStore(os.getenv('SHEET_FILE', None) or os.path.expanduser('~/.ti-sheet')) -use_color = True if __name__ == '__main__': main()