Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion plann/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 4 additions & 1 deletion plann/lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions tests/test_lib.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 == []
17 changes: 17 additions & 0 deletions tests/test_plann_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Loading