From 97400ca881c6d975798e4d44136d2f4b07e9e1f0 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Tue, 20 Oct 2026 01:07:33 +0200 Subject: [PATCH 1/2] fix: honour --set-dtstart together with --set-due edit sets DUE with move_dtstart=True, which keeps the old duration. If --set-dtstart was applied first, the DTSTART that had just been set was shifted by that duration (19:00 on 23 October became 18:00 on 7 October). Now DTSTART is applied after DUE. Prompt: [plann edit --set-parent ... --set-dtstart "2026-10-23 19:00" --set-due "2026-10-23 20:00" set DUE right but DTSTART to 2026-10-07 18:00] dig into it Assisted-By: Claude Opus 5.5 (1M context) Reviewed-by: Tobias Brox --- CHANGELOG.md | 1 + plann/commands.py | 3 ++- plann/lib.py | 2 +- tests/test_plann_cli.py | 17 +++++++++++++++++ 4 files changed, 21 insertions(+), 2 deletions(-) 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..3cb616a 100644 --- a/plann/lib.py +++ b/plann/lib.py @@ -615,7 +615,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_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() From a82a575fcd32ed7e6c28670913f48e814c385f0b Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Tue, 20 Oct 2026 04:51:49 +0200 Subject: [PATCH 2/2] fix: skip event children when completing a task Completing a task whose children include an event (or journal) crashed with "'Event' object has no attribute 'is_pending'", as the auto-completion of children from https://github.com/pycalendar/plann/pull/76 assumed every child is a task. Non-task children have nothing to complete and are skipped. Prompt: The task has UID:50511558-eaec-11ef-9d3f-1c1bb5134174 and it is a VTODO, but still: (traceback pasted: AttributeError: 'Event' object has no attribute 'is_pending' in _complete, after answering "complete" in the one-by-one prompt) (when doing `plann interactive manage-tasks`) Assisted-By: Claude Opus 5.5 (1M context) Reviewed-by: Tobias Brox --- plann/lib.py | 3 +++ tests/test_lib.py | 26 ++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/plann/lib.py b/plann/lib.py index 3cb616a..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 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 == []