diff --git a/.gitignore b/.gitignore index 41ea6bdc..d02b1012 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ site/ .vagrant/ .venv/ .pytest_cache/ +venv/ # files diff --git a/docs/user-guide/commands.md b/docs/user-guide/commands.md index aae3d9b4..58bf3dbd 100644 --- a/docs/user-guide/commands.md +++ b/docs/user-guide/commands.md @@ -689,17 +689,23 @@ If '--at' option is given, the provided stopping time is used. The specified time must be after the begin of the to be ended frame and must not be in the future. -Example: +You can optionally pass a log message to be saved with the frame via +the ``-n/--note`` option. +Example: $ watson stop --at 13:37 Stopping project apollo11, started an hour ago and stopped 30 minutes ago. (id: e9ccd52) # noqa: E501 + $ watson stop -n "Done some thinking" + Stopping project apollo11, started a minute ago. (id: e7ccd52) + Log message: Done some thinking ### Options Flag | Help -----|----- `--at TIME` | Stop frame at this time. Must be in (YYYY-MM-DDT)?HH:MM(:SS)? format. +`-n, --note TEXT` | Save given log message with the project frame. `--help` | Show this message and exit. ## `sync` diff --git a/tests/test_utils.py b/tests/test_utils.py index d31793ba..08b1cc52 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -283,7 +283,7 @@ def test_frames_to_csv(watson): result = frames_to_csv(watson.frames) read_csv = list(csv.reader(StringIO(result))) - header = ['id', 'start', 'stop', 'project', 'tags'] + header = ['id', 'start', 'stop', 'project', 'tags', 'note'] assert len(read_csv) == 2 assert read_csv[0] == header assert read_csv[1][3] == 'foo' @@ -302,7 +302,7 @@ def test_frames_to_json(watson): result = json.loads(frames_to_json(watson.frames)) - keys = {'id', 'start', 'stop', 'project', 'tags'} + keys = {'id', 'start', 'stop', 'project', 'tags', 'note'} assert len(result) == 1 assert set(result[0].keys()) == keys assert result[0]['project'] == 'foo' diff --git a/tests/test_watson.py b/tests/test_watson.py index bb295485..0e2fcb5f 100644 --- a/tests/test_watson.py +++ b/tests/test_watson.py @@ -150,6 +150,54 @@ def test_frames_without_tags(mocker, watson): assert watson.frames[0].tags == [] +def test_frames_with_note(mocker, watson): + """Test loading frames with notes.""" + content = json.dumps([ + [3601, 3610, 'foo', 'abcdefg', ['A', 'B', 'C'], 3650, + "My hovercraft is full of eels"] + ]) + + mocker.patch('%s.open' % builtins, mocker.mock_open(read_data=content)) + assert len(watson.frames) == 1 + frame = watson.frames['abcdefg'] + assert frame.id == 'abcdefg' + assert frame.project == 'foo' + assert frame.start == arrow.get(3601) + assert frame.stop == arrow.get(3610) + assert frame.tags == ['A', 'B', 'C'] + assert frame.note == "My hovercraft is full of eels" + + +def test_frames_without_note(mocker, watson): + """Test loading frames without notes.""" + content = json.dumps([ + [3601, 3610, 'foo', 'abcdefg'], + [3611, 3620, 'foo', 'hijklmn', ['A', 'B', 'C']], + [3621, 3630, 'foo', 'opqrstu', ['A', 'B', 'C'], 3630] + ]) + + mocker.patch('%s.open' % builtins, mocker.mock_open(read_data=content)) + assert len(watson.frames) == 3 + frame = watson.frames['abcdefg'] + assert frame.id == 'abcdefg' + assert frame.project == 'foo' + assert frame.start == arrow.get(3601) + assert frame.stop == arrow.get(3610) + assert frame.tags == [] + assert frame.note is None + + frame = watson.frames['hijklmn'] + assert frame.id == 'hijklmn' + assert frame.tags == ['A', 'B', 'C'] + assert frame.note is None + + frame = watson.frames['opqrstu'] + assert frame.id == 'opqrstu' + assert frame.tags == ['A', 'B', 'C'] + assert frame.updated_at == arrow.get(3630) + assert frame.note is None + + def test_frames_with_empty_file(mocker, watson): mocker.patch('%s.open' % builtins, mocker.mock_open(read_data="")) mocker.patch('os.path.getsize', return_value=0) @@ -302,6 +350,32 @@ def test_stop_started_project_without_tags(watson): assert watson.frames[0].tags == [] +def test_stop_started_project_without_note(watson): + """Test stopping watson without adding a note.""" + watson.start('foo') + watson.stop() + + assert watson.current == {} + assert watson.is_started is False + assert len(watson.frames) == 1 + frame = watson.frames[0] + assert frame.project == 'foo' + assert frame.note is None + + +def test_stop_started_project_with_note(watson): + """Test stopping watson when adding a note.""" + watson.start('foo') + watson.stop(None, "My hovercraft is full of eels") + + assert watson.current == {} + assert watson.is_started is False + assert len(watson.frames) == 1 + frame = watson.frames[0] + assert frame.project == 'foo' + assert frame.note == "My hovercraft is full of eels" + + def test_stop_no_project(watson): with pytest.raises(WatsonError): watson.stop() @@ -388,7 +462,8 @@ def test_save_empty_current(config_dir, mocker, json_mock): assert json_mock.call_count == 1 result = json_mock.call_args[0][0] - assert result == {'project': 'foo', 'start': 4000, 'tags': []} + assert result == {'project': 'foo', 'start': 4000, + 'tags': [], 'note': None} watson.current = {} watson.save() @@ -748,9 +823,12 @@ def test_report(watson): assert 'time' in report['projects'][0]['tags'][0] assert report['projects'][0]['tags'][1]['name'] == 'B' assert 'time' in report['projects'][0]['tags'][1] + assert len(report['projects'][0]['notes']) == 0 + assert len(report['projects'][0]['tags'][0]['notes']) == 0 + assert len(report['projects'][0]['tags'][1]['notes']) == 0 watson.start('bar', tags=['C']) - watson.stop() + watson.stop(note='bar note') report = watson.report(arrow.now(), arrow.now()) assert len(report['projects']) == 2 @@ -759,6 +837,13 @@ def test_report(watson): assert len(report['projects'][0]['tags']) == 1 assert report['projects'][0]['tags'][0]['name'] == 'C' + assert len(report['projects'][1]['notes']) == 0 + assert len(report['projects'][1]['tags'][0]['notes']) == 0 + assert len(report['projects'][1]['tags'][1]['notes']) == 0 + assert len(report['projects'][0]['notes']) == 0 + assert len(report['projects'][0]['tags'][0]['notes']) == 1 + assert report['projects'][0]['tags'][0]['notes'][0] == 'bar note' + report = watson.report( arrow.now(), arrow.now(), projects=['foo'], tags=['B'] ) @@ -768,16 +853,36 @@ def test_report(watson): assert report['projects'][0]['tags'][0]['name'] == 'B' watson.start('baz', tags=['D']) - watson.stop() + watson.stop(note='baz note') + + watson.start('foo') + watson.stop(note='foo no tags') + + watson.start('foo', tags=['A']) + watson.stop(note='foo one tag A') report = watson.report(arrow.now(), arrow.now(), projects=["foo"]) + assert len(report['projects']) == 1 + assert len(report['projects'][0]['notes']) == 1 + # A project-level note because this frame has no tags + assert report['projects'][0]['notes'][0] == 'foo no tags' + assert len(report['projects'][0]['tags']) == 2 + assert report['projects'][0]['tags'][0]['name'] == 'A' + assert report['projects'][0]['tags'][1]['name'] == 'B' + assert len(report['projects'][0]['tags'][0]['notes']) == 1 + assert len(report['projects'][0]['tags'][1]['notes']) == 0 + # A tag-level note because this frame has tags + assert report['projects'][0]['tags'][0]['notes'][0] == 'foo one tag A' report = watson.report(arrow.now(), arrow.now(), ignore_projects=["bar"]) assert len(report['projects']) == 2 report = watson.report(arrow.now(), arrow.now(), tags=["A"]) assert len(report['projects']) == 1 + assert len(report['projects'][0]['notes']) == 0 + assert len(report['projects'][0]['tags'][0]['notes']) == 1 + assert report['projects'][0]['tags'][0]['notes'][0] == 'foo one tag A' report = watson.report(arrow.now(), arrow.now(), ignore_tags=["D"]) assert len(report['projects']) == 2 diff --git a/watson.completion b/watson.completion index 6c675b3c..942a4391 100644 --- a/watson.completion +++ b/watson.completion @@ -18,4 +18,4 @@ _watson_completionetup() { complete $COMPLETION_OPTIONS -F _watson_completion watson } -_watson_completionetup; +_watson_completionetup; \ No newline at end of file diff --git a/watson/cli.py b/watson/cli.py index 1646dc1a..ff903789 100644 --- a/watson/cli.py +++ b/watson/cli.py @@ -29,6 +29,7 @@ confirm_tags, create_watson, flatten_report_for_csv, + format_note, format_timedelta, frames_to_csv, frames_to_json, @@ -161,16 +162,21 @@ def help(ctx, command): click.echo(cmd.get_help(ctx)) -def _start(watson, project, tags, restart=False, gap=True): +def _start(watson, project, tags, restart=False, gap=True, + note=None): """ Start project with given list of tags and save status. """ - current = watson.start(project, tags, restart=restart, gap=gap) + current = watson.start(project, tags, restart=restart, gap=gap, + note=note) click.echo(u"Starting project {}{} at {}".format( style('project', project), (" " if current['tags'] else "") + style('tags', current['tags']), style('time', "{:HH:mm}".format(current['start'])) )) + if note: + click.echo(format_note(note)) + watson.save() @@ -184,10 +190,13 @@ def _start(watson, project, tags, restart=False, gap=True): help="Confirm addition of new project.") @click.option('-b', '--confirm-new-tag', is_flag=True, default=False, help="Confirm creation of new tag.") +@click.option('-n', '--note', type=str, default=None, + help="A brief note that describe time entry being started") @click.pass_obj @click.pass_context @catch_watson_error -def start(ctx, watson, confirm_new_project, confirm_new_tag, args, gap_=True): +def start(ctx, watson, confirm_new_project, confirm_new_tag, args, gap_=True, + note=None): """ Start monitoring time for the given project. You can add tags indicating more specifically what you are working on with @@ -227,6 +236,7 @@ def start(ctx, watson, confirm_new_project, confirm_new_tag, args, gap_=True): if project and watson.is_started and not gap_: current = watson.current + # TODO: log in error note errmsg = ("Project '{}' is already started and '--no-gap' is passed. " "Please stop manually.") raise click.ClickException( @@ -239,16 +249,18 @@ def start(ctx, watson, confirm_new_project, confirm_new_tag, args, gap_=True): watson.config.getboolean('options', 'stop_on_start')): ctx.invoke(stop) - _start(watson, project, tags, gap=gap_) + _start(watson, project, tags, gap=gap_, note=note) @cli.command(context_settings={'ignore_unknown_options': True}) @click.option('--at', 'at_', type=DateTime, default=None, help=('Stop frame at this time. Must be in ' '(YYYY-MM-DDT)?HH:MM(:SS)? format.')) +@click.option('-n', '--note', 'note', default=None, + help="Save given log note with the project frame.") @click.pass_obj @catch_watson_error -def stop(watson, at_): +def stop(watson, at_, note): """ Stop monitoring time for the current project. @@ -256,13 +268,22 @@ def stop(watson, at_): specified time must be after the beginning of the to-be-ended frame and must not be in the future. - Example: + You can optionally pass a log message to be saved with the frame via + the ``-n/--note`` option. + + Examples: \b + + $ watson stop -n "Done some thinking" + Stopping project apollo11, started a minute ago. (id: e7ccd52) + >> Done some thinking + $ watson stop --at 13:37 Stopping project apollo11, started an hour ago and stopped 30 minutes ago. (id: e9ccd52) # noqa: E501 """ - frame = watson.stop(stop_at=at_) + frame = watson.stop(stop_at=at_, note=note) + output_str = u"Stopping project {}{}, started {} and stopped {}. (id: {})" click.echo(output_str.format( style('project', frame.project), @@ -271,6 +292,10 @@ def stop(watson, at_): style('time', frame.stop.humanize()), style('short_id', frame.id), )) + + if frame.note: + click.echo(format_note(frame.note)) + watson.save() @@ -411,6 +436,12 @@ def status(watson, project, tags, elapsed): style('time', current['start'].strftime(timefmt)) )) + if current['note']: + click.echo(u"{}{}".format( + style('note', '>> '), + style('note', current['note']) + )) + _SHORTCUT_OPTIONS = ['all', 'year', 'month', 'luna', 'week', 'day'] _SHORTCUT_OPTIONS_VALUES = { @@ -485,11 +516,13 @@ def status(watson, project, tags, elapsed): help="Format output in plain text (default)") @click.option('-g/-G', '--pager/--no-pager', 'pager', default=None, help="(Don't) view output through a pager.") +@click.option('-n', '--note', 'show_notes', default=False, is_flag=True, + help="Show frame notes in report.") @click.pass_obj @catch_watson_error def report(watson, current, from_, to, projects, tags, ignore_projects, ignore_tags, year, month, week, day, luna, all, output_format, - pager, aggregated=False): + pager, aggregated=False, show_notes=False): """ Display a report of the time spent on each project. @@ -515,6 +548,10 @@ def report(watson, current, from_, to, projects, tags, ignore_projects, If you are outputting to the terminal, you can selectively enable a pager through the `--pager` option. + You can include frame notes in the report by passing the --notes + option. Messages will always be present in *JSON* reports. Messages are + never included in *CSV* reports. + You can change the output format for the report from *plain text* to *JSON* using the `--json` option or to *CSV* using the `--csv` option. Only one of these two options can be used at once. @@ -569,14 +606,16 @@ def report(watson, current, from_, to, projects, tags, ignore_projects, "tags": [ { "name": "export", - "time": 530.0 + "time": 530.0, + "notes": ["working hard"] }, { "name": "report", "time": 530.0 } ], - "time": 530.0 + "time": 530.0, + "notes": ["fixing bug #74", "refactor tests"] } ], "time": 530.0, @@ -675,6 +714,13 @@ def _final_print(lines): project=style('project', project['name']) )) + if show_notes: + for note in project['notes']: + _print(u'{tab}{note}'.format( + tab=tab, + note=format_note(note), + )) + tags = project['tags'] if tags: longest_tag = max(len(tag) for tag in tags or ['']) @@ -688,6 +734,13 @@ def _final_print(lines): tag['name'], longest_tag )), )) + + if show_notes: + for note in tag['notes']: + _print(u'\t{tab}{note}'.format( + tab=tab, + note=format_note(note), + )) _print("") # only show total time at the bottom for a project if it is not @@ -742,11 +795,13 @@ def _final_print(lines): help="Format output in plain text (default)") @click.option('-g/-G', '--pager/--no-pager', 'pager', default=None, help="(Don't) view output through a pager.") +@click.option('-n', '--note', 'show_notes', default=False, is_flag=True, + help="Show frame notes in report.") @click.pass_obj @click.pass_context @catch_watson_error def aggregate(ctx, watson, current, from_, to, projects, tags, output_format, - pager): + pager, show_notes): """ Display a report of the time spent on each project aggregated by day. @@ -825,7 +880,7 @@ def aggregate(ctx, watson, current, from_, to, projects, tags, output_format, output = ctx.invoke(report, current=current, from_=from_offset, to=from_offset, projects=projects, tags=tags, output_format=output_format, - pager=pager, aggregated=True) + pager=pager, aggregated=True, show_notes=show_notes) if 'json' in output_format: lines.append(output) @@ -907,10 +962,12 @@ def aggregate(ctx, watson, current, from_, to, projects, tags, output_format, help="Format output in plain text (default)") @click.option('-g/-G', '--pager/--no-pager', 'pager', default=None, help="(Don't) view output through a pager.") +@click.option('-n/-N', '--notes/--no-notes', 'show_notes', default=True, + help="(Don't) output notes.") @click.pass_obj @catch_watson_error def log(watson, current, from_, to, projects, tags, year, month, week, day, - luna, all, output_format, pager): + luna, all, output_format, pager, show_notes): """ Display each recorded session during the given timespan. @@ -936,6 +993,9 @@ def log(watson, current, from_, to, projects, tags, year, month, week, day, `--json` option or to *CSV* using the `--csv` option. Only one of these two options can be used at once. + You can control whether or not notes for each frame are displayed by + passing --notes or --no-notes. + Example: \b @@ -966,12 +1026,12 @@ def log(watson, current, from_, to, projects, tags, year, month, week, day, 1070ddb 13:48 to 16:17 2h 29m 11s voyager1 [antenna, sensors] \b $ watson log --from 2014-04-16 --to 2014-04-17 --csv - id,start,stop,project,tags - a96fcde,2014-04-17 09:15,2014-04-17 09:43,hubble,"lens, camera, transmission" - 5e91316,2014-04-17 10:19,2014-04-17 12:59,hubble,"camera, transmission" - 761dd51,2014-04-17 14:42,2014-04-17 15:54,voyager1,antenna - 02cb269,2014-04-16 09:53,2014-04-16 12:43,apollo11,wheels - 1070ddb,2014-04-16 13:48,2014-04-16 16:17,voyager1,"antenna, sensors" + id,start,stop,project,tags,note + a96fcde,2014-04-17 09:15,2014-04-17 09:43,hubble,"lens, camera, transmission", + 5e91316,2014-04-17 10:19,2014-04-17 12:59,hubble,"camera, transmission", + 761dd51,2014-04-17 14:42,2014-04-17 15:54,voyager1,antenna, + 02cb269,2014-04-16 09:53,2014-04-16 12:43,apollo11,wheels, + 1070ddb,2014-04-16 13:48,2014-04-16 16:17,voyager1,"antenna, sensors", """ # noqa for start_time in (_ for _ in [day, week, month, luna, year, all] if _ is not None): @@ -985,7 +1045,8 @@ def log(watson, current, from_, to, projects, tags, year, month, week, day, watson.config.getboolean('options', 'log_current')): cur = watson.current watson.frames.add(cur['project'], cur['start'], arrow.utcnow(), - cur['tags'], id="current") + cur['tags'], id="current", + note=cur['note']) span = watson.frames.span(from_, to) filtered_frames = watson.frames.filter( @@ -1042,20 +1103,22 @@ def _final_print(lines): ) ) - _print("\n".join( - u"\t{id} {start} to {stop} {delta:>11} {project}{tags}".format( - delta=format_timedelta(frame.stop - frame.start), - project=style('project', u'{:>{}}'.format( - frame.project, longest_project - )), - pad=longest_project, - tags=(" "*2 if frame.tags else "") + style('tags', frame.tags), - start=style('time', '{:HH:mm}'.format(frame.start)), - stop=style('time', '{:HH:mm}'.format(frame.stop)), - id=style('short_id', frame.id) - ) - for frame in frames - )) + for frame in frames: + _print(u"\t{id} {start} to {stop} {delta:>11} {project}{tags}" + .format( + delta=format_timedelta(frame.stop - frame.start), + project=style('project', u'{:>{}}'.format( + frame.project, longest_project + )), + pad=longest_project, + tags=(" "*2 if frame.tags else "") + + style('tags', frame.tags), + start=style('time', '{:HH:mm}'.format(frame.start)), + stop=style('time', '{:HH:mm}'.format(frame.stop)), + id=style('short_id', frame.id) + )) + if frame.note is not None and show_notes: + _print(u"\t{}{}".format(" "*9, format_note(frame.note))) _final_print(lines) @@ -1218,7 +1281,8 @@ def edit(watson, confirm_new_project, confirm_new_tag, id): id = frame.id elif watson.is_started: frame = Frame(watson.current['start'], None, watson.current['project'], - None, watson.current['tags']) + None, watson.current['tags'], None, + watson.current['note']) elif watson.frames: frame = watson.frames[-1] id = frame.id @@ -1231,11 +1295,15 @@ def edit(watson, confirm_new_project, confirm_new_tag, id): 'start': frame.start.format(datetime_format), 'project': frame.project, 'tags': frame.tags, + 'note': "" if frame.note is None else frame.note, } if id: data['stop'] = frame.stop.format(datetime_format) + if frame.note is not None and len(frame.note) > 0: + data['note'] = frame.note + text = json.dumps(data, indent=4, sort_keys=True, ensure_ascii=False) start = None @@ -1271,6 +1339,7 @@ def edit(watson, confirm_new_project, confirm_new_tag, id): if not watson.is_started and start > stop: raise ValueError( "Task cannot end before it starts.") + note = data.get('note') # break out of while loop and continue execution of # the edit function normally break @@ -1291,9 +1360,17 @@ def edit(watson, confirm_new_project, confirm_new_tag, id): # we reach this when we break out of the while loop above if id: - watson.frames[id] = (project, start, stop, tags) + if all((project == frame.project, start == frame.start, + stop == frame.stop, tags == frame.tags, + note == frame.note)): + updated_at = frame.updated_at + else: + updated_at = arrow.utcnow() + + watson.frames[id] = (project, start, stop, tags, updated_at, note) else: - watson.current = dict(start=start, project=project, tags=tags) + watson.current = dict(start=start, project=project, tags=tags, + note=note) watson.save() click.echo( @@ -1313,6 +1390,9 @@ def edit(watson, confirm_new_project, confirm_new_tag, id): ) ) + if note is not None: + click.echo("Message: {}".format(style('note', note))) + @cli.command(context_settings={'ignore_unknown_options': True}) @click.argument('id', autocompletion=get_frames) @@ -1552,7 +1632,8 @@ def merge(watson, frames_with_conflict, force): 'project': original_frame.project, 'start': original_frame.start.format(date_format), 'stop': original_frame.stop.format(date_format), - 'tags': original_frame.tags + 'tags': original_frame.tags, + 'note': original_frame.note } click.echo("frame {}:".format(style('short_id', original_frame.id))) click.echo(u"{}".format('\n'.join('<' + line for line in json.dumps( @@ -1584,7 +1665,8 @@ def merge(watson, frames_with_conflict, force): 'project': conflict_frame_copy.project, 'start': conflict_frame_copy.start.format(date_format), 'stop': conflict_frame_copy.stop.format(date_format), - 'tags': conflict_frame_copy.tags + 'tags': conflict_frame_copy.tags, + 'note': conflict_frame_copy.note } click.echo("{}".format('\n'.join('>' + line for line in json.dumps( conflict_frame_data, indent=4, ensure_ascii=False).splitlines()))) @@ -1598,10 +1680,9 @@ def merge(watson, frames_with_conflict, force): # merge in any non-conflicting frames for frame in merging: - start, stop, project, id, tags, updated_at = frame.dump() + start, stop, project, id, tags, updated_at, note = frame.dump() original_frames.add(project, start, stop, tags=tags, id=id, - updated_at=updated_at) - + updated_at=updated_at, note=note) watson.frames = original_frames watson.frames.changed = True watson.save() diff --git a/watson/frames.py b/watson/frames.py index b70b2cac..eeea903f 100644 --- a/watson/frames.py +++ b/watson/frames.py @@ -4,11 +4,12 @@ from collections import namedtuple -HEADERS = ('start', 'stop', 'project', 'id', 'tags', 'updated_at') +HEADERS = ('start', 'stop', 'project', 'id', 'tags', 'updated_at', 'note') class Frame(namedtuple('Frame', HEADERS)): - def __new__(cls, start, stop, project, id, tags=None, updated_at=None,): + def __new__(cls, start, stop, project, id, tags=None, updated_at=None, + note=None): try: if not isinstance(start, arrow.Arrow): start = arrow.get(start) @@ -31,7 +32,7 @@ def __new__(cls, start, stop, project, id, tags=None, updated_at=None,): tags = [] return super(Frame, cls).__new__( - cls, start, stop, project, id, tags, updated_at + cls, start, stop, project, id, tags, updated_at, note ) def dump(self): @@ -39,7 +40,8 @@ def dump(self): stop = self.stop.to('utc').timestamp updated_at = self.updated_at.timestamp - return (start, stop, self.project, self.id, self.tags, updated_at) + return (start, stop, self.project, self.id, self.tags, updated_at, + self.note) @property def day(self): @@ -134,11 +136,11 @@ def add(self, *args, **kwargs): return frame def new_frame(self, project, start, stop, tags=None, id=None, - updated_at=None): + updated_at=None, note=None): if not id: id = uuid.uuid4().hex return Frame(start, stop, project, id, tags=tags, - updated_at=updated_at) + updated_at=updated_at, note=note) def dump(self): return tuple(frame.dump() for frame in self._rows) diff --git a/watson/utils.py b/watson/utils.py index 2ec24039..2c1a08dc 100644 --- a/watson/utils.py +++ b/watson/utils.py @@ -84,7 +84,8 @@ def _style_short_id(id): 'error': {'fg': 'red'}, 'date': {'fg': 'cyan'}, 'short_id': _style_short_id, - 'id': {'fg': 'white'} + 'id': {'fg': 'white'}, + 'note': {'fg': 'white'}, } fmt = formats.get(name, {}) @@ -318,6 +319,7 @@ def frames_to_json(frames): ('stop', frame.stop.isoformat()), ('project', frame.project), ('tags', frame.tags), + ('note', frame.note), ]) for frame in frames ] @@ -340,6 +342,7 @@ def frames_to_csv(frames): ('stop', frame.stop.format('YYYY-MM-DD HH:mm:ss')), ('project', frame.project), ('tags', ', '.join(frame.tags)), + ('note', frame.note if frame.note else "") ]) for frame in frames ] @@ -420,3 +423,10 @@ def json_arrow_encoder(obj): return obj.for_json() raise TypeError("Object {} is not JSON serializable".format(obj)) + + +def format_note(note): + return u"{}{}".format( + style('note', '>> '), + style('note', note) + ) diff --git a/watson/watson.py b/watson/watson.py index 9077c888..69c03bae 100644 --- a/watson/watson.py +++ b/watson/watson.py @@ -148,6 +148,7 @@ def save(self): 'project': self.current['project'], 'start': self._format_date(self.current['start']), 'tags': self.current['tags'], + 'note': self.current.get('note'), } else: current = {} @@ -209,7 +210,8 @@ def current(self, value): self._current = { 'project': value['project'], 'start': start, - 'tags': value.get('tags') or [] + 'tags': value.get('tags') or [], + 'note': value.get('note'), } if self._old_state is None: @@ -251,7 +253,7 @@ def add(self, project, from_date, to_date, tags): frame = self.frames.add(project, from_date, to_date, tags=tags) return frame - def start(self, project, tags=None, restart=False, gap=True): + def start(self, project, tags=None, restart=False, gap=True, note=None): if self.is_started: raise WatsonError( u"Project {} is already started.".format( @@ -263,14 +265,15 @@ def start(self, project, tags=None, restart=False, gap=True): if not restart: tags = (tags or []) + default_tags - new_frame = {'project': project, 'tags': deduplicate(tags)} + new_frame = {'project': project, 'tags': deduplicate(tags), + 'note': note} if not gap: stop_of_prev_frame = self.frames[-1].stop new_frame['start'] = stop_of_prev_frame self.current = new_frame return self.current - def stop(self, stop_at=None): + def stop(self, stop_at=None, note=None): if not self.is_started: raise WatsonError("No project started.") @@ -288,9 +291,14 @@ def stop(self, stop_at=None): if stop_at > arrow.now(): raise WatsonError('Task cannot end in the future.') + if note is None: + note = old.get('note') + frame = self.frames.add( - old['project'], old['start'], stop_at, tags=old['tags'] + old['project'], old['start'], stop_at, tags=old['tags'], + note=note ) + self.current = None return frame @@ -476,6 +484,9 @@ def report(self, from_, to, current=None, projects=None, tags=None, span = self.frames.span(from_, to) + if tags is None: + tags = [] + frames_by_project = sorted_groupby( self.frames.filter( projects=projects or None, tags=tags or None, @@ -508,20 +519,34 @@ def report(self, from_, to, current=None, projects=None, tags=None, ) total += delta - project_report = { - 'name': project, - 'time': delta.total_seconds(), - 'tags': [] - } - - if tags is None: - tags = [] - tags_to_print = sorted( set(tag for frame in frames for tag in frame.tags if tag in tags or not tags) ) + project_notes = [] + for frame in frames: + # If the user is trying to print out all frames in the project + # (tags will be empty because no tags were passed) + if not tags and frame.note: + # And this frame has no tags... + if not frame.tags: + # Add it to the project-level notes because it + # won't get included in the tag-level notes + # because it has no tag. + project_notes.append(frame.note) + # And this frame has a tag... + else: + # Let the tag-level filter handle this frame later on + pass + + project_report = { + 'name': project, + 'time': delta.total_seconds(), + 'tags': [], + 'notes': project_notes, + } + for tag in tags_to_print: delta = reduce( operator.add, @@ -529,9 +554,13 @@ def report(self, from_, to, current=None, projects=None, tags=None, datetime.timedelta() ) + tag_notes = [frame.note for frame in frames + if tag in frame.tags and frame.note] + project_report['tags'].append({ 'name': tag, - 'time': delta.total_seconds() + 'time': delta.total_seconds(), + 'notes': tag_notes }) report['projects'].append(project_report)