diff --git a/CHANGELOG.md b/CHANGELOG.md index efb4360..fb77ac9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ and I do try to adhere to [Semantic Versioning](https://semver.org/spec/v2.0.0.h * `interactive set-task-attribs` (also run by `interactive manage-tasks`): a task marked `completed!` at one prompt was asked about again at the next one, and answering `completed!` there crashed on the already-completed task. Regression in v1.2.0. * `list-categories` and timewarrior time tracking crashed on a task with more than one CATEGORIES line. https://github.com/pycalendar/plann/issues/71 * `{CATEGORIES}` in a `list --template` printed the internal `vCategory([...])` representation instead of a comma-separated list. +* `edit --set-dtstart X --set-due Y` did not end up with DTSTART at X: moving DUE kept the duration and shifted the DTSTART that had just been set. ## v1.2.0 - 2026-09-16 diff --git a/plann/commands.py b/plann/commands.py index acf88f0..0b58fc7 100644 --- a/plann/commands.py +++ b/plann/commands.py @@ -310,7 +310,8 @@ def _edit(ctx, cancel=None, interactive_ical=False, interactive_relations=False, comp = obj.icalendar_component if kwargs.get('pdb'): _pdb_edit(obj) - for arg in ctx.obj['set_args']: + ## DTSTART last: setting DUE/DTEND moves DTSTART along with it + for arg in sorted(ctx.obj['set_args'], key=lambda a: a.lower() == 'dtstart'): _set_something(obj, arg, ctx.obj['set_args'][arg]) for canonical, tokens in add_args: _add_comma_list(obj, canonical, tokens) diff --git a/plann/lib.py b/plann/lib.py index e78ca50..4db1baf 100644 --- a/plann/lib.py +++ b/plann/lib.py @@ -555,6 +555,9 @@ def _complete(obj, handle_rrule=True, rrule_mode='safe', err_callback=print, cac for child in _relships_by_type(obj, 'CHILD', cache=cache).get('CHILD', []): if str(child.icalendar_component['UID']) in _seen: continue + if not isinstance(child, caldav.Todo): + ## an event or journal has nothing to complete + continue try: if not child.is_pending(): continue @@ -615,7 +618,7 @@ def _set_something(obj, arg, value): obj.set_relation(reltype=arg, other=val) elif arg == 'duration': obj.set_duration(value) - elif arg in ('due', 'dtend'): ## TODO: dtstart! + elif arg in ('due', 'dtend'): ## moves DTSTART too - _edit sets DTSTART after this getattr(obj, f"set_{arg}")(value, move_dtstart=True, check_dependent=True) elif _is_comma_list_attr(arg): ## a list (already processed by _process_set_arg) or a raw comma string diff --git a/tests/test_lib.py b/tests/test_lib.py index e027777..1e8006c 100644 --- a/tests/test_lib.py +++ b/tests/test_lib.py @@ -657,3 +657,29 @@ def relships(obj, reltype_wanted=None, cache=None): assert odd.icalendar_component['STATUS'] == 'WAITING' assert child.icalendar_component['STATUS'] == 'NEEDS-ACTION' assert warnings == ["Not completing 'task odd': STATUS is WAITING"] + +def test_complete_skips_event_child(): + """A task may have an event (or journal) as a child; it has no + completion state and is left alone instead of crashing""" + from caldav import Event + parent = _todo_with_uid('parent') + event = Event(data="""BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VEVENT +UID:event-child +DTSTAMP:20200516T060000Z +DTSTART:20200516T080000Z +DTEND:20200516T081000Z +SUMMARY:an event +END:VEVENT +END:VCALENDAR +""") + def relships(obj, reltype_wanted=None, cache=None): + return {'CHILD': [event] if obj is parent else []} + warnings = [] + with patch('plann.lib._relships_by_type', side_effect=relships), \ + patch.object(Todo, 'save'), patch.object(Todo, 'load'): + _complete(parent, err_callback=warnings.append) + assert parent.icalendar_component['STATUS'] == 'COMPLETED' + assert warnings == [] diff --git a/tests/test_plann_cli.py b/tests/test_plann_cli.py index 981724b..928cb1f 100644 --- a/tests/test_plann_cli.py +++ b/tests/test_plann_cli.py @@ -569,3 +569,20 @@ def test_mass_interactive_edit_works_on_tasks_fetched_after_editing(): assert len(saved) == 1 assert saved[0] is not task assert saved[0].icalendar_component['PRIORITY'] == 3 + + +def test_edit_sets_both_dtstart_and_due(): + """`edit --set-dtstart X --set-due Y` ends up with exactly X and Y, in + whatever order the options come: moving DUE used to shift the DTSTART + that had just been set by the old duration""" + dtstart = '2025-01-10 10:00:00' + due = '2025-01-10 12:00:00' + for kwargs in ({'set_dtstart': dtstart, 'set_due': due}, {'set_due': due, 'set_dtstart': dtstart}): + obj = _make_todo('t', dtstart=True, due=True) + ctx = _FakeCtx() + ctx.obj['objs'] = [obj] + with patch.object(Todo, 'save'): + commands_mod._edit(ctx, **kwargs) + comp = obj.icalendar_component + assert comp['DTSTART'].dt == datetime(2025, 1, 10, 10).astimezone() + assert comp['DUE'].dt == datetime(2025, 1, 10, 12).astimezone()