From a62b57ac6283a0d0a3b00e482bf7ae59f97c2748 Mon Sep 17 00:00:00 2001 From: cernst Date: Thu, 2 Mar 2023 15:25:26 +0000 Subject: [PATCH] feat(caldav): import caldav categories as Labels (#1413) Resolves #1274 Co-authored-by: ce72 Reviewed-on: https://kolaente.dev/vikunja/api/pulls/1413 Reviewed-by: konrad Co-authored-by: cernst Co-committed-by: cernst --- docs/content/doc/usage/caldav.md | 6 +-- pkg/caldav/parsing.go | 12 +++++ pkg/caldav/parsing_test.go | 33 ++++++++++++ pkg/db/fixtures/buckets.yml | 6 +++ pkg/db/fixtures/label_tasks.yml | 4 ++ pkg/db/fixtures/lists.yml | 10 ++++ pkg/db/fixtures/namespaces.yml | 6 +++ pkg/db/fixtures/tasks.yml | 14 +++++ pkg/db/fixtures/users.yml | 7 +++ pkg/db/fixtures/users_lists.yml | 6 +++ pkg/integrations/caldav_test.go | 69 ++++++++++++++++++++++++ pkg/integrations/integrations.go | 20 +++++++ pkg/models/kanban_test.go | 2 +- pkg/models/label.go | 26 ++++----- pkg/models/label_task.go | 4 +- pkg/routes/caldav/listStorageProvider.go | 40 ++++++++++++++ pkg/user/user_test.go | 2 +- 17 files changed, 248 insertions(+), 19 deletions(-) create mode 100644 pkg/integrations/caldav_test.go diff --git a/docs/content/doc/usage/caldav.md b/docs/content/doc/usage/caldav.md index c0158f0c61..4887886a62 100644 --- a/docs/content/doc/usage/caldav.md +++ b/docs/content/doc/usage/caldav.md @@ -10,9 +10,9 @@ menu: # Caldav -> **Warning:** The caldav integration is in an early alpha stage and has bugs. +> **Warning:** The caldav integration is in an early alpha stage and has bugs. > It works well with some clients while having issues with others. -> If you encounter issues, please [report them](https://code.vikunja.io/api/issues/new?body=[caldav]) +> If you encounter issues, please [report them](https://code.vikunja.io/api/issues/new?body=[caldav]) Vikunja supports managing tasks via the [caldav VTODO](https://tools.ietf.org/html/rfc5545#section-3.6.2) extension. @@ -37,6 +37,7 @@ Vikunja currently supports the following properties: * `SUMMARY` * `DESCRIPTION` * `PRIORITY` +* `CATEGORIES` * `COMPLETED` * `DUE` * `DTSTART` @@ -51,7 +52,6 @@ Vikunja currently supports the following properties: Vikunja **currently does not** support these properties: * `ATTACH` -* `CATEGORIES` * `CLASS` * `COMMENT` * `GEO` diff --git a/pkg/caldav/parsing.go b/pkg/caldav/parsing.go index f66fdcb1a0..26efee04f6 100644 --- a/pkg/caldav/parsing.go +++ b/pkg/caldav/parsing.go @@ -96,11 +96,23 @@ func ParseTaskFromVTODO(content string) (vTask *models.Task, err error) { description := strings.ReplaceAll(task["DESCRIPTION"], "\\,", ",") description = strings.ReplaceAll(description, "\\n", "\n") + var labels []*models.Label + if val, ok := task["CATEGORIES"]; ok { + categories := strings.Split(val, ",") + labels = make([]*models.Label, 0, len(categories)) + for _, category := range categories { + labels = append(labels, &models.Label{ + Title: category, + }) + } + } + vTask = &models.Task{ UID: task["UID"], Title: task["SUMMARY"], Description: description, Priority: priority, + Labels: labels, DueDate: caldavTimeToTimestamp(task["DUE"]), Updated: caldavTimeToTimestamp(task["DTSTAMP"]), StartDate: caldavTimeToTimestamp(task["DTSTART"]), diff --git a/pkg/caldav/parsing_test.go b/pkg/caldav/parsing_test.go index 2fd8423834..e23ba57616 100644 --- a/pkg/caldav/parsing_test.go +++ b/pkg/caldav/parsing_test.go @@ -85,6 +85,39 @@ END:VCALENDAR`, Updated: time.Unix(1543626724, 0).In(config.GetTimeZone()), }, }, + { + name: "With categories", + 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:Todo #1 +DESCRIPTION:Lorem Ipsum +CATEGORIES:cat1,cat2 +LAST-MODIFIED:00010101T000000 +END:VTODO +END:VCALENDAR`, + }, + wantVTask: &models.Task{ + Title: "Todo #1", + UID: "randomuid", + Description: "Lorem Ipsum", + Labels: []*models.Label{ + { + Title: "cat1", + }, + { + Title: "cat2", + }, + }, + Updated: time.Unix(1543626724, 0).In(config.GetTimeZone()), + }, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/pkg/db/fixtures/buckets.yml b/pkg/db/fixtures/buckets.yml index 64d3b037e5..3cca1393fe 100644 --- a/pkg/db/fixtures/buckets.yml +++ b/pkg/db/fixtures/buckets.yml @@ -218,3 +218,9 @@ created_by_id: -2 created: 2020-04-18 21:13:52 updated: 2020-04-18 21:13:52 +- id: 36 + title: testbucket36 + list_id: 26 + created_by_id: 15 + created: 2020-04-18 21:13:52 + updated: 2020-04-18 21:13:52 diff --git a/pkg/db/fixtures/label_tasks.yml b/pkg/db/fixtures/label_tasks.yml index 3f4aae844d..bdf836f723 100644 --- a/pkg/db/fixtures/label_tasks.yml +++ b/pkg/db/fixtures/label_tasks.yml @@ -14,3 +14,7 @@ task_id: 36 label_id: 4 created: 2018-12-01 15:13:12 +- id: 5 + task_id: 39 + label_id: 4 + created: 2018-12-01 15:13:12 diff --git a/pkg/db/fixtures/lists.yml b/pkg/db/fixtures/lists.yml index 356253955c..f74ceac7fe 100644 --- a/pkg/db/fixtures/lists.yml +++ b/pkg/db/fixtures/lists.yml @@ -253,3 +253,13 @@ position: 8 updated: 2018-12-02 15:13:12 created: 2018-12-01 15:13:12 +- + id: 26 + title: List 26 for Caldav tests + description: Lorem Ipsum + identifier: test26 + owner_id: 15 + namespace_id: 18 + position: 1 + updated: 2018-12-02 15:13:12 + created: 2018-12-01 15:13:12 diff --git a/pkg/db/fixtures/namespaces.yml b/pkg/db/fixtures/namespaces.yml index 41f996e033..282409f9b9 100644 --- a/pkg/db/fixtures/namespaces.yml +++ b/pkg/db/fixtures/namespaces.yml @@ -88,3 +88,9 @@ owner_id: 12 updated: 2018-12-02 15:13:12 created: 2018-12-01 15:13:12 +- id: 18 + title: testnamespace18 + description: Lorem Ipsum + owner_id: 15 + updated: 2018-12-02 15:13:12 + created: 2018-12-01 15:13:12 diff --git a/pkg/db/fixtures/tasks.yml b/pkg/db/fixtures/tasks.yml index b6dc09e31b..10e81b2f49 100644 --- a/pkg/db/fixtures/tasks.yml +++ b/pkg/db/fixtures/tasks.yml @@ -355,3 +355,17 @@ created: 2018-12-01 01:12:04 updated: 2018-12-01 01:12:04 due_date: 2018-10-30 22:25:24 +- id: 39 + uid: 'uid-caldav-test' + title: 'Title Caldav Test' + description: 'Description Caldav Test' + priority: 3 + done: false + created_by_id: 15 + list_id: 26 + index: 39 + due_date: 2023-03-01 15:00:00 + created: 2018-12-01 01:12:04 + updated: 2018-12-01 01:12:04 + bucket_id: 1 + position: 39 diff --git a/pkg/db/fixtures/users.yml b/pkg/db/fixtures/users.yml index 7ad3728118..50ee9ef2af 100644 --- a/pkg/db/fixtures/users.yml +++ b/pkg/db/fixtures/users.yml @@ -109,3 +109,10 @@ subject: '12345' updated: 2018-12-02 15:13:12 created: 2018-12-01 15:13:12 +- id: 15 + username: 'user15' + password: '$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.' # 1234 + email: 'user15@example.com' + issuer: local + updated: 2018-12-02 15:13:12 + created: 2018-12-01 15:13:12 diff --git a/pkg/db/fixtures/users_lists.yml b/pkg/db/fixtures/users_lists.yml index eb1e623546..51764e7315 100644 --- a/pkg/db/fixtures/users_lists.yml +++ b/pkg/db/fixtures/users_lists.yml @@ -46,3 +46,9 @@ right: 2 updated: 2018-12-02 15:13:12 created: 2018-12-01 15:13:12 +- id: 9 + user_id: 15 + list_id: 26 + right: 0 + updated: 2018-12-02 15:13:12 + created: 2018-12-01 15:13:12 diff --git a/pkg/integrations/caldav_test.go b/pkg/integrations/caldav_test.go new file mode 100644 index 0000000000..bbb2269300 --- /dev/null +++ b/pkg/integrations/caldav_test.go @@ -0,0 +1,69 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-2021 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 . + +package integrations + +import ( + "net/http" + "testing" + + "code.vikunja.io/api/pkg/routes/caldav" + "github.com/stretchr/testify/assert" +) + +const vtodo = `BEGIN:VCALENDAR +VERSION:2.0 +METHOD:PUBLISH +X-PUBLISHED-TTL:PT4H +X-WR-CALNAME:List 26 for Caldav tests +PRODID:-//Vikunja Todo App//EN +BEGIN:VTODO +UID:uid +DTSTAMP:20230301T073337Z +SUMMARY:Caldav Task 1 +CATEGORIES:tag1,tag2,tag3 +CREATED:20230301T073337Z +LAST-MODIFIED:20230301T073337Z +END:VTODO +END:VCALENDAR` + +func TestCaldav(t *testing.T) { + t.Run("Delivers VTODO for list", func(t *testing.T) { + rec, err := newCaldavTestRequestWithUser(t, http.MethodGet, caldav.ListHandler, &testuser15, ``, nil, map[string]string{"list": "26"}) + 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(), "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{"list": "26", "task": "uid"}) + 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{"list": "26", "task": "uid-caldav-test"}) + assert.NoError(t, err) + assert.Contains(t, rec.Body.String(), "BEGIN:VCALENDAR") + assert.Contains(t, rec.Body.String(), "SUMMARY:Title Caldav Test") + assert.Contains(t, rec.Body.String(), "DESCRIPTION:Description Caldav Test") + assert.Contains(t, rec.Body.String(), "DUE:20230301T150000Z") + assert.Contains(t, rec.Body.String(), "PRIORITY:3") + assert.Contains(t, rec.Body.String(), "CATEGORIES:Label #4") + }) +} diff --git a/pkg/integrations/integrations.go b/pkg/integrations/integrations.go index 472c5d47a7..e59fddd921 100644 --- a/pkg/integrations/integrations.go +++ b/pkg/integrations/integrations.go @@ -33,6 +33,7 @@ import ( "code.vikunja.io/api/pkg/modules/auth" "code.vikunja.io/api/pkg/modules/keyvalue" "code.vikunja.io/api/pkg/routes" + "code.vikunja.io/api/pkg/routes/caldav" "code.vikunja.io/api/pkg/user" "code.vikunja.io/web" "code.vikunja.io/web/handler" @@ -49,6 +50,12 @@ var ( Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", Email: "user1@example.com", } + testuser15 = user.User{ + ID: 15, + Username: "user15", + Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", + Email: "user15@example.com", + } ) func setupTestEnv() (e *echo.Echo, err error) { @@ -145,6 +152,19 @@ func newTestRequestWithLinkShare(t *testing.T, method string, handler echo.Handl 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) + c.Request().Header.Set(echo.HeaderContentType, echo.MIMETextPlain) + + result, _ := caldav.BasicAuth(user.Username, "1234", c) + if !result { + t.Error("BasicAuth for caldav failed") + t.FailNow() + } + err = handler(c) + return +} + func assertHandlerErrorCode(t *testing.T, err error, expectedErrorCode int) { if err == nil { t.Error("Error is nil") diff --git a/pkg/models/kanban_test.go b/pkg/models/kanban_test.go index ae91f58bba..dd1dc759ca 100644 --- a/pkg/models/kanban_test.go +++ b/pkg/models/kanban_test.go @@ -147,7 +147,7 @@ func TestBucket_Delete(t *testing.T) { tasks := []*Task{} err = s.Where("bucket_id = ?", 1).Find(&tasks) assert.NoError(t, err) - assert.Len(t, tasks, 15) + assert.Len(t, tasks, 16) db.AssertMissing(t, "buckets", map[string]interface{}{ "id": 2, "list_id": 1, diff --git a/pkg/models/label.go b/pkg/models/label.go index 49da1acca7..d06990bdc8 100644 --- a/pkg/models/label.go +++ b/pkg/models/label.go @@ -178,13 +178,13 @@ func (l *Label) ReadAll(s *xorm.Session, a web.Auth, search string, page int, pe func (l *Label) ReadOne(s *xorm.Session, a web.Auth) (err error) { label, err := getLabelByIDSimple(s, l.ID) if err != nil { - return err + return } *l = *label u, err := user.GetUserByID(s, l.CreatedByID) if err != nil { - return err + return } l.CreatedBy = u @@ -192,14 +192,16 @@ func (l *Label) ReadOne(s *xorm.Session, a web.Auth) (err error) { } func getLabelByIDSimple(s *xorm.Session, labelID int64) (*Label, error) { - label := Label{} - exists, err := s.ID(labelID).Get(&label) - if err != nil { - return &label, err - } - - if !exists { - return &Label{}, ErrLabelDoesNotExist{labelID} - } - return &label, err + return GetLabelSimple(s, &Label{ID: labelID}) +} + +func GetLabelSimple(s *xorm.Session, l *Label) (*Label, error) { + exists, err := s.Get(l) + if err != nil { + return l, err + } + if !exists { + return &Label{}, ErrLabelDoesNotExist{l.ID} + } + return l, err } diff --git a/pkg/models/label_task.go b/pkg/models/label_task.go index 132c210d7e..0d67c0994f 100644 --- a/pkg/models/label_task.go +++ b/pkg/models/label_task.go @@ -264,7 +264,7 @@ func getLabelsByTaskIDs(s *xorm.Session, opts *LabelByTaskIDsOptions) (ls []*lab } // Create or update a bunch of task labels -func (t *Task) updateTaskLabels(s *xorm.Session, creator web.Auth, labels []*Label) (err error) { +func (t *Task) UpdateTaskLabels(s *xorm.Session, creator web.Auth, labels []*Label) (err error) { // If we don't have any new labels, delete everything right away. Saves us some hassle. if len(labels) == 0 && len(t.Labels) > 0 { @@ -390,5 +390,5 @@ func (ltb *LabelTaskBulk) Create(s *xorm.Session, a web.Auth) (err error) { for _, l := range labels { task.Labels = append(task.Labels, &l.Label) } - return task.updateTaskLabels(s, a, ltb.Labels) + return task.UpdateTaskLabels(s, a, ltb.Labels) } diff --git a/pkg/routes/caldav/listStorageProvider.go b/pkg/routes/caldav/listStorageProvider.go index 9302ebf189..62209634fa 100644 --- a/pkg/routes/caldav/listStorageProvider.go +++ b/pkg/routes/caldav/listStorageProvider.go @@ -26,8 +26,10 @@ import ( "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/models" user2 "code.vikunja.io/api/pkg/user" + "code.vikunja.io/web" "github.com/samedi/caldav-go/data" "github.com/samedi/caldav-go/errs" + "xorm.io/xorm" ) // DavBasePath is the base url path @@ -285,6 +287,13 @@ func (vcls *VikunjaCaldavListStorage) CreateResource(rpath, content string) (*da return nil, err } + vcls.task.ID = vTask.ID + err = persistLabels(s, vcls.user, vcls.task, vTask.Labels) + if err != nil { + _ = s.Rollback() + return nil, err + } + if err := s.Commit(); err != nil { return nil, err } @@ -330,6 +339,12 @@ func (vcls *VikunjaCaldavListStorage) UpdateResource(rpath, content string) (*da return nil, err } + err = persistLabels(s, vcls.user, vcls.task, vTask.Labels) + if err != nil { + _ = s.Rollback() + return nil, err + } + if err := s.Commit(); err != nil { return nil, err } @@ -372,6 +387,31 @@ func (vcls *VikunjaCaldavListStorage) DeleteResource(rpath string) error { return nil } +func persistLabels(s *xorm.Session, a web.Auth, task *models.Task, labels []*models.Label) (err error) { + // Find or create Labels by title + for _, label := range labels { + l, err := models.GetLabelSimple(s, &models.Label{Title: label.Title}) + if err != nil { + if models.IsErrLabelDoesNotExist(err) { + err = label.Create(s, a) + if err != nil { + return err + } + } else { + return err + } + } else { + *label = *l + } + } + // Insert LabelTask relation + err = task.UpdateTaskLabels(s, a, labels) + if err != nil { + return + } + return nil +} + // VikunjaListResourceAdapter holds the actual resource type VikunjaListResourceAdapter struct { list *models.ListWithTasksAndBuckets diff --git a/pkg/user/user_test.go b/pkg/user/user_test.go index fd0e865038..3b20587836 100644 --- a/pkg/user/user_test.go +++ b/pkg/user/user_test.go @@ -386,7 +386,7 @@ func TestListUsers(t *testing.T) { all, err := ListAllUsers(s) assert.NoError(t, err) - assert.Len(t, all, 14) + assert.Len(t, all, 15) }) t.Run("no search term", func(t *testing.T) { db.LoadAndAssertFixtures(t)