diff --git a/go.mod b/go.mod index ba05f4665ee..9bd006b398c 100644 --- a/go.mod +++ b/go.mod @@ -65,6 +65,7 @@ require ( github.com/wneessen/go-mail v0.3.8 github.com/yuin/goldmark v1.5.4 golang.org/x/crypto v0.7.0 + golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 golang.org/x/image v0.6.0 golang.org/x/oauth2 v0.6.0 golang.org/x/sync v0.1.0 diff --git a/go.sum b/go.sum index 9b5fb5d8051..f23d91ac0b2 100644 --- a/go.sum +++ b/go.sum @@ -782,6 +782,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0 h1:pVgRXcIictcr+lBQIFeiwuwtDIs4eL21OuM9nyAADmo= +golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= diff --git a/pkg/caldav/caldav.go b/pkg/caldav/caldav.go index 0f324f1c80a..92dcfe70521 100644 --- a/pkg/caldav/caldav.go +++ b/pkg/caldav/caldav.go @@ -51,20 +51,20 @@ type Todo struct { UID string // Optional - Summary string - Description string - Completed time.Time - Organizer *user.User - Priority int64 // 0-9, 1 is highest - RelatedToUID string - Color string - Categories []string - Start time.Time - End time.Time - DueDate time.Time - Duration time.Duration - RepeatAfter int64 - RepeatMode models.TaskRepeatMode + Summary string + Description string + Completed time.Time + Organizer *user.User + Priority int64 // 0-9, 1 is highest + RelatedToParentUID string + Color string + Categories []string + Start time.Time + End time.Time + DueDate time.Time + Duration time.Duration + RepeatAfter int64 + RepeatMode models.TaskRepeatMode Created time.Time Updated time.Time // last-mod @@ -209,9 +209,9 @@ STATUS:COMPLETED` ORGANIZER;CN=:` + t.Organizer.Username } - if t.RelatedToUID != "" { + if t.RelatedToParentUID != "" { caldavtodos += ` -RELATED-TO:` + t.RelatedToUID +RELATED-TO;RELTYPE=PARENT:` + t.RelatedToParentUID } if t.DueDate.Unix() > 0 { diff --git a/pkg/caldav/caldav_test.go b/pkg/caldav/caldav_test.go index a8da6e24007..a2e654628c3 100644 --- a/pkg/caldav/caldav_test.go +++ b/pkg/caldav/caldav_test.go @@ -520,6 +520,45 @@ X-FUNAMBOL-COLOR:#affffeFF CATEGORIES:label1,label2 LAST-MODIFIED:00010101T000000Z END:VTODO +END:VCALENDAR`, + }, + { + name: "with parent task", + args: args{ + config: &Config{ + Name: "test", + ProdID: "RandomProdID which is not random", + Color: "ffffff", + }, + todos: []*Todo{ + { + Summary: "Todo #1", + UID: "randommduid", + Timestamp: time.Unix(1543626724, 0).In(config.GetTimeZone()), + Color: "affffe", + RelatedToParentUID: "another_random_uid", + }, + }, + }, + wantCaldavtasks: `BEGIN:VCALENDAR +VERSION:2.0 +METHOD:PUBLISH +X-PUBLISHED-TTL:PT4H +X-WR-CALNAME:test +PRODID:-//RandomProdID which is not random//EN +X-APPLE-CALENDAR-COLOR:#ffffffFF +X-OUTLOOK-COLOR:#ffffffFF +X-FUNAMBOL-COLOR:#ffffffFF +BEGIN:VTODO +UID:randommduid +DTSTAMP:20181201T011204Z +SUMMARY:Todo #1 +X-APPLE-CALENDAR-COLOR:#affffeFF +X-OUTLOOK-COLOR:#affffeFF +X-FUNAMBOL-COLOR:#affffeFF +RELATED-TO;RELTYPE=PARENT:another_random_uid +LAST-MODIFIED:00010101T000000Z +END:VTODO END:VCALENDAR`, }, } diff --git a/pkg/caldav/parsing.go b/pkg/caldav/parsing.go index 4b887f86b3c..5e704fdf369 100644 --- a/pkg/caldav/parsing.go +++ b/pkg/caldav/parsing.go @@ -39,6 +39,12 @@ func GetCaldavTodosForTasks(project *models.ProjectWithTasksAndBuckets, projectT categories = append(categories, label.Title) } + // Find the UID of the parent task, if it exists: + var parentTaskUID string + if parentTasks, ok := t.RelatedTasks[models.RelationKindParenttask]; ok { + parentTaskUID = parentTasks[0].UID + } + caldavtodos = append(caldavtodos, &Todo{ Timestamp: t.Updated, UID: t.UID, @@ -46,16 +52,17 @@ func GetCaldavTodosForTasks(project *models.ProjectWithTasksAndBuckets, projectT Description: t.Description, Completed: t.DoneAt, // Organizer: &t.CreatedBy, // Disabled until we figure out how this works - Categories: categories, - Priority: t.Priority, - Start: t.StartDate, - End: t.EndDate, - Created: t.Created, - Updated: t.Updated, - DueDate: t.DueDate, - Duration: duration, - RepeatAfter: t.RepeatAfter, - RepeatMode: t.RepeatMode, + Categories: categories, + Priority: t.Priority, + RelatedToParentUID: parentTaskUID, + Start: t.StartDate, + End: t.EndDate, + Created: t.Created, + Updated: t.Updated, + DueDate: t.DueDate, + Duration: duration, + RepeatAfter: t.RepeatAfter, + RepeatMode: t.RepeatMode, }) } @@ -73,9 +80,11 @@ func ParseTaskFromVTODO(content string) (vTask *models.Task, err error) { return nil, err } + var parsedProperties = parsed.Components[0].UnknownPropertiesIANAProperties() + // We put the task details in a map to be able to handle them more easily task := make(map[string]string) - for _, c := range parsed.Components[0].UnknownPropertiesIANAProperties() { + for _, c := range parsedProperties { task[c.IANAToken] = c.Value } @@ -90,6 +99,26 @@ func ParseTaskFromVTODO(content string) (vTask *models.Task, err error) { priority = parseVTODOPriority(priorityParsed) } + // Check if the task has a parent: + var parentTaskUID string + for _, c := range parsedProperties { + // Check if the entry is a relation: + if c.IANAToken != "RELATED-TO" { + continue + } + // Check if the relation has a type: + if _, ok := c.ICalParameters["RELTYPE"]; !ok { + continue + } + // Check that the type is "PARENT": + if len(c.ICalParameters["RELTYPE"]) != 1 || c.ICalParameters["RELTYPE"][0] != "PARENT" { + continue + } + + // We have the id of the parent task: + parentTaskUID = c.Value + } + // Parse the enddate duration, _ := time.ParseDuration(task["DURATION"]) @@ -123,6 +152,19 @@ func ParseTaskFromVTODO(content string) (vTask *models.Task, err error) { vTask.Done = true } + // Check if the task has a parent and create a dummy relation if yes: + if parentTaskUID != "" { + var parentTaskUID = parentTaskUID + + if vTask.RelatedTasks == nil { + vTask.RelatedTasks = make(models.RelatedTaskMap) + } + + vTask.RelatedTasks[models.RelationKindParenttask] = append(vTask.RelatedTasks[models.RelationKindParenttask], &models.Task{ + UID: parentTaskUID, + }) + } + if duration > 0 && !vTask.StartDate.IsZero() { vTask.EndDate = vTask.StartDate.Add(duration) } diff --git a/pkg/caldav/parsing_test.go b/pkg/caldav/parsing_test.go index f5923b60c43..b6619ec553d 100644 --- a/pkg/caldav/parsing_test.go +++ b/pkg/caldav/parsing_test.go @@ -118,6 +118,63 @@ END:VCALENDAR`, Updated: time.Unix(1543626724, 0).In(config.GetTimeZone()), }, }, + { + name: "With parent", + args: args{content: `BEGIN:VCALENDAR +VERSION:2.0 +METHOD:PUBLISH +X-PUBLISHED-TTL:PT4H +X-WR-CALNAME:test +PRODID:-//RandomProdID which is not random//EN +BEGIN:VTODO +UID:randomuid +DTSTAMP:20181201T011204 +SUMMARY:SubTask #1 +DESCRIPTION:Lorem Ipsum +RELATED-TO;RELTYPE=PARENT:randomuid_parent +LAST-MODIFIED:00010101T000000 +END:VTODO +END:VCALENDAR`, + }, + wantVTask: &models.Task{ + Title: "SubTask #1", + UID: "randomuid", + Description: "Lorem Ipsum", + Updated: time.Unix(1543626724, 0).In(config.GetTimeZone()), + RelatedTasks: map[models.RelationKind][]*models.Task{ + models.RelationKindParenttask: { + { + UID: "randomuid_parent", + }, + }, + }, + }, + }, + { + name: "With non-parent relation we ignore", + args: args{content: `BEGIN:VCALENDAR +VERSION:2.0 +METHOD:PUBLISH +X-PUBLISHED-TTL:PT4H +X-WR-CALNAME:test +PRODID:-//RandomProdID which is not random//EN +BEGIN:VTODO +UID:randomuid +DTSTAMP:20181201T011204 +SUMMARY:Parent task +DESCRIPTION:Lorem Ipsum +RELATED-TO;RELTYPE=CHILD:randomuid_child +LAST-MODIFIED:00010101T000000 +END:VTODO +END:VCALENDAR`, + }, + wantVTask: &models.Task{ + Title: "Parent task", + UID: "randomuid", + Description: "Lorem Ipsum", + Updated: time.Unix(1543626724, 0).In(config.GetTimeZone()), + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -135,8 +192,8 @@ END:VCALENDAR`, func TestGetCaldavTodosForTasks(t *testing.T) { type args struct { - list *models.ProjectWithTasksAndBuckets - tasks []*models.TaskWithComments + project *models.ProjectWithTasksAndBuckets + tasks []*models.TaskWithComments } tests := []struct { name string @@ -146,9 +203,9 @@ func TestGetCaldavTodosForTasks(t *testing.T) { { name: "Format single Task as Caldav", args: args{ - list: &models.ProjectWithTasksAndBuckets{ + project: &models.ProjectWithTasksAndBuckets{ Project: models.Project{ - Title: "List title", + Title: "Project title", }, }, tasks: []*models.TaskWithComments{ @@ -183,7 +240,7 @@ func TestGetCaldavTodosForTasks(t *testing.T) { VERSION:2.0 METHOD:PUBLISH X-PUBLISHED-TTL:PT4H -X-WR-CALNAME:List title +X-WR-CALNAME:Project title PRODID:-//Vikunja Todo App//EN BEGIN:VTODO UID:randomuid @@ -201,12 +258,126 @@ RRULE:FREQ=SECONDLY;INTERVAL=86400 CATEGORIES:label1,label2 LAST-MODIFIED:20181201T011205Z END:VTODO +END:VCALENDAR`, + }, + { + name: "Format tasks with relationship as Caldav", + args: args{ + project: &models.ProjectWithTasksAndBuckets{ + Project: models.Project{ + Title: "Project title", + }, + }, + tasks: []*models.TaskWithComments{ + { + Task: models.Task{ + Title: "Parent task", + UID: "randomuid_parent", + Description: "This is a parent task", + Priority: 3, + Created: time.Unix(1543626721, 0).In(config.GetTimeZone()), + Updated: time.Unix(1543626725, 0).In(config.GetTimeZone()), + RelatedTasks: map[models.RelationKind][]*models.Task{ + models.RelationKindSubtask: { + { + Title: "Subtask 1", + UID: "randomuid_child_1", + Description: "This is the first child task", + Created: time.Unix(1543626724, 0).In(config.GetTimeZone()), + Updated: time.Unix(1543626724, 0).In(config.GetTimeZone()), + }, + { + Title: "Subtask 2", + UID: "randomuid_child_2", + Description: "This is the second child task", + Created: time.Unix(1543626724, 0).In(config.GetTimeZone()), + Updated: time.Unix(1543626724, 0).In(config.GetTimeZone()), + }, + }, + }, + }, + }, + { + Task: models.Task{ + Title: "Subtask 1", + UID: "randomuid_child_1", + Description: "This is the first child task", + Created: time.Unix(1543626724, 0).In(config.GetTimeZone()), + Updated: time.Unix(1543626724, 0).In(config.GetTimeZone()), + RelatedTasks: map[models.RelationKind][]*models.Task{ + models.RelationKindParenttask: { + { + Title: "Parent task", + UID: "randomuid_parent", + Description: "This is a parent task", + Priority: 3, + Created: time.Unix(1543626721, 0).In(config.GetTimeZone()), + }, + }, + }, + }, + }, + { + Task: models.Task{ + Title: "Subtask 2", + UID: "randomuid_child_2", + Description: "This is the second child task", + Created: time.Unix(1543626724, 0).In(config.GetTimeZone()), + Updated: time.Unix(1543626724, 0).In(config.GetTimeZone()), + RelatedTasks: map[models.RelationKind][]*models.Task{ + models.RelationKindParenttask: { + { + Title: "Parent task", + UID: "randomuid_parent", + Description: "This is a parent task", + Priority: 3, + Created: time.Unix(1543626721, 0).In(config.GetTimeZone()), + }, + }, + }, + }, + }, + }, + }, + wantCaldav: `BEGIN:VCALENDAR +VERSION:2.0 +METHOD:PUBLISH +X-PUBLISHED-TTL:PT4H +X-WR-CALNAME:Project title +PRODID:-//Vikunja Todo App//EN +BEGIN:VTODO +UID:randomuid_parent +DTSTAMP:20181201T011205Z +SUMMARY:Parent task +DESCRIPTION:This is a parent task +CREATED:20181201T011201Z +PRIORITY:3 +LAST-MODIFIED:20181201T011205Z +END:VTODO +BEGIN:VTODO +UID:randomuid_child_1 +DTSTAMP:20181201T011204Z +SUMMARY:Subtask 1 +DESCRIPTION:This is the first child task +RELATED-TO;RELTYPE=PARENT:randomuid_parent +CREATED:20181201T011204Z +LAST-MODIFIED:20181201T011204Z +END:VTODO +BEGIN:VTODO +UID:randomuid_child_2 +DTSTAMP:20181201T011204Z +SUMMARY:Subtask 2 +DESCRIPTION:This is the second child task +RELATED-TO;RELTYPE=PARENT:randomuid_parent +CREATED:20181201T011204Z +LAST-MODIFIED:20181201T011204Z +END:VTODO END:VCALENDAR`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got := GetCaldavTodosForTasks(tt.args.list, tt.args.tasks) + got := GetCaldavTodosForTasks(tt.args.project, tt.args.tasks) if diff, equal := messagediff.PrettyDiff(got, tt.wantCaldav); !equal { t.Errorf("GetCaldavTodosForTasks() gotVTask = %v, want %v, diff = %s", got, tt.wantCaldav, diff) } diff --git a/pkg/db/fixtures/buckets.yml b/pkg/db/fixtures/buckets.yml index e49816b2a30..c5b1ab96c25 100644 --- a/pkg/db/fixtures/buckets.yml +++ b/pkg/db/fixtures/buckets.yml @@ -224,3 +224,9 @@ created_by_id: 15 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 +- id: 37 + title: testbucket37 + project_id: 27 + created_by_id: 15 + created: 2020-04-18 21:13:52 + updated: 2020-04-18 21:13:52 diff --git a/pkg/db/fixtures/projects.yml b/pkg/db/fixtures/projects.yml index f2df5cf1206..8b1a5a10bba 100644 --- a/pkg/db/fixtures/projects.yml +++ b/pkg/db/fixtures/projects.yml @@ -255,7 +255,7 @@ created: 2018-12-01 15:13:12 - id: 26 - title: List 26 for Caldav tests + title: Project 26 for Caldav tests description: Lorem Ipsum identifier: test26 owner_id: 15 @@ -263,3 +263,14 @@ position: 1 updated: 2018-12-02 15:13:12 created: 2018-12-01 15:13:12 + +- + id: 27 + title: Project 27 for Caldav tests + description: Lorem Ipsum + identifier: test27 + owner_id: 15 + namespace_id: 18 + position: 2 + updated: 2018-12-02 15:13:12 + created: 2018-12-01 15:13:12 diff --git a/pkg/db/fixtures/users_projects.yml b/pkg/db/fixtures/users_projects.yml index 14b3d74ede1..ea71375ffb3 100644 --- a/pkg/db/fixtures/users_projects.yml +++ b/pkg/db/fixtures/users_projects.yml @@ -52,3 +52,9 @@ right: 0 updated: 2018-12-02 15:13:12 created: 2018-12-01 15:13:12 +- id: 10 + user_id: 15 + project_id: 27 + right: 0 + updated: 2018-12-02 15:13:12 + created: 2018-12-01 15:13:12 diff --git a/pkg/files/filehandling.go b/pkg/files/filehandling.go index 7186c36385c..2091c509bb6 100644 --- a/pkg/files/filehandling.go +++ b/pkg/files/filehandling.go @@ -59,7 +59,7 @@ func InitTestFileFixtures(t *testing.T) { } // InitTests handles the actual bootstrapping of the test env -func InitTests() { +func InitTests(loadFixtures bool) { var err error x, err = db.CreateTestEngine() if err != nil { @@ -71,9 +71,11 @@ func InitTests() { log.Fatal(err) } - err = db.InitTestFixtures("files") - if err != nil { - log.Fatal(err) + if loadFixtures { + err = db.InitTestFixtures("files") + if err != nil { + log.Fatal(err) + } } InitTestFileHandler() diff --git a/pkg/files/main_test.go b/pkg/files/main_test.go index 68216086b2b..0014b5c9744 100644 --- a/pkg/files/main_test.go +++ b/pkg/files/main_test.go @@ -23,6 +23,6 @@ import ( // TestMain is the main test function used to bootstrap the test env func TestMain(m *testing.M) { - InitTests() + InitTests(true) os.Exit(m.Run()) } diff --git a/pkg/integrations/caldav_test.go b/pkg/integrations/caldav_test.go index 5036a305ceb..a866e759444 100644 --- a/pkg/integrations/caldav_test.go +++ b/pkg/integrations/caldav_test.go @@ -28,7 +28,7 @@ const vtodo = `BEGIN:VCALENDAR VERSION:2.0 METHOD:PUBLISH X-PUBLISHED-TTL:PT4H -X-WR-CALNAME:List 26 for Caldav tests +X-WR-CALNAME:Project 26 for Caldav tests PRODID:-//Vikunja Todo App//EN BEGIN:VTODO UID:uid @@ -42,22 +42,22 @@ END:VCALENDAR` func TestCaldav(t *testing.T) { t.Run("Delivers VTODO for project", func(t *testing.T) { - rec, err := newCaldavTestRequestWithUser(t, http.MethodGet, caldav.ProjectHandler, &testuser15, ``, nil, map[string]string{"project": "26"}) + rec, err := newCaldavTestRequestWithUser(t, http.MethodGet, caldav.ProjectHandler, &testuser15, ``, nil, map[string]string{"project": "26"}, true) assert.NoError(t, err) assert.Contains(t, rec.Body.String(), "BEGIN:VCALENDAR") assert.Contains(t, rec.Body.String(), "PRODID:-//Vikunja Todo App//EN") - assert.Contains(t, rec.Body.String(), "X-WR-CALNAME:List 26 for Caldav tests") + assert.Contains(t, rec.Body.String(), "X-WR-CALNAME:Project 26 for Caldav tests") assert.Contains(t, rec.Body.String(), "BEGIN:VTODO") assert.Contains(t, rec.Body.String(), "END:VTODO") assert.Contains(t, rec.Body.String(), "END:VCALENDAR") }) t.Run("Import VTODO", func(t *testing.T) { - rec, err := newCaldavTestRequestWithUser(t, http.MethodPut, caldav.TaskHandler, &testuser15, vtodo, nil, map[string]string{"project": "26", "task": "uid"}) + rec, err := newCaldavTestRequestWithUser(t, http.MethodPut, caldav.TaskHandler, &testuser15, vtodo, nil, map[string]string{"project": "26", "task": "uid"}, true) assert.NoError(t, err) assert.Equal(t, rec.Result().StatusCode, 201) }) t.Run("Export VTODO", func(t *testing.T) { - rec, err := newCaldavTestRequestWithUser(t, http.MethodGet, caldav.TaskHandler, &testuser15, ``, nil, map[string]string{"project": "26", "task": "uid-caldav-test"}) + rec, err := newCaldavTestRequestWithUser(t, http.MethodGet, caldav.TaskHandler, &testuser15, ``, nil, map[string]string{"project": "26", "task": "uid-caldav-test"}, true) assert.NoError(t, err) assert.Contains(t, rec.Body.String(), "BEGIN:VCALENDAR") assert.Contains(t, rec.Body.String(), "SUMMARY:Title Caldav Test") @@ -67,3 +67,366 @@ func TestCaldav(t *testing.T) { assert.Contains(t, rec.Body.String(), "CATEGORIES:Label #4") }) } + +// Here we check that the CALDAV implementation correctly supports subtasks: +func TestCaldavSubtasks(t *testing.T) { + const vtodoParentTask = `BEGIN:VCALENDAR +VERSION:2.0 +METHOD:PUBLISH +X-PUBLISHED-TTL:PT4H +X-WR-CALNAME:Project 26 for Caldav tests +PRODID:-//Vikunja Todo App//EN +BEGIN:VTODO +UID:uid_parent_task +DTSTAMP:20230301T073337Z +SUMMARY:Caldav parent task +CREATED:20230301T073337Z +LAST-MODIFIED:20230301T073337Z +END:VTODO +END:VCALENDAR` + + const vtodoChildTask1 = `BEGIN:VCALENDAR +VERSION:2.0 +METHOD:PUBLISH +X-PUBLISHED-TTL:PT4H +X-WR-CALNAME:Project 26 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_parent_task +END:VTODO +END:VCALENDAR` + + const vtodoChildTask2 = `BEGIN:VCALENDAR +VERSION:2.0 +METHOD:PUBLISH +X-PUBLISHED-TTL:PT4H +X-WR-CALNAME:Project 26 for Caldav tests +PRODID:-//Vikunja Todo App//EN +BEGIN:VTODO +UID:uid_child2 +DTSTAMP:20230301T073337Z +SUMMARY:Caldav child task 2 +CREATED:20230301T073337Z +LAST-MODIFIED:20230301T073337Z +RELATED-TO;RELTYPE=PARENT:uid_parent_task +END:VTODO +END:VCALENDAR` + + const vtodoGrandChildTask = `BEGIN:VCALENDAR +VERSION:2.0 +METHOD:PUBLISH +X-PUBLISHED-TTL:PT4H +X-WR-CALNAME:Project 26 for Caldav tests +PRODID:-//Vikunja Todo App//EN +BEGIN:VTODO +UID:uid_grand_child +DTSTAMP:20230301T073337Z +SUMMARY:Caldav grand child task +CREATED:20230301T073337Z +LAST-MODIFIED:20230301T073337Z +RELATED-TO;RELTYPE=PARENT:uid_child1 +END:VTODO +END:VCALENDAR` + + t.Run("Import parent task", func(t *testing.T) { + rec, err := newCaldavTestRequestWithUser(t, http.MethodPut, caldav.TaskHandler, &testuser15, vtodoParentTask, nil, map[string]string{"project": "26", "task": "uid_parent_task"}, true) + assert.NoError(t, err) + assert.Equal(t, rec.Result().StatusCode, 201) + }) + + t.Run("Import children tasks", func(t *testing.T) { + rec, err := newCaldavTestRequestWithUser(t, http.MethodPut, caldav.TaskHandler, &testuser15, vtodoChildTask1, nil, map[string]string{"project": "26", "task": "uid_child1"}, false) + assert.NoError(t, err) + assert.Equal(t, rec.Result().StatusCode, 201) + + rec, err = newCaldavTestRequestWithUser(t, http.MethodPut, caldav.TaskHandler, &testuser15, vtodoChildTask2, nil, map[string]string{"project": "26", "task": "uid_child2"}, false) + assert.NoError(t, err) + assert.Equal(t, rec.Result().StatusCode, 201) + }) + + t.Run("Import grand child task", func(t *testing.T) { + rec, err := newCaldavTestRequestWithUser(t, http.MethodPut, caldav.TaskHandler, &testuser15, vtodoGrandChildTask, nil, map[string]string{"project": "26", "task": "uid_grand_child"}, false) + assert.NoError(t, err) + assert.Equal(t, rec.Result().StatusCode, 201) + }) + + t.Run("Check the relationship between all the tasks by fetching them one by one", func(t *testing.T) { + rec, err := newCaldavTestRequestWithUser(t, http.MethodGet, caldav.TaskHandler, &testuser15, ``, nil, map[string]string{"project": "26", "task": "uid_parent_task"}, false) + assert.NoError(t, err) + assert.Equal(t, rec.Result().StatusCode, 200) + assert.Contains(t, rec.Body.String(), "BEGIN:VCALENDAR") + assert.Contains(t, rec.Body.String(), "UID:uid_parent_task") + assert.NotContains(t, rec.Body.String(), "RELATED-TO") + + rec, err = newCaldavTestRequestWithUser(t, http.MethodGet, caldav.TaskHandler, &testuser15, ``, nil, map[string]string{"project": "26", "task": "uid_child1"}, false) + assert.NoError(t, err) + assert.Equal(t, rec.Result().StatusCode, 200) + assert.Contains(t, rec.Body.String(), "BEGIN:VCALENDAR") + assert.Contains(t, rec.Body.String(), "UID:uid_child1") + assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:uid_parent_task") + + rec, err = newCaldavTestRequestWithUser(t, http.MethodGet, caldav.TaskHandler, &testuser15, ``, nil, map[string]string{"project": "26", "task": "uid_child2"}, false) + assert.NoError(t, err) + assert.Equal(t, rec.Result().StatusCode, 200) + assert.Contains(t, rec.Body.String(), "BEGIN:VCALENDAR") + assert.Contains(t, rec.Body.String(), "UID:uid_child2") + assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:uid_parent_task") + + rec, err = newCaldavTestRequestWithUser(t, http.MethodGet, caldav.TaskHandler, &testuser15, ``, nil, map[string]string{"project": "26", "task": "uid_grand_child"}, false) + assert.NoError(t, err) + assert.Equal(t, rec.Result().StatusCode, 200) + assert.Contains(t, rec.Body.String(), "BEGIN:VCALENDAR") + assert.Contains(t, rec.Body.String(), "UID:uid_grand_child") + assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:uid_child1") + }) + + const vtodoEditedGrandChildTask = `BEGIN:VCALENDAR +VERSION:2.0 +METHOD:PUBLISH +X-PUBLISHED-TTL:PT4H +X-WR-CALNAME:Project 26 for Caldav tests +PRODID:-//Vikunja Todo App//EN +BEGIN:VTODO +UID:uid_grand_child +DTSTAMP:20230301T073337Z +SUMMARY:Caldav grand child task edited +CREATED:20230301T073337Z +LAST-MODIFIED:20230301T073337Z +RELATED-TO;RELTYPE=PARENT:uid_child1 +END:VTODO +END:VCALENDAR` + + t.Run("Update the grand child task again and check that the relation is still there", func(t *testing.T) { + rec, err := newCaldavTestRequestWithUser(t, http.MethodPut, caldav.TaskHandler, &testuser15, vtodoEditedGrandChildTask, nil, map[string]string{"project": "26", "task": "uid_grand_child"}, false) + assert.NoError(t, err) + assert.Equal(t, rec.Result().StatusCode, 201) + + rec, err = newCaldavTestRequestWithUser(t, http.MethodGet, caldav.TaskHandler, &testuser15, ``, nil, map[string]string{"project": "26", "task": "uid_grand_child"}, false) + assert.NoError(t, err) + assert.Equal(t, rec.Result().StatusCode, 200) + assert.Contains(t, rec.Body.String(), "BEGIN:VCALENDAR") + assert.Contains(t, rec.Body.String(), "UID:uid_grand_child") + assert.Contains(t, rec.Body.String(), "SUMMARY:Caldav grand child task edited") + assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:uid_child1") + }) + + const vtodoChildTask2WithoutRelation = `BEGIN:VCALENDAR +VERSION:2.0 +METHOD:PUBLISH +X-PUBLISHED-TTL:PT4H +X-WR-CALNAME:Project 26 for Caldav tests +PRODID:-//Vikunja Todo App//EN +BEGIN:VTODO +UID:uid_child2 +DTSTAMP:20230301T073337Z +SUMMARY:Caldav child task 2 +CREATED:20230301T073337Z +LAST-MODIFIED:20230301T073337Z +END:VTODO +END:VCALENDAR` + + t.Run("Remove the relation from the second child", func(t *testing.T) { + rec, err := newCaldavTestRequestWithUser(t, http.MethodPut, caldav.TaskHandler, &testuser15, vtodoChildTask2WithoutRelation, nil, map[string]string{"project": "26", "task": "uid_child2"}, false) + assert.NoError(t, err) + assert.Equal(t, rec.Result().StatusCode, 201) + + // Check that the relation was removed from the DB, and isn't returned anymore: + rec, err = newCaldavTestRequestWithUser(t, http.MethodGet, caldav.TaskHandler, &testuser15, ``, nil, map[string]string{"project": "26", "task": "uid_child2"}, false) + assert.NoError(t, err) + assert.Equal(t, rec.Result().StatusCode, 200) + assert.Contains(t, rec.Body.String(), "BEGIN:VCALENDAR") + assert.Contains(t, rec.Body.String(), "UID:uid_child2") + assert.NotContains(t, rec.Body.String(), "RELATED-TO") + }) + + const vtodoGrandChildTaskNewParent = `BEGIN:VCALENDAR +VERSION:2.0 +METHOD:PUBLISH +X-PUBLISHED-TTL:PT4H +X-WR-CALNAME:Project 26 for Caldav tests +PRODID:-//Vikunja Todo App//EN +BEGIN:VTODO +UID:uid_grand_child +DTSTAMP:20230301T073337Z +SUMMARY:Caldav grand child task edited +CREATED:20230301T073337Z +LAST-MODIFIED:20230301T073337Z +RELATED-TO;RELTYPE=PARENT:uid_parent_task +END:VTODO +END:VCALENDAR` + + t.Run("Update the grand child task again and change its parent", func(t *testing.T) { + rec, err := newCaldavTestRequestWithUser(t, http.MethodPut, caldav.TaskHandler, &testuser15, vtodoGrandChildTaskNewParent, nil, map[string]string{"project": "26", "task": "uid_grand_child"}, false) + assert.NoError(t, err) + assert.Equal(t, rec.Result().StatusCode, 201) + + rec, err = newCaldavTestRequestWithUser(t, http.MethodGet, caldav.TaskHandler, &testuser15, ``, nil, map[string]string{"project": "26", "task": "uid_grand_child"}, false) + assert.NoError(t, err) + assert.Equal(t, rec.Result().StatusCode, 200) + assert.Contains(t, rec.Body.String(), "BEGIN:VCALENDAR") + assert.Contains(t, rec.Body.String(), "UID:uid_grand_child") + assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:uid_parent_task") + }) +} + +// Here we check that the CALDAV implementation correctly supports task relations from different lists: +func TestCaldavSubtasksDifferentLists(t *testing.T) { + const vtodoParentTask = `BEGIN:VCALENDAR +VERSION:2.0 +METHOD:PUBLISH +X-PUBLISHED-TTL:PT4H +X-WR-CALNAME:Project 26 for Caldav tests +PRODID:-//Vikunja Todo App//EN +BEGIN:VTODO +UID:uid_parent_task +DTSTAMP:20230301T073337Z +SUMMARY:Caldav parent task +CREATED:20230301T073337Z +LAST-MODIFIED:20230301T073337Z +END:VTODO +END:VCALENDAR` + + const vtodoChildTask1 = `BEGIN:VCALENDAR +VERSION:2.0 +METHOD:PUBLISH +X-PUBLISHED-TTL:PT4H +X-WR-CALNAME:Project 27 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_parent_task +END:VTODO +END:VCALENDAR` + + t.Run("Import parent task", func(t *testing.T) { + rec, err := newCaldavTestRequestWithUser(t, http.MethodPut, caldav.TaskHandler, &testuser15, vtodoParentTask, nil, map[string]string{"project": "26", "task": "uid_parent_task"}, true) + assert.NoError(t, err) + assert.Equal(t, rec.Result().StatusCode, 201) + }) + + t.Run("Import child tasks into a different list", func(t *testing.T) { + rec, err := newCaldavTestRequestWithUser(t, http.MethodPut, caldav.TaskHandler, &testuser15, vtodoChildTask1, nil, map[string]string{"project": "27", "task": "uid_child1"}, false) + assert.NoError(t, err) + assert.Equal(t, rec.Result().StatusCode, 201) + }) + + t.Run("Check the relationship between all the tasks by fetching them one by one", func(t *testing.T) { + rec, err := newCaldavTestRequestWithUser(t, http.MethodGet, caldav.TaskHandler, &testuser15, ``, nil, map[string]string{"project": "26", "task": "uid_parent_task"}, false) + assert.NoError(t, err) + assert.Equal(t, rec.Result().StatusCode, 200) + assert.Contains(t, rec.Body.String(), "BEGIN:VCALENDAR") + assert.Contains(t, rec.Body.String(), "UID:uid_parent_task") + assert.NotContains(t, rec.Body.String(), "RELATED-TO") + + rec, err = newCaldavTestRequestWithUser(t, http.MethodGet, caldav.TaskHandler, &testuser15, ``, nil, map[string]string{"project": "27", "task": "uid_child1"}, false) + assert.NoError(t, err) + assert.Equal(t, rec.Result().StatusCode, 200) + assert.Contains(t, rec.Body.String(), "BEGIN:VCALENDAR") + assert.Contains(t, rec.Body.String(), "UID:uid_child1") + assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:uid_parent_task") + }) +} + +// Here we check that subtasks are handled properly even if the children tasks are created before the parent tasks +func TestCaldavSubtasksInverseOrder(t *testing.T) { + const vtodoGrandChildTask = `BEGIN:VCALENDAR +VERSION:2.0 +METHOD:PUBLISH +X-PUBLISHED-TTL:PT4H +X-WR-CALNAME:Project 26 for Caldav tests +PRODID:-//Vikunja Todo App//EN +BEGIN:VTODO +UID:uid_grand_child +DTSTAMP:20230301T073337Z +SUMMARY:Caldav grand child task +CREATED:20230301T073337Z +LAST-MODIFIED:20230301T073337Z +RELATED-TO;RELTYPE=PARENT:uid_child1 +END:VTODO +END:VCALENDAR` + + const vtodoChildTask1 = `BEGIN:VCALENDAR +VERSION:2.0 +METHOD:PUBLISH +X-PUBLISHED-TTL:PT4H +X-WR-CALNAME:Project 26 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_parent_task +END:VTODO +END:VCALENDAR` + + const vtodoParentTask = `BEGIN:VCALENDAR +VERSION:2.0 +METHOD:PUBLISH +X-PUBLISHED-TTL:PT4H +X-WR-CALNAME:Project 26 for Caldav tests +PRODID:-//Vikunja Todo App//EN +BEGIN:VTODO +UID:uid_parent_task +DTSTAMP:20230301T073337Z +SUMMARY:Caldav parent task +CREATED:20230301T073337Z +LAST-MODIFIED:20230301T073337Z +END:VTODO +END:VCALENDAR` + + t.Run("Import grand child task", func(t *testing.T) { + rec, err := newCaldavTestRequestWithUser(t, http.MethodPut, caldav.TaskHandler, &testuser15, vtodoGrandChildTask, nil, map[string]string{"project": "26", "task": "uid_grand_child"}, true) + assert.NoError(t, err) + assert.Equal(t, rec.Result().StatusCode, 201) + }) + + t.Run("Import children tasks", func(t *testing.T) { + rec, err := newCaldavTestRequestWithUser(t, http.MethodPut, caldav.TaskHandler, &testuser15, vtodoChildTask1, nil, map[string]string{"project": "26", "task": "uid_child1"}, false) + assert.NoError(t, err) + assert.Equal(t, rec.Result().StatusCode, 201) + }) + + t.Run("Import parent task", func(t *testing.T) { + rec, err := newCaldavTestRequestWithUser(t, http.MethodPut, caldav.TaskHandler, &testuser15, vtodoParentTask, nil, map[string]string{"project": "26", "task": "uid_parent_task"}, false) + assert.NoError(t, err) + assert.Equal(t, rec.Result().StatusCode, 201) + }) + + t.Run("Check the relationship between all the tasks by fetching them one by one", func(t *testing.T) { + rec, err := newCaldavTestRequestWithUser(t, http.MethodGet, caldav.TaskHandler, &testuser15, ``, nil, map[string]string{"project": "26", "task": "uid_parent_task"}, false) + assert.NoError(t, err) + assert.Equal(t, rec.Result().StatusCode, 200) + assert.Contains(t, rec.Body.String(), "BEGIN:VCALENDAR") + assert.Contains(t, rec.Body.String(), "UID:uid_parent_task") + assert.Contains(t, rec.Body.String(), "SUMMARY:Caldav parent task") + assert.NotContains(t, rec.Body.String(), "RELATED-TO") + + rec, err = newCaldavTestRequestWithUser(t, http.MethodGet, caldav.TaskHandler, &testuser15, ``, nil, map[string]string{"project": "26", "task": "uid_child1"}, false) + assert.NoError(t, err) + assert.Equal(t, rec.Result().StatusCode, 200) + assert.Contains(t, rec.Body.String(), "BEGIN:VCALENDAR") + assert.Contains(t, rec.Body.String(), "UID:uid_child1") + assert.Contains(t, rec.Body.String(), "SUMMARY:Caldav child task 1") + assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:uid_parent_task") + + rec, err = newCaldavTestRequestWithUser(t, http.MethodGet, caldav.TaskHandler, &testuser15, ``, nil, map[string]string{"project": "26", "task": "uid_grand_child"}, false) + assert.NoError(t, err) + assert.Equal(t, rec.Result().StatusCode, 200) + assert.Contains(t, rec.Body.String(), "BEGIN:VCALENDAR") + assert.Contains(t, rec.Body.String(), "UID:uid_grand_child") + assert.Contains(t, rec.Body.String(), "SUMMARY:Caldav grand child task") + assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:uid_child1") + }) + +} diff --git a/pkg/integrations/integrations.go b/pkg/integrations/integrations.go index e59fddd9211..826a777455e 100644 --- a/pkg/integrations/integrations.go +++ b/pkg/integrations/integrations.go @@ -58,20 +58,22 @@ var ( } ) -func setupTestEnv() (e *echo.Echo, err error) { +func setupTestEnv(loadFixtures bool) (e *echo.Echo, err error) { config.InitDefaultConfig() // We need to set the root path even if we're not using the config, otherwise fixtures are not loaded correctly config.ServiceRootpath.Set(os.Getenv("VIKUNJA_SERVICE_ROOTPATH")) // Some tests use the file engine, so we'll need to initialize that - files.InitTests() + files.InitTests(loadFixtures) user.InitTests() models.SetupTests() events.Fake() keyvalue.InitStorage() - err = db.LoadFixtures() - if err != nil { - return + if loadFixtures { + err = db.LoadFixtures() + if err != nil { + return + } } e = routes.NewEcho() @@ -79,9 +81,9 @@ func setupTestEnv() (e *echo.Echo, err error) { return } -func bootstrapTestRequest(t *testing.T, method string, payload string, queryParam url.Values) (c echo.Context, rec *httptest.ResponseRecorder) { +func bootstrapTestRequest(t *testing.T, method string, payload string, queryParam url.Values, loadFixtures bool) (c echo.Context, rec *httptest.ResponseRecorder) { // Setup - e, err := setupTestEnv() + e, err := setupTestEnv(loadFixtures) assert.NoError(t, err) // Do the actual request @@ -95,7 +97,7 @@ func bootstrapTestRequest(t *testing.T, method string, payload string, queryPara } func newTestRequest(t *testing.T, method string, handler func(ctx echo.Context) error, payload string, queryParams url.Values, urlParams map[string]string) (rec *httptest.ResponseRecorder, err error) { - rec, c := testRequestSetup(t, method, payload, queryParams, urlParams) + rec, c := testRequestSetup(t, method, payload, queryParams, urlParams, true) err = handler(c) return } @@ -124,8 +126,8 @@ func addLinkShareTokenToContext(t *testing.T, share *models.LinkSharing, c echo. c.Set("user", tken) } -func testRequestSetup(t *testing.T, method string, payload string, queryParams url.Values, urlParams map[string]string) (rec *httptest.ResponseRecorder, c echo.Context) { - c, rec = bootstrapTestRequest(t, method, payload, queryParams) +func testRequestSetup(t *testing.T, method string, payload string, queryParams url.Values, urlParams map[string]string, loadFixtures bool) (rec *httptest.ResponseRecorder, c echo.Context) { + c, rec = bootstrapTestRequest(t, method, payload, queryParams, loadFixtures) var paramNames []string var paramValues []string @@ -139,21 +141,21 @@ func testRequestSetup(t *testing.T, method string, payload string, queryParams u } func newTestRequestWithUser(t *testing.T, method string, handler echo.HandlerFunc, user *user.User, payload string, queryParams url.Values, urlParams map[string]string) (rec *httptest.ResponseRecorder, err error) { - rec, c := testRequestSetup(t, method, payload, queryParams, urlParams) + rec, c := testRequestSetup(t, method, payload, queryParams, urlParams, true) addUserTokenToContext(t, user, c) err = handler(c) return } func newTestRequestWithLinkShare(t *testing.T, method string, handler echo.HandlerFunc, share *models.LinkSharing, payload string, queryParams url.Values, urlParams map[string]string) (rec *httptest.ResponseRecorder, err error) { - rec, c := testRequestSetup(t, method, payload, queryParams, urlParams) + rec, c := testRequestSetup(t, method, payload, queryParams, urlParams, true) addLinkShareTokenToContext(t, share, c) err = handler(c) return } -func newCaldavTestRequestWithUser(t *testing.T, method string, handler echo.HandlerFunc, user *user.User, payload string, queryParams url.Values, urlParams map[string]string) (rec *httptest.ResponseRecorder, err error) { - rec, c := testRequestSetup(t, method, payload, queryParams, urlParams) +func newCaldavTestRequestWithUser(t *testing.T, method string, handler echo.HandlerFunc, user *user.User, payload string, queryParams url.Values, urlParams map[string]string, loadFixtures bool) (rec *httptest.ResponseRecorder, err error) { + rec, c := testRequestSetup(t, method, payload, queryParams, urlParams, loadFixtures) c.Request().Header.Set(echo.HeaderContentType, echo.MIMETextPlain) result, _ := caldav.BasicAuth(user.Username, "1234", c) diff --git a/pkg/models/main_test.go b/pkg/models/main_test.go index b86b07b279d..9270b760491 100644 --- a/pkg/models/main_test.go +++ b/pkg/models/main_test.go @@ -59,7 +59,7 @@ func TestMain(m *testing.M) { config.ServiceRootpath.Set(os.Getenv("VIKUNJA_SERVICE_ROOTPATH")) // Some tests use the file engine, so we'll need to initialize that - files.InitTests() + files.InitTests(true) user.InitTests() diff --git a/pkg/modules/auth/openid/main_test.go b/pkg/modules/auth/openid/main_test.go index 21b51d68ea9..a5e2c28ad24 100644 --- a/pkg/modules/auth/openid/main_test.go +++ b/pkg/modules/auth/openid/main_test.go @@ -30,7 +30,7 @@ import ( // TestMain is the main test function used to bootstrap the test env func TestMain(m *testing.M) { user.InitTests() - files.InitTests() + files.InitTests(true) models.SetupTests() events.Fake() os.Exit(m.Run()) diff --git a/pkg/modules/migration/main_test.go b/pkg/modules/migration/main_test.go index 0ceca155942..6c8f0cfa301 100644 --- a/pkg/modules/migration/main_test.go +++ b/pkg/modules/migration/main_test.go @@ -36,7 +36,7 @@ func TestMain(m *testing.M) { config.ServiceRootpath.Set(os.Getenv("VIKUNJA_SERVICE_ROOTPATH")) // Some tests use the file engine, so we'll need to initialize that - files.InitTests() + files.InitTests(true) user.InitTests() models.SetupTests() events.Fake() diff --git a/pkg/modules/migration/vikunja-file/main_test.go b/pkg/modules/migration/vikunja-file/main_test.go index d618888df4e..ec32a4d6f83 100644 --- a/pkg/modules/migration/vikunja-file/main_test.go +++ b/pkg/modules/migration/vikunja-file/main_test.go @@ -36,7 +36,7 @@ func TestMain(m *testing.M) { config.ServiceRootpath.Set(os.Getenv("VIKUNJA_SERVICE_ROOTPATH")) // Some tests use the file engine, so we'll need to initialize that - files.InitTests() + files.InitTests(true) user.InitTests() models.SetupTests() events.Fake() diff --git a/pkg/routes/caldav/listStorageProvider.go b/pkg/routes/caldav/listStorageProvider.go index 88841670fb4..4d0602f2694 100644 --- a/pkg/routes/caldav/listStorageProvider.go +++ b/pkg/routes/caldav/listStorageProvider.go @@ -17,6 +17,7 @@ package caldav import ( + "golang.org/x/exp/slices" "strconv" "strings" "time" @@ -294,6 +295,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 } @@ -318,6 +326,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 + // Explicitely 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() @@ -345,6 +357,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 } @@ -432,6 +450,88 @@ func persistLabels(s *xorm.Session, a web.Auth, task *models.Task, labels []*mod return task.UpdateTaskLabels(s, a, labels) } +// When a VTODO entry doesn't have a parent anymore, but we do, we need to remove it as well. +func removeLegacyParentRelations(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} + err = existingTask.ReadOne(s, a) + if err != nil { + return + } + + // Loop through all the existing task's parent relationships: + if _, ok := existingTask.RelatedTasks[models.RelationKindParenttask]; ok { + for _, parentTask := range existingTask.RelatedTasks[models.RelationKindParenttask] { + + // Check if the existing parent relation is in the new list: + parentRelationInNewList := slices.ContainsFunc(newRelations[models.RelationKindParenttask], func(newRelation *models.Task) bool { return newRelation.UID == parentTask.UID }) + + // Remove the relations if it's not there in the new list anymore: + if !parentRelationInNewList { + rel := models.TaskRelation{ + TaskID: task.ID, + OtherTaskID: parentTask.ID, + RelationKind: models.RelationKindParenttask, + } + 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) { + + // Remove existing "parent" relations that are not present in the new list: + err = removeLegacyParentRelations(s, a, task, newRelations) + if err != nil { + return err + } + + // Ensure the current relations exist: + for relationType, relatedTasks := range newRelations { + + // Persist each relation independently: + for _, relatedTask := range relatedTasks { + + // Get the task from the DB: + has, err := s.Get(relatedTask) + if err != nil { + return err + } + + // 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: + if !has { + relatedTask.ProjectID = task.ProjectID + relatedTask.Title = "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