diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go index ec6200c48..978a0f850 100644 --- a/pkg/models/tasks.go +++ b/pkg/models/tasks.go @@ -1747,6 +1747,20 @@ func setTaskDatesFromCurrentDateRepeat(oldTask, newTask *Task) { newTask.Done = false } +var ( + checklistTiptapCheckedRegex = regexp.MustCompile(`(data-checked=")true(")`) + checklistInputCheckedRegex = regexp.MustCompile(`(]*type=["']checkbox["'][^>]*?)\s+checked(?:=["'][^"']*["'])?`) +) + +// resetDescriptionChecklist unchecks every checklist item in a TipTap HTML description +// (descriptions are always stored as HTML, never markdown) without touching other content, +// so a recurring task's next occurrence does not inherit checked items. +func resetDescriptionChecklist(description string) string { + description = checklistTiptapCheckedRegex.ReplaceAllString(description, "${1}false${2}") + description = checklistInputCheckedRegex.ReplaceAllString(description, "$1") + return description +} + // This helper function updates the reminders, doneAt, start, end and due dates of the *old* task // and saves the new values in the newTask object. // We make a few assumptions here: @@ -1766,6 +1780,11 @@ func updateDone(oldTask *Task, newTask *Task) (updateDoneAt bool) { setTaskDatesDefault(oldTask, newTask) } + // A recurring task reopens for its next occurrence, so its checklist starts fresh. + if oldTask.isRepeating() && !newTask.Done { + newTask.Description = resetDescriptionChecklist(newTask.Description) + } + newTask.DoneAt = time.Now() } diff --git a/pkg/models/tasks_test.go b/pkg/models/tasks_test.go index 7219b5ab8..433bee18a 100644 --- a/pkg/models/tasks_test.go +++ b/pkg/models/tasks_test.go @@ -986,6 +986,45 @@ func TestUpdateDone(t *testing.T) { assert.False(t, newTask.Done) }) }) + t.Run("reset checklist on recurrence", func(t *testing.T) { + const checked = `before
Item
after` + const unchecked = `beforeItem
after` + + oldTask := &Task{ + Done: false, + RepeatAfter: 8600, + DueDate: time.Unix(1550000000, 0), + } + newTask := &Task{ + Done: true, + Description: checked, + } + + updateDone(oldTask, newTask) + + assert.False(t, newTask.Done) + assert.True(t, newTask.DueDate.After(oldTask.DueDate)) + assert.Equal(t, unchecked, newTask.Description) + }) + t.Run("non-recurring description untouched", func(t *testing.T) { + const checked = `beforeItem
after` + + oldTask := &Task{ + Done: false, + RepeatAfter: 0, + RepeatMode: TaskRepeatModeDefault, + DueDate: time.Unix(1550000000, 0), + } + newTask := &Task{ + Done: true, + Description: checked, + } + + updateDone(oldTask, newTask) + + assert.True(t, newTask.Done) + assert.Equal(t, checked, newTask.Description) + }) }) }