diff --git a/.gitignore b/.gitignore index 41ea6bd..7ef80db 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ site/ .vagrant/ .venv/ .pytest_cache/ +data/ # files diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d6e3a8..a4e99ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- notes can be added to frames, + reapplied aero31aeros work on the original watson https://github.com/jazzband/Watson/pull/405 + to watson-next, fixed some bugs, added some features + +## [3.1.1] - 2024-10-07 + + - ??? + ## [2.1.0] - 2022-05-16 ### Added diff --git a/Makefile b/Makefile index b3c8427..545c427 100644 --- a/Makefile +++ b/Makefile @@ -3,8 +3,8 @@ PYTHON ?= python PIP ?= pip -VENV = virtualenv -VENV_ARGS = -p $(PYTHON) +VENV = $(PYTHON) -m venv +VENV_ARGS = VENV_DIR = $(CURDIR)/.venv VENV_WATSON_DIR = $(CURDIR)/data @@ -22,16 +22,16 @@ env: $(VENV_DIR) .PHONY: install install: - $(PYTHON) setup.py install + $(PYTHON) -m pip install . .PHONY: install-dev install-dev: $(PIP) install -r requirements-dev.txt - $(PYTHON) setup.py develop + $(PYTHON) -m pip install --editable . .PHONY: check check: clean - $(PYTHON) setup.py test + pytest .PHONY: clean clean: diff --git a/README.md b/README.md index bf0dc8a..d7c6b6e 100644 --- a/README.md +++ b/README.md @@ -61,3 +61,20 @@ License Watson-next is released under the MIT License. See the bundled LICENSE file for details. + +Develop +------- + +1. Make an environment +``` +make env +``` +2. Activate your environment +``` +source .venv/bin/activate +``` +3. Make it dev +``` +make install-dev +``` + diff --git a/docs/user-guide/commands.md b/docs/user-guide/commands.md index 09dba25..dd91366 100644 --- a/docs/user-guide/commands.md +++ b/docs/user-guide/commands.md @@ -28,6 +28,7 @@ Flag | Help `-t, --to DATETIME` | Date and time of end of tracked activity [required] `-c, --confirm-new-project` | Confirm addition of new project. `-b, --confirm-new-tag` | Confirm creation of new tag. +`-n, --note TEXT` | Add log message with the added project frame. `--help` | Show this message and exit. ## `aggregate` @@ -663,6 +664,7 @@ Flag | Help `-g, --gap / -G, --no-gap` | (Don't) leave gap between end time of previous project and start time of the current. `-c, --confirm-new-project` | Confirm addition of new project. `-b, --confirm-new-tag` | Confirm creation of new tag. +`-n, --note TEXT` | Add log message to started frame. `--help` | Show this message and exit. ## `status` @@ -710,17 +712,25 @@ If `--at` option is given, the provided stopping time is used. The specified time must be after the beginning of the to-be-ended frame and must not be in the future. +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 DATETIME` | 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/docs/user-guide/configuration.md b/docs/user-guide/configuration.md index fbccd93..5fb750d 100644 --- a/docs/user-guide/configuration.md +++ b/docs/user-guide/configuration.md @@ -184,6 +184,15 @@ Globally configure which day corresponds to the start of a week. Allowable values are `monday`, `tuesday`, `wednesday`, `thursday`, `friday`, `saturday`, and `sunday`. +#### `options.notes_concatenate` (default: `false`) + +Using `--note` notes can be specified on start and stop of a frame. + +If you stop a frame and specify a note on a frame that already has a note +assigend, it will by default replace the note with the last one specified. + +Changing this option to `true` will instead concatenate both note strings. + ### Default tags diff --git a/tests/test_utils.py b/tests/test_utils.py index b993767..71854ab 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -300,7 +300,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' @@ -319,7 +319,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 0261835..e108171 100644 --- a/tests/test_watson.py +++ b/tests/test_watson.py @@ -198,6 +198,54 @@ def test_frames_with_empty_given_state(config_dir, mocker): assert len(watson.frames) == 0 +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('builtins.open', 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('builtins.open', 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 + + # config def test_empty_config_dir(): @@ -356,6 +404,32 @@ def test_stop_started_project_at(watson): assert watson.frames[-1].stop == now +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" + + # cancel def test_cancel_started_project(watson): @@ -419,7 +493,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() @@ -779,9 +854,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 @@ -790,6 +868,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'] ) @@ -799,16 +884,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/cli.py b/watson/cli.py index 80790dd..40d5106 100644 --- a/watson/cli.py +++ b/watson/cli.py @@ -28,6 +28,7 @@ confirm_tags, create_watson, flatten_report_for_csv, + format_note, format_timedelta, frames_to_csv, frames_to_json, @@ -184,17 +185,20 @@ def help(ctx, command): click.echo(cmd.get_help(ctx)) -def _start(watson, project, tags, restart=False, start_at=None, gap=True): +def _start(watson, project, tags, restart=False, start_at=None, gap=True, + note=None): """ Start project with given list of tags and save status. """ current = watson.start(project, tags, restart=restart, start_at=start_at, - gap=gap,) + gap=gap, note=note) click.echo("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() @@ -213,11 +217,13 @@ def _start(watson, project, tags, restart=False, start_at=None, 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, at_, - gap_=True): + gap_=True, note=None): """ Start monitoring time for the given project. You can add tags indicating more specifically what you are working on with @@ -280,16 +286,23 @@ def start(ctx, watson, confirm_new_project, confirm_new_tag, args, at_, watson.config.getboolean('options', 'stop_on_start')): ctx.invoke(stop, at_=at_) - _start(watson, project, tags, start_at=at_, gap=gap_) + _start(watson, project, tags, start_at=at_, 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', type=str, default=None, + help="A brief note that describe time entry being stopped." + + "Unless -c is specified, will by default replace a " + + "note specified on start.") +@click.option('-c', '--concat-note', is_flag=True, default=False, + help="Will concatenate the note specified at start " + + "to the note specified on stop.") @click.pass_obj @catch_watson_error -def stop(watson, at_): +def stop(watson, at_, note, concat_note): """ Stop monitoring time for the current project. @@ -297,13 +310,20 @@ 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 --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) + >> Done some thinking """ - frame = watson.stop(stop_at=at_) + frame = watson.stop(stop_at=at_, note=note, concat_note=concat_note) output_str = "Stopping project {}{}, started {} and stopped {}. (id: {})" click.echo(output_str.format( style('project', frame.project), @@ -312,6 +332,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() @@ -474,6 +498,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 = { @@ -546,11 +576,14 @@ 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', '--notes', '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, include_partial_frames=True): + pager, aggregated=False, include_partial_frames=True, + show_notes=False): """ Display a report of the time spent on each project. @@ -576,6 +609,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. @@ -630,14 +667,17 @@ 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, @@ -737,6 +777,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 ['']) @@ -750,6 +797,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("") # if this is a report invoked from `aggregate` return the lines; do not @@ -808,11 +862,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', '--notes', '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, aggregation): + pager, aggregation, show_notes): """ Display a report of the time spent on each project aggregated by day. @@ -909,7 +965,8 @@ def aggregate(ctx, watson, current, from_, to, projects, tags, output_format, to=end, projects=projects, tags=tags, output_format=output_format, pager=pager, aggregated=True, - include_partial_frames=True) + include_partial_frames=True, + show_notes=show_notes) if 'json' in output_format: lines.append(output) @@ -999,10 +1056,13 @@ 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, reverse, from_, to, projects, tags, ignore_projects, - ignore_tags, year, month, week, day, luna, all, output_format, pager): + ignore_tags, year, month, week, day, luna, all, output_format, pager, + show_notes): """ Display each recorded session during the given timespan. @@ -1029,6 +1089,9 @@ def log(watson, current, reverse, from_, to, projects, tags, ignore_projects, `--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 @@ -1059,12 +1122,12 @@ def log(watson, current, reverse, from_, to, projects, tags, ignore_projects, 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): @@ -1087,7 +1150,7 @@ def log(watson, current, reverse, from_, to, projects, tags, ignore_projects, 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']) if reverse is None: reverse = watson.config.getboolean('options', 'reverse_log', True) @@ -1150,8 +1213,14 @@ def _final_print(lines): ) ) + def get_note_string(frame): + if frame.note is not None and frame.note != '' and show_notes: + return u"\t{}{}".format(" "*9, format_note(frame.note)) + return '' + _print("\n".join( - "\t{id} {start} to {stop} {delta:>11} {project}{tags}".format( + ("\t{id} {start} to {stop} {delta:>11} " + "{project}{tags}{notes}").format( delta=format_timedelta(frame.stop - frame.start), project=style('project', '{:>{}}'.format( frame.project, longest_project @@ -1159,7 +1228,8 @@ def _final_print(lines): 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) + id=style('short_id', frame.id), + notes=get_note_string(frame) ) for frame in frames )) @@ -1246,9 +1316,11 @@ def frames(watson): 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 added.") @click.pass_obj @catch_watson_error -def add(watson, args, from_, to, confirm_new_project, confirm_new_tag): +def add(watson, args, from_, to, confirm_new_project, confirm_new_tag, note): """ Add time to a project with tag(s) that was not tracked live. @@ -1279,7 +1351,8 @@ def add(watson, args, from_, to, confirm_new_project, confirm_new_tag): confirm_tags(tags, watson.tags) # add a new frame, call watson save to update state files - frame = watson.add(project=project, tags=tags, from_date=from_, to_date=to) + frame = watson.add(project=project, tags=tags, from_date=from_, to_date=to, + note=note) click.echo( "Adding project {}{}, started {} and stopped {}. (id: {})".format( style('project', frame.project), @@ -1289,6 +1362,9 @@ def add(watson, args, from_, to, confirm_new_project, confirm_new_tag): style('short_id', frame.id) ) ) + if note: + click.echo(format_note(note)) + watson.save() @@ -1325,7 +1401,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 @@ -1338,6 +1415,7 @@ 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: @@ -1382,6 +1460,7 @@ def edit(watson, confirm_new_project, confirm_new_tag, id): raise ValueError("Start time cannot be in the future") if stop and stop > arrow.utcnow(): raise ValueError("Stop time cannot be in the future") + note = data.get('note') # break out of while loop and continue execution of # the edit function normally break @@ -1402,9 +1481,19 @@ 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) + # This is new: + 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, id, 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( @@ -1424,6 +1513,9 @@ def edit(watson, confirm_new_project, confirm_new_tag, id): ) ) + if note is not None and note != '': + click.echo("Note: {}".format(style('note', note))) + @cli.command(context_settings={'ignore_unknown_options': True}) @click.argument('id', shell_complete=get_frames) @@ -1666,7 +1758,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("{}".format('\n'.join('<' + line for line in json.dumps( @@ -1698,7 +1791,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()))) @@ -1712,9 +1806,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 diff --git a/watson/frames.py b/watson/frames.py index 0e51157..c9b3ea1 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) @@ -33,7 +34,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): @@ -41,7 +42,8 @@ def dump(self): stop = self.stop.to('utc').int_timestamp if self.stop else None updated_at = self.updated_at.int_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): @@ -139,11 +141,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 c276860..9fc4c8f 100644 --- a/watson/utils.py +++ b/watson/utils.py @@ -68,7 +68,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, {}) @@ -298,6 +299,7 @@ def frames_to_json(frames): ('stop', frame.stop.isoformat()), ('project', frame.project), ('tags', frame.tags), + ('note', frame.note), ]) for frame in frames ] @@ -320,6 +322,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 ] @@ -400,3 +403,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.replace('\n', '\n' + ' '*20)) + ) diff --git a/watson/watson.py b/watson/watson.py index 52460f4..b61ce5d 100644 --- a/watson/watson.py +++ b/watson/watson.py @@ -144,6 +144,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 = {} @@ -205,7 +206,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: @@ -235,7 +237,7 @@ def last_sync(self, value): def is_started(self): return bool(self.current) - def add(self, project, from_date, to_date, tags): + def add(self, project, from_date, to_date, tags, note=None): if not project: raise WatsonError("No project given.") if from_date > to_date: @@ -244,11 +246,12 @@ def add(self, project, from_date, to_date, tags): default_tags = self.config.getlist('default_tags', project) tags = (tags or []) + default_tags - frame = self.frames.add(project, from_date, to_date, tags=tags) + frame = self.frames.add(project, from_date, to_date, tags=tags, + note=note) return frame def start(self, project, tags=None, restart=False, start_at=None, - gap=True): + gap=True, note=None): if self.is_started: raise WatsonError( "Project {} is already started.".format( @@ -272,7 +275,8 @@ def start(self, project, tags=None, restart=False, start_at=None, if start_at > arrow.now(): raise WatsonError('Task cannot start in the future.') - new_frame = {'project': project, 'tags': deduplicate(tags)} + new_frame = {'project': project, 'tags': deduplicate(tags), + 'note': note} new_frame['start'] = start_at if not gap: stop_of_prev_frame = self.frames[-1].stop @@ -280,7 +284,7 @@ def start(self, project, tags=None, restart=False, start_at=None, self.current = new_frame return self.current - def stop(self, stop_at=None): + def stop(self, stop_at=None, note=None, concat_note=False): if not self.is_started: raise WatsonError("No project started.") @@ -298,8 +302,16 @@ def stop(self, stop_at=None): if stop_at > arrow.now(): raise WatsonError('Task cannot end in the future.') + concat_note = concat_note or self.config.getboolean( + 'options', 'notes_concatenate') + + if note is None: + note = old.get('note') + elif concat_note: + note = old.get('note', "") + 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 @@ -549,7 +561,7 @@ def report(self, from_, to, current=None, projects=None, tags=None, if self.current and current: cur = self.current self.frames.add(cur['project'], cur['start'], arrow.utcnow(), - cur['tags'], id="current") + cur['tags'], note=cur['note'], id="current") span = self.frames.span(from_, to) @@ -585,10 +597,27 @@ def report(self, from_, to, current=None, projects=None, tags=None, ) total += delta + 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': [] + 'tags': [], + 'notes': project_notes, } if tags is None: @@ -606,9 +635,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)