1
0

feat(caldav): Add support for subtasks (i.e. RELATED-TO property) in CalDAV (#1634)

As I mentioned [here](https://kolaente.dev/vikunja/api/pulls/1442#issuecomment-55215), this is mainly a cleanup of @zewaren 's original [PR](https://kolaente.dev/vikunja/api/pulls/1442).

It adds support for the `RELATED-TO` property in CalDAV's `VTODO` and the `RELTYPE=PARENT` and `RELTYPE=CHILD` relationships. In other words, it allows for `ParentTask->SubTask` relations to be handled supported through CalDAV.

In addition to the included tests, this has been tested by both @zewaren & myself with DAVx5 & Tasks (Android) and it's been working great.

Resolves https://kolaente.dev/vikunja/api/issues/1345

Co-authored-by: Miguel A. Arroyo <miguel@codeheads.dev>
Co-authored-by: Erwan Martin <public@fzwte.net>
Reviewed-on: https://kolaente.dev/vikunja/api/pulls/1634
Reviewed-by: konrad <k@knt.li>
Co-authored-by: Miguel Arroyo <mayanez@noreply.kolaente.de>
Co-committed-by: Miguel Arroyo <mayanez@noreply.kolaente.de>
This commit is contained in:
Miguel Arroyo
2023-11-10 22:44:03 +00:00
committed by konrad
parent 6169c2e12e
commit 225d65268d
15 changed files with 1351 additions and 74 deletions

View File

@ -17,6 +17,7 @@
package caldav
import (
"slices"
"strconv"
"strings"
"time"
@ -292,6 +293,13 @@ func (vcls *VikunjaCaldavProjectStorage) CreateResource(rpath, content string) (
return nil, err
}
vcls.task.ProjectID = vcls.project.ID
err = persistRelations(s, vcls.user, vcls.task, vTask.RelatedTasks)
if err != nil {
_ = s.Rollback()
return nil, err
}
if err := s.Commit(); err != nil {
return nil, err
}
@ -316,6 +324,10 @@ func (vcls *VikunjaCaldavProjectStorage) UpdateResource(rpath, content string) (
// At this point, we already have the right task in vcls.task, so we can use that ID directly
vTask.ID = vcls.task.ID
// Explicitly set the ProjectID in case the task now belongs to a different project:
vTask.ProjectID = vcls.project.ID
vcls.task.ProjectID = vcls.project.ID
s := db.NewSession()
defer s.Close()
@ -343,6 +355,12 @@ func (vcls *VikunjaCaldavProjectStorage) UpdateResource(rpath, content string) (
return nil, err
}
err = persistRelations(s, vcls.user, vcls.task, vTask.RelatedTasks)
if err != nil {
_ = s.Rollback()
return nil, err
}
if err := s.Commit(); err != nil {
return nil, err
}
@ -430,6 +448,91 @@ func persistLabels(s *xorm.Session, a web.Auth, task *models.Task, labels []*mod
return task.UpdateTaskLabels(s, a, labels)
}
func removeStaleRelations(s *xorm.Session, a web.Auth, task *models.Task, newRelations map[models.RelationKind][]*models.Task) (err error) {
// Get the existing task with details:
existingTask := &models.Task{ID: task.ID}
// FIXME: Optimize to get only required attributes (ie. RelatedTasks).
err = existingTask.ReadOne(s, a)
if err != nil {
return
}
for relationKind, relatedTasks := range existingTask.RelatedTasks {
for _, relatedTask := range relatedTasks {
relationInNewList := slices.ContainsFunc(newRelations[relationKind], func(newRelation *models.Task) bool { return newRelation.UID == relatedTask.UID })
if !relationInNewList {
rel := models.TaskRelation{
TaskID: task.ID,
OtherTaskID: relatedTask.ID,
RelationKind: relationKind,
}
err = rel.Delete(s, a)
if err != nil {
return
}
}
}
}
return
}
// Persist new relations provided by the VTODO entry:
func persistRelations(s *xorm.Session, a web.Auth, task *models.Task, newRelations map[models.RelationKind][]*models.Task) (err error) {
err = removeStaleRelations(s, a, task, newRelations)
if err != nil {
return err
}
// Ensure the current relations exist:
for relationType, relatedTasksInVTODO := range newRelations {
// Persist each relation independently:
for _, relatedTaskInVTODO := range relatedTasksInVTODO {
var relatedTask *models.Task
createDummy := false
// Get the task from the DB:
relatedTaskInDB, err := models.GetTaskSimpleByUUID(s, relatedTaskInVTODO.UID)
if err != nil {
relatedTask = relatedTaskInVTODO
createDummy = true
} else {
relatedTask = relatedTaskInDB
}
// If the related task doesn't exist, create a dummy one now in the same list.
// It'll probably be populated right after in a following request.
// In the worst case, this was an error by the client and we are left with
// this dummy task to clean up.
if createDummy {
relatedTask.ProjectID = task.ProjectID
relatedTask.Title = "DUMMY-UID-" + relatedTask.UID
err = relatedTask.Create(s, a)
if err != nil {
return err
}
}
// Create the relation:
rel := models.TaskRelation{
TaskID: task.ID,
OtherTaskID: relatedTask.ID,
RelationKind: relationType,
}
err = rel.Create(s, a)
if err != nil && !models.IsErrRelationAlreadyExists(err) {
return err
}
}
}
return err
}
// VikunjaProjectResourceAdapter holds the actual resource
type VikunjaProjectResourceAdapter struct {
project *models.ProjectWithTasksAndBuckets

View File

@ -0,0 +1,484 @@
// Vikunja is a to-do list application to facilitate your life.
// Copyright 2018-present Vikunja and contributors. All rights reserved.
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public Licensee as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public Licensee for more details.
//
// You should have received a copy of the GNU Affero General Public Licensee
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package caldav
// This file tests logic related to handling tasks in CALDAV format
import (
"testing"
"code.vikunja.io/api/pkg/config"
"code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/files"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/user"
"github.com/samedi/caldav-go/data"
"github.com/stretchr/testify/assert"
)
// Check logic related to creating sub-tasks
func TestSubTask_Create(t *testing.T) {
u := &user.User{
ID: 15,
Username: "user15",
Email: "user15@example.com",
}
config.InitDefaultConfig()
files.InitTests()
user.InitTests()
models.SetupTests()
//
// Create a subtask
//
t.Run("create", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
const taskUID = "uid_child1"
const taskContent = `BEGIN:VCALENDAR
VERSION:2.0
METHOD:PUBLISH
X-PUBLISHED-TTL:PT4H
X-WR-CALNAME:Project 36 for Caldav tests
PRODID:-//Vikunja Todo App//EN
BEGIN:VTODO
UID:uid_child1
DTSTAMP:20230301T073337Z
SUMMARY:Caldav child task 1
CREATED:20230301T073337Z
LAST-MODIFIED:20230301T073337Z
RELATED-TO;RELTYPE=PARENT:uid-caldav-test-parent-task
END:VTODO
END:VCALENDAR`
storage := &VikunjaCaldavProjectStorage{
project: &models.ProjectWithTasksAndBuckets{Project: models.Project{ID: 36}},
task: &models.Task{UID: taskUID},
user: u,
}
// Create the subtask:
taskResource, err := storage.CreateResource(taskUID, taskContent)
assert.NoError(t, err)
// Check that the result CALDAV contains the relation:
content, _ := taskResource.GetContentData()
assert.Contains(t, content, "UID:"+taskUID)
assert.Contains(t, content, "RELATED-TO;RELTYPE=PARENT:uid-caldav-test-parent-task")
// Get the task from the DB:
tasks, err := models.GetTasksByUIDs(s, []string{taskUID}, u)
assert.NoError(t, err)
task := tasks[0]
// Check that the parent-child relationship is present:
assert.Len(t, task.RelatedTasks[models.RelationKindParenttask], 1)
parentTask := task.RelatedTasks[models.RelationKindParenttask][0]
assert.Equal(t, "uid-caldav-test-parent-task", parentTask.UID)
})
//
// Create a subtask on a subtask, i.e. create a grand-child
//
t.Run("create grandchild on child task", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
const taskUIDChild = "uid_child1"
const taskContentChild = `BEGIN:VCALENDAR
VERSION:2.0
METHOD:PUBLISH
X-PUBLISHED-TTL:PT4H
X-WR-CALNAME:Project 36 for Caldav tests
PRODID:-//Vikunja Todo App//EN
BEGIN:VTODO
UID:uid_child1
DTSTAMP:20230301T073337Z
SUMMARY:Caldav child task 1
CREATED:20230301T073337Z
LAST-MODIFIED:20230301T073337Z
RELATED-TO;RELTYPE=PARENT:uid-caldav-test-parent-task
END:VTODO
END:VCALENDAR`
storage := &VikunjaCaldavProjectStorage{
project: &models.ProjectWithTasksAndBuckets{Project: models.Project{ID: 36}},
task: &models.Task{UID: taskUIDChild},
user: u,
}
// Create the subtask:
_, err := storage.CreateResource(taskUIDChild, taskContentChild)
assert.NoError(t, err)
const taskUID = "uid_grand_child1"
const taskContent = `BEGIN:VCALENDAR
VERSION:2.0
METHOD:PUBLISH
X-PUBLISHED-TTL:PT4H
X-WR-CALNAME:Project 36 for Caldav tests
PRODID:-//Vikunja Todo App//EN
BEGIN:VTODO
UID:uid_grand_child1
DTSTAMP:20230301T073337Z
SUMMARY:Caldav grand child task 1
CREATED:20230301T073337Z
LAST-MODIFIED:20230301T073337Z
RELATED-TO;RELTYPE=PARENT:uid_child1
END:VTODO
END:VCALENDAR`
storage = &VikunjaCaldavProjectStorage{
project: &models.ProjectWithTasksAndBuckets{Project: models.Project{ID: 36}},
task: &models.Task{UID: taskUID},
user: u,
}
// Create the task:
var taskResource *data.Resource
taskResource, err = storage.CreateResource(taskUID, taskContent)
assert.NoError(t, err)
// Check that the result CALDAV contains the relation:
content, _ := taskResource.GetContentData()
assert.Contains(t, content, "UID:"+taskUID)
assert.Contains(t, content, "RELATED-TO;RELTYPE=PARENT:uid_child1")
// Get the task from the DB:
tasks, err := models.GetTasksByUIDs(s, []string{taskUID}, u)
assert.NoError(t, err)
task := tasks[0]
// Check that the parent-child relationship of the grandchildren is present:
assert.Len(t, task.RelatedTasks[models.RelationKindParenttask], 1)
parentTask := task.RelatedTasks[models.RelationKindParenttask][0]
assert.Equal(t, "uid_child1", parentTask.UID)
// Get the child task and check that it now has a parent and a child:
tasks, err = models.GetTasksByUIDs(s, []string{"uid_child1"}, u)
assert.NoError(t, err)
task = tasks[0]
assert.Len(t, task.RelatedTasks[models.RelationKindParenttask], 1)
parentTask = task.RelatedTasks[models.RelationKindParenttask][0]
assert.Equal(t, "uid-caldav-test-parent-task", parentTask.UID)
assert.Len(t, task.RelatedTasks[models.RelationKindSubtask], 1)
childTask := task.RelatedTasks[models.RelationKindSubtask][0]
assert.Equal(t, taskUID, childTask.UID)
})
//
// Create a subtask on a parent that we don't know anything about (yet)
//
t.Run("create subtask on unknown parent", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// Create a subtask:
const taskUID = "uid_child1"
const taskContent = `BEGIN:VCALENDAR
VERSION:2.0
METHOD:PUBLISH
X-PUBLISHED-TTL:PT4H
X-WR-CALNAME:Project 36 for Caldav tests
PRODID:-//Vikunja Todo App//EN
BEGIN:VTODO
UID:uid_child1
DTSTAMP:20230301T073337Z
SUMMARY:Caldav child task 1
CREATED:20230301T073337Z
LAST-MODIFIED:20230301T073337Z
RELATED-TO;RELTYPE=PARENT:uid-caldav-test-parent-doesnt-exist-yet
END:VTODO
END:VCALENDAR`
storage := &VikunjaCaldavProjectStorage{
project: &models.ProjectWithTasksAndBuckets{Project: models.Project{ID: 36}},
task: &models.Task{UID: taskUID},
user: u,
}
// Create the task:
taskResource, err := storage.CreateResource(taskUID, taskContent)
assert.NoError(t, err)
// Check that the result CALDAV contains the relation:
content, _ := taskResource.GetContentData()
assert.Contains(t, content, "UID:"+taskUID)
assert.Contains(t, content, "RELATED-TO;RELTYPE=PARENT:uid-caldav-test-parent-doesnt-exist-yet")
// Get the task from the DB:
tasks, err := models.GetTasksByUIDs(s, []string{taskUID}, u)
assert.NoError(t, err)
task := tasks[0]
// Check that the parent-child relationship is present:
assert.Len(t, task.RelatedTasks[models.RelationKindParenttask], 1)
parentTask := task.RelatedTasks[models.RelationKindParenttask][0]
assert.Equal(t, "uid-caldav-test-parent-doesnt-exist-yet", parentTask.UID)
// Check that the non-existent parent task was created in the process:
tasks, err = models.GetTasksByUIDs(s, []string{"uid-caldav-test-parent-doesnt-exist-yet"}, u)
assert.NoError(t, err)
task = tasks[0]
assert.Equal(t, "uid-caldav-test-parent-doesnt-exist-yet", task.UID)
})
}
// Logic related to editing tasks and subtasks
func TestSubTask_Update(t *testing.T) {
u := &user.User{
ID: 15,
Username: "user15",
Email: "user15@example.com",
}
//
// Edit a subtask and check that the relations are not gone
//
t.Run("edit subtask", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// Edit the subtask:
const taskUID = "uid-caldav-test-child-task"
const taskContent = `BEGIN:VCALENDAR
VERSION:2.0
METHOD:PUBLISH
X-PUBLISHED-TTL:PT4H
X-WR-CALNAME:Project 36 for Caldav tests
PRODID:-//Vikunja Todo App//EN
BEGIN:VTODO
UID:uid-caldav-test-child-task
DTSTAMP:20230301T073337Z
SUMMARY:Child task for Caldav Test (edited)
CREATED:20230301T073337Z
LAST-MODIFIED:20230301T073337Z
RELATED-TO;RELTYPE=PARENT:uid-caldav-test-parent-task
END:VTODO
END:VCALENDAR`
tasks, err := models.GetTasksByUIDs(s, []string{taskUID}, u)
assert.NoError(t, err)
task := tasks[0]
storage := &VikunjaCaldavProjectStorage{
project: &models.ProjectWithTasksAndBuckets{Project: models.Project{ID: 36}},
task: task,
user: u,
}
// Edit the task:
taskResource, err := storage.UpdateResource(taskUID, taskContent)
assert.NoError(t, err)
// Check that the result CALDAV still contains the relation:
content, _ := taskResource.GetContentData()
assert.Contains(t, content, "UID:"+taskUID)
assert.Contains(t, content, "RELATED-TO;RELTYPE=PARENT:uid-caldav-test-parent-task")
// Get the task from the DB:
tasks, err = models.GetTasksByUIDs(s, []string{taskUID}, u)
assert.NoError(t, err)
task = tasks[0]
// Check that the parent-child relationship is still present:
assert.Len(t, task.RelatedTasks[models.RelationKindParenttask], 1)
parentTask := task.RelatedTasks[models.RelationKindParenttask][0]
assert.Equal(t, "uid-caldav-test-parent-task", parentTask.UID)
})
//
// Edit a parent task and check that the subtasks are still linked
//
t.Run("edit parent", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// Edit the parent task:
const taskUID = "uid-caldav-test-parent-task"
const taskContent = `BEGIN:VCALENDAR
VERSION:2.0
METHOD:PUBLISH
X-PUBLISHED-TTL:PT4H
X-WR-CALNAME:Project 36 for Caldav tests
PRODID:-//Vikunja Todo App//EN
BEGIN:VTODO
UID:uid-caldav-test-parent-task
DTSTAMP:20230301T073337Z
SUMMARY:Parent task for Caldav Test (edited)
CREATED:20230301T073337Z
LAST-MODIFIED:20230301T073337Z
RELATED-TO;RELTYPE=CHILD:uid-caldav-test-child-task
RELATED-TO;RELTYPE=CHILD:uid-caldav-test-child-task-2
END:VTODO
END:VCALENDAR`
tasks, err := models.GetTasksByUIDs(s, []string{taskUID}, u)
assert.NoError(t, err)
task := tasks[0]
storage := &VikunjaCaldavProjectStorage{
project: &models.ProjectWithTasksAndBuckets{Project: models.Project{ID: 36}},
task: task,
user: u,
}
// Edit the task:
_, err = storage.UpdateResource(taskUID, taskContent)
assert.NoError(t, err)
// Get the task from the DB:
tasks, err = models.GetTasksByUIDs(s, []string{taskUID}, u)
assert.NoError(t, err)
task = tasks[0]
// Check that the subtasks are still linked:
assert.Len(t, task.RelatedTasks[models.RelationKindSubtask], 2)
existingSubTask := task.RelatedTasks[models.RelationKindSubtask][0]
assert.Equal(t, "uid-caldav-test-child-task", existingSubTask.UID)
existingSubTask = task.RelatedTasks[models.RelationKindSubtask][1]
assert.Equal(t, "uid-caldav-test-child-task-2", existingSubTask.UID)
})
//
// Edit a subtask and change its parent
//
t.Run("edit subtask change parent", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// Edit the subtask:
const taskUID = "uid-caldav-test-child-task"
const taskContent = `BEGIN:VCALENDAR
VERSION:2.0
METHOD:PUBLISH
X-PUBLISHED-TTL:PT4H
X-WR-CALNAME:Project 36 for Caldav tests
PRODID:-//Vikunja Todo App//EN
BEGIN:VTODO
UID:uid-caldav-test-child-task
DTSTAMP:20230301T073337Z
SUMMARY:Child task for Caldav Test (edited)
CREATED:20230301T073337Z
LAST-MODIFIED:20230301T073337Z
RELATED-TO;RELTYPE=PARENT:uid-caldav-test-parent-task-2
END:VTODO
END:VCALENDAR`
tasks, err := models.GetTasksByUIDs(s, []string{taskUID}, u)
assert.NoError(t, err)
task := tasks[0]
storage := &VikunjaCaldavProjectStorage{
project: &models.ProjectWithTasksAndBuckets{Project: models.Project{ID: 36}},
task: task,
user: u,
}
// Edit the task:
taskResource, err := storage.UpdateResource(taskUID, taskContent)
assert.NoError(t, err)
// Check that the result CALDAV contains the new relation:
content, _ := taskResource.GetContentData()
assert.Contains(t, content, "UID:"+taskUID)
assert.Contains(t, content, "RELATED-TO;RELTYPE=PARENT:uid-caldav-test-parent-task-2")
// Get the task from the DB:
tasks, err = models.GetTasksByUIDs(s, []string{taskUID}, u)
assert.NoError(t, err)
task = tasks[0]
// Check that the parent-child relationship has changed to the new parent:
assert.Len(t, task.RelatedTasks[models.RelationKindParenttask], 1)
parentTask := task.RelatedTasks[models.RelationKindParenttask][0]
assert.Equal(t, "uid-caldav-test-parent-task-2", parentTask.UID)
// Get the previous parent from the DB and check that its previous child is gone:
tasks, err = models.GetTasksByUIDs(s, []string{"uid-caldav-test-parent-task"}, u)
assert.NoError(t, err)
task = tasks[0]
assert.Len(t, task.RelatedTasks[models.RelationKindSubtask], 1)
// We're gone, but our former sibling is still there:
formerSiblingSubTask := task.RelatedTasks[models.RelationKindSubtask][0]
assert.Equal(t, "uid-caldav-test-child-task-2", formerSiblingSubTask.UID)
})
//
// Edit a subtask and remove its parent
//
t.Run("edit subtask remove parent", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
// Edit the subtask:
const taskUID = "uid-caldav-test-child-task"
const taskContent = `BEGIN:VCALENDAR
VERSION:2.0
METHOD:PUBLISH
X-PUBLISHED-TTL:PT4H
X-WR-CALNAME:Project 36 for Caldav tests
PRODID:-//Vikunja Todo App//EN
BEGIN:VTODO
UID:uid-caldav-test-child-task
DTSTAMP:20230301T073337Z
SUMMARY:Child task for Caldav Test (edited)
CREATED:20230301T073337Z
LAST-MODIFIED:20230301T073337Z
END:VTODO
END:VCALENDAR`
tasks, err := models.GetTasksByUIDs(s, []string{taskUID}, u)
assert.NoError(t, err)
task := tasks[0]
storage := &VikunjaCaldavProjectStorage{
project: &models.ProjectWithTasksAndBuckets{Project: models.Project{ID: 36}},
task: task,
user: u,
}
// Edit the task:
taskResource, err := storage.UpdateResource(taskUID, taskContent)
assert.NoError(t, err)
// Check that the result CALDAV contains the new relation:
content, _ := taskResource.GetContentData()
assert.Contains(t, content, "UID:"+taskUID)
assert.NotContains(t, content, "RELATED-TO;RELTYPE=PARENT:uid-caldav-test-parent-task")
// Get the task from the DB:
tasks, err = models.GetTasksByUIDs(s, []string{taskUID}, u)
assert.NoError(t, err)
task = tasks[0]
// Check that the parent-child relationship is gone:
assert.Len(t, task.RelatedTasks[models.RelationKindParenttask], 0)
// Get the previous parent from the DB and check that its child is gone:
tasks, err = models.GetTasksByUIDs(s, []string{"uid-caldav-test-parent-task"}, u)
assert.NoError(t, err)
task = tasks[0]
// We're gone, but our former sibling is still there:
assert.Len(t, task.RelatedTasks[models.RelationKindSubtask], 1)
formerSiblingSubTask := task.RelatedTasks[models.RelationKindSubtask][0]
assert.Equal(t, "uid-caldav-test-child-task-2", formerSiblingSubTask.UID)
})
}