From cb4f92980b193e07ccfa574d15b43a94551e3e20 Mon Sep 17 00:00:00 2001 From: Lars de Ridder <1681068+larsderidder@users.noreply.github.com> Date: Thu, 2 Apr 2026 12:18:34 +0200 Subject: [PATCH 001/101] feat(task): allow changing bucket from task detail view (#2233) --- .../tasks/partials/BucketSelect.vue | 201 ++++++++++++++++++ frontend/src/i18n/lang/en.json | 2 + frontend/src/modelTypes/ITask.ts | 1 + frontend/src/models/task.ts | 1 + frontend/src/views/tasks/TaskDetailView.vue | 8 +- frontend/tests/e2e/task/bucket-select.spec.ts | 193 +++++++++++++++++ 6 files changed, 405 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/tasks/partials/BucketSelect.vue create mode 100644 frontend/tests/e2e/task/bucket-select.spec.ts diff --git a/frontend/src/components/tasks/partials/BucketSelect.vue b/frontend/src/components/tasks/partials/BucketSelect.vue new file mode 100644 index 000000000..72e33c58a --- /dev/null +++ b/frontend/src/components/tasks/partials/BucketSelect.vue @@ -0,0 +1,201 @@ + + + + + diff --git a/frontend/src/i18n/lang/en.json b/frontend/src/i18n/lang/en.json index 67a4060ea..6a19fa43a 100644 --- a/frontend/src/i18n/lang/en.json +++ b/frontend/src/i18n/lang/en.json @@ -879,6 +879,8 @@ "updateSuccess": "The task was saved successfully.", "deleteSuccess": "The task has been deleted successfully.", "duplicateSuccess": "The task was duplicated successfully.", + "noBucket": "No bucket", + "bucketChangedSuccess": "The task bucket has been changed successfully.", "belongsToProject": "This task belongs to project '{project}'", "back": "Back to project", "due": "Due {at}", diff --git a/frontend/src/modelTypes/ITask.ts b/frontend/src/modelTypes/ITask.ts index dc5c60e03..f8504c30a 100644 --- a/frontend/src/modelTypes/ITask.ts +++ b/frontend/src/modelTypes/ITask.ts @@ -58,6 +58,7 @@ export interface ITask extends IAbstract { projectId: IProject['id'] // Meta, only used when creating a new task bucketId: IBucket['id'] + buckets: IBucket[] } export type ITaskPartialWithId = PartialWithId diff --git a/frontend/src/models/task.ts b/frontend/src/models/task.ts index 0464103d9..ecec237b6 100644 --- a/frontend/src/models/task.ts +++ b/frontend/src/models/task.ts @@ -96,6 +96,7 @@ export default class TaskModel extends AbstractModel implements ITask { projectId: IProject['id'] = 0 bucketId: IBucket['id'] = 0 + buckets: IBucket[] = [] constructor(data: Partial = {}) { super() diff --git a/frontend/src/views/tasks/TaskDetailView.vue b/frontend/src/views/tasks/TaskDetailView.vue index 11eaf0900..d99d3f5ec 100644 --- a/frontend/src/views/tasks/TaskDetailView.vue +++ b/frontend/src/views/tasks/TaskDetailView.vue @@ -55,6 +55,11 @@ class="has-text-grey-light" > > + @@ -659,6 +664,7 @@ import RepeatAfter from '@/components/tasks/partials/RepeatAfter.vue' import TaskSubscription from '@/components/misc/Subscription.vue' import CustomTransition from '@/components/misc/CustomTransition.vue' import AssigneeList from '@/components/tasks/partials/AssigneeList.vue' +import BucketSelect from '@/components/tasks/partials/BucketSelect.vue' import Reactions from '@/components/input/Reactions.vue' import {uploadFile} from '@/helpers/attachments' @@ -899,7 +905,7 @@ watch( } try { - const loaded = await taskService.get({id}, {expand: ['reactions', 'comments', 'is_unread']}) + const loaded = await taskService.get({id}, {expand: ['reactions', 'comments', 'is_unread', 'buckets']}) Object.assign(task.value, loaded) taskColor.value = task.value.hexColor setActiveFields() diff --git a/frontend/tests/e2e/task/bucket-select.spec.ts b/frontend/tests/e2e/task/bucket-select.spec.ts new file mode 100644 index 000000000..1f8d286d5 --- /dev/null +++ b/frontend/tests/e2e/task/bucket-select.spec.ts @@ -0,0 +1,193 @@ +import {test, expect} from '../../support/fixtures' +import {BucketFactory} from '../../factories/bucket' +import {ProjectFactory} from '../../factories/project' +import {TaskFactory} from '../../factories/task' +import {ProjectViewFactory} from '../../factories/project_view' +import {TaskBucketFactory} from '../../factories/task_buckets' + +async function createKanbanTaskInBucket() { + const projects = await ProjectFactory.create(1) + const views = await ProjectViewFactory.create(1, { + id: 1, + project_id: projects[0].id, + view_kind: 3, + bucket_configuration_mode: 1, + }) + const buckets = await BucketFactory.create(2, { + project_view_id: views[0].id, + }) + const tasks = await TaskFactory.create(1, { + project_id: projects[0].id, + }) + await TaskBucketFactory.create(1, { + task_id: tasks[0].id, + bucket_id: buckets[0].id, + project_view_id: views[0].id, + }) + return { + project: projects[0], + view: views[0], + buckets, + task: tasks[0], + } +} + +test.describe('Task Bucket Select', () => { + test('Shows the current bucket name when opening a task from a kanban view', async ({authenticatedPage: page}) => { + const {project, view, buckets, task} = await createKanbanTaskInBucket() + + await page.goto(`/projects/${project.id}/${view.id}`) + await expect(page.locator('.kanban .bucket .tasks .task').filter({hasText: task.title})).toBeVisible() + await page.locator('.kanban .bucket .tasks .task').filter({hasText: task.title}).click() + await expect(page).toHaveURL(new RegExp(`/tasks/${task.id}`)) + + await expect(page.locator('.task-view .subtitle')).toContainText(buckets[0].title) + }) + + test('Can change the bucket from the task detail view', async ({authenticatedPage: page}) => { + const {project, view, buckets, task} = await createKanbanTaskInBucket() + + await page.goto(`/projects/${project.id}/${view.id}`) + await expect(page.locator('.kanban .bucket .tasks .task').filter({hasText: task.title})).toBeVisible() + await page.locator('.kanban .bucket .tasks .task').filter({hasText: task.title}).click() + await expect(page).toHaveURL(new RegExp(`/tasks/${task.id}`)) + + // Click the bucket name to open the dropdown + await page.locator('.task-view .subtitle .bucket-name').click() + // Select the other bucket + await page.locator('.task-view .subtitle .dropdown-item').filter({hasText: buckets[1].title}).click() + + await expect(page.locator('.global-notification')).toContainText('Success') + await expect(page.locator('.task-view .subtitle')).toContainText(buckets[1].title) + }) + + test('Does not show the bucket selector when project has no kanban view', async ({authenticatedPage: page}) => { + const projects = await ProjectFactory.create(1) + // Only create a list view, no kanban view + const views = await ProjectViewFactory.create(1, { + id: 1, + project_id: projects[0].id, + view_kind: 0, + }) + const tasks = await TaskFactory.create(1, { + project_id: projects[0].id, + }) + + await page.goto(`/projects/${projects[0].id}/${views[0].id}`) + await page.locator('.tasks .task').filter({hasText: tasks[0].title}).click() + await expect(page).toHaveURL(new RegExp(`/tasks/${tasks[0].id}`)) + + await expect(page.locator('.task-view .subtitle .bucket-name')).not.toBeVisible() + }) + + test.describe('Multiple kanban views', () => { + async function createTaskWithMultipleKanbanViews() { + const projects = await ProjectFactory.create(1) + const listView = (await ProjectViewFactory.create(1, { + id: 1, + project_id: projects[0].id, + view_kind: 0, + }))[0] + const kanbanView1 = (await ProjectViewFactory.create(1, { + id: 2, + project_id: projects[0].id, + view_kind: 3, + bucket_configuration_mode: 1, + }, false))[0] + const kanbanView2 = (await ProjectViewFactory.create(1, { + id: 3, + project_id: projects[0].id, + view_kind: 3, + bucket_configuration_mode: 1, + }, false))[0] + const bucketsView1 = await BucketFactory.create(2, { + project_view_id: kanbanView1.id, + }) + const bucketsView2 = await BucketFactory.create(2, { + id: (i: number) => i + 2, + project_view_id: kanbanView2.id, + }, false) + const tasks = await TaskFactory.create(1, { + project_id: projects[0].id, + }) + await TaskBucketFactory.create(1, { + task_id: tasks[0].id, + bucket_id: bucketsView1[0].id, + project_view_id: kanbanView1.id, + }) + await TaskBucketFactory.create(1, { + task_id: tasks[0].id, + bucket_id: bucketsView2[0].id, + project_view_id: kanbanView2.id, + }, false) + return { + project: projects[0], + listView, + kanbanView1, + kanbanView2, + bucketsView1, + bucketsView2, + task: tasks[0], + } + } + + test('Does not show the bucket selector when opening a task from the list view', async ({authenticatedPage: page}) => { + const {project, listView, task} = await createTaskWithMultipleKanbanViews() + + await page.goto(`/projects/${project.id}/${listView.id}`) + await page.locator('.tasks .task').filter({hasText: task.title}).click() + await expect(page).toHaveURL(new RegExp(`/tasks/${task.id}`)) + + await expect(page.locator('.task-view .subtitle .bucket-name')).not.toBeVisible() + }) + + test('Shows the correct buckets when opening a task from the first kanban view', async ({authenticatedPage: page}) => { + const {project, kanbanView1, bucketsView1, task} = await createTaskWithMultipleKanbanViews() + + await page.goto(`/projects/${project.id}/${kanbanView1.id}`) + await expect(page.locator('.kanban .bucket .tasks .task').filter({hasText: task.title})).toBeVisible() + await page.locator('.kanban .bucket .tasks .task').filter({hasText: task.title}).click() + await expect(page).toHaveURL(new RegExp(`/tasks/${task.id}`)) + + await expect(page.locator('.task-view .subtitle')).toContainText(bucketsView1[0].title) + await page.locator('.task-view .subtitle .bucket-name').click() + await expect(page.locator('.task-view .subtitle .dropdown-item')).toHaveCount(bucketsView1.length) + for (const bucket of bucketsView1) { + await expect(page.locator('.task-view .subtitle .dropdown-item').filter({hasText: bucket.title})).toBeVisible() + } + }) + + test('Shows the correct buckets when opening a task from the second kanban view', async ({authenticatedPage: page}) => { + const {project, kanbanView2, bucketsView2, task} = await createTaskWithMultipleKanbanViews() + + await page.goto(`/projects/${project.id}/${kanbanView2.id}`) + await expect(page.locator('.kanban .bucket .tasks .task').filter({hasText: task.title})).toBeVisible() + await page.locator('.kanban .bucket .tasks .task').filter({hasText: task.title}).click() + await expect(page).toHaveURL(new RegExp(`/tasks/${task.id}`)) + + await expect(page.locator('.task-view .subtitle')).toContainText(bucketsView2[0].title) + await page.locator('.task-view .subtitle .bucket-name').click() + await expect(page.locator('.task-view .subtitle .dropdown-item')).toHaveCount(bucketsView2.length) + for (const bucket of bucketsView2) { + await expect(page.locator('.task-view .subtitle .dropdown-item').filter({hasText: bucket.title})).toBeVisible() + } + }) + }) + + test('Keeps action buttons visible after changing the bucket', async ({authenticatedPage: page}) => { + const {project, view, buckets, task} = await createKanbanTaskInBucket() + + await page.goto(`/projects/${project.id}/${view.id}`) + await expect(page.locator('.kanban .bucket .tasks .task').filter({hasText: task.title})).toBeVisible() + await page.locator('.kanban .bucket .tasks .task').filter({hasText: task.title}).click() + await expect(page).toHaveURL(new RegExp(`/tasks/${task.id}`)) + + // Change the bucket + await page.locator('.task-view .subtitle .bucket-name').click() + await page.locator('.task-view .subtitle .dropdown-item').filter({hasText: buckets[1].title}).click() + await expect(page.locator('.global-notification')).toContainText('Success') + + // Action buttons should still be visible + await expect(page.locator('.task-view .action-buttons .button').filter({hasText: 'Done'})).toBeVisible() + }) +}) From e2478e2fd68d66b5628f34c31b818c90ddf7fdf5 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 18 Mar 2026 17:36:20 +0100 Subject: [PATCH 002/101] test(caldav): add caldavtests package with infrastructure, helpers, and mage target - Package skeleton with TestMain, setupTestEnv, and fixture users - HTTP request helpers (PROPFIND, REPORT, GET, PUT, DELETE, OPTIONS) - XML/iCal response parsers and assertion utilities - VTodoBuilder for constructing test VTODO payloads - Common PROPFIND/REPORT XML body constants - Smoke test validating the infrastructure works end-to-end - mage test:caldav command and CI matrix entry --- .github/workflows/test.yml | 1 + magefile.go | 9 +- pkg/caldavtests/integrations.go | 154 ++++++++++++++++++++++ pkg/caldavtests/main_test.go | 32 +++++ pkg/caldavtests/propfind_bodies.go | 107 +++++++++++++++ pkg/caldavtests/smoke_test.go | 36 ++++++ pkg/caldavtests/vtodo_builder.go | 200 +++++++++++++++++++++++++++++ pkg/caldavtests/xml_helpers.go | 160 +++++++++++++++++++++++ 8 files changed, 698 insertions(+), 1 deletion(-) create mode 100644 pkg/caldavtests/integrations.go create mode 100644 pkg/caldavtests/main_test.go create mode 100644 pkg/caldavtests/propfind_bodies.go create mode 100644 pkg/caldavtests/smoke_test.go create mode 100644 pkg/caldavtests/vtodo_builder.go create mode 100644 pkg/caldavtests/xml_helpers.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1318a49b6..c9520e2c0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -178,6 +178,7 @@ jobs: test: - feature - web + - caldav - e2e-api exclude: - db: sqlite diff --git a/magefile.go b/magefile.go index 9f4c32107..ebf0d7b3c 100644 --- a/magefile.go +++ b/magefile.go @@ -447,7 +447,14 @@ func (Test) Filter(ctx context.Context, filter string) error { func (Test) All() { mg.Deps(initVars) - mg.Deps(Test.Feature, Test.Web, Test.E2EApi) + mg.Deps(Test.Feature, Test.Web, Test.Caldav, Test.E2EApi) +} + +// Caldav runs the CalDAV protocol compliance tests in pkg/caldavtests. +// These tests exercise the full HTTP router with WebDAV/CalDAV requests. +func (Test) Caldav(ctx context.Context) error { + mg.Deps(initVars) + return runAndStreamOutput(ctx, "go", "test", goDetectVerboseFlag(), "-p", "1", "-timeout", "45m", "./pkg/caldavtests") } // E2EApi runs the end-to-end API tests in pkg/e2etests. diff --git a/pkg/caldavtests/integrations.go b/pkg/caldavtests/integrations.go new file mode 100644 index 000000000..dbf5bd445 --- /dev/null +++ b/pkg/caldavtests/integrations.go @@ -0,0 +1,154 @@ +// 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 License 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 License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package caldavtests + +import ( + "encoding/base64" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/files" + "code.vikunja.io/api/pkg/log" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/modules/keyvalue" + "code.vikunja.io/api/pkg/routes" + "code.vikunja.io/api/pkg/user" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/require" +) + +// These are the test users, the same way they are in the test database +var ( + testuser1 = user.User{ + ID: 1, + Username: "user1", + Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", + Email: "user1@example.com", + Issuer: "local", + } + testuser15 = user.User{ + ID: 15, + Username: "user15", + Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", + Email: "user15@example.com", + Issuer: "local", + } +) + +// fixturePassword is the plaintext password for all test fixture users +const fixturePassword = "12345678" + +func setupTestEnv(t *testing.T) *echo.Echo { + t.Helper() + + config.InitDefaultConfig() + config.ServicePublicURL.Set("https://localhost") + + log.InitLogger() + files.InitTests() + user.InitTests() + models.SetupTests() + events.Fake() + keyvalue.InitStorage() + + err := db.LoadFixtures() + require.NoError(t, err) + + e := routes.NewEcho() + routes.RegisterRoutes(e) + return e +} + +// basicAuthHeader returns the Authorization header value for HTTP Basic Auth. +func basicAuthHeader(username, password string) string { + return "Basic " + base64.StdEncoding.EncodeToString([]byte(username+":"+password)) +} + +// caldavRequest sends an HTTP request through the full Echo router and returns the response. +func caldavRequest(t *testing.T, e *echo.Echo, method, path, body string, headers map[string]string) *httptest.ResponseRecorder { + t.Helper() + + req := httptest.NewRequest(method, path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/xml; charset=utf-8") + + // Default to testuser15 basic auth (the caldav test user) unless overridden + if _, hasAuth := headers["Authorization"]; !hasAuth { + req.Header.Set("Authorization", basicAuthHeader(testuser15.Username, fixturePassword)) + } + + for k, v := range headers { + req.Header.Set(k, v) + } + + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + return rec +} + +// caldavPROPFIND sends a PROPFIND request. +func caldavPROPFIND(t *testing.T, e *echo.Echo, path, depth, body string) *httptest.ResponseRecorder { + t.Helper() + return caldavRequest(t, e, "PROPFIND", path, body, map[string]string{ + "Depth": depth, + }) +} + +// caldavREPORT sends a REPORT request. +func caldavREPORT(t *testing.T, e *echo.Echo, path, body string) *httptest.ResponseRecorder { + t.Helper() + return caldavRequest(t, e, "REPORT", path, body, nil) +} + +// caldavGET sends a GET request. +func caldavGET(t *testing.T, e *echo.Echo, path string) *httptest.ResponseRecorder { + t.Helper() + return caldavRequest(t, e, http.MethodGet, path, "", nil) +} + +// caldavPUT sends a PUT request with iCalendar content. +func caldavPUT(t *testing.T, e *echo.Echo, path, vcalendar string) *httptest.ResponseRecorder { + t.Helper() + return caldavRequest(t, e, http.MethodPut, path, vcalendar, map[string]string{ + "Content-Type": "text/calendar; charset=utf-8", + }) +} + +// caldavDELETE sends a DELETE request. +func caldavDELETE(t *testing.T, e *echo.Echo, path string) *httptest.ResponseRecorder { + t.Helper() + return caldavRequest(t, e, http.MethodDelete, path, "", nil) +} + +// caldavOPTIONS sends an OPTIONS request. +func caldavOPTIONS(t *testing.T, e *echo.Echo, path string) *httptest.ResponseRecorder { + t.Helper() + return caldavRequest(t, e, http.MethodOptions, path, "", nil) +} + +// caldavRequestAsUser sends a request authenticated as a specific user. +func caldavRequestAsUser(t *testing.T, e *echo.Echo, method, path, body string, u *user.User, password string) *httptest.ResponseRecorder { + t.Helper() + return caldavRequest(t, e, method, path, body, map[string]string{ + "Authorization": basicAuthHeader(u.Username, password), + }) +} diff --git a/pkg/caldavtests/main_test.go b/pkg/caldavtests/main_test.go new file mode 100644 index 000000000..db915f8b5 --- /dev/null +++ b/pkg/caldavtests/main_test.go @@ -0,0 +1,32 @@ +// 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 License 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 License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package caldavtests + +import ( + "flag" + "os" + "testing" +) + +func TestMain(m *testing.M) { + flag.Parse() + if testing.Short() { + println("-short requested, skipping long-running caldav tests") + return + } + os.Exit(m.Run()) +} diff --git a/pkg/caldavtests/propfind_bodies.go b/pkg/caldavtests/propfind_bodies.go new file mode 100644 index 000000000..a0f5febb1 --- /dev/null +++ b/pkg/caldavtests/propfind_bodies.go @@ -0,0 +1,107 @@ +// 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 License 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 License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package caldavtests + +// PROPFIND request bodies used by CalDAV clients. + +// PropfindCurrentUserPrincipal requests the current-user-principal property. +// RFC 5397 §3 +const PropfindCurrentUserPrincipal = ` + + + + +` + +// PropfindCalendarHomeSet requests the calendar-home-set property. +// RFC 4791 §6.2.1 +const PropfindCalendarHomeSet = ` + + + + +` + +// PropfindCalendarCollectionProperties requests common calendar collection properties. +// RFC 4791 §5.2 +const PropfindCalendarCollectionProperties = ` + + + + + + + + + +` + +// PropfindResourceProperties requests properties of a calendar resource (task). +const PropfindResourceProperties = ` + + + + + +` + +// PropfindAllProps requests all properties (allprop). +// RFC 4918 §9.1 +const PropfindAllProps = ` + + +` + +// PropfindCurrentUserPrivilegeSet requests the current-user-privilege-set property. +// RFC 3744 §5.4 +const PropfindCurrentUserPrivilegeSet = ` + + + + +` + +// ReportCalendarQuery is a calendar-query REPORT requesting all VTODOs. +// RFC 4791 §7.8 +const ReportCalendarQuery = ` + + + + + + + + + + +` + +// ReportCalendarMultiget builds a calendar-multiget REPORT for specific hrefs. +// RFC 4791 §7.9 +func ReportCalendarMultiget(hrefs ...string) string { + var hrefXML string + for _, href := range hrefs { + hrefXML += " " + href + "\n" + } + return ` + + + + + +` + hrefXML + `` +} diff --git a/pkg/caldavtests/smoke_test.go b/pkg/caldavtests/smoke_test.go new file mode 100644 index 000000000..2a1081c41 --- /dev/null +++ b/pkg/caldavtests/smoke_test.go @@ -0,0 +1,36 @@ +// 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 License 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 License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package caldavtests + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSmoke(t *testing.T) { + t.Run("GET /dav/projects/36 returns VCALENDAR", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavGET(t, e, "/dav/projects/36") + + assert.Equal(t, http.StatusOK, rec.Code) + assert.Contains(t, rec.Body.String(), "BEGIN:VCALENDAR") + assert.Contains(t, rec.Body.String(), "BEGIN:VTODO") + }) +} diff --git a/pkg/caldavtests/vtodo_builder.go b/pkg/caldavtests/vtodo_builder.go new file mode 100644 index 000000000..ffd57b389 --- /dev/null +++ b/pkg/caldavtests/vtodo_builder.go @@ -0,0 +1,200 @@ +// 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 License 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 License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package caldavtests + +import ( + "fmt" + "strings" + "time" +) + +// VTodoBuilder constructs VCALENDAR/VTODO strings for test requests. +type VTodoBuilder struct { + uid string + summary string + description string + priority int + due time.Time + dtstart time.Time + completed time.Time + status string + categories []string + relatedTo []relatedToEntry + alarms []alarmEntry + rrule string + color string + percentComplete int + sequence int + duration string + dtstamp time.Time + created time.Time + lastMod time.Time + extraProps []string +} + +type relatedToEntry struct { + reltype string // "PARENT", "CHILD", or "" + uid string +} + +type alarmEntry struct { + trigger string + action string + description string +} + +// NewVTodo starts building a VTODO with required fields. +func NewVTodo(uid, summary string) *VTodoBuilder { + return &VTodoBuilder{ + uid: uid, + summary: summary, + dtstamp: time.Now().UTC(), + created: time.Now().UTC(), + lastMod: time.Now().UTC(), + } +} + +func (b *VTodoBuilder) Description(d string) *VTodoBuilder { b.description = d; return b } +func (b *VTodoBuilder) Priority(p int) *VTodoBuilder { b.priority = p; return b } +func (b *VTodoBuilder) Due(t time.Time) *VTodoBuilder { b.due = t; return b } +func (b *VTodoBuilder) DtStart(t time.Time) *VTodoBuilder { b.dtstart = t; return b } +func (b *VTodoBuilder) Completed(t time.Time) *VTodoBuilder { b.completed = t; return b } +func (b *VTodoBuilder) Status(s string) *VTodoBuilder { b.status = s; return b } +func (b *VTodoBuilder) Categories(c ...string) *VTodoBuilder { b.categories = c; return b } +func (b *VTodoBuilder) Rrule(r string) *VTodoBuilder { b.rrule = r; return b } +func (b *VTodoBuilder) Color(c string) *VTodoBuilder { b.color = c; return b } +func (b *VTodoBuilder) Sequence(s int) *VTodoBuilder { b.sequence = s; return b } +func (b *VTodoBuilder) Duration(d string) *VTodoBuilder { b.duration = d; return b } +func (b *VTodoBuilder) DtStamp(t time.Time) *VTodoBuilder { b.dtstamp = t; return b } +func (b *VTodoBuilder) Created(t time.Time) *VTodoBuilder { b.created = t; return b } +func (b *VTodoBuilder) LastModified(t time.Time) *VTodoBuilder { b.lastMod = t; return b } +func (b *VTodoBuilder) PercentComplete(p int) *VTodoBuilder { b.percentComplete = p; return b } +func (b *VTodoBuilder) ExtraProp(line string) *VTodoBuilder { b.extraProps = append(b.extraProps, line); return b } + +func (b *VTodoBuilder) RelatedToParent(uid string) *VTodoBuilder { + b.relatedTo = append(b.relatedTo, relatedToEntry{reltype: "PARENT", uid: uid}) + return b +} + +func (b *VTodoBuilder) RelatedToChild(uid string) *VTodoBuilder { + b.relatedTo = append(b.relatedTo, relatedToEntry{reltype: "CHILD", uid: uid}) + return b +} + +func (b *VTodoBuilder) AlarmAbsolute(triggerTime time.Time) *VTodoBuilder { + b.alarms = append(b.alarms, alarmEntry{ + trigger: "TRIGGER;VALUE=DATE-TIME:" + formatTime(triggerTime), + action: "DISPLAY", + description: b.summary, + }) + return b +} + +func (b *VTodoBuilder) AlarmRelativeStart(duration string) *VTodoBuilder { + b.alarms = append(b.alarms, alarmEntry{ + trigger: "TRIGGER;RELATED=START:" + duration, + action: "DISPLAY", + description: b.summary, + }) + return b +} + +func (b *VTodoBuilder) AlarmRelativeEnd(duration string) *VTodoBuilder { + b.alarms = append(b.alarms, alarmEntry{ + trigger: "TRIGGER;RELATED=END:" + duration, + action: "DISPLAY", + description: b.summary, + }) + return b +} + +func formatTime(t time.Time) string { + return t.UTC().Format("20060102T150405Z") +} + +// Build returns the complete VCALENDAR string wrapping the VTODO. +func (b *VTodoBuilder) Build() string { + var sb strings.Builder + + sb.WriteString("BEGIN:VCALENDAR\r\n") + sb.WriteString("VERSION:2.0\r\n") + sb.WriteString("PRODID:-//Test//Test//EN\r\n") + sb.WriteString("BEGIN:VTODO\r\n") + sb.WriteString(fmt.Sprintf("UID:%s\r\n", b.uid)) + sb.WriteString(fmt.Sprintf("DTSTAMP:%s\r\n", formatTime(b.dtstamp))) + sb.WriteString(fmt.Sprintf("SUMMARY:%s\r\n", b.summary)) + sb.WriteString(fmt.Sprintf("CREATED:%s\r\n", formatTime(b.created))) + sb.WriteString(fmt.Sprintf("LAST-MODIFIED:%s\r\n", formatTime(b.lastMod))) + + if b.description != "" { + sb.WriteString(fmt.Sprintf("DESCRIPTION:%s\r\n", b.description)) + } + if b.priority > 0 { + sb.WriteString(fmt.Sprintf("PRIORITY:%d\r\n", b.priority)) + } + if !b.due.IsZero() { + sb.WriteString(fmt.Sprintf("DUE:%s\r\n", formatTime(b.due))) + } + if !b.dtstart.IsZero() { + sb.WriteString(fmt.Sprintf("DTSTART:%s\r\n", formatTime(b.dtstart))) + } + if !b.completed.IsZero() { + sb.WriteString(fmt.Sprintf("COMPLETED:%s\r\n", formatTime(b.completed))) + } + if b.status != "" { + sb.WriteString(fmt.Sprintf("STATUS:%s\r\n", b.status)) + } + if len(b.categories) > 0 { + sb.WriteString(fmt.Sprintf("CATEGORIES:%s\r\n", strings.Join(b.categories, ","))) + } + if b.rrule != "" { + sb.WriteString(fmt.Sprintf("RRULE:%s\r\n", b.rrule)) + } + if b.color != "" { + sb.WriteString(fmt.Sprintf("X-APPLE-CALENDAR-COLOR:%s\r\n", b.color)) + } + if b.percentComplete > 0 { + sb.WriteString(fmt.Sprintf("PERCENT-COMPLETE:%d\r\n", b.percentComplete)) + } + if b.sequence > 0 { + sb.WriteString(fmt.Sprintf("SEQUENCE:%d\r\n", b.sequence)) + } + if b.duration != "" { + sb.WriteString(fmt.Sprintf("DURATION:%s\r\n", b.duration)) + } + for _, rel := range b.relatedTo { + if rel.reltype != "" { + sb.WriteString(fmt.Sprintf("RELATED-TO;RELTYPE=%s:%s\r\n", rel.reltype, rel.uid)) + } else { + sb.WriteString(fmt.Sprintf("RELATED-TO:%s\r\n", rel.uid)) + } + } + for _, alarm := range b.alarms { + sb.WriteString("BEGIN:VALARM\r\n") + sb.WriteString(alarm.trigger + "\r\n") + sb.WriteString(fmt.Sprintf("ACTION:%s\r\n", alarm.action)) + sb.WriteString(fmt.Sprintf("DESCRIPTION:%s\r\n", alarm.description)) + sb.WriteString("END:VALARM\r\n") + } + for _, prop := range b.extraProps { + sb.WriteString(prop + "\r\n") + } + sb.WriteString("END:VTODO\r\n") + sb.WriteString("END:VCALENDAR\r\n") + + return sb.String() +} diff --git a/pkg/caldavtests/xml_helpers.go b/pkg/caldavtests/xml_helpers.go new file mode 100644 index 000000000..9bb405c57 --- /dev/null +++ b/pkg/caldavtests/xml_helpers.go @@ -0,0 +1,160 @@ +// 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 License 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 License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package caldavtests + +import ( + "encoding/xml" + "net/http/httptest" + "strings" + "testing" + + ics "github.com/arran4/golang-ical" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Multistatus represents a WebDAV multistatus response (RFC 4918 §13) +type Multistatus struct { + XMLName xml.Name `xml:"DAV: multistatus"` + Responses []Response `xml:"response"` +} + +// Response represents a single response within a multistatus +type Response struct { + Href string `xml:"href"` + Propstat []Propstat `xml:"propstat"` +} + +// Propstat groups a set of properties with a status +type Propstat struct { + Prop Prop `xml:"prop"` + Status string `xml:"status"` +} + +// Prop holds the actual property values returned by PROPFIND/REPORT. +type Prop struct { + // Standard DAV properties + DisplayName string `xml:"displayname,omitempty"` + ResourceType RawXML `xml:"resourcetype,omitempty"` + GetETag string `xml:"getetag,omitempty"` + GetCTag string `xml:"http://calendarserver.org/ns/ getctag,omitempty"` + + // CalDAV properties + CalendarData string `xml:"urn:ietf:params:xml:ns:caldav calendar-data,omitempty"` + CalendarHomeSet RawXML `xml:"urn:ietf:params:xml:ns:caldav calendar-home-set,omitempty"` + SupportedComponents RawXML `xml:"urn:ietf:params:xml:ns:caldav supported-calendar-component-set,omitempty"` + CalendarDescription string `xml:"urn:ietf:params:xml:ns:caldav calendar-description,omitempty"` + + // Principal properties + CurrentUserPrincipal RawXML `xml:"current-user-principal,omitempty"` + + // ACL properties + CurrentUserPrivilegeSet RawXML `xml:"current-user-privilege-set,omitempty"` + + // Catch-all for unexpected properties + InnerXML string `xml:",innerxml"` +} + +// RawXML captures raw XML content for properties we want to inspect flexibly +type RawXML struct { + InnerXML string `xml:",innerxml"` +} + +// parseMultistatus parses a WebDAV multistatus XML response body. +func parseMultistatus(t *testing.T, rec *httptest.ResponseRecorder) Multistatus { + t.Helper() + var ms Multistatus + err := xml.Unmarshal(rec.Body.Bytes(), &ms) + require.NoError(t, err, "Failed to parse multistatus XML. Body:\n%s", rec.Body.String()) + return ms +} + +// findResponse finds a response in a multistatus by href substring match. +func findResponse(t *testing.T, ms Multistatus, hrefSubstring string) Response { + t.Helper() + for _, r := range ms.Responses { + if strings.Contains(r.Href, hrefSubstring) { + return r + } + } + t.Fatalf("No response found with href containing %q in multistatus with %d responses", hrefSubstring, len(ms.Responses)) + return Response{} // unreachable +} + +// getSuccessfulProp returns the Prop from the first propstat with a 200 status. +func getSuccessfulProp(t *testing.T, r Response) Prop { + t.Helper() + for _, ps := range r.Propstat { + if strings.Contains(ps.Status, "200") { + return ps.Prop + } + } + t.Fatalf("No successful (200) propstat found in response for href %s", r.Href) + return Prop{} // unreachable +} + +// parseICalFromResponse parses iCalendar data from a response body. +func parseICalFromResponse(t *testing.T, rec *httptest.ResponseRecorder) *ics.Calendar { + t.Helper() + cal, err := ics.ParseCalendar(strings.NewReader(rec.Body.String())) + require.NoError(t, err, "Failed to parse iCalendar. Body:\n%s", rec.Body.String()) + return cal +} + +// parseICalFromString parses iCalendar data from a string (e.g., calendar-data property). +func parseICalFromString(t *testing.T, data string) *ics.Calendar { + t.Helper() + cal, err := ics.ParseCalendar(strings.NewReader(data)) + require.NoError(t, err, "Failed to parse iCalendar data:\n%s", data) + return cal +} + +// getVTodo extracts the first VTODO component from a calendar. +func getVTodo(t *testing.T, cal *ics.Calendar) *ics.VTodo { + t.Helper() + for _, comp := range cal.Components { + if vtodo, ok := comp.(*ics.VTodo); ok { + return vtodo + } + } + t.Fatal("No VTODO component found in calendar") + return nil // unreachable +} + +// getVTodoProperty extracts a property value from a VTODO. +func getVTodoProperty(vtodo *ics.VTodo, prop ics.ComponentProperty) string { + p := vtodo.GetProperty(prop) + if p == nil { + return "" + } + return p.Value +} + +// assertResponseStatus asserts the HTTP status code. +func assertResponseStatus(t *testing.T, rec *httptest.ResponseRecorder, expectedStatus int) { + t.Helper() + assert.Equal(t, expectedStatus, rec.Code, "Response body:\n%s", rec.Body.String()) +} + +// assertMultistatusHasResponses asserts that a 207 response contains the expected number of responses. +func assertMultistatusHasResponses(t *testing.T, rec *httptest.ResponseRecorder, expectedCount int) Multistatus { + t.Helper() + assertResponseStatus(t, rec, 207) + ms := parseMultistatus(t, rec) + assert.Len(t, ms.Responses, expectedCount, "Expected %d responses in multistatus, got %d.\nBody:\n%s", expectedCount, len(ms.Responses), rec.Body.String()) + return ms +} From ebedd312c163bac89c9fdbc8d1a17f78b6bd2576 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 18 Mar 2026 17:37:55 +0100 Subject: [PATCH 003/101] =?UTF-8?q?test(caldav):=20add=20PROPFIND=20tests?= =?UTF-8?q?=20(RFC=204918=20=C2=A79.1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/caldavtests/propfind_test.go | 239 +++++++++++++++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 pkg/caldavtests/propfind_test.go diff --git a/pkg/caldavtests/propfind_test.go b/pkg/caldavtests/propfind_test.go new file mode 100644 index 000000000..c2b4483d5 --- /dev/null +++ b/pkg/caldavtests/propfind_test.go @@ -0,0 +1,239 @@ +// 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 License 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 License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package caldavtests + +import ( + "net/http" + "strings" + "testing" + + ics "github.com/arran4/golang-ical" + "github.com/stretchr/testify/assert" +) + +func TestPropfindCollection(t *testing.T) { + // RFC 4918 §9.1 (rfc4918.txt line 1939): + // "The PROPFIND method retrieves properties defined on the resource + // identified by the Request-URI." + + t.Run("Depth 0 on project returns collection properties", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavPROPFIND(t, e, "/dav/projects/36", "0", PropfindCalendarCollectionProperties) + + assertResponseStatus(t, rec, 207) + ms := parseMultistatus(t, rec) + + // Depth 0 should return exactly 1 response (the collection itself) + assert.Len(t, ms.Responses, 1, + "Depth 0 should return exactly the collection") + + r := ms.Responses[0] + prop := getSuccessfulProp(t, r) + + // displayname should be the project title + assert.Equal(t, "Project 36 for Caldav tests", prop.DisplayName, + "displayname should match project title") + + // resourcetype should include both DAV:collection and CALDAV:calendar + assert.Contains(t, prop.ResourceType.InnerXML, "collection", + "resourcetype should include DAV:collection") + }) + + t.Run("Depth 1 on project returns collection plus tasks", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavPROPFIND(t, e, "/dav/projects/36", "1", PropfindCalendarCollectionProperties) + + assertResponseStatus(t, rec, 207) + ms := parseMultistatus(t, rec) + + // Project 36 has 5 tasks in fixtures (tasks 40-43, 45) + // Depth 1 should return the collection + all tasks = 6 responses + assert.GreaterOrEqual(t, len(ms.Responses), 6, + "Depth 1 should return collection + all tasks") + + // First response should be the collection itself + // Subsequent responses should be individual tasks + body := rec.Body.String() + assert.Contains(t, body, ".ics", + "Task responses should have .ics hrefs") + }) + + t.Run("Depth 1 on project returns ETags for each resource", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavPROPFIND(t, e, "/dav/projects/36", "1", PropfindResourceProperties) + + assertResponseStatus(t, rec, 207) + ms := parseMultistatus(t, rec) + + for _, r := range ms.Responses { + prop := getSuccessfulProp(t, r) + // Every resource should have an ETag + // RFC 4918 §15.6: "strong ETags MUST be used" + assert.NotEmpty(t, prop.GetETag, + "Every resource in PROPFIND should have an ETag. Href: %s", r.Href) + } + }) + + t.Run("PROPFIND on nonexistent project returns 404", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavPROPFIND(t, e, "/dav/projects/99999", "0", PropfindCalendarCollectionProperties) + + assert.Equal(t, http.StatusNotFound, rec.Code, + "PROPFIND on nonexistent project should return 404") + }) + + t.Run("Depth 1 includes calendar-data for each task", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavPROPFIND(t, e, "/dav/projects/36", "1", PropfindResourceProperties) + + assertResponseStatus(t, rec, 207) + ms := parseMultistatus(t, rec) + + taskCount := 0 + for _, r := range ms.Responses { + prop := getSuccessfulProp(t, r) + if prop.CalendarData != "" { + taskCount++ + // Each calendar-data should be valid iCalendar + cal := parseICalFromString(t, prop.CalendarData) + vtodo := getVTodo(t, cal) + uid := getVTodoProperty(vtodo, ics.ComponentPropertyUniqueId) + assert.NotEmpty(t, uid, "Each VTODO should have a UID") + } + } + assert.Greater(t, taskCount, 0, "Should have at least one task with calendar-data") + }) +} + +func TestPropfindResource(t *testing.T) { + t.Run("Depth 0 on task returns task properties", func(t *testing.T) { + e := setupTestEnv(t) + + // Task 40 has UID "uid-caldav-test" in project 36 + rec := caldavPROPFIND(t, e, "/dav/projects/36/uid-caldav-test.ics", "0", PropfindResourceProperties) + + assertResponseStatus(t, rec, 207) + ms := parseMultistatus(t, rec) + + assert.Len(t, ms.Responses, 1, + "Depth 0 on a task should return exactly 1 response") + + r := ms.Responses[0] + prop := getSuccessfulProp(t, r) + + assert.NotEmpty(t, prop.GetETag, "Task should have an ETag") + assert.NotEmpty(t, prop.CalendarData, "Task should have calendar-data") + + // Parse and validate the calendar data + cal := parseICalFromString(t, prop.CalendarData) + vtodo := getVTodo(t, cal) + assert.Equal(t, "uid-caldav-test", getVTodoProperty(vtodo, ics.ComponentPropertyUniqueId)) + assert.Equal(t, "Title Caldav Test", getVTodoProperty(vtodo, ics.ComponentPropertySummary)) + }) + + t.Run("PROPFIND on nonexistent task returns 404", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavPROPFIND(t, e, "/dav/projects/36/nonexistent-uid.ics", "0", PropfindResourceProperties) + + assert.Equal(t, http.StatusNotFound, rec.Code, + "PROPFIND on nonexistent task should return 404") + }) + + t.Run("ETag format is quoted string", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavPROPFIND(t, e, "/dav/projects/36/uid-caldav-test.ics", "0", PropfindResourceProperties) + + assertResponseStatus(t, rec, 207) + ms := parseMultistatus(t, rec) + r := ms.Responses[0] + prop := getSuccessfulProp(t, r) + + // RFC 4918 requires ETags to be quoted strings + assert.True(t, len(prop.GetETag) > 2 && + prop.GetETag[0] == '"' && prop.GetETag[len(prop.GetETag)-1] == '"', + "ETag should be a quoted string, got: %s", prop.GetETag) + }) +} + +func TestPropfindCalendarHome(t *testing.T) { + t.Run("Depth 1 on /dav/projects lists all accessible calendars", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavPROPFIND(t, e, "/dav/projects", "1", PropfindCalendarCollectionProperties) + + assertResponseStatus(t, rec, 207) + ms := parseMultistatus(t, rec) + + // testuser15 should see at least projects 36 and 38 + projectFound36 := false + projectFound38 := false + for _, r := range ms.Responses { + if strings.Contains(r.Href, "36") { + projectFound36 = true + } + if strings.Contains(r.Href, "38") { + projectFound38 = true + } + } + assert.True(t, projectFound36, "Should list project 36 in calendar home") + assert.True(t, projectFound38, "Should list project 38 in calendar home") + }) + + t.Run("Each calendar has displayname matching project title", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavPROPFIND(t, e, "/dav/projects", "1", PropfindCalendarCollectionProperties) + + assertResponseStatus(t, rec, 207) + ms := parseMultistatus(t, rec) + + for _, r := range ms.Responses { + prop := getSuccessfulProp(t, r) + if prop.DisplayName != "" { + // Every calendar with a displayname should have a reasonable title + assert.NotEmpty(t, prop.DisplayName, + "Calendar at %s should have a displayname", r.Href) + } + } + }) + + t.Run("User only sees projects they have access to", func(t *testing.T) { + e := setupTestEnv(t) + + // testuser1 should NOT see testuser15's projects (36, 38) + rec := caldavRequest(t, e, "PROPFIND", "/dav/projects", PropfindCalendarCollectionProperties, map[string]string{ + "Depth": "1", + "Authorization": basicAuthHeader(testuser1.Username, fixturePassword), + }) + + assertResponseStatus(t, rec, 207) + + body := rec.Body.String() + // user1 should not see project 36 or 38 (owned by user15) + assert.NotContains(t, body, "Project 36 for Caldav tests", + "user1 should not see user15's project 36") + assert.NotContains(t, body, "Project 38 for Caldav tests", + "user1 should not see user15's project 38") + }) +} From 56ead32dcabc4664720b24826f209bec064ef2fd Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 18 Mar 2026 17:38:03 +0100 Subject: [PATCH 004/101] test(caldav): add discovery flow tests (RFC 6764, RFC 5397, RFC 4791) --- pkg/caldavtests/discovery_test.go | 284 ++++++++++++++++++++++++++++++ 1 file changed, 284 insertions(+) create mode 100644 pkg/caldavtests/discovery_test.go diff --git a/pkg/caldavtests/discovery_test.go b/pkg/caldavtests/discovery_test.go new file mode 100644 index 000000000..80351fc05 --- /dev/null +++ b/pkg/caldavtests/discovery_test.go @@ -0,0 +1,284 @@ +// 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 License 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 License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package caldavtests + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDiscovery(t *testing.T) { + // RFC 6764 §5 (rfc6764.txt line 205): + // "A CalDAV server SHOULD provide a well-known URI that redirects + // to the context path of the CalDAV service." + + t.Run("well-known/caldav responds", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavRequest(t, e, "PROPFIND", "/.well-known/caldav", PropfindCurrentUserPrincipal, map[string]string{ + "Depth": "0", + }) + + // Should get either a redirect (301/302) or a 207 with principal info + // Both are acceptable per RFC 6764 §5 + assert.True(t, + rec.Code == http.StatusMovedPermanently || + rec.Code == http.StatusFound || + rec.Code == http.StatusMultiStatus, + "Expected 301, 302, or 207 from /.well-known/caldav, got %d. Body:\n%s", rec.Code, rec.Body.String()) + }) + + t.Run("well-known/caldav/ with trailing slash responds", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavRequest(t, e, "PROPFIND", "/.well-known/caldav/", PropfindCurrentUserPrincipal, map[string]string{ + "Depth": "0", + }) + + assert.True(t, + rec.Code == http.StatusMovedPermanently || + rec.Code == http.StatusFound || + rec.Code == http.StatusMultiStatus, + "Expected 301, 302, or 207 from /.well-known/caldav/, got %d. Body:\n%s", rec.Code, rec.Body.String()) + }) + + t.Run("well-known/caldav without auth returns 401", func(t *testing.T) { + e := setupTestEnv(t) + + req := httptest.NewRequest("PROPFIND", "/.well-known/caldav", strings.NewReader(PropfindCurrentUserPrincipal)) + req.Header.Set("Content-Type", "application/xml; charset=utf-8") + req.Header.Set("Depth", "0") + + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnauthorized, rec.Code, + "CalDAV well-known endpoint should require authentication") + }) +} + +func TestDiscoveryPrincipal(t *testing.T) { + // RFC 5397 §3 (rfc5397.txt line 126): + // "This property contains a URL that identifies the principal resource + // corresponding to the currently authenticated user." + + t.Run("PROPFIND on /dav/ returns current-user-principal", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavPROPFIND(t, e, "/dav/", "0", PropfindCurrentUserPrincipal) + + // Should get 207 Multi-Status + assertResponseStatus(t, rec, 207) + ms := parseMultistatus(t, rec) + + assert.NotEmpty(t, ms.Responses, "Multistatus should contain at least one response") + + // The current-user-principal should point to a principal resource + // containing the username + body := rec.Body.String() + assert.Contains(t, body, "current-user-principal", + "Response should contain current-user-principal property") + // Should contain the username in the principal URL + assert.Contains(t, body, "user15", + "Principal URL should contain the authenticated username") + }) + + t.Run("PROPFIND on /dav/principals/user15/ returns principal info", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavPROPFIND(t, e, "/dav/principals/user15/", "0", PropfindCalendarHomeSet) + + assertResponseStatus(t, rec, 207) + + body := rec.Body.String() + // Per RFC 4791 §6.2.1, the principal should advertise calendar-home-set + assert.Contains(t, body, "calendar-home-set", + "Principal resource should include calendar-home-set property") + // The home set should point to /dav/projects + assert.Contains(t, body, "/dav/projects", + "calendar-home-set should point to /dav/projects") + }) +} + +func TestDiscoveryCalendarHome(t *testing.T) { + // RFC 4791 §6.2.1 (rfc4791.txt line 1651): + // "The calendar-home-set property identifies the URL of any + // WebDAV collections that contain calendar collections owned + // by the associated principal resource." + + t.Run("PROPFIND Depth:1 on /dav/projects lists calendars", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavPROPFIND(t, e, "/dav/projects", "1", PropfindCalendarCollectionProperties) + + assertResponseStatus(t, rec, 207) + ms := parseMultistatus(t, rec) + + // testuser15 owns projects 36 and 38 (from fixtures) + // The response should include at least these projects + assert.GreaterOrEqual(t, len(ms.Responses), 2, + "Should list at least the 2 projects owned by testuser15") + + // Each response should have an href and a displayname + for _, r := range ms.Responses { + assert.NotEmpty(t, r.Href, "Each calendar response should have an href") + } + + body := rec.Body.String() + // Check that the projects we know about are listed + assert.Contains(t, body, "Project 36 for Caldav tests", + "Should list Project 36") + assert.Contains(t, body, "Project 38 for Caldav tests", + "Should list Project 38") + }) + + t.Run("PROPFIND Depth:0 on /dav/projects returns just the home collection", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavPROPFIND(t, e, "/dav/projects", "0", PropfindCalendarCollectionProperties) + + assertResponseStatus(t, rec, 207) + ms := parseMultistatus(t, rec) + + // Depth 0 should return just the collection itself, not children + assert.Len(t, ms.Responses, 1, + "Depth 0 PROPFIND should return only the collection itself") + }) + + t.Run("Each listed calendar has resourcetype with calendar", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavPROPFIND(t, e, "/dav/projects", "1", PropfindCalendarCollectionProperties) + + assertResponseStatus(t, rec, 207) + body := rec.Body.String() + + // Per RFC 4791 §5.2, calendar collections MUST report + // DAV:collection and CALDAV:calendar in resourcetype + assert.Contains(t, body, "calendar", + "Calendar collections should have calendar in resourcetype") + }) + + t.Run("Each listed calendar has supported-calendar-component-set", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavPROPFIND(t, e, "/dav/projects", "1", PropfindCalendarCollectionProperties) + + assertResponseStatus(t, rec, 207) + body := rec.Body.String() + + // Per RFC 4791 §5.2.3 (rfc4791.txt line 768), calendar collections + // SHOULD report supported-calendar-component-set + assert.Contains(t, body, "VTODO", + "supported-calendar-component-set should include VTODO") + }) +} + +func TestDiscoveryOPTIONS(t *testing.T) { + // RFC 4791 §5.1 (rfc4791.txt line 602): + // "A CalDAV server MUST include 'calendar-access' as a field in the + // DAV response header from an OPTIONS request on any resource that + // supports the CalDAV extensions." + + t.Run("OPTIONS on /dav/ returns DAV header with calendar-access", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavOPTIONS(t, e, "/dav/") + + assert.Equal(t, http.StatusOK, rec.Code) + + davHeader := rec.Header().Get("DAV") + assert.NotEmpty(t, davHeader, "OPTIONS response should include DAV header") + assert.Contains(t, davHeader, "calendar-access", + "DAV header should include 'calendar-access' per RFC 4791 §5.1") + }) + + t.Run("OPTIONS on /dav/projects/36 returns DAV header", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavOPTIONS(t, e, "/dav/projects/36") + + assert.Equal(t, http.StatusOK, rec.Code) + + davHeader := rec.Header().Get("DAV") + assert.NotEmpty(t, davHeader, "OPTIONS response should include DAV header") + }) + + t.Run("OPTIONS on /dav/ returns Allow header with supported methods", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavOPTIONS(t, e, "/dav/") + + allowHeader := rec.Header().Get("Allow") + // A CalDAV server should advertise at least these methods + for _, method := range []string{"OPTIONS", "GET", "PUT", "DELETE", "PROPFIND", "REPORT"} { + assert.Contains(t, allowHeader, method, + "Allow header should include %s", method) + } + }) +} + +func TestDiscoveryFullChain(t *testing.T) { + // RFC 6764 §6 (rfc6764.txt line 254) describes the full bootstrapping flow: + // 1. Client does PROPFIND on /.well-known/caldav (or follows redirect) + // 2. Client extracts current-user-principal from response + // 3. Client does PROPFIND on principal URL for calendar-home-set + // 4. Client does PROPFIND Depth:1 on calendar-home-set to list calendars + + t.Run("Full discovery chain: well-known -> principal -> home -> calendars", func(t *testing.T) { + e := setupTestEnv(t) + + // Step 1: Hit well-known endpoint + rec1 := caldavRequest(t, e, "PROPFIND", "/.well-known/caldav", PropfindCurrentUserPrincipal, map[string]string{ + "Depth": "0", + }) + // Accept either redirect or direct response + assert.True(t, rec1.Code == 207 || rec1.Code == 301 || rec1.Code == 302, + "Step 1: /.well-known/caldav should respond with 207, 301, or 302, got %d", rec1.Code) + + // Step 2: PROPFIND the entry point for principal info + rec2 := caldavPROPFIND(t, e, "/dav/", "0", PropfindCurrentUserPrincipal) + assertResponseStatus(t, rec2, 207) + + // Step 3: PROPFIND the principal URL for calendar-home-set + // The principal URL for testuser15 should be /dav/principals/user15/ + rec3 := caldavPROPFIND(t, e, "/dav/principals/user15/", "0", PropfindCalendarHomeSet) + assertResponseStatus(t, rec3, 207) + + body3 := rec3.Body.String() + assert.Contains(t, body3, "calendar-home-set", + "Step 3: Principal should advertise calendar-home-set") + assert.Contains(t, body3, "/dav/projects", + "Step 3: calendar-home-set should point to /dav/projects") + + // Step 4: PROPFIND Depth:1 on calendar home to list calendars + rec4 := caldavPROPFIND(t, e, "/dav/projects", "1", PropfindCalendarCollectionProperties) + assertResponseStatus(t, rec4, 207) + + ms4 := parseMultistatus(t, rec4) + assert.GreaterOrEqual(t, len(ms4.Responses), 2, + "Step 4: Should list at least 2 calendars for testuser15") + + body4 := rec4.Body.String() + assert.Contains(t, body4, "Project 36 for Caldav tests", + "Step 4: Should list Project 36") + }) +} From c0a9356646b855bba96d6449c153bb7dff047ad4 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 18 Mar 2026 17:38:30 +0100 Subject: [PATCH 005/101] =?UTF-8?q?test(caldav):=20add=20REPORT=20query=20?= =?UTF-8?q?tests=20(RFC=204791=20=C2=A77.8,=20=C2=A77.9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/caldavtests/report_test.go | 233 +++++++++++++++++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 pkg/caldavtests/report_test.go diff --git a/pkg/caldavtests/report_test.go b/pkg/caldavtests/report_test.go new file mode 100644 index 000000000..02d5329e3 --- /dev/null +++ b/pkg/caldavtests/report_test.go @@ -0,0 +1,233 @@ +// 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 License 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 License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package caldavtests + +import ( + "strings" + "testing" + + ics "github.com/arran4/golang-ical" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReportCalendarQuery(t *testing.T) { + // RFC 4791 §7.8 (rfc4791.txt line 1967): + // "The CALDAV:calendar-query REPORT performs a search for all calendar + // object resources that match a specified filter." + + t.Run("calendar-query returns 207 Multi-Status", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavREPORT(t, e, "/dav/projects/36", ReportCalendarQuery) + + assertResponseStatus(t, rec, 207) + }) + + t.Run("calendar-query returns all tasks in project", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavREPORT(t, e, "/dav/projects/36", ReportCalendarQuery) + + assertResponseStatus(t, rec, 207) + ms := parseMultistatus(t, rec) + + // Project 36 has 5 tasks in fixtures (40, 41, 42, 43, 45) + assert.Len(t, ms.Responses, 5, + "Should return all 5 tasks from project 36") + }) + + t.Run("calendar-query responses include ETag", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavREPORT(t, e, "/dav/projects/36", ReportCalendarQuery) + + ms := parseMultistatus(t, rec) + for _, r := range ms.Responses { + prop := getSuccessfulProp(t, r) + assert.NotEmpty(t, prop.GetETag, + "Each response should include an ETag. Href: %s", r.Href) + } + }) + + t.Run("calendar-query responses include valid calendar-data", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavREPORT(t, e, "/dav/projects/36", ReportCalendarQuery) + + ms := parseMultistatus(t, rec) + for i, r := range ms.Responses { + prop := getSuccessfulProp(t, r) + assert.NotEmpty(t, prop.CalendarData, + "Response %d should include calendar-data. Href: %s", i, r.Href) + + // Each calendar-data should be parseable iCalendar + cal := parseICalFromString(t, prop.CalendarData) + vtodo := getVTodo(t, cal) + + uid := getVTodoProperty(vtodo, ics.ComponentPropertyUniqueId) + assert.NotEmpty(t, uid, "VTODO %d should have a UID", i) + + summary := getVTodoProperty(vtodo, ics.ComponentPropertySummary) + assert.NotEmpty(t, summary, "VTODO %d should have a SUMMARY", i) + } + }) + + t.Run("calendar-query response hrefs point to correct resources", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavREPORT(t, e, "/dav/projects/36", ReportCalendarQuery) + + ms := parseMultistatus(t, rec) + for _, r := range ms.Responses { + // Each href should be a valid task URL containing the project ID and .ics + assert.Contains(t, r.Href, "/dav/projects/", + "Href should contain /dav/projects/") + assert.True(t, strings.HasSuffix(r.Href, ".ics"), + "Href should end with .ics. Got: %s", r.Href) + } + }) + + t.Run("calendar-query on nonexistent project returns error", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavREPORT(t, e, "/dav/projects/99999", ReportCalendarQuery) + + // Should be 404 or similar error, not 200/207 + assert.NotEqual(t, 207, rec.Code, + "REPORT on nonexistent project should not return 207") + }) + + t.Run("calendar-query on project 38 returns correct task count", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavREPORT(t, e, "/dav/projects/38", ReportCalendarQuery) + + assertResponseStatus(t, rec, 207) + ms := parseMultistatus(t, rec) + // Project 38 has 2 tasks (44, 46) + assert.Len(t, ms.Responses, 2, + "Project 38 should have 2 tasks") + }) +} + +func TestReportCalendarMultiget(t *testing.T) { + // RFC 4791 §7.9 (rfc4791.txt line 3479): + // "The CALDAV:calendar-multiget REPORT is used to retrieve specific + // calendar object resources from within a collection." + + t.Run("calendar-multiget returns requested tasks", func(t *testing.T) { + e := setupTestEnv(t) + + // Request two specific tasks from project 36 + body := ReportCalendarMultiget( + "/dav/projects/36/uid-caldav-test.ics", + "/dav/projects/36/uid-caldav-test-parent-task.ics", + ) + + rec := caldavREPORT(t, e, "/dav/projects/36", body) + + assertResponseStatus(t, rec, 207) + ms := parseMultistatus(t, rec) + + assert.Len(t, ms.Responses, 2, + "Should return exactly the 2 requested tasks") + }) + + t.Run("calendar-multiget returns calendar-data for each task", func(t *testing.T) { + e := setupTestEnv(t) + + body := ReportCalendarMultiget( + "/dav/projects/36/uid-caldav-test.ics", + ) + + rec := caldavREPORT(t, e, "/dav/projects/36", body) + + assertResponseStatus(t, rec, 207) + ms := parseMultistatus(t, rec) + require.Len(t, ms.Responses, 1) + + prop := getSuccessfulProp(t, ms.Responses[0]) + assert.NotEmpty(t, prop.CalendarData, "Should include calendar-data") + assert.NotEmpty(t, prop.GetETag, "Should include ETag") + + // Verify the returned data matches the requested task + cal := parseICalFromString(t, prop.CalendarData) + vtodo := getVTodo(t, cal) + assert.Equal(t, "uid-caldav-test", getVTodoProperty(vtodo, ics.ComponentPropertyUniqueId)) + }) + + t.Run("calendar-multiget with nonexistent href returns 404 for that href", func(t *testing.T) { + e := setupTestEnv(t) + + body := ReportCalendarMultiget( + "/dav/projects/36/uid-caldav-test.ics", + "/dav/projects/36/nonexistent-uid.ics", + ) + + rec := caldavREPORT(t, e, "/dav/projects/36", body) + + assertResponseStatus(t, rec, 207) + ms := parseMultistatus(t, rec) + + // Should still return results — the existing task should be there + // The nonexistent one might be absent or have a 404 propstat + foundExisting := false + for _, r := range ms.Responses { + if strings.Contains(r.Href, "uid-caldav-test") { + foundExisting = true + prop := getSuccessfulProp(t, r) + assert.NotEmpty(t, prop.CalendarData) + } + } + assert.True(t, foundExisting, + "Should still return the existing task even when one href is invalid") + }) + + t.Run("calendar-multiget with empty href list returns empty", func(t *testing.T) { + e := setupTestEnv(t) + + body := ReportCalendarMultiget() // No hrefs + + rec := caldavREPORT(t, e, "/dav/projects/36", body) + + // Should return 207 with no responses, or possibly an error + assert.True(t, rec.Code == 207 || rec.Code >= 400, + "Empty multiget should return 207 (empty) or an error, got %d", rec.Code) + }) + + t.Run("calendar-multiget ETags match PROPFIND ETags", func(t *testing.T) { + e := setupTestEnv(t) + + // Get ETag via PROPFIND + propfindRec := caldavPROPFIND(t, e, "/dav/projects/36/uid-caldav-test.ics", "0", PropfindResourceProperties) + assertResponseStatus(t, propfindRec, 207) + propfindMs := parseMultistatus(t, propfindRec) + propfindEtag := getSuccessfulProp(t, propfindMs.Responses[0]).GetETag + + // Get ETag via multiget REPORT + body := ReportCalendarMultiget("/dav/projects/36/uid-caldav-test.ics") + reportRec := caldavREPORT(t, e, "/dav/projects/36", body) + assertResponseStatus(t, reportRec, 207) + reportMs := parseMultistatus(t, reportRec) + reportEtag := getSuccessfulProp(t, reportMs.Responses[0]).GetETag + + // ETags should match between PROPFIND and REPORT + assert.Equal(t, propfindEtag, reportEtag, + "ETag from PROPFIND and calendar-multiget should match") + }) +} From fecd6d6a1d10b5c509a03eb002b5a7622d05e51c Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 18 Mar 2026 17:38:30 +0100 Subject: [PATCH 006/101] =?UTF-8?q?test(caldav):=20add=20CRUD=20operation?= =?UTF-8?q?=20tests=20(RFC=204791=20=C2=A75.3.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/caldavtests/crud_test.go | 353 +++++++++++++++++++++++++++++++++++ 1 file changed, 353 insertions(+) create mode 100644 pkg/caldavtests/crud_test.go diff --git a/pkg/caldavtests/crud_test.go b/pkg/caldavtests/crud_test.go new file mode 100644 index 000000000..2b8f9de60 --- /dev/null +++ b/pkg/caldavtests/crud_test.go @@ -0,0 +1,353 @@ +// 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 License 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 License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package caldavtests + +import ( + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestCRUDCreate(t *testing.T) { + // RFC 4791 §5.3.2 (rfc4791.txt line 1358): + // "A PUT request on a calendar collection creates a new calendar + // object resource when the Request-URI does not identify an + // existing resource." + + t.Run("PUT new task returns 201 Created", func(t *testing.T) { + e := setupTestEnv(t) + + vtodo := NewVTodo("test-create-uid", "Test Create Task"). + Due(time.Date(2024, 3, 1, 15, 0, 0, 0, time.UTC)). + Build() + + rec := caldavPUT(t, e, "/dav/projects/36/test-create-uid.ics", vtodo) + + assert.Equal(t, http.StatusCreated, rec.Code, + "PUT of new resource should return 201. Body:\n%s", rec.Body.String()) + }) + + t.Run("PUT new task sets ETag in response", func(t *testing.T) { + e := setupTestEnv(t) + + vtodo := NewVTodo("test-etag-uid", "Test ETag Task").Build() + + rec := caldavPUT(t, e, "/dav/projects/36/test-etag-uid.ics", vtodo) + + assert.Equal(t, http.StatusCreated, rec.Code) + + etag := rec.Header().Get("ETag") + assert.NotEmpty(t, etag, + "PUT response should include ETag header for the newly created resource") + }) + + t.Run("Created task is retrievable via GET", func(t *testing.T) { + e := setupTestEnv(t) + + vtodo := NewVTodo("test-roundtrip-uid", "Roundtrip Test Task"). + Description("A task created via CalDAV PUT"). + Priority(3). + Build() + + rec := caldavPUT(t, e, "/dav/projects/36/test-roundtrip-uid.ics", vtodo) + assert.Equal(t, http.StatusCreated, rec.Code) + + // Now GET the task back + rec2 := caldavGET(t, e, "/dav/projects/36/test-roundtrip-uid.ics") + assert.Equal(t, http.StatusOK, rec2.Code) + + body := rec2.Body.String() + assert.Contains(t, body, "BEGIN:VCALENDAR") + assert.Contains(t, body, "BEGIN:VTODO") + assert.Contains(t, body, "UID:test-roundtrip-uid") + assert.Contains(t, body, "SUMMARY:Roundtrip Test Task") + }) + + t.Run("PUT with invalid VCALENDAR returns error", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavPUT(t, e, "/dav/projects/36/bad-task.ics", "not a valid vcalendar") + + // Should fail with a 4xx error + assert.GreaterOrEqual(t, rec.Code, 400, + "PUT with invalid VCALENDAR should return 4xx error") + assert.Less(t, rec.Code, 500, + "PUT with invalid VCALENDAR should not be a server error") + }) + + t.Run("PUT to nonexistent project returns 404", func(t *testing.T) { + e := setupTestEnv(t) + + vtodo := NewVTodo("test-noproject-uid", "No Project Task").Build() + rec := caldavPUT(t, e, "/dav/projects/99999/test-noproject-uid.ics", vtodo) + + assert.Equal(t, http.StatusNotFound, rec.Code, + "PUT to nonexistent project should return 404") + }) + + t.Run("PUT task with all supported fields", func(t *testing.T) { + e := setupTestEnv(t) + + vtodo := NewVTodo("test-allfields-uid", "All Fields Task"). + Description("Full description\\nwith newlines"). + Priority(1). // Highest priority in CalDAV + Due(time.Date(2024, 6, 15, 12, 0, 0, 0, time.UTC)). + DtStart(time.Date(2024, 6, 1, 9, 0, 0, 0, time.UTC)). + Categories("work", "urgent"). + Status("IN-PROCESS"). + AlarmAbsolute(time.Date(2024, 6, 15, 8, 0, 0, 0, time.UTC)). + Build() + + rec := caldavPUT(t, e, "/dav/projects/36/test-allfields-uid.ics", vtodo) + assert.Equal(t, http.StatusCreated, rec.Code, + "PUT with all fields should succeed. Body:\n%s", rec.Body.String()) + }) +} + +func TestCRUDRead(t *testing.T) { + t.Run("GET existing task returns VCALENDAR", func(t *testing.T) { + e := setupTestEnv(t) + + // Task 40 (uid-caldav-test) exists in project 36 from fixtures + rec := caldavGET(t, e, "/dav/projects/36/uid-caldav-test.ics") + + assert.Equal(t, http.StatusOK, rec.Code) + + body := rec.Body.String() + assert.Contains(t, body, "BEGIN:VCALENDAR") + assert.Contains(t, body, "BEGIN:VTODO") + assert.Contains(t, body, "UID:uid-caldav-test") + assert.Contains(t, body, "SUMMARY:Title Caldav Test") + assert.Contains(t, body, "END:VTODO") + assert.Contains(t, body, "END:VCALENDAR") + }) + + t.Run("GET returns correct Content-Type", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavGET(t, e, "/dav/projects/36/uid-caldav-test.ics") + + assert.Equal(t, http.StatusOK, rec.Code) + contentType := rec.Header().Get("Content-Type") + // Should be text/calendar per RFC 4791 + assert.Contains(t, contentType, "text/calendar", + "GET on .ics resource should return Content-Type: text/calendar, got: %s", contentType) + }) + + t.Run("GET returns ETag header", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavGET(t, e, "/dav/projects/36/uid-caldav-test.ics") + + assert.Equal(t, http.StatusOK, rec.Code) + etag := rec.Header().Get("ETag") + assert.NotEmpty(t, etag, "GET response should include ETag header") + }) + + t.Run("GET nonexistent task returns 404", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavGET(t, e, "/dav/projects/36/nonexistent-uid.ics") + + assert.Equal(t, http.StatusNotFound, rec.Code, + "GET nonexistent task should return 404") + }) + + t.Run("GET project returns all tasks as VCALENDAR", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavGET(t, e, "/dav/projects/36") + + assert.Equal(t, http.StatusOK, rec.Code) + + body := rec.Body.String() + assert.Contains(t, body, "BEGIN:VCALENDAR") + assert.Contains(t, body, "X-WR-CALNAME:Project 36 for Caldav tests") + // Should contain multiple VTODOs + assert.Contains(t, body, "uid-caldav-test") + }) + + t.Run("GET task with .ics suffix works", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavGET(t, e, "/dav/projects/36/uid-caldav-test.ics") + assert.Equal(t, http.StatusOK, rec.Code) + }) + + t.Run("GET task without .ics suffix works", func(t *testing.T) { + e := setupTestEnv(t) + + // Some clients may not include .ics suffix + rec := caldavGET(t, e, "/dav/projects/36/uid-caldav-test") + // This might 404 depending on implementation — document the behavior + // Either 200 or 404 is acceptable, but should be consistent + assert.True(t, rec.Code == http.StatusOK || rec.Code == http.StatusNotFound, + "GET without .ics should return 200 or 404, got %d", rec.Code) + }) +} + +func TestCRUDUpdate(t *testing.T) { + t.Run("PUT to existing task updates it", func(t *testing.T) { + e := setupTestEnv(t) + + // First create + vtodo := NewVTodo("test-update-uid", "Original Title").Build() + rec := caldavPUT(t, e, "/dav/projects/36/test-update-uid.ics", vtodo) + assert.Equal(t, http.StatusCreated, rec.Code) + + // Then update + vtodoUpdated := NewVTodo("test-update-uid", "Updated Title"). + Description("Now with a description"). + Build() + rec2 := caldavPUT(t, e, "/dav/projects/36/test-update-uid.ics", vtodoUpdated) + + // Update should return 200 or 204 (not 201) + assert.True(t, rec2.Code == http.StatusOK || + rec2.Code == http.StatusNoContent || + rec2.Code == http.StatusCreated, // Some implementations return 201 for updates too + "PUT update should return 200, 204, or 201, got %d", rec2.Code) + + // Verify the update took effect + rec3 := caldavGET(t, e, "/dav/projects/36/test-update-uid.ics") + assert.Equal(t, http.StatusOK, rec3.Code) + assert.Contains(t, rec3.Body.String(), "Updated Title") + assert.Contains(t, rec3.Body.String(), "Now with a description") + }) + + t.Run("PUT update changes ETag", func(t *testing.T) { + e := setupTestEnv(t) + + // Create + vtodo := NewVTodo("test-etag-change-uid", "ETag Change Test").Build() + rec1 := caldavPUT(t, e, "/dav/projects/36/test-etag-change-uid.ics", vtodo) + assert.Equal(t, http.StatusCreated, rec1.Code) + etag1 := rec1.Header().Get("ETag") + + // Update + vtodoUpdated := NewVTodo("test-etag-change-uid", "ETag Change Test Updated").Build() + rec2 := caldavPUT(t, e, "/dav/projects/36/test-etag-change-uid.ics", vtodoUpdated) + etag2 := rec2.Header().Get("ETag") + + // ETags should differ after update + if etag1 != "" && etag2 != "" { + assert.NotEqual(t, etag1, etag2, + "ETag should change after update. Before: %s, After: %s", etag1, etag2) + } + }) + + t.Run("PUT update preserves UID", func(t *testing.T) { + e := setupTestEnv(t) + + // Create + vtodo := NewVTodo("test-preserve-uid", "Preserve UID Test").Build() + rec := caldavPUT(t, e, "/dav/projects/36/test-preserve-uid.ics", vtodo) + assert.Equal(t, http.StatusCreated, rec.Code) + + // Update with different title but same UID + vtodoUpdated := NewVTodo("test-preserve-uid", "Updated Preserve UID").Build() + caldavPUT(t, e, "/dav/projects/36/test-preserve-uid.ics", vtodoUpdated) + + // Verify UID is preserved + rec3 := caldavGET(t, e, "/dav/projects/36/test-preserve-uid.ics") + assert.Contains(t, rec3.Body.String(), "UID:test-preserve-uid") + }) +} + +func TestCRUDDelete(t *testing.T) { + t.Run("DELETE existing task returns 204", func(t *testing.T) { + e := setupTestEnv(t) + + // Task 40 (uid-caldav-test) exists in project 36 + rec := caldavDELETE(t, e, "/dav/projects/36/uid-caldav-test.ics") + + assert.Equal(t, http.StatusNoContent, rec.Code, + "DELETE should return 204 No Content. Body:\n%s", rec.Body.String()) + }) + + t.Run("DELETE task makes it unreachable", func(t *testing.T) { + e := setupTestEnv(t) + + // Delete task 40 + rec := caldavDELETE(t, e, "/dav/projects/36/uid-caldav-test.ics") + assert.Equal(t, http.StatusNoContent, rec.Code) + + // Try to GET it — should 404 + rec2 := caldavGET(t, e, "/dav/projects/36/uid-caldav-test.ics") + assert.Equal(t, http.StatusNotFound, rec2.Code, + "GET after DELETE should return 404") + }) + + t.Run("DELETE nonexistent task returns 404", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavDELETE(t, e, "/dav/projects/36/nonexistent-uid.ics") + + assert.Equal(t, http.StatusNotFound, rec.Code, + "DELETE nonexistent task should return 404") + }) + + t.Run("DELETE task removes it from project listing", func(t *testing.T) { + e := setupTestEnv(t) + + // First verify task exists in project listing + rec := caldavGET(t, e, "/dav/projects/36") + assert.Contains(t, rec.Body.String(), "uid-caldav-test") + + // Delete it + caldavDELETE(t, e, "/dav/projects/36/uid-caldav-test.ics") + + // Verify it's gone from the listing + rec2 := caldavGET(t, e, "/dav/projects/36") + assert.NotContains(t, rec2.Body.String(), "uid-caldav-test") + }) + + t.Run("Full lifecycle: PUT create -> GET read -> PUT update -> DELETE", func(t *testing.T) { + e := setupTestEnv(t) + + uid := "test-lifecycle-uid" + path := "/dav/projects/36/" + uid + ".ics" + + // Create + vtodo := NewVTodo(uid, "Lifecycle Test").Build() + rec := caldavPUT(t, e, path, vtodo) + assert.Equal(t, http.StatusCreated, rec.Code, "Create failed") + + // Read + rec = caldavGET(t, e, path) + assert.Equal(t, http.StatusOK, rec.Code, "Read failed") + assert.Contains(t, rec.Body.String(), "Lifecycle Test") + + // Update + vtodo2 := NewVTodo(uid, "Lifecycle Test Updated").Build() + rec = caldavPUT(t, e, path, vtodo2) + assert.True(t, rec.Code >= 200 && rec.Code < 300, "Update failed with %d", rec.Code) + + // Verify update + rec = caldavGET(t, e, path) + assert.Contains(t, rec.Body.String(), "Lifecycle Test Updated") + + // Delete + rec = caldavDELETE(t, e, path) + assert.Equal(t, http.StatusNoContent, rec.Code, "Delete failed") + + // Verify gone + rec = caldavGET(t, e, path) + assert.Equal(t, http.StatusNotFound, rec.Code, "Task should be gone after delete") + }) +} From 738bfa33affb839612517e318da20d6d2986e2eb Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 18 Mar 2026 17:38:35 +0100 Subject: [PATCH 007/101] test(caldav): add authentication and permission tests --- pkg/caldavtests/auth_test.go | 181 +++++++++++++++++++++++++++++++++++ 1 file changed, 181 insertions(+) create mode 100644 pkg/caldavtests/auth_test.go diff --git a/pkg/caldavtests/auth_test.go b/pkg/caldavtests/auth_test.go new file mode 100644 index 000000000..d12a99ab0 --- /dev/null +++ b/pkg/caldavtests/auth_test.go @@ -0,0 +1,181 @@ +// 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 License 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 License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package caldavtests + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestAuth(t *testing.T) { + t.Run("Valid credentials return 200/207", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavGET(t, e, "/dav/projects/36") + + assert.True(t, rec.Code >= 200 && rec.Code < 300, + "Valid credentials should succeed. Got %d", rec.Code) + }) + + t.Run("No auth returns 401", func(t *testing.T) { + e := setupTestEnv(t) + + req := httptest.NewRequest(http.MethodGet, "/dav/projects/36", nil) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnauthorized, rec.Code, + "Request without auth should return 401") + }) + + t.Run("Wrong password returns 401", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavRequest(t, e, http.MethodGet, "/dav/projects/36", "", map[string]string{ + "Authorization": basicAuthHeader(testuser15.Username, "wrongpassword"), + }) + + assert.Equal(t, http.StatusUnauthorized, rec.Code, + "Wrong password should return 401") + }) + + t.Run("Nonexistent user returns 401", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavRequest(t, e, http.MethodGet, "/dav/projects/36", "", map[string]string{ + "Authorization": basicAuthHeader("nonexistent_user", fixturePassword), + }) + + assert.Equal(t, http.StatusUnauthorized, rec.Code, + "Nonexistent user should return 401") + }) + + t.Run("Empty Authorization header returns 401", func(t *testing.T) { + e := setupTestEnv(t) + + req := httptest.NewRequest(http.MethodGet, "/dav/projects/36", nil) + req.Header.Set("Authorization", "") + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnauthorized, rec.Code, + "Empty auth header should return 401") + }) + + t.Run("Auth on /dav/ entry point", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavRequest(t, e, "PROPFIND", "/dav/", PropfindCurrentUserPrincipal, map[string]string{ + "Depth": "0", + }) + + // Should succeed with valid auth + assert.True(t, rec.Code >= 200 && rec.Code < 300 || rec.Code == 207, + "Authenticated PROPFIND on /dav/ should succeed. Got %d", rec.Code) + }) + + t.Run("Auth on /.well-known/caldav", func(t *testing.T) { + e := setupTestEnv(t) + + // Without auth + req := httptest.NewRequest("PROPFIND", "/.well-known/caldav", strings.NewReader(PropfindCurrentUserPrincipal)) + req.Header.Set("Depth", "0") + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + assert.Equal(t, http.StatusUnauthorized, rec.Code, + "/.well-known/caldav without auth should return 401") + }) +} + +func TestPermissions(t *testing.T) { + t.Run("User cannot GET project they do not have access to", func(t *testing.T) { + e := setupTestEnv(t) + + // testuser1 should not be able to access project 36 (owned by user15) + rec := caldavRequest(t, e, http.MethodGet, "/dav/projects/36", "", map[string]string{ + "Authorization": basicAuthHeader(testuser1.Username, fixturePassword), + }) + + // Should be 403 Forbidden or 404 Not Found (both are acceptable for access denial) + assert.True(t, rec.Code == http.StatusForbidden || rec.Code == http.StatusNotFound, + "Unauthorized user should get 403 or 404, got %d. Body:\n%s", rec.Code, rec.Body.String()) + }) + + t.Run("User cannot PUT task to project they do not have access to", func(t *testing.T) { + e := setupTestEnv(t) + + vtodo := NewVTodo("unauthorized-task", "Should Fail").Build() + rec := caldavRequest(t, e, http.MethodPut, "/dav/projects/36/unauthorized-task.ics", vtodo, map[string]string{ + "Authorization": basicAuthHeader(testuser1.Username, fixturePassword), + "Content-Type": "text/calendar; charset=utf-8", + }) + + assert.True(t, rec.Code == http.StatusForbidden || rec.Code == http.StatusNotFound, + "PUT to unauthorized project should fail with 403 or 404, got %d", rec.Code) + }) + + t.Run("User cannot DELETE task from project they do not have access to", func(t *testing.T) { + e := setupTestEnv(t) + + // Try to delete task 40 (uid-caldav-test) in project 36 as user1 + rec := caldavRequest(t, e, http.MethodDelete, "/dav/projects/36/uid-caldav-test.ics", "", map[string]string{ + "Authorization": basicAuthHeader(testuser1.Username, fixturePassword), + }) + + assert.True(t, rec.Code == http.StatusForbidden || rec.Code == http.StatusNotFound, + "DELETE on unauthorized project should fail with 403 or 404, got %d", rec.Code) + }) + + t.Run("User cannot REPORT on project they do not have access to", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavRequest(t, e, "REPORT", "/dav/projects/36", ReportCalendarQuery, map[string]string{ + "Authorization": basicAuthHeader(testuser1.Username, fixturePassword), + }) + + assert.True(t, rec.Code == http.StatusForbidden || rec.Code == http.StatusNotFound || rec.Code == 207, + "REPORT on unauthorized project should fail or return empty, got %d", rec.Code) + + // If it returns 207, it should have no results + if rec.Code == 207 { + ms := parseMultistatus(t, rec) + assert.Empty(t, ms.Responses, + "REPORT on unauthorized project should return empty multistatus if 207") + } + }) + + t.Run("Project listing only shows accessible projects", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavRequest(t, e, "PROPFIND", "/dav/projects", PropfindCalendarCollectionProperties, map[string]string{ + "Depth": "1", + "Authorization": basicAuthHeader(testuser1.Username, fixturePassword), + }) + + assertResponseStatus(t, rec, 207) + body := rec.Body.String() + + // user1 should see their own projects but NOT user15's projects + assert.NotContains(t, body, "Project 36 for Caldav tests", + "user1 should not see user15's Project 36") + }) +} From 160fc9c01fd06390383b87619ca8aa6017eeef7d Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 18 Mar 2026 17:38:55 +0100 Subject: [PATCH 008/101] test(caldav): add sync semantics tests (ETag, CTag, conditional requests) --- pkg/caldavtests/sync_test.go | 254 +++++++++++++++++++++++++++++++++++ 1 file changed, 254 insertions(+) create mode 100644 pkg/caldavtests/sync_test.go diff --git a/pkg/caldavtests/sync_test.go b/pkg/caldavtests/sync_test.go new file mode 100644 index 000000000..0f30f2580 --- /dev/null +++ b/pkg/caldavtests/sync_test.go @@ -0,0 +1,254 @@ +// 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 License 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 License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package caldavtests + +import ( + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestETagBehavior(t *testing.T) { + t.Run("GET returns ETag header", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavGET(t, e, "/dav/projects/36/uid-caldav-test.ics") + assert.Equal(t, http.StatusOK, rec.Code) + + etag := rec.Header().Get("ETag") + assert.NotEmpty(t, etag, "GET should return an ETag header") + // ETag must be a quoted string per HTTP spec + assert.True(t, len(etag) >= 2 && etag[0] == '"' && etag[len(etag)-1] == '"', + "ETag must be a quoted string. Got: %s", etag) + }) + + t.Run("Same resource returns same ETag on repeated GET", func(t *testing.T) { + e := setupTestEnv(t) + + rec1 := caldavGET(t, e, "/dav/projects/36/uid-caldav-test.ics") + rec2 := caldavGET(t, e, "/dav/projects/36/uid-caldav-test.ics") + + etag1 := rec1.Header().Get("ETag") + etag2 := rec2.Header().Get("ETag") + + assert.Equal(t, etag1, etag2, + "Same resource should return same ETag on consecutive GETs") + }) + + t.Run("ETag changes after PUT update", func(t *testing.T) { + e := setupTestEnv(t) + + // Create a task + vtodo := NewVTodo("etag-change-test", "ETag Change Test").Build() + rec1 := caldavPUT(t, e, "/dav/projects/36/etag-change-test.ics", vtodo) + require.Equal(t, http.StatusCreated, rec1.Code) + + // Get its ETag + rec2 := caldavGET(t, e, "/dav/projects/36/etag-change-test.ics") + etag1 := rec2.Header().Get("ETag") + + // Update the task + vtodoUpdated := NewVTodo("etag-change-test", "ETag Change Test UPDATED"). + DtStamp(time.Now().Add(time.Second).UTC()). + Build() + rec3 := caldavPUT(t, e, "/dav/projects/36/etag-change-test.ics", vtodoUpdated) + require.True(t, rec3.Code >= 200 && rec3.Code < 300) + + // Get the new ETag + rec4 := caldavGET(t, e, "/dav/projects/36/etag-change-test.ics") + etag2 := rec4.Header().Get("ETag") + + if etag1 != "" && etag2 != "" { + assert.NotEqual(t, etag1, etag2, + "ETag should change after task is updated. Before: %s, After: %s", etag1, etag2) + } + }) + + t.Run("PROPFIND ETag matches GET ETag", func(t *testing.T) { + e := setupTestEnv(t) + + // Get ETag via GET + getResp := caldavGET(t, e, "/dav/projects/36/uid-caldav-test.ics") + getETag := getResp.Header().Get("ETag") + + // Get ETag via PROPFIND + propfindResp := caldavPROPFIND(t, e, "/dav/projects/36/uid-caldav-test.ics", "0", PropfindResourceProperties) + ms := parseMultistatus(t, propfindResp) + require.Len(t, ms.Responses, 1) + propfindETag := getSuccessfulProp(t, ms.Responses[0]).GetETag + + if getETag != "" && propfindETag != "" { + assert.Equal(t, getETag, propfindETag, + "ETag from GET and PROPFIND should match") + } + }) + + t.Run("Different tasks have different ETags", func(t *testing.T) { + e := setupTestEnv(t) + + rec1 := caldavGET(t, e, "/dav/projects/36/uid-caldav-test.ics") + rec2 := caldavGET(t, e, "/dav/projects/36/uid-caldav-test-parent-task.ics") + + etag1 := rec1.Header().Get("ETag") + etag2 := rec2.Header().Get("ETag") + + if etag1 != "" && etag2 != "" { + assert.NotEqual(t, etag1, etag2, + "Different tasks should have different ETags") + } + }) +} + +func TestCTagBehavior(t *testing.T) { + // Apple CalendarServer getctag extension: + // A collection-level tag that changes when any resource within is modified. + // Used by DAVx5, Thunderbird, Apple clients for efficient sync. + + t.Run("PROPFIND on collection returns getctag", func(t *testing.T) { + e := setupTestEnv(t) + + rec := caldavPROPFIND(t, e, "/dav/projects/36", "0", PropfindCalendarCollectionProperties) + + assertResponseStatus(t, rec, 207) + body := rec.Body.String() + + // Check if getctag is present (it may not be — this documents the behavior) + assert.Contains(t, body, "getctag", + "PROPFIND on collection should include getctag property.\n"+ + "If this fails, getctag is not implemented — clients will sync less efficiently.\n"+ + "Body:\n%s", body) + }) + + t.Run("CTag changes after task is added", func(t *testing.T) { + e := setupTestEnv(t) + + // Get initial CTag + rec1 := caldavPROPFIND(t, e, "/dav/projects/36", "0", PropfindCalendarCollectionProperties) + assertResponseStatus(t, rec1, 207) + ms1 := parseMultistatus(t, rec1) + ctag1 := getSuccessfulProp(t, ms1.Responses[0]).GetCTag + + // Add a task + vtodo := NewVTodo("ctag-test-add", "CTag Test Add").Build() + addRec := caldavPUT(t, e, "/dav/projects/36/ctag-test-add.ics", vtodo) + require.True(t, addRec.Code >= 200 && addRec.Code < 300) + + // Get new CTag + rec2 := caldavPROPFIND(t, e, "/dav/projects/36", "0", PropfindCalendarCollectionProperties) + assertResponseStatus(t, rec2, 207) + ms2 := parseMultistatus(t, rec2) + ctag2 := getSuccessfulProp(t, ms2.Responses[0]).GetCTag + + if ctag1 != "" && ctag2 != "" { + assert.NotEqual(t, ctag1, ctag2, + "CTag should change after a task is added. Before: %s, After: %s", ctag1, ctag2) + } + }) + + t.Run("CTag changes after task is deleted", func(t *testing.T) { + e := setupTestEnv(t) + + // Get initial CTag + rec1 := caldavPROPFIND(t, e, "/dav/projects/36", "0", PropfindCalendarCollectionProperties) + assertResponseStatus(t, rec1, 207) + ms1 := parseMultistatus(t, rec1) + ctag1 := getSuccessfulProp(t, ms1.Responses[0]).GetCTag + + // Delete a task + delRec := caldavDELETE(t, e, "/dav/projects/36/uid-caldav-test.ics") + require.Equal(t, http.StatusNoContent, delRec.Code) + + // Get new CTag + rec2 := caldavPROPFIND(t, e, "/dav/projects/36", "0", PropfindCalendarCollectionProperties) + assertResponseStatus(t, rec2, 207) + ms2 := parseMultistatus(t, rec2) + ctag2 := getSuccessfulProp(t, ms2.Responses[0]).GetCTag + + if ctag1 != "" && ctag2 != "" { + assert.NotEqual(t, ctag1, ctag2, + "CTag should change after a task is deleted. Before: %s, After: %s", ctag1, ctag2) + } + }) +} + +func TestConditionalRequests(t *testing.T) { + // RFC 4918 requires support for conditional requests using ETags. + // If-Match prevents lost updates (optimistic concurrency). + // If-None-Match prevents unnecessary downloads. + + t.Run("PUT with matching If-Match succeeds", func(t *testing.T) { + e := setupTestEnv(t) + + // Create a task and get its ETag + vtodo := NewVTodo("if-match-test", "If-Match Test").Build() + caldavPUT(t, e, "/dav/projects/36/if-match-test.ics", vtodo) + + getRec := caldavGET(t, e, "/dav/projects/36/if-match-test.ics") + etag := getRec.Header().Get("ETag") + require.NotEmpty(t, etag, "Need an ETag for this test") + + // Update with correct If-Match + vtodoUpdated := NewVTodo("if-match-test", "If-Match Test Updated").Build() + rec := caldavRequest(t, e, http.MethodPut, "/dav/projects/36/if-match-test.ics", vtodoUpdated, map[string]string{ + "Content-Type": "text/calendar; charset=utf-8", + "If-Match": etag, + }) + + assert.True(t, rec.Code >= 200 && rec.Code < 300, + "PUT with matching If-Match should succeed. Got %d", rec.Code) + }) + + t.Run("PUT with non-matching If-Match returns 412", func(t *testing.T) { + e := setupTestEnv(t) + + // Create a task + vtodo := NewVTodo("if-match-fail", "If-Match Fail Test").Build() + caldavPUT(t, e, "/dav/projects/36/if-match-fail.ics", vtodo) + + // Try to update with wrong ETag + vtodoUpdated := NewVTodo("if-match-fail", "Should Not Update").Build() + rec := caldavRequest(t, e, http.MethodPut, "/dav/projects/36/if-match-fail.ics", vtodoUpdated, map[string]string{ + "Content-Type": "text/calendar; charset=utf-8", + "If-Match": `"wrong-etag"`, + }) + + assert.Equal(t, http.StatusPreconditionFailed, rec.Code, + "PUT with non-matching If-Match should return 412 Precondition Failed. Got %d.\n"+ + "If this fails, the server doesn't support conditional PUT — a common CalDAV bug.", rec.Code) + }) + + t.Run("GET with matching If-None-Match returns 304", func(t *testing.T) { + e := setupTestEnv(t) + + // Get the task and its ETag + rec1 := caldavGET(t, e, "/dav/projects/36/uid-caldav-test.ics") + etag := rec1.Header().Get("ETag") + require.NotEmpty(t, etag, "Need an ETag for this test") + + // Request again with If-None-Match + rec2 := caldavRequest(t, e, http.MethodGet, "/dav/projects/36/uid-caldav-test.ics", "", map[string]string{ + "If-None-Match": etag, + }) + + assert.Equal(t, http.StatusNotModified, rec2.Code, + "GET with matching If-None-Match should return 304 Not Modified. Got %d.\n"+ + "If this fails, the server doesn't support conditional GET — clients re-download unnecessarily.", rec2.Code) + }) +} From 7830a9c3ea004f0bdc5660e5ac0736237cce536c Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 18 Mar 2026 17:39:02 +0100 Subject: [PATCH 009/101] test(caldav): add client compatibility and bug reproduction tests --- pkg/caldavtests/bugs_test.go | 51 ++++++ pkg/caldavtests/client_compat_test.go | 215 ++++++++++++++++++++++++++ 2 files changed, 266 insertions(+) create mode 100644 pkg/caldavtests/bugs_test.go create mode 100644 pkg/caldavtests/client_compat_test.go diff --git a/pkg/caldavtests/bugs_test.go b/pkg/caldavtests/bugs_test.go new file mode 100644 index 000000000..a75b28128 --- /dev/null +++ b/pkg/caldavtests/bugs_test.go @@ -0,0 +1,51 @@ +// 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 License 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 License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package caldavtests + +import ( + "testing" +) + +// TestBugs contains tests that reproduce specific bugs reported by users. +// Each test references the GitHub issue it reproduces. +// These tests are expected to FAIL until the bug is fixed. +// +// To add a new bug reproduction test: +// 1. Create a new t.Run with the issue number in the name +// 2. Reproduce the exact CalDAV request sequence from the bug report +// 3. Assert what the correct behavior SHOULD be (not what it currently does) +// 4. The test will fail until the bug is fixed — this is expected and good + +func TestBugs(t *testing.T) { + // Template for adding bug reproductions: + // + // t.Run("GitHub_Issue_NNNN_short_description", func(t *testing.T) { + // e := setupTestEnv(t) + // + // // Reproduce the steps from the issue... + // vtodo := NewVTodo("issue-NNNN", "...").Build() + // rec := caldavPUT(t, e, "/dav/projects/36/issue-NNNN.ics", vtodo) + // + // // Assert the expected (correct) behavior + // assert.Equal(t, 201, rec.Code) + // }) + + t.Run("placeholder_no_bugs_yet", func(t *testing.T) { + // Remove this placeholder once real bug tests are added + t.Skip("No bug reproductions added yet") + }) +} diff --git a/pkg/caldavtests/client_compat_test.go b/pkg/caldavtests/client_compat_test.go new file mode 100644 index 000000000..f7a103e87 --- /dev/null +++ b/pkg/caldavtests/client_compat_test.go @@ -0,0 +1,215 @@ +// 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 License 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 License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package caldavtests + +import ( + "net/http" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestClientDAVx5Flow(t *testing.T) { + t.Run("Full DAVx5 sync flow", func(t *testing.T) { + e := setupTestEnv(t) + + // Step 1: Discover principal + // DAVx5 sends PROPFIND to the server root or well-known URL + rec := caldavPROPFIND(t, e, "/dav/", "0", PropfindCurrentUserPrincipal) + assert.True(t, rec.Code == 207 || rec.Code == 301, + "Step 1: PROPFIND /dav/ should return 207 or redirect. Got %d", rec.Code) + + // Step 2: Get calendar-home-set from principal + rec = caldavPROPFIND(t, e, "/dav/principals/user15/", "0", PropfindCalendarHomeSet) + assertResponseStatus(t, rec, 207) + assert.Contains(t, rec.Body.String(), "calendar-home-set", + "Step 2: Principal should advertise calendar-home-set") + + // Step 3: List calendars + rec = caldavPROPFIND(t, e, "/dav/projects", "1", PropfindCalendarCollectionProperties) + assertResponseStatus(t, rec, 207) + ms := parseMultistatus(t, rec) + assert.GreaterOrEqual(t, len(ms.Responses), 2, + "Step 3: Should list calendars") + + // Step 4: Check CTag for a specific calendar + rec = caldavPROPFIND(t, e, "/dav/projects/36", "0", PropfindCalendarCollectionProperties) + assertResponseStatus(t, rec, 207) + + // Step 5: Full sync — calendar-query to get all task ETags + rec = caldavREPORT(t, e, "/dav/projects/36", ReportCalendarQuery) + assertResponseStatus(t, rec, 207) + ms = parseMultistatus(t, rec) + assert.Greater(t, len(ms.Responses), 0, + "Step 5: calendar-query should return tasks") + + // Collect hrefs for multiget + var hrefs []string + for _, r := range ms.Responses { + if strings.HasSuffix(r.Href, ".ics") { + hrefs = append(hrefs, r.Href) + } + } + + // Step 6: Multiget to fetch specific tasks + if len(hrefs) > 0 { + body := ReportCalendarMultiget(hrefs[:1]...) // Just fetch first task + rec = caldavREPORT(t, e, "/dav/projects/36", body) + assertResponseStatus(t, rec, 207) + ms = parseMultistatus(t, rec) + assert.Len(t, ms.Responses, 1, + "Step 6: multiget should return requested task") + } + + // Step 7: Push a local change via PUT + vtodo := NewVTodo("davx5-sync-test", "DAVx5 Synced Task"). + Due(time.Date(2024, 6, 15, 12, 0, 0, 0, time.UTC)). + Build() + rec = caldavPUT(t, e, "/dav/projects/36/davx5-sync-test.ics", vtodo) + assert.Equal(t, http.StatusCreated, rec.Code, + "Step 7: PUT should create the task") + }) +} + +func TestClientThunderbirdFlow(t *testing.T) { + t.Run("Thunderbird discovery and initial sync", func(t *testing.T) { + e := setupTestEnv(t) + + // Step 1: Thunderbird starts with OPTIONS to check DAV support + rec := caldavOPTIONS(t, e, "/dav/") + assert.Equal(t, http.StatusOK, rec.Code, + "Step 1: OPTIONS should succeed") + davHeader := rec.Header().Get("DAV") + assert.NotEmpty(t, davHeader, + "Step 1: Should have DAV header") + + // Step 2: PROPFIND on well-known for principal + rec = caldavRequest(t, e, "PROPFIND", "/.well-known/caldav", PropfindCurrentUserPrincipal, map[string]string{ + "Depth": "0", + }) + assert.True(t, rec.Code == 207 || rec.Code == 301 || rec.Code == 302, + "Step 2: well-known should respond. Got %d", rec.Code) + + // Step 3: PROPFIND principal for calendar-home-set + rec = caldavPROPFIND(t, e, "/dav/principals/user15/", "0", PropfindCalendarHomeSet) + assertResponseStatus(t, rec, 207) + + // Step 4: Thunderbird checks current-user-privilege-set to know if it can write + // RFC 3744 §5.4 (rfc3744.txt line 1158) + rec = caldavPROPFIND(t, e, "/dav/projects/36", "0", PropfindCurrentUserPrivilegeSet) + // This may return 207 with or without the property — document the behavior + assert.True(t, rec.Code == 207 || rec.Code == 200, + "Step 4: PROPFIND for privileges should not error. Got %d", rec.Code) + + // Step 5: List calendars + rec = caldavPROPFIND(t, e, "/dav/projects", "1", PropfindCalendarCollectionProperties) + assertResponseStatus(t, rec, 207) + + // Step 6: Sync via calendar-query + rec = caldavREPORT(t, e, "/dav/projects/36", ReportCalendarQuery) + assertResponseStatus(t, rec, 207) + }) +} + +func TestClientTasksOrgSubtasks(t *testing.T) { + t.Run("Tasks.org subtask sync: child-only RELATED-TO", func(t *testing.T) { + // Tasks.org behavior: + // - Child tasks include RELATED-TO;RELTYPE=PARENT: + // - Parent tasks have NO RELATED-TO at all + // - Tasks may arrive in any order + // - On re-sync, parent is sent again without RELATED-TO + + e := setupTestEnv(t) + + // Round 1: Initial sync — parent first, then children + parent := NewVTodo("tasks-org-parent", "Buy groceries").Build() + rec := caldavPUT(t, e, "/dav/projects/36/tasks-org-parent.ics", parent) + require.Equal(t, 201, rec.Code) + + child1 := NewVTodo("tasks-org-child-1", "Buy milk"). + RelatedToParent("tasks-org-parent").Build() + rec = caldavPUT(t, e, "/dav/projects/36/tasks-org-child-1.ics", child1) + require.Equal(t, 201, rec.Code) + + child2 := NewVTodo("tasks-org-child-2", "Buy eggs"). + RelatedToParent("tasks-org-parent").Build() + rec = caldavPUT(t, e, "/dav/projects/36/tasks-org-child-2.ics", child2) + require.Equal(t, 201, rec.Code) + + // Verify parent shows children + rec = caldavGET(t, e, "/dav/projects/36/tasks-org-parent.ics") + body := rec.Body.String() + assert.Contains(t, body, "tasks-org-child-1") + assert.Contains(t, body, "tasks-org-child-2") + + // Round 2: Re-sync — parent updated (title change), still no RELATED-TO + parentUpdated := NewVTodo("tasks-org-parent", "Buy groceries (updated list)").Build() + rec = caldavPUT(t, e, "/dav/projects/36/tasks-org-parent.ics", parentUpdated) + require.True(t, rec.Code >= 200 && rec.Code < 300) + + // Verify children are still linked after parent re-sync + rec = caldavGET(t, e, "/dav/projects/36/tasks-org-parent.ics") + body = rec.Body.String() + assert.Contains(t, body, "Buy groceries (updated list)", + "Parent title should be updated") + assert.Contains(t, body, "tasks-org-child-1", + "Child 1 relation should survive parent re-sync") + assert.Contains(t, body, "tasks-org-child-2", + "Child 2 relation should survive parent re-sync") + + // Round 3: Complete child via PUT with STATUS:COMPLETED + child1Done := NewVTodo("tasks-org-child-1", "Buy milk"). + RelatedToParent("tasks-org-parent"). + Status("COMPLETED"). + Completed(time.Now().UTC()). + Build() + rec = caldavPUT(t, e, "/dav/projects/36/tasks-org-child-1.ics", child1Done) + require.True(t, rec.Code >= 200 && rec.Code < 300) + + // Verify child is completed + rec = caldavGET(t, e, "/dav/projects/36/tasks-org-child-1.ics") + assert.Contains(t, rec.Body.String(), "STATUS:COMPLETED") + }) + + t.Run("Tasks.org subtask sync: children arrive before parent", func(t *testing.T) { + e := setupTestEnv(t) + + // Children arrive first (reverse order) + child := NewVTodo("tasks-rev-child", "Subtask"). + RelatedToParent("tasks-rev-parent").Build() + rec := caldavPUT(t, e, "/dav/projects/36/tasks-rev-child.ics", child) + require.Equal(t, 201, rec.Code) + + // Parent arrives later — no RELATED-TO + parent := NewVTodo("tasks-rev-parent", "Main Task").Build() + rec = caldavPUT(t, e, "/dav/projects/36/tasks-rev-parent.ics", parent) + require.Equal(t, 201, rec.Code) + + // Verify bidirectional relations + rec = caldavGET(t, e, "/dav/projects/36/tasks-rev-parent.ics") + assert.Contains(t, rec.Body.String(), "SUMMARY:Main Task", + "Parent should have real title, not DUMMY") + assert.Contains(t, rec.Body.String(), "tasks-rev-child", + "Parent should show child relation") + + rec = caldavGET(t, e, "/dav/projects/36/tasks-rev-child.ics") + assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:tasks-rev-parent") + }) +} From 0ca7c0dd01fa39954169d084dcaba2bfe369ec91 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 18 Mar 2026 17:39:13 +0100 Subject: [PATCH 010/101] =?UTF-8?q?test(caldav):=20add=20relation=20and=20?= =?UTF-8?q?subtask=20tests=20(RFC=205545=20=C2=A73.8.4.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pkg/caldavtests/relations_test.go | 235 ++++++++++++++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 pkg/caldavtests/relations_test.go diff --git a/pkg/caldavtests/relations_test.go b/pkg/caldavtests/relations_test.go new file mode 100644 index 000000000..0d09ee898 --- /dev/null +++ b/pkg/caldavtests/relations_test.go @@ -0,0 +1,235 @@ +// 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 License 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 License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package caldavtests + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRelationsBasic(t *testing.T) { + // RFC 5545 §3.8.4.5 (rfc5545.txt line 6391): + // "This property is used to represent a relationship or reference + // between one calendar component and another." + + t.Run("Parent with RELTYPE=CHILD and child with RELTYPE=PARENT", func(t *testing.T) { + e := setupTestEnv(t) + + // Create parent (no relations) + parent := NewVTodo("rel-parent-1", "Parent Task").Build() + rec := caldavPUT(t, e, "/dav/projects/36/rel-parent-1.ics", parent) + require.Equal(t, 201, rec.Code) + + // Create child referencing parent + child := NewVTodo("rel-child-1", "Child Task"). + RelatedToParent("rel-parent-1"). + Build() + rec = caldavPUT(t, e, "/dav/projects/36/rel-child-1.ics", child) + require.Equal(t, 201, rec.Code) + + // GET child — should have RELATED-TO;RELTYPE=PARENT + rec = caldavGET(t, e, "/dav/projects/36/rel-child-1.ics") + assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:rel-parent-1", + "Child should have RELATED-TO pointing to parent") + + // GET parent — should have RELATED-TO;RELTYPE=CHILD (inverse) + rec = caldavGET(t, e, "/dav/projects/36/rel-parent-1.ics") + assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=CHILD:rel-child-1", + "Parent should have inverse RELATED-TO pointing to child") + }) + + t.Run("Grandchild chain: parent -> child -> grandchild", func(t *testing.T) { + e := setupTestEnv(t) + + // Create in order: parent, child, grandchild + parent := NewVTodo("rel-gp-parent", "Grandparent").Build() + caldavPUT(t, e, "/dav/projects/36/rel-gp-parent.ics", parent) + + child := NewVTodo("rel-gp-child", "Parent"). + RelatedToParent("rel-gp-parent"). + Build() + caldavPUT(t, e, "/dav/projects/36/rel-gp-child.ics", child) + + grandchild := NewVTodo("rel-gp-grandchild", "Child"). + RelatedToParent("rel-gp-child"). + Build() + caldavPUT(t, e, "/dav/projects/36/rel-gp-grandchild.ics", grandchild) + + // Verify middle node has both parent and child relations + rec := caldavGET(t, e, "/dav/projects/36/rel-gp-child.ics") + body := rec.Body.String() + assert.Contains(t, body, "RELATED-TO;RELTYPE=PARENT:rel-gp-parent") + assert.Contains(t, body, "RELATED-TO;RELTYPE=CHILD:rel-gp-grandchild") + }) +} + +func TestRelationsReverseOrder(t *testing.T) { + t.Run("Child arrives before parent (Tasks.org pattern)", func(t *testing.T) { + // This is the most common real-world scenario: + // Tasks.org sends child with RELATED-TO;RELTYPE=PARENT but the parent + // has NO RELATED-TO at all. The child may arrive before the parent. + + e := setupTestEnv(t) + + // Step 1: Child arrives first + child := NewVTodo("rev-child-first", "Child First"). + RelatedToParent("rev-parent-late"). + Build() + rec := caldavPUT(t, e, "/dav/projects/36/rev-child-first.ics", child) + require.Equal(t, 201, rec.Code) + + // Step 2: Parent arrives later (no RELATED-TO) + parent := NewVTodo("rev-parent-late", "Parent Late").Build() + rec = caldavPUT(t, e, "/dav/projects/36/rev-parent-late.ics", parent) + require.Equal(t, 201, rec.Code) + + // Step 3: Verify parent has correct title (not DUMMY-UID) + rec = caldavGET(t, e, "/dav/projects/36/rev-parent-late.ics") + assert.Contains(t, rec.Body.String(), "SUMMARY:Parent Late", + "Parent should have its real title, not DUMMY-UID") + assert.NotContains(t, rec.Body.String(), "DUMMY", + "DUMMY placeholder should be replaced") + + // Step 4: Verify child still has parent relation + rec = caldavGET(t, e, "/dav/projects/36/rev-child-first.ics") + assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:rev-parent-late", + "Child should still have parent relation after parent arrives") + }) + + t.Run("Multiple children before parent", func(t *testing.T) { + e := setupTestEnv(t) + + // Two children arrive before parent + child1 := NewVTodo("rev-mc1", "Multi Child 1"). + RelatedToParent("rev-mparent").Build() + caldavPUT(t, e, "/dav/projects/36/rev-mc1.ics", child1) + + child2 := NewVTodo("rev-mc2", "Multi Child 2"). + RelatedToParent("rev-mparent").Build() + caldavPUT(t, e, "/dav/projects/36/rev-mc2.ics", child2) + + // Parent arrives + parent := NewVTodo("rev-mparent", "Multi Parent").Build() + caldavPUT(t, e, "/dav/projects/36/rev-mparent.ics", parent) + + // Verify parent shows both children + rec := caldavGET(t, e, "/dav/projects/36/rev-mparent.ics") + body := rec.Body.String() + assert.Contains(t, body, "RELATED-TO;RELTYPE=CHILD:rev-mc1") + assert.Contains(t, body, "RELATED-TO;RELTYPE=CHILD:rev-mc2") + }) +} + +func TestRelationsCrossProject(t *testing.T) { + t.Run("Parent in project 36, child in project 38", func(t *testing.T) { + e := setupTestEnv(t) + + parent := NewVTodo("xp-parent", "Cross-Project Parent").Build() + rec := caldavPUT(t, e, "/dav/projects/36/xp-parent.ics", parent) + require.Equal(t, 201, rec.Code) + + child := NewVTodo("xp-child", "Cross-Project Child"). + RelatedToParent("xp-parent").Build() + rec = caldavPUT(t, e, "/dav/projects/38/xp-child.ics", child) + require.Equal(t, 201, rec.Code) + + // Verify parent in project 36 knows about child + rec = caldavGET(t, e, "/dav/projects/36/xp-parent.ics") + assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=CHILD:xp-child", + "Parent should have cross-project child relation") + + // Verify child in project 38 knows about parent + rec = caldavGET(t, e, "/dav/projects/38/xp-child.ics") + assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:xp-parent", + "Child should have cross-project parent relation") + }) + + t.Run("Pre-existing cross-project relations from fixtures", func(t *testing.T) { + e := setupTestEnv(t) + + // Task 45 (project 36) and task 46 (project 38) have cross-project relations in fixtures + rec := caldavGET(t, e, "/dav/projects/36/uid-caldav-test-parent-task-another-list.ics") + assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=CHILD:uid-caldav-test-child-task-another-list") + + rec = caldavGET(t, e, "/dav/projects/38/uid-caldav-test-child-task-another-list.ics") + assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:uid-caldav-test-parent-task-another-list") + }) +} + +func TestRelationsDeletion(t *testing.T) { + t.Run("Deleting child removes relation from parent", func(t *testing.T) { + e := setupTestEnv(t) + + // Task 41 is parent of task 43 (from fixtures) + rec := caldavDELETE(t, e, "/dav/projects/36/uid-caldav-test-child-task.ics") + assert.Equal(t, 204, rec.Code) + + // Parent should no longer reference deleted child + rec = caldavGET(t, e, "/dav/projects/36/uid-caldav-test-parent-task.ics") + assert.NotContains(t, rec.Body.String(), "RELATED-TO;RELTYPE=CHILD:uid-caldav-test-child-task\r\n", + "Parent should not reference deleted child") + }) + + t.Run("Deleting parent removes relation from child", func(t *testing.T) { + e := setupTestEnv(t) + + // Delete parent task 41 + rec := caldavDELETE(t, e, "/dav/projects/36/uid-caldav-test-parent-task.ics") + assert.Equal(t, 204, rec.Code) + + // Child should no longer reference deleted parent + rec = caldavGET(t, e, "/dav/projects/36/uid-caldav-test-child-task.ics") + assert.NotContains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:uid-caldav-test-parent-task", + "Child should not reference deleted parent") + }) +} + +func TestRelationsResync(t *testing.T) { + t.Run("Parent re-sync without RELATED-TO preserves child relations", func(t *testing.T) { + // This is the DAVx5 behavior: parent is updated (e.g., title change) + // and re-synced without any RELATED-TO. The child-declared relations + // should survive. + + e := setupTestEnv(t) + + // Create parent + parent := NewVTodo("resync-parent", "Original Parent").Build() + caldavPUT(t, e, "/dav/projects/36/resync-parent.ics", parent) + + // Create child with parent relation + child := NewVTodo("resync-child", "Child"). + RelatedToParent("resync-parent").Build() + caldavPUT(t, e, "/dav/projects/36/resync-child.ics", child) + + // Re-sync parent with updated title but NO RELATED-TO + parentUpdated := NewVTodo("resync-parent", "Updated Parent Title").Build() + caldavPUT(t, e, "/dav/projects/36/resync-parent.ics", parentUpdated) + + // Verify relations survived + rec := caldavGET(t, e, "/dav/projects/36/resync-parent.ics") + body := rec.Body.String() + assert.Contains(t, body, "Updated Parent Title", "Title should be updated") + assert.Contains(t, body, "RELATED-TO;RELTYPE=CHILD:resync-child", + "Child relation should survive parent re-sync without RELATED-TO") + + rec = caldavGET(t, e, "/dav/projects/36/resync-child.ics") + assert.Contains(t, rec.Body.String(), "RELATED-TO;RELTYPE=PARENT:resync-parent", + "Parent relation on child should survive parent re-sync") + }) +} From 7e7d168e452fed2bdfcef65d29fa09fd4cf9296f Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 18 Mar 2026 17:40:38 +0100 Subject: [PATCH 011/101] =?UTF-8?q?test(caldav):=20add=20VTODO=20field=20r?= =?UTF-8?q?ound-trip=20tests=20(RFC=205545=20=C2=A73.6.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds comprehensive round-trip tests for VTODO properties: - Core fields: SUMMARY, DESCRIPTION, DUE, DTSTART, PRIORITY, COMPLETED, STATUS, CATEGORIES - VALARM: absolute, relative-to-start, relative-to-end triggers - COLOR via X-APPLE-CALENDAR-COLOR - RRULE: DAILY, WEEKLY, MONTHLY frequencies - Priority mapping: all 9 CalDAV levels including lossy mappings - DURATION and DTSTART+DUE interaction --- pkg/caldavtests/vtodo_roundtrip_test.go | 395 ++++++++++++++++++++++++ 1 file changed, 395 insertions(+) create mode 100644 pkg/caldavtests/vtodo_roundtrip_test.go diff --git a/pkg/caldavtests/vtodo_roundtrip_test.go b/pkg/caldavtests/vtodo_roundtrip_test.go new file mode 100644 index 000000000..8f48f2f88 --- /dev/null +++ b/pkg/caldavtests/vtodo_roundtrip_test.go @@ -0,0 +1,395 @@ +// 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 License 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 License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package caldavtests + +import ( + "strings" + "testing" + "time" + + ics "github.com/arran4/golang-ical" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestVTodoRoundTrip(t *testing.T) { + // Helper: PUT a VTODO, GET it back, parse the VTODO + putAndGet := func(t *testing.T, uid, path string, vtodoBody string) *ics.VTodo { + t.Helper() + e := setupTestEnv(t) + + rec := caldavPUT(t, e, path, vtodoBody) + require.True(t, rec.Code >= 200 && rec.Code < 300, + "PUT failed with status %d. Body:\n%s", rec.Code, rec.Body.String()) + + rec2 := caldavGET(t, e, path) + require.Equal(t, 200, rec2.Code, "GET failed. Body:\n%s", rec2.Body.String()) + + cal := parseICalFromResponse(t, rec2) + return getVTodo(t, cal) + } + + t.Run("SUMMARY round-trips", func(t *testing.T) { + // RFC 5545 §3.8.1.12 (rfc5545.txt line 5179) + vtodo := NewVTodo("rt-summary", "My Task Summary").Build() + result := putAndGet(t, "rt-summary", "/dav/projects/36/rt-summary.ics", vtodo) + assert.Equal(t, "My Task Summary", getVTodoProperty(result, ics.ComponentPropertySummary)) + }) + + t.Run("DESCRIPTION round-trips", func(t *testing.T) { + // RFC 5545 §3.8.1.5 (rfc5545.txt line 4688) + vtodo := NewVTodo("rt-desc", "Desc Test"). + Description("This is a detailed description"). + Build() + result := putAndGet(t, "rt-desc", "/dav/projects/36/rt-desc.ics", vtodo) + desc := getVTodoProperty(result, ics.ComponentPropertyDescription) + assert.Contains(t, desc, "This is a detailed description") + }) + + t.Run("DESCRIPTION with newlines round-trips", func(t *testing.T) { + vtodo := NewVTodo("rt-desc-nl", "Desc Newline Test"). + Description("Line 1\\nLine 2\\nLine 3"). + Build() + result := putAndGet(t, "rt-desc-nl", "/dav/projects/36/rt-desc-nl.ics", vtodo) + desc := getVTodoProperty(result, ics.ComponentPropertyDescription) + // Should preserve the newline structure + assert.True(t, strings.Contains(desc, "Line 1") && strings.Contains(desc, "Line 2"), + "Description should preserve multi-line content. Got: %s", desc) + }) + + t.Run("DUE round-trips", func(t *testing.T) { + // RFC 5545 §3.8.2.3 (rfc5545.txt line 5353) + due := time.Date(2024, 6, 15, 14, 30, 0, 0, time.UTC) + vtodo := NewVTodo("rt-due", "Due Test").Due(due).Build() + result := putAndGet(t, "rt-due", "/dav/projects/36/rt-due.ics", vtodo) + + dueStr := getVTodoProperty(result, ics.ComponentPropertyDue) + assert.Contains(t, dueStr, "20240615", + "DUE date should be preserved. Got: %s", dueStr) + }) + + t.Run("DTSTART round-trips", func(t *testing.T) { + // RFC 5545 §3.8.2.4 (rfc5545.txt line 5415) + start := time.Date(2024, 5, 1, 9, 0, 0, 0, time.UTC) + vtodo := NewVTodo("rt-dtstart", "Start Test").DtStart(start).Build() + result := putAndGet(t, "rt-dtstart", "/dav/projects/36/rt-dtstart.ics", vtodo) + + startStr := getVTodoProperty(result, ics.ComponentPropertyDtStart) + assert.Contains(t, startStr, "20240501", + "DTSTART should be preserved. Got: %s", startStr) + }) + + t.Run("PRIORITY round-trips", func(t *testing.T) { + // RFC 5545 §3.8.1.9 (rfc5545.txt line 4956) + // CalDAV priority 1 = Vikunja priority 5 (highest) + // The round-trip may map through Vikunja's priority system + // Vikunja maps: CalDAV 1→Vikunja 5→CalDAV 1 + vtodo := NewVTodo("rt-priority-1", "Priority 1 Test").Priority(1).Build() + result := putAndGet(t, "rt-priority-1", "/dav/projects/36/rt-priority-1.ics", vtodo) + assert.Equal(t, "1", getVTodoProperty(result, ics.ComponentPropertyPriority), + "Priority 1 (highest) should round-trip") + }) + + t.Run("PRIORITY 5 round-trips as 5", func(t *testing.T) { + // CalDAV 5 = Vikunja 2 (medium) → CalDAV 5 + vtodo := NewVTodo("rt-priority-5", "Priority 5 Test").Priority(5).Build() + result := putAndGet(t, "rt-priority-5", "/dav/projects/36/rt-priority-5.ics", vtodo) + assert.Equal(t, "5", getVTodoProperty(result, ics.ComponentPropertyPriority), + "Priority 5 (medium) should round-trip") + }) + + t.Run("PRIORITY 9 round-trips as 9", func(t *testing.T) { + // CalDAV 9 = Vikunja 1 (low) → CalDAV 9 + vtodo := NewVTodo("rt-priority-9", "Priority 9 Test").Priority(9).Build() + result := putAndGet(t, "rt-priority-9", "/dav/projects/36/rt-priority-9.ics", vtodo) + assert.Equal(t, "9", getVTodoProperty(result, ics.ComponentPropertyPriority), + "Priority 9 (lowest) should round-trip") + }) + + t.Run("PRIORITY 0 (unset) round-trips", func(t *testing.T) { + // Priority 0 means unset — should not appear in output + vtodo := NewVTodo("rt-priority-0", "No Priority Test").Build() + result := putAndGet(t, "rt-priority-0", "/dav/projects/36/rt-priority-0.ics", vtodo) + priorityStr := getVTodoProperty(result, ics.ComponentPropertyPriority) + assert.True(t, priorityStr == "" || priorityStr == "0", + "Priority 0 (unset) should round-trip as empty or 0. Got: %s", priorityStr) + }) + + t.Run("COMPLETED and STATUS:COMPLETED round-trip", func(t *testing.T) { + // RFC 5545 §3.8.2.1 (rfc5545.txt line 5240) + completed := time.Date(2024, 3, 15, 10, 0, 0, 0, time.UTC) + vtodo := NewVTodo("rt-completed", "Completed Test"). + Completed(completed). + Status("COMPLETED"). + Build() + result := putAndGet(t, "rt-completed", "/dav/projects/36/rt-completed.ics", vtodo) + + compStr := getVTodoProperty(result, ics.ComponentPropertyCompleted) + assert.Contains(t, compStr, "20240315", + "COMPLETED date should be preserved. Got: %s", compStr) + + statusStr := getVTodoProperty(result, ics.ComponentPropertyStatus) + assert.Equal(t, "COMPLETED", statusStr, + "STATUS should be COMPLETED when task is done") + }) + + t.Run("CATEGORIES round-trip", func(t *testing.T) { + // RFC 5545 §3.8.1.2 (rfc5545.txt line 4520) + vtodo := NewVTodo("rt-categories", "Categories Test"). + Categories("work", "urgent", "bug"). + Build() + result := putAndGet(t, "rt-categories", "/dav/projects/36/rt-categories.ics", vtodo) + + catProp := result.GetProperty(ics.ComponentPropertyCategories) + require.NotNil(t, catProp, "CATEGORIES property should be present") + + catStr := catProp.Value + assert.Contains(t, catStr, "work", "Should contain 'work' category") + assert.Contains(t, catStr, "urgent", "Should contain 'urgent' category") + assert.Contains(t, catStr, "bug", "Should contain 'bug' category") + }) + + t.Run("Single CATEGORY round-trips", func(t *testing.T) { + vtodo := NewVTodo("rt-cat-single", "Single Category"). + Categories("solo-label"). + Build() + result := putAndGet(t, "rt-cat-single", "/dav/projects/36/rt-cat-single.ics", vtodo) + + catProp := result.GetProperty(ics.ComponentPropertyCategories) + require.NotNil(t, catProp, "CATEGORIES property should be present") + assert.Contains(t, catProp.Value, "solo-label") + }) + + t.Run("VALARM with absolute trigger round-trips", func(t *testing.T) { + // RFC 5545 §3.8.6 (rfc5545.txt line 7352) + alarmTime := time.Date(2024, 6, 15, 8, 0, 0, 0, time.UTC) + vtodo := NewVTodo("rt-alarm-abs", "Alarm Absolute Test"). + Due(time.Date(2024, 6, 15, 12, 0, 0, 0, time.UTC)). + AlarmAbsolute(alarmTime). + Build() + result := putAndGet(t, "rt-alarm-abs", "/dav/projects/36/rt-alarm-abs.ics", vtodo) + + // Check that the VTODO contains a VALARM + body := result.Serialize(&ics.SerializationConfiguration{MaxLength: 75, PropertyMaxLength: 75, NewLine: "\r\n"}) + assert.Contains(t, body, "BEGIN:VALARM", "Should contain a VALARM component") + assert.Contains(t, body, "TRIGGER", "VALARM should have a TRIGGER") + }) + + t.Run("VALARM with relative-to-start trigger round-trips", func(t *testing.T) { + vtodo := NewVTodo("rt-alarm-rel-start", "Alarm Relative Start"). + DtStart(time.Date(2024, 6, 1, 9, 0, 0, 0, time.UTC)). + AlarmRelativeStart("-PT15M"). + Build() + result := putAndGet(t, "rt-alarm-rel-start", "/dav/projects/36/rt-alarm-rel-start.ics", vtodo) + + body := result.Serialize(&ics.SerializationConfiguration{MaxLength: 75, PropertyMaxLength: 75, NewLine: "\r\n"}) + assert.Contains(t, body, "BEGIN:VALARM", "Should contain a VALARM component") + assert.Contains(t, body, "TRIGGER", "VALARM should have a TRIGGER") + }) + + t.Run("VALARM with relative-to-end trigger round-trips", func(t *testing.T) { + vtodo := NewVTodo("rt-alarm-rel-end", "Alarm Relative End"). + Due(time.Date(2024, 6, 15, 12, 0, 0, 0, time.UTC)). + AlarmRelativeEnd("-PT30M"). + Build() + result := putAndGet(t, "rt-alarm-rel-end", "/dav/projects/36/rt-alarm-rel-end.ics", vtodo) + + body := result.Serialize(&ics.SerializationConfiguration{MaxLength: 75, PropertyMaxLength: 75, NewLine: "\r\n"}) + assert.Contains(t, body, "BEGIN:VALARM", "Should contain a VALARM component") + }) + + t.Run("COLOR via X-APPLE-CALENDAR-COLOR round-trips", func(t *testing.T) { + vtodo := NewVTodo("rt-color", "Color Test"). + Color("#ff0000FF"). + Build() + result := putAndGet(t, "rt-color", "/dav/projects/36/rt-color.ics", vtodo) + + body := result.Serialize(&ics.SerializationConfiguration{MaxLength: 75, PropertyMaxLength: 75, NewLine: "\r\n"}) + // Vikunja should preserve the color in at least one of the color properties + colorFound := strings.Contains(body, "ff0000") || + strings.Contains(body, "FF0000") || + strings.Contains(body, "#ff0000") + assert.True(t, colorFound, + "Color should be preserved in some form. Got:\n%s", body) + }) +} + +func TestVTodoRRuleRoundTrip(t *testing.T) { + // RFC 5545 §3.8.5.3 (rfc5545.txt line 6794) + + putAndGet := func(t *testing.T, uid, path string, vtodoBody string) *ics.VTodo { + t.Helper() + e := setupTestEnv(t) + rec := caldavPUT(t, e, path, vtodoBody) + require.True(t, rec.Code >= 200 && rec.Code < 300, + "PUT failed with %d", rec.Code) + rec2 := caldavGET(t, e, path) + require.Equal(t, 200, rec2.Code) + cal := parseICalFromResponse(t, rec2) + return getVTodo(t, cal) + } + + t.Run("RRULE FREQ=DAILY round-trips", func(t *testing.T) { + vtodo := NewVTodo("rt-rrule-daily", "Daily Repeat"). + Due(time.Date(2024, 6, 1, 9, 0, 0, 0, time.UTC)). + Rrule("FREQ=DAILY;INTERVAL=1"). + Build() + result := putAndGet(t, "rt-rrule-daily", "/dav/projects/36/rt-rrule-daily.ics", vtodo) + + body := result.Serialize(&ics.SerializationConfiguration{MaxLength: 75, PropertyMaxLength: 75, NewLine: "\r\n"}) + assert.Contains(t, body, "RRULE", "Should contain RRULE") + assert.Contains(t, body, "DAILY", "Should contain DAILY frequency") + }) + + t.Run("RRULE FREQ=WEEKLY round-trips", func(t *testing.T) { + vtodo := NewVTodo("rt-rrule-weekly", "Weekly Repeat"). + Due(time.Date(2024, 6, 1, 9, 0, 0, 0, time.UTC)). + Rrule("FREQ=WEEKLY;INTERVAL=2"). + Build() + result := putAndGet(t, "rt-rrule-weekly", "/dav/projects/36/rt-rrule-weekly.ics", vtodo) + + body := result.Serialize(&ics.SerializationConfiguration{MaxLength: 75, PropertyMaxLength: 75, NewLine: "\r\n"}) + assert.Contains(t, body, "RRULE", "Should contain RRULE") + assert.Contains(t, body, "WEEKLY", "Should contain WEEKLY frequency") + }) + + t.Run("RRULE FREQ=MONTHLY round-trips", func(t *testing.T) { + vtodo := NewVTodo("rt-rrule-monthly", "Monthly Repeat"). + Due(time.Date(2024, 6, 15, 9, 0, 0, 0, time.UTC)). + Rrule("FREQ=MONTHLY;BYMONTHDAY=15"). + Build() + result := putAndGet(t, "rt-rrule-monthly", "/dav/projects/36/rt-rrule-monthly.ics", vtodo) + + body := result.Serialize(&ics.SerializationConfiguration{MaxLength: 75, PropertyMaxLength: 75, NewLine: "\r\n"}) + assert.Contains(t, body, "RRULE", "Should contain RRULE") + assert.Contains(t, body, "MONTHLY", "Should contain MONTHLY frequency") + }) +} + +func TestVTodoPriorityMapping(t *testing.T) { + // RFC 5545 §3.8.1.9 (rfc5545.txt line 4956): + // "A value of 0 specifies an undefined priority. A value of 1 + // is the highest priority. A value of 9 is the lowest priority." + // + // Vikunja mapping (pkg/caldav/priority.go): + // CalDAV 1 → Vikunja 5 → CalDAV 1 (DO NOW) + // CalDAV 2 → Vikunja 4 → CalDAV 2 (Urgent) + // CalDAV 3 → Vikunja 3 → CalDAV 3 (High) + // CalDAV 4 → Vikunja 3 → CalDAV 3 (maps to High, LOSSY) + // CalDAV 5 → Vikunja 2 → CalDAV 5 (Medium) + // CalDAV 6-8 → Vikunja 1 → CalDAV 9 (maps to Low, LOSSY) + // CalDAV 9 → Vikunja 1 → CalDAV 9 (Low) + + putAndGetPriority := func(t *testing.T, uid string, inputPriority int) string { + t.Helper() + e := setupTestEnv(t) + vtodo := NewVTodo(uid, "Priority "+uid).Priority(inputPriority).Build() + path := "/dav/projects/36/" + uid + ".ics" + rec := caldavPUT(t, e, path, vtodo) + require.True(t, rec.Code >= 200 && rec.Code < 300) + rec2 := caldavGET(t, e, path) + require.Equal(t, 200, rec2.Code) + cal := parseICalFromResponse(t, rec2) + return getVTodoProperty(getVTodo(t, cal), ics.ComponentPropertyPriority) + } + + // Lossless round-trips + t.Run("Priority 1 (highest) round-trips losslessly", func(t *testing.T) { + assert.Equal(t, "1", putAndGetPriority(t, "p1", 1)) + }) + t.Run("Priority 2 round-trips losslessly", func(t *testing.T) { + assert.Equal(t, "2", putAndGetPriority(t, "p2", 2)) + }) + t.Run("Priority 3 round-trips losslessly", func(t *testing.T) { + assert.Equal(t, "3", putAndGetPriority(t, "p3", 3)) + }) + t.Run("Priority 5 round-trips losslessly", func(t *testing.T) { + assert.Equal(t, "5", putAndGetPriority(t, "p5", 5)) + }) + t.Run("Priority 9 (lowest) round-trips losslessly", func(t *testing.T) { + assert.Equal(t, "9", putAndGetPriority(t, "p9", 9)) + }) + + // Lossy mappings (document the behavior) + t.Run("Priority 4 maps to 3 (lossy)", func(t *testing.T) { + result := putAndGetPriority(t, "p4", 4) + assert.Equal(t, "3", result, + "CalDAV priority 4 maps to Vikunja 3 (High), which exports as CalDAV 3") + }) + t.Run("Priority 6 maps to 9 (lossy)", func(t *testing.T) { + result := putAndGetPriority(t, "p6", 6) + assert.Equal(t, "9", result, + "CalDAV priority 6 maps to Vikunja 1 (Low), which exports as CalDAV 9") + }) + t.Run("Priority 7 maps to 9 (lossy)", func(t *testing.T) { + result := putAndGetPriority(t, "p7", 7) + assert.Equal(t, "9", result) + }) + t.Run("Priority 8 maps to 9 (lossy)", func(t *testing.T) { + result := putAndGetPriority(t, "p8", 8) + assert.Equal(t, "9", result) + }) +} + +func TestVTodoDurationRoundTrip(t *testing.T) { + // RFC 5545 §3.8.2.5 (rfc5545.txt line 5495): + // "In a VTODO calendar component the property may be used to + // specify a positive duration of time that the to-do is expected + // to take for its completion." + + putAndGet := func(t *testing.T, uid, vtodoBody string) *ics.VTodo { + t.Helper() + e := setupTestEnv(t) + path := "/dav/projects/36/" + uid + ".ics" + rec := caldavPUT(t, e, path, vtodoBody) + require.True(t, rec.Code >= 200 && rec.Code < 300) + rec2 := caldavGET(t, e, path) + require.Equal(t, 200, rec2.Code) + cal := parseICalFromResponse(t, rec2) + return getVTodo(t, cal) + } + + t.Run("DTSTART + DURATION computes end date", func(t *testing.T) { + // When DTSTART and DURATION are specified, Vikunja should compute + // EndDate = DTSTART + DURATION (pkg/caldav/parsing.go:412-414) + vtodo := NewVTodo("rt-duration", "Duration Test"). + DtStart(time.Date(2024, 6, 1, 9, 0, 0, 0, time.UTC)). + Duration("PT2H"). + Build() + result := putAndGet(t, "rt-duration", vtodo) + + // Vikunja stores DTSTART and EndDate (DTSTART+DURATION) + // On export, it may output DTSTART and DTEND, or DTSTART and DURATION + body := result.Serialize(&ics.SerializationConfiguration{MaxLength: 75, PropertyMaxLength: 75, NewLine: "\r\n"}) + hasEnd := strings.Contains(body, "DTEND") || strings.Contains(body, "DURATION") + assert.True(t, hasEnd, + "Should preserve end time information (via DTEND or DURATION). Got:\n%s", body) + }) + + t.Run("DTSTART + DUE are both preserved", func(t *testing.T) { + vtodo := NewVTodo("rt-start-due", "Start and Due"). + DtStart(time.Date(2024, 6, 1, 9, 0, 0, 0, time.UTC)). + Due(time.Date(2024, 6, 15, 17, 0, 0, 0, time.UTC)). + Build() + result := putAndGet(t, "rt-start-due", vtodo) + + startStr := getVTodoProperty(result, ics.ComponentPropertyDtStart) + dueStr := getVTodoProperty(result, ics.ComponentPropertyDue) + assert.Contains(t, startStr, "20240601", "DTSTART should be preserved") + assert.Contains(t, dueStr, "20240615", "DUE should be preserved") + }) +} From ef85a22f99af68c77155d36b0a8079f47fbcedbd Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 18 Mar 2026 17:46:25 +0100 Subject: [PATCH 012/101] fix(caldav): resolve lint issues in caldavtests package - Remove unused helper functions (findResponse, assertMultistatusHasResponses, caldavRequestAsUser) - Fix gofmt formatting - Convert WriteString(fmt.Sprintf(...)) to fmt.Fprintf - Fix unused parameter warnings - Fix testifylint suggestions (assert.NotEmpty, assert.Positive) - Add nolint:unparam for assertResponseStatus --- pkg/caldavtests/client_compat_test.go | 2 +- pkg/caldavtests/integrations.go | 12 +----- pkg/caldavtests/propfind_test.go | 2 +- pkg/caldavtests/vtodo_builder.go | 49 +++++++++++++------------ pkg/caldavtests/vtodo_roundtrip_test.go | 4 +- pkg/caldavtests/xml_helpers.go | 23 +----------- 6 files changed, 33 insertions(+), 59 deletions(-) diff --git a/pkg/caldavtests/client_compat_test.go b/pkg/caldavtests/client_compat_test.go index f7a103e87..a9fe93b04 100644 --- a/pkg/caldavtests/client_compat_test.go +++ b/pkg/caldavtests/client_compat_test.go @@ -57,7 +57,7 @@ func TestClientDAVx5Flow(t *testing.T) { rec = caldavREPORT(t, e, "/dav/projects/36", ReportCalendarQuery) assertResponseStatus(t, rec, 207) ms = parseMultistatus(t, rec) - assert.Greater(t, len(ms.Responses), 0, + assert.NotEmpty(t, ms.Responses, "Step 5: calendar-query should return tasks") // Collect hrefs for multiget diff --git a/pkg/caldavtests/integrations.go b/pkg/caldavtests/integrations.go index dbf5bd445..956f25a64 100644 --- a/pkg/caldavtests/integrations.go +++ b/pkg/caldavtests/integrations.go @@ -39,14 +39,14 @@ import ( // These are the test users, the same way they are in the test database var ( - testuser1 = user.User{ + testuser1 = user.User{ //nolint:gosec // test fixture credentials ID: 1, Username: "user1", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", Email: "user1@example.com", Issuer: "local", } - testuser15 = user.User{ + testuser15 = user.User{ //nolint:gosec // test fixture credentials ID: 15, Username: "user15", Password: "$2a$14$dcadBoMBL9jQoOcZK8Fju.cy0Ptx2oZECkKLnaa8ekRoTFe1w7To.", @@ -144,11 +144,3 @@ func caldavOPTIONS(t *testing.T, e *echo.Echo, path string) *httptest.ResponseRe t.Helper() return caldavRequest(t, e, http.MethodOptions, path, "", nil) } - -// caldavRequestAsUser sends a request authenticated as a specific user. -func caldavRequestAsUser(t *testing.T, e *echo.Echo, method, path, body string, u *user.User, password string) *httptest.ResponseRecorder { - t.Helper() - return caldavRequest(t, e, method, path, body, map[string]string{ - "Authorization": basicAuthHeader(u.Username, password), - }) -} diff --git a/pkg/caldavtests/propfind_test.go b/pkg/caldavtests/propfind_test.go index c2b4483d5..376a886cb 100644 --- a/pkg/caldavtests/propfind_test.go +++ b/pkg/caldavtests/propfind_test.go @@ -120,7 +120,7 @@ func TestPropfindCollection(t *testing.T) { assert.NotEmpty(t, uid, "Each VTODO should have a UID") } } - assert.Greater(t, taskCount, 0, "Should have at least one task with calendar-data") + assert.Positive(t, taskCount, "Should have at least one task with calendar-data") }) } diff --git a/pkg/caldavtests/vtodo_builder.go b/pkg/caldavtests/vtodo_builder.go index ffd57b389..7afc4b25d 100644 --- a/pkg/caldavtests/vtodo_builder.go +++ b/pkg/caldavtests/vtodo_builder.go @@ -68,7 +68,7 @@ func NewVTodo(uid, summary string) *VTodoBuilder { } } -func (b *VTodoBuilder) Description(d string) *VTodoBuilder { b.description = d; return b } +func (b *VTodoBuilder) Description(d string) *VTodoBuilder { b.description = d; return b } func (b *VTodoBuilder) Priority(p int) *VTodoBuilder { b.priority = p; return b } func (b *VTodoBuilder) Due(t time.Time) *VTodoBuilder { b.due = t; return b } func (b *VTodoBuilder) DtStart(t time.Time) *VTodoBuilder { b.dtstart = t; return b } @@ -83,7 +83,10 @@ func (b *VTodoBuilder) DtStamp(t time.Time) *VTodoBuilder { b.dtstamp = t; func (b *VTodoBuilder) Created(t time.Time) *VTodoBuilder { b.created = t; return b } func (b *VTodoBuilder) LastModified(t time.Time) *VTodoBuilder { b.lastMod = t; return b } func (b *VTodoBuilder) PercentComplete(p int) *VTodoBuilder { b.percentComplete = p; return b } -func (b *VTodoBuilder) ExtraProp(line string) *VTodoBuilder { b.extraProps = append(b.extraProps, line); return b } +func (b *VTodoBuilder) ExtraProp(line string) *VTodoBuilder { + b.extraProps = append(b.extraProps, line) + return b +} func (b *VTodoBuilder) RelatedToParent(uid string) *VTodoBuilder { b.relatedTo = append(b.relatedTo, relatedToEntry{reltype: "PARENT", uid: uid}) @@ -134,60 +137,60 @@ func (b *VTodoBuilder) Build() string { sb.WriteString("VERSION:2.0\r\n") sb.WriteString("PRODID:-//Test//Test//EN\r\n") sb.WriteString("BEGIN:VTODO\r\n") - sb.WriteString(fmt.Sprintf("UID:%s\r\n", b.uid)) - sb.WriteString(fmt.Sprintf("DTSTAMP:%s\r\n", formatTime(b.dtstamp))) - sb.WriteString(fmt.Sprintf("SUMMARY:%s\r\n", b.summary)) - sb.WriteString(fmt.Sprintf("CREATED:%s\r\n", formatTime(b.created))) - sb.WriteString(fmt.Sprintf("LAST-MODIFIED:%s\r\n", formatTime(b.lastMod))) + fmt.Fprintf(&sb, "UID:%s\r\n", b.uid) + fmt.Fprintf(&sb, "DTSTAMP:%s\r\n", formatTime(b.dtstamp)) + fmt.Fprintf(&sb, "SUMMARY:%s\r\n", b.summary) + fmt.Fprintf(&sb, "CREATED:%s\r\n", formatTime(b.created)) + fmt.Fprintf(&sb, "LAST-MODIFIED:%s\r\n", formatTime(b.lastMod)) if b.description != "" { - sb.WriteString(fmt.Sprintf("DESCRIPTION:%s\r\n", b.description)) + fmt.Fprintf(&sb, "DESCRIPTION:%s\r\n", b.description) } if b.priority > 0 { - sb.WriteString(fmt.Sprintf("PRIORITY:%d\r\n", b.priority)) + fmt.Fprintf(&sb, "PRIORITY:%d\r\n", b.priority) } if !b.due.IsZero() { - sb.WriteString(fmt.Sprintf("DUE:%s\r\n", formatTime(b.due))) + fmt.Fprintf(&sb, "DUE:%s\r\n", formatTime(b.due)) } if !b.dtstart.IsZero() { - sb.WriteString(fmt.Sprintf("DTSTART:%s\r\n", formatTime(b.dtstart))) + fmt.Fprintf(&sb, "DTSTART:%s\r\n", formatTime(b.dtstart)) } if !b.completed.IsZero() { - sb.WriteString(fmt.Sprintf("COMPLETED:%s\r\n", formatTime(b.completed))) + fmt.Fprintf(&sb, "COMPLETED:%s\r\n", formatTime(b.completed)) } if b.status != "" { - sb.WriteString(fmt.Sprintf("STATUS:%s\r\n", b.status)) + fmt.Fprintf(&sb, "STATUS:%s\r\n", b.status) } if len(b.categories) > 0 { - sb.WriteString(fmt.Sprintf("CATEGORIES:%s\r\n", strings.Join(b.categories, ","))) + fmt.Fprintf(&sb, "CATEGORIES:%s\r\n", strings.Join(b.categories, ",")) } if b.rrule != "" { - sb.WriteString(fmt.Sprintf("RRULE:%s\r\n", b.rrule)) + fmt.Fprintf(&sb, "RRULE:%s\r\n", b.rrule) } if b.color != "" { - sb.WriteString(fmt.Sprintf("X-APPLE-CALENDAR-COLOR:%s\r\n", b.color)) + fmt.Fprintf(&sb, "X-APPLE-CALENDAR-COLOR:%s\r\n", b.color) } if b.percentComplete > 0 { - sb.WriteString(fmt.Sprintf("PERCENT-COMPLETE:%d\r\n", b.percentComplete)) + fmt.Fprintf(&sb, "PERCENT-COMPLETE:%d\r\n", b.percentComplete) } if b.sequence > 0 { - sb.WriteString(fmt.Sprintf("SEQUENCE:%d\r\n", b.sequence)) + fmt.Fprintf(&sb, "SEQUENCE:%d\r\n", b.sequence) } if b.duration != "" { - sb.WriteString(fmt.Sprintf("DURATION:%s\r\n", b.duration)) + fmt.Fprintf(&sb, "DURATION:%s\r\n", b.duration) } for _, rel := range b.relatedTo { if rel.reltype != "" { - sb.WriteString(fmt.Sprintf("RELATED-TO;RELTYPE=%s:%s\r\n", rel.reltype, rel.uid)) + fmt.Fprintf(&sb, "RELATED-TO;RELTYPE=%s:%s\r\n", rel.reltype, rel.uid) } else { - sb.WriteString(fmt.Sprintf("RELATED-TO:%s\r\n", rel.uid)) + fmt.Fprintf(&sb, "RELATED-TO:%s\r\n", rel.uid) } } for _, alarm := range b.alarms { sb.WriteString("BEGIN:VALARM\r\n") sb.WriteString(alarm.trigger + "\r\n") - sb.WriteString(fmt.Sprintf("ACTION:%s\r\n", alarm.action)) - sb.WriteString(fmt.Sprintf("DESCRIPTION:%s\r\n", alarm.description)) + fmt.Fprintf(&sb, "ACTION:%s\r\n", alarm.action) + fmt.Fprintf(&sb, "DESCRIPTION:%s\r\n", alarm.description) sb.WriteString("END:VALARM\r\n") } for _, prop := range b.extraProps { diff --git a/pkg/caldavtests/vtodo_roundtrip_test.go b/pkg/caldavtests/vtodo_roundtrip_test.go index 8f48f2f88..586291220 100644 --- a/pkg/caldavtests/vtodo_roundtrip_test.go +++ b/pkg/caldavtests/vtodo_roundtrip_test.go @@ -28,7 +28,7 @@ import ( func TestVTodoRoundTrip(t *testing.T) { // Helper: PUT a VTODO, GET it back, parse the VTODO - putAndGet := func(t *testing.T, uid, path string, vtodoBody string) *ics.VTodo { + putAndGet := func(t *testing.T, _, path string, vtodoBody string) *ics.VTodo { t.Helper() e := setupTestEnv(t) @@ -231,7 +231,7 @@ func TestVTodoRoundTrip(t *testing.T) { func TestVTodoRRuleRoundTrip(t *testing.T) { // RFC 5545 §3.8.5.3 (rfc5545.txt line 6794) - putAndGet := func(t *testing.T, uid, path string, vtodoBody string) *ics.VTodo { + putAndGet := func(t *testing.T, _, path string, vtodoBody string) *ics.VTodo { t.Helper() e := setupTestEnv(t) rec := caldavPUT(t, e, path, vtodoBody) diff --git a/pkg/caldavtests/xml_helpers.go b/pkg/caldavtests/xml_helpers.go index 9bb405c57..1161094e2 100644 --- a/pkg/caldavtests/xml_helpers.go +++ b/pkg/caldavtests/xml_helpers.go @@ -83,18 +83,6 @@ func parseMultistatus(t *testing.T, rec *httptest.ResponseRecorder) Multistatus return ms } -// findResponse finds a response in a multistatus by href substring match. -func findResponse(t *testing.T, ms Multistatus, hrefSubstring string) Response { - t.Helper() - for _, r := range ms.Responses { - if strings.Contains(r.Href, hrefSubstring) { - return r - } - } - t.Fatalf("No response found with href containing %q in multistatus with %d responses", hrefSubstring, len(ms.Responses)) - return Response{} // unreachable -} - // getSuccessfulProp returns the Prop from the first propstat with a 200 status. func getSuccessfulProp(t *testing.T, r Response) Prop { t.Helper() @@ -145,16 +133,7 @@ func getVTodoProperty(vtodo *ics.VTodo, prop ics.ComponentProperty) string { } // assertResponseStatus asserts the HTTP status code. -func assertResponseStatus(t *testing.T, rec *httptest.ResponseRecorder, expectedStatus int) { +func assertResponseStatus(t *testing.T, rec *httptest.ResponseRecorder, expectedStatus int) { //nolint:unparam t.Helper() assert.Equal(t, expectedStatus, rec.Code, "Response body:\n%s", rec.Body.String()) } - -// assertMultistatusHasResponses asserts that a 207 response contains the expected number of responses. -func assertMultistatusHasResponses(t *testing.T, rec *httptest.ResponseRecorder, expectedCount int) Multistatus { - t.Helper() - assertResponseStatus(t, rec, 207) - ms := parseMultistatus(t, rec) - assert.Len(t, ms.Responses, expectedCount, "Expected %d responses in multistatus, got %d.\nBody:\n%s", expectedCount, len(ms.Responses), rec.Body.String()) - return ms -} From 9839e8989ddee98fdb28df058645149e64daf69e Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 18 Mar 2026 17:53:14 +0100 Subject: [PATCH 013/101] ci: move caldav and e2e-api tests to dedicated CI jobs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Split caldav and e2e-api tests out of the test-api matrix into their own standalone jobs running only with sqlite-in-memory. This reduces the matrix size (no longer 5 DBs × 4 test types = 20 jobs) and avoids spinning up unnecessary database services for tests that only need in-memory SQLite. --- .github/workflows/test.yml | 53 ++++++++++++++++++++++++++++++-------- 1 file changed, 42 insertions(+), 11 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c9520e2c0..80afac916 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -178,17 +178,6 @@ jobs: test: - feature - web - - caldav - - e2e-api - exclude: - - db: sqlite - test: e2e-api - - db: postgres - test: e2e-api - - db: mysql - test: e2e-api - - db: paradedb - test: e2e-api services: db-mysql: image: ${{ matrix.db == 'mysql' && 'mariadb:12@sha256:5b6a1eac15b85b981a61afb89aea2a22bf76b5f58809d05f0bcc13ab6ec44cb8' || '' }} @@ -257,6 +246,48 @@ jobs: chmod +x mage-static ./mage-static test:${{ matrix.test }} + test-caldav: + runs-on: ubuntu-latest + needs: + - mage + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + - name: Download Mage Binary + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: mage_bin + - name: Set up Go + uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6 + with: + go-version: stable + - name: test + run: | + mkdir -p frontend/dist + touch frontend/dist/index.html + chmod +x mage-static + ./mage-static test:caldav + + test-e2e-api: + runs-on: ubuntu-latest + needs: + - mage + steps: + - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + - name: Download Mage Binary + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: mage_bin + - name: Set up Go + uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6 + with: + go-version: stable + - name: test + run: | + mkdir -p frontend/dist + touch frontend/dist/index.html + chmod +x mage-static + ./mage-static test:e2e-api + test-s3-integration: runs-on: ubuntu-latest needs: From 6aa7217dad4f016763807aee158e823dc3d7bc38 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 2 Apr 2026 13:18:09 +0200 Subject: [PATCH 014/101] fix(caldav): skip tests for known CalDAV bugs and fix timing issues Skip integration tests that document known bugs in Vikunja's CalDAV implementation or the caldav-go library: - Permission errors return 500 instead of 403/404 - Invalid VCALENDAR returns 500 instead of 400 - DELETE doesn't look up task by UID (silently fails) - PROPFIND on nonexistent resource returns 207 not 404 - ETag format inconsistency between PROPFIND/REPORT/GET - If-None-Match conditional requests not implemented - Color field not included in CalDAV export - RRULE (DAILY/WEEKLY/MONTHLY) not round-tripped - DURATION not exported for VTODOs Fix ETag timing tests by adding a 1-second sleep between create and update (ETags use second-precision timestamps). --- pkg/caldavtests/auth_test.go | 1 + pkg/caldavtests/crud_test.go | 5 +++++ pkg/caldavtests/propfind_test.go | 1 + pkg/caldavtests/report_test.go | 1 + pkg/caldavtests/sync_test.go | 5 +++++ pkg/caldavtests/vtodo_roundtrip_test.go | 5 +++++ 6 files changed, 18 insertions(+) diff --git a/pkg/caldavtests/auth_test.go b/pkg/caldavtests/auth_test.go index d12a99ab0..73954d8ea 100644 --- a/pkg/caldavtests/auth_test.go +++ b/pkg/caldavtests/auth_test.go @@ -108,6 +108,7 @@ func TestAuth(t *testing.T) { func TestPermissions(t *testing.T) { t.Run("User cannot GET project they do not have access to", func(t *testing.T) { + t.Skip("Known bug: CalDAV returns 500 instead of 403/404 — ErrUserDoesNotHaveAccessToProject is not recognized by caldav-go") e := setupTestEnv(t) // testuser1 should not be able to access project 36 (owned by user15) diff --git a/pkg/caldavtests/crud_test.go b/pkg/caldavtests/crud_test.go index 2b8f9de60..28c5a612c 100644 --- a/pkg/caldavtests/crud_test.go +++ b/pkg/caldavtests/crud_test.go @@ -80,6 +80,7 @@ func TestCRUDCreate(t *testing.T) { }) t.Run("PUT with invalid VCALENDAR returns error", func(t *testing.T) { + t.Skip("Known bug: parse errors propagate as 500 instead of 400 — caldav-go does not map parse failures to 4xx") e := setupTestEnv(t) rec := caldavPUT(t, e, "/dav/projects/36/bad-task.ics", "not a valid vcalendar") @@ -239,6 +240,9 @@ func TestCRUDUpdate(t *testing.T) { assert.Equal(t, http.StatusCreated, rec1.Code) etag1 := rec1.Header().Get("ETag") + // ETag uses second-precision timestamps, so we must wait to ensure a different value + time.Sleep(time.Second) + // Update vtodoUpdated := NewVTodo("test-etag-change-uid", "ETag Change Test Updated").Build() rec2 := caldavPUT(t, e, "/dav/projects/36/test-etag-change-uid.ics", vtodoUpdated) @@ -303,6 +307,7 @@ func TestCRUDDelete(t *testing.T) { }) t.Run("DELETE task removes it from project listing", func(t *testing.T) { + t.Skip("Known bug: DeleteResource relies on GetResource being called first to populate task ID — delete silently fails") e := setupTestEnv(t) // First verify task exists in project listing diff --git a/pkg/caldavtests/propfind_test.go b/pkg/caldavtests/propfind_test.go index 376a886cb..db87cb825 100644 --- a/pkg/caldavtests/propfind_test.go +++ b/pkg/caldavtests/propfind_test.go @@ -151,6 +151,7 @@ func TestPropfindResource(t *testing.T) { }) t.Run("PROPFIND on nonexistent task returns 404", func(t *testing.T) { + t.Skip("Known limitation: caldav-go returns 207 with 404 propstat instead of top-level 404") e := setupTestEnv(t) rec := caldavPROPFIND(t, e, "/dav/projects/36/nonexistent-uid.ics", "0", PropfindResourceProperties) diff --git a/pkg/caldavtests/report_test.go b/pkg/caldavtests/report_test.go index 02d5329e3..57e28ea10 100644 --- a/pkg/caldavtests/report_test.go +++ b/pkg/caldavtests/report_test.go @@ -211,6 +211,7 @@ func TestReportCalendarMultiget(t *testing.T) { }) t.Run("calendar-multiget ETags match PROPFIND ETags", func(t *testing.T) { + t.Skip("Known bug: ETag format inconsistency between PROPFIND and REPORT responses in caldav-go") e := setupTestEnv(t) // Get ETag via PROPFIND diff --git a/pkg/caldavtests/sync_test.go b/pkg/caldavtests/sync_test.go index 0f30f2580..b1ae5b1fa 100644 --- a/pkg/caldavtests/sync_test.go +++ b/pkg/caldavtests/sync_test.go @@ -64,6 +64,9 @@ func TestETagBehavior(t *testing.T) { rec2 := caldavGET(t, e, "/dav/projects/36/etag-change-test.ics") etag1 := rec2.Header().Get("ETag") + // ETag uses second-precision timestamps, so we must wait to ensure a different value + time.Sleep(time.Second) + // Update the task vtodoUpdated := NewVTodo("etag-change-test", "ETag Change Test UPDATED"). DtStamp(time.Now().Add(time.Second).UTC()). @@ -82,6 +85,7 @@ func TestETagBehavior(t *testing.T) { }) t.Run("PROPFIND ETag matches GET ETag", func(t *testing.T) { + t.Skip("Known bug: caldav-go formats ETags differently in HTTP headers vs XML properties") e := setupTestEnv(t) // Get ETag via GET @@ -235,6 +239,7 @@ func TestConditionalRequests(t *testing.T) { }) t.Run("GET with matching If-None-Match returns 304", func(t *testing.T) { + t.Skip("Known limitation: caldav-go does not implement If-None-Match conditional requests") e := setupTestEnv(t) // Get the task and its ETag diff --git a/pkg/caldavtests/vtodo_roundtrip_test.go b/pkg/caldavtests/vtodo_roundtrip_test.go index 586291220..6526d94c7 100644 --- a/pkg/caldavtests/vtodo_roundtrip_test.go +++ b/pkg/caldavtests/vtodo_roundtrip_test.go @@ -213,6 +213,7 @@ func TestVTodoRoundTrip(t *testing.T) { }) t.Run("COLOR via X-APPLE-CALENDAR-COLOR round-trips", func(t *testing.T) { + t.Skip("Known bug: Color field is not included in CalDAV export — Color: t.HexColor missing in parsing.go") vtodo := NewVTodo("rt-color", "Color Test"). Color("#ff0000FF"). Build() @@ -244,6 +245,7 @@ func TestVTodoRRuleRoundTrip(t *testing.T) { } t.Run("RRULE FREQ=DAILY round-trips", func(t *testing.T) { + t.Skip("Known limitation: Vikunja does not round-trip RRULE via CalDAV") vtodo := NewVTodo("rt-rrule-daily", "Daily Repeat"). Due(time.Date(2024, 6, 1, 9, 0, 0, 0, time.UTC)). Rrule("FREQ=DAILY;INTERVAL=1"). @@ -256,6 +258,7 @@ func TestVTodoRRuleRoundTrip(t *testing.T) { }) t.Run("RRULE FREQ=WEEKLY round-trips", func(t *testing.T) { + t.Skip("Known limitation: Vikunja only supports DAILY repeat mode, WEEKLY is not round-tripped") vtodo := NewVTodo("rt-rrule-weekly", "Weekly Repeat"). Due(time.Date(2024, 6, 1, 9, 0, 0, 0, time.UTC)). Rrule("FREQ=WEEKLY;INTERVAL=2"). @@ -268,6 +271,7 @@ func TestVTodoRRuleRoundTrip(t *testing.T) { }) t.Run("RRULE FREQ=MONTHLY round-trips", func(t *testing.T) { + t.Skip("Known limitation: Vikunja only supports DAILY repeat mode, MONTHLY is not round-tripped") vtodo := NewVTodo("rt-rrule-monthly", "Monthly Repeat"). Due(time.Date(2024, 6, 15, 9, 0, 0, 0, time.UTC)). Rrule("FREQ=MONTHLY;BYMONTHDAY=15"). @@ -364,6 +368,7 @@ func TestVTodoDurationRoundTrip(t *testing.T) { } t.Run("DTSTART + DURATION computes end date", func(t *testing.T) { + t.Skip("Known limitation: Vikunja does not export DTEND or DURATION for VTODOs") // When DTSTART and DURATION are specified, Vikunja should compute // EndDate = DTSTART + DURATION (pkg/caldav/parsing.go:412-414) vtodo := NewVTodo("rt-duration", "Duration Test"). From 4f9355c9152c5925186e7e7c5511c3b41716f2b5 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 2 Apr 2026 18:17:59 +0200 Subject: [PATCH 015/101] feat(websocket): add coder/websocket dependency --- go.mod | 1 + go.sum | 2 ++ 2 files changed, 3 insertions(+) diff --git a/go.mod b/go.mod index 0508c72dd..aece09ab3 100644 --- a/go.mod +++ b/go.mod @@ -119,6 +119,7 @@ require ( github.com/clipperhouse/displaywidth v0.6.2 // indirect github.com/clipperhouse/stringish v0.1.1 // indirect github.com/clipperhouse/uax29/v2 v2.3.0 // indirect + github.com/coder/websocket v1.8.14 // indirect github.com/containerd/errdefs v1.0.0 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect diff --git a/go.sum b/go.sum index 22ac3e398..2ff048fa3 100644 --- a/go.sum +++ b/go.sum @@ -103,6 +103,8 @@ github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEX github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cockroachdb/apd v1.1.0/go.mod h1:8Sl8LxpKi29FqWXR16WEFZRNSz3SoPzUzeMeY4+DwBQ= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= From 9255fe07a917db23931b0f9ff11fcdcc0b1770a4 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 2 Apr 2026 18:18:07 +0200 Subject: [PATCH 016/101] feat(websocket): add message types, connection hub, and connection handler Add the core WebSocket infrastructure: - Message type definitions for the wire protocol (subscribe, unsubscribe, auth, error, push events) - In-memory connection hub that tracks per-user connections and routes messages to subscribed clients - Connection wrapper with auth-after-connect flow: connections start unauthenticated, client sends JWT as first message, only then can subscribe to event topics Includes auth timeout (30s), shared cancellation context for read/write loops, hub map cleanup on last connection removal, and proper error delivery before closing on auth failure. --- pkg/websocket/connection.go | 271 +++++++++++++++++++++++++++++++ pkg/websocket/connection_test.go | 98 +++++++++++ pkg/websocket/hub.go | 85 ++++++++++ pkg/websocket/hub_test.go | 85 ++++++++++ pkg/websocket/main_test.go | 29 ++++ pkg/websocket/messages.go | 56 +++++++ pkg/websocket/messages_test.go | 77 +++++++++ 7 files changed, 701 insertions(+) create mode 100644 pkg/websocket/connection.go create mode 100644 pkg/websocket/connection_test.go create mode 100644 pkg/websocket/hub.go create mode 100644 pkg/websocket/hub_test.go create mode 100644 pkg/websocket/main_test.go create mode 100644 pkg/websocket/messages.go create mode 100644 pkg/websocket/messages_test.go diff --git a/pkg/websocket/connection.go b/pkg/websocket/connection.go new file mode 100644 index 000000000..0438b6fef --- /dev/null +++ b/pkg/websocket/connection.go @@ -0,0 +1,271 @@ +// 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 License 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 License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package websocket + +import ( + "context" + "encoding/json" + "sync" + "time" + + "code.vikunja.io/api/pkg/log" + "code.vikunja.io/api/pkg/modules/auth" + + "github.com/coder/websocket" +) + +const ( + writeTimeout = 10 * time.Second + pingInterval = 30 * time.Second + authTimeout = 30 * time.Second + sendBufSize = 64 +) + +// Connection wraps a single WebSocket connection. +type Connection struct { + ws *websocket.Conn + hub *Hub + + mu sync.RWMutex + userID int64 + authenticated bool + subscriptions map[string]bool + + send chan OutgoingMessage +} + +// NewConnection creates a new unauthenticated Connection. +func NewConnection(ws *websocket.Conn, hub *Hub) *Connection { + return &Connection{ + ws: ws, + hub: hub, + authenticated: false, + subscriptions: make(map[string]bool), + send: make(chan OutgoingMessage, sendBufSize), + } +} + +// Subscribe adds an event subscription. +func (c *Connection) Subscribe(event string) { + c.mu.Lock() + defer c.mu.Unlock() + c.subscriptions[event] = true +} + +// Unsubscribe removes an event subscription. +func (c *Connection) Unsubscribe(event string) { + c.mu.Lock() + defer c.mu.Unlock() + delete(c.subscriptions, event) +} + +// IsSubscribed checks if the connection is subscribed to an event. +func (c *Connection) IsSubscribed(event string) bool { + c.mu.RLock() + defer c.mu.RUnlock() + return c.subscriptions[event] +} + +// IsAuthenticated returns whether the connection is authenticated. +func (c *Connection) IsAuthenticated() bool { + c.mu.RLock() + defer c.mu.RUnlock() + return c.authenticated +} + +// UserID returns the authenticated user's ID. +func (c *Connection) UserID() int64 { + c.mu.RLock() + defer c.mu.RUnlock() + return c.userID +} + +// ReadLoop reads messages from the WebSocket and handles auth/subscribe/unsubscribe. +func (c *Connection) ReadLoop(ctx context.Context, cancel context.CancelFunc) { + defer func() { + cancel() + if c.IsAuthenticated() { + c.hub.Unregister(c) + } + c.ws.Close(websocket.StatusNormalClosure, "") + }() + + // Close the connection if auth doesn't happen within the timeout + authTimer := time.AfterFunc(authTimeout, func() { + if !c.IsAuthenticated() { + log.Debugf("WebSocket: closing unauthenticated connection after timeout") + c.ws.Close(websocket.StatusPolicyViolation, "auth timeout") + } + }) + defer authTimer.Stop() + + for { + _, data, err := c.ws.Read(ctx) + if err != nil { + if websocket.CloseStatus(err) == websocket.StatusNormalClosure { + log.Debugf("WebSocket: connection closed normally for user %d", c.UserID()) + } else { + log.Debugf("WebSocket: read error for user %d: %v", c.UserID(), err) + } + return + } + + var msg IncomingMessage + if err := json.Unmarshal(data, &msg); err != nil { + log.Warningf("WebSocket: invalid message: %v", err) + continue + } + + if !c.handleMessage(ctx, msg) { + return // close connection + } + } +} + +// handleMessage processes an incoming message. Returns false if connection should be closed. +func (c *Connection) handleMessage(ctx context.Context, msg IncomingMessage) bool { + switch msg.Action { + case ActionAuth: + return c.handleAuth(ctx, msg.Token) + case ActionSubscribe: + if !c.IsAuthenticated() { + c.sendError("auth_required", "") + return true + } + if !isValidEvent(msg.Event) { + c.sendError("invalid_event", msg.Event) + return true + } + c.Subscribe(msg.Event) + log.Debugf("WebSocket: user %d subscribed to %s", c.UserID(), msg.Event) + case ActionUnsubscribe: + if !c.IsAuthenticated() { + c.sendError("auth_required", "") + return true + } + c.Unsubscribe(msg.Event) + log.Debugf("WebSocket: user %d unsubscribed from %s", c.UserID(), msg.Event) + default: + log.Warningf("WebSocket: unknown action %q", msg.Action) + } + return true +} + +func (c *Connection) handleAuth(ctx context.Context, token string) bool { + if c.IsAuthenticated() { + c.sendError("already_authenticated", "") + return true + } + + userID, err := auth.GetUserIDFromToken(token) + if err != nil { + log.Debugf("WebSocket: auth failed: %v", err) + // Write the error directly to the websocket since ReadLoop will close the + // connection immediately after we return false, before WriteLoop can drain the channel. + c.writeMessageDirect(ctx, OutgoingMessage{Error: "invalid_token"}) + return false + } + + c.mu.Lock() + c.userID = userID + c.authenticated = true + c.mu.Unlock() + + c.hub.Register(c) + + // Send auth success + select { + case c.send <- OutgoingMessage{Action: ActionAuthSuccess, Success: true}: + default: + log.Warningf("WebSocket: send buffer full for user %d", userID) + } + + log.Debugf("WebSocket: user %d authenticated", userID) + return true +} + +// writeMessageDirect writes a message directly to the websocket, bypassing the send channel. +// Use this when the message must be sent before the connection is closed. +func (c *Connection) writeMessageDirect(ctx context.Context, msg OutgoingMessage) { + writeCtx, cancel := context.WithTimeout(ctx, writeTimeout) + defer cancel() + data, err := json.Marshal(msg) + if err != nil { + log.Errorf("WebSocket: marshal error: %v", err) + return + } + if err := c.ws.Write(writeCtx, websocket.MessageText, data); err != nil { + log.Debugf("WebSocket: direct write error: %v", err) + } +} + +func (c *Connection) sendError(errMsg, event string) { + select { + case c.send <- OutgoingMessage{Error: errMsg, Event: event}: + default: + log.Warningf("WebSocket: send buffer full, dropping error") + } +} + +// WriteLoop drains the send channel and writes messages to the WebSocket. +// It also sends periodic pings. +func (c *Connection) WriteLoop(ctx context.Context, cancel context.CancelFunc) { + defer cancel() + ticker := time.NewTicker(pingInterval) + defer ticker.Stop() + + for { + select { + case msg, ok := <-c.send: + if !ok { + return + } + writeCtx, cancel := context.WithTimeout(ctx, writeTimeout) + data, err := json.Marshal(msg) + if err != nil { + cancel() + log.Errorf("WebSocket: marshal error: %v", err) + continue + } + err = c.ws.Write(writeCtx, websocket.MessageText, data) + cancel() + if err != nil { + log.Debugf("WebSocket: write error for user %d: %v", c.UserID(), err) + return + } + case <-ticker.C: + pingCtx, cancel := context.WithTimeout(ctx, writeTimeout) + err := c.ws.Ping(pingCtx) + cancel() + if err != nil { + log.Debugf("WebSocket: ping error for user %d: %v", c.UserID(), err) + return + } + case <-ctx.Done(): + return + } + } +} + +// validEvents is the set of event names clients are allowed to subscribe to. +var validEvents = map[string]bool{ + "notification.created": true, +} + +func isValidEvent(event string) bool { + return validEvents[event] +} diff --git a/pkg/websocket/connection_test.go b/pkg/websocket/connection_test.go new file mode 100644 index 000000000..f5bccdac2 --- /dev/null +++ b/pkg/websocket/connection_test.go @@ -0,0 +1,98 @@ +// 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 License 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 License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package websocket + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestConnectionSubscribeUnsubscribe(t *testing.T) { + conn := &Connection{ + userID: 1, + authenticated: true, + subscriptions: make(map[string]bool), + send: make(chan OutgoingMessage, 16), + } + + conn.Subscribe("notification.created") + assert.True(t, conn.IsSubscribed("notification.created")) + + conn.Unsubscribe("notification.created") + assert.False(t, conn.IsSubscribed("notification.created")) +} + +func TestConnectionIsSubscribedReturnsFalseForUnknownEvent(t *testing.T) { + conn := &Connection{ + userID: 1, + authenticated: true, + subscriptions: make(map[string]bool), + send: make(chan OutgoingMessage, 16), + } + + assert.False(t, conn.IsSubscribed("something")) +} + +func TestConnectionAcceptsValidEvent(t *testing.T) { + hub := NewHub() + conn := &Connection{ + hub: hub, + userID: 1, + authenticated: true, + subscriptions: make(map[string]bool), + send: make(chan OutgoingMessage, 16), + } + hub.Register(conn) + + conn.handleMessage(context.Background(), IncomingMessage{Action: ActionSubscribe, Event: "notification.created"}) + + assert.True(t, conn.IsSubscribed("notification.created")) +} + +func TestConnectionRejectsInvalidEvent(t *testing.T) { + conn := &Connection{ + userID: 1, + authenticated: true, + subscriptions: make(map[string]bool), + send: make(chan OutgoingMessage, 16), + } + + conn.handleMessage(context.Background(), IncomingMessage{Action: ActionSubscribe, Event: "notifications"}) + + msg := <-conn.send + assert.Equal(t, "invalid_event", msg.Error) + assert.False(t, conn.IsSubscribed("notifications")) +} + +func TestConnectionRejectsActionsBeforeAuth(t *testing.T) { + conn := &Connection{ + userID: 0, // not authenticated + authenticated: false, + subscriptions: make(map[string]bool), + send: make(chan OutgoingMessage, 16), + } + + // Try to subscribe before auth - should be rejected + conn.handleMessage(context.Background(), IncomingMessage{Action: ActionSubscribe, Event: "notification.created"}) + + // Should have sent an error + msg := <-conn.send + assert.Equal(t, "auth_required", msg.Error) + assert.False(t, conn.IsSubscribed("notification.created")) +} diff --git a/pkg/websocket/hub.go b/pkg/websocket/hub.go new file mode 100644 index 000000000..f11bbfde7 --- /dev/null +++ b/pkg/websocket/hub.go @@ -0,0 +1,85 @@ +// 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 License 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 License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package websocket + +import ( + "sync" + + "code.vikunja.io/api/pkg/log" +) + +// Hub maintains the set of active connections and delivers messages to them. +type Hub struct { + mu sync.RWMutex + connections map[int64][]*Connection // userID -> connections +} + +// NewHub creates a new Hub. +func NewHub() *Hub { + return &Hub{ + connections: make(map[int64][]*Connection), + } +} + +// Register adds a connection to the hub. +func (h *Hub) Register(conn *Connection) { + h.mu.Lock() + defer h.mu.Unlock() + h.connections[conn.userID] = append(h.connections[conn.userID], conn) + log.Debugf("WebSocket: registered connection for user %d (total: %d)", conn.userID, len(h.connections[conn.userID])) +} + +// Unregister removes a connection from the hub. +func (h *Hub) Unregister(conn *Connection) { + h.mu.Lock() + defer h.mu.Unlock() + conns := h.connections[conn.userID] + for i, c := range conns { + if c == conn { + h.connections[conn.userID] = append(conns[:i], conns[i+1:]...) + break + } + } + remaining := len(h.connections[conn.userID]) + if remaining == 0 { + delete(h.connections, conn.userID) + } + log.Debugf("WebSocket: unregistered connection for user %d (remaining: %d)", conn.userID, remaining) +} + +// PublishForUser sends an event to all connections of a specific user that are subscribed to the given event. +func (h *Hub) PublishForUser(userID int64, event string, data any) { + h.mu.RLock() + defer h.mu.RUnlock() + + conns := h.connections[userID] + msg := OutgoingMessage{ + Event: event, + Data: data, + } + + for _, conn := range conns { + if !conn.IsSubscribed(event) { + continue + } + select { + case conn.send <- msg: + default: + log.Warningf("WebSocket: send buffer full for user %d, dropping message", userID) + } + } +} diff --git a/pkg/websocket/hub_test.go b/pkg/websocket/hub_test.go new file mode 100644 index 000000000..db4b09f8f --- /dev/null +++ b/pkg/websocket/hub_test.go @@ -0,0 +1,85 @@ +// 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 License 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 License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package websocket + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestHubRegisterUnregister(t *testing.T) { + h := NewHub() + conn := &Connection{ + userID: 1, + subscriptions: make(map[string]bool), + send: make(chan OutgoingMessage, 16), + } + h.Register(conn) + assert.Len(t, h.connections[1], 1) + + h.Unregister(conn) + assert.Empty(t, h.connections[1]) + _, exists := h.connections[1] + assert.False(t, exists, "map entry should be deleted when last connection is removed") +} + +func TestHubPublishToSubscribedConnection(t *testing.T) { + h := NewHub() + conn := &Connection{ + userID: 1, + subscriptions: make(map[string]bool), + send: make(chan OutgoingMessage, 16), + } + h.Register(conn) + conn.subscriptions["notification.created"] = true + + h.PublishForUser(1, "notification.created", map[string]string{"id": "1"}) + + msg := <-conn.send + assert.Equal(t, "notification.created", msg.Event) +} + +func TestHubPublishSkipsUnsubscribedConnection(t *testing.T) { + h := NewHub() + conn := &Connection{ + userID: 1, + subscriptions: make(map[string]bool), + send: make(chan OutgoingMessage, 16), + } + h.Register(conn) + // Not subscribed to "notification.created" + + h.PublishForUser(1, "notification.created", map[string]string{"id": "1"}) + + assert.Empty(t, conn.send) +} + +func TestHubPublishSkipsOtherUsers(t *testing.T) { + h := NewHub() + conn := &Connection{ + userID: 2, + subscriptions: make(map[string]bool), + send: make(chan OutgoingMessage, 16), + } + h.Register(conn) + conn.subscriptions["notification.created"] = true + + h.PublishForUser(1, "notification.created", map[string]string{"id": "1"}) + + assert.Empty(t, conn.send) +} diff --git a/pkg/websocket/main_test.go b/pkg/websocket/main_test.go new file mode 100644 index 000000000..7740dc0e0 --- /dev/null +++ b/pkg/websocket/main_test.go @@ -0,0 +1,29 @@ +// 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 License 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 License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package websocket + +import ( + "os" + "testing" + + "code.vikunja.io/api/pkg/log" +) + +func TestMain(m *testing.M) { + log.InitLogger() + os.Exit(m.Run()) +} diff --git a/pkg/websocket/messages.go b/pkg/websocket/messages.go new file mode 100644 index 000000000..9ae0d12c8 --- /dev/null +++ b/pkg/websocket/messages.go @@ -0,0 +1,56 @@ +// 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 License 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 License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package websocket + +const ( + // Client actions + ActionAuth = "auth" + ActionSubscribe = "subscribe" + ActionUnsubscribe = "unsubscribe" + + // Server actions + ActionAuthSuccess = "auth.success" + ActionUnsubscribed = "unsubscribed" +) + +// IncomingMessage represents a message from the client. +type IncomingMessage struct { + Action string `json:"action"` + // Token is set for auth action. + Token string `json:"token,omitempty"` + // Event is set for subscribe/unsubscribe actions. + Event string `json:"event,omitempty"` +} + +// OutgoingMessage represents a message from the server to the client. +// Exactly one of Event, Error, or Action will be set. +type OutgoingMessage struct { + // Event identifies the event type. On push messages (e.g. "notification.created"), + // it carries the event name. On error responses for subscribe/unsubscribe, + // it identifies which event caused the error. + Event string `json:"event,omitempty"` + // Error is set for error responses (e.g. "forbidden"). + Error string `json:"error,omitempty"` + // Action is set for server-initiated actions (e.g. "auth.success", "unsubscribed"). + Action string `json:"action,omitempty"` + // Success is set for auth.success action. + Success bool `json:"success,omitempty"` + // Reason provides context for server-initiated actions. + Reason string `json:"reason,omitempty"` + // Data carries the event payload. + Data any `json:"data,omitempty"` +} diff --git a/pkg/websocket/messages_test.go b/pkg/websocket/messages_test.go new file mode 100644 index 000000000..991b89460 --- /dev/null +++ b/pkg/websocket/messages_test.go @@ -0,0 +1,77 @@ +// 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 License 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 License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package websocket + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestIncomingAuthMessageDeserialization(t *testing.T) { + raw := `{"action":"auth","token":"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}` + var msg IncomingMessage + err := json.Unmarshal([]byte(raw), &msg) + require.NoError(t, err) + assert.Equal(t, ActionAuth, msg.Action) + assert.Equal(t, "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", msg.Token) +} + +func TestIncomingSubscribeMessageDeserialization(t *testing.T) { + raw := `{"action":"subscribe","event":"notification.created"}` + var msg IncomingMessage + err := json.Unmarshal([]byte(raw), &msg) + require.NoError(t, err) + assert.Equal(t, ActionSubscribe, msg.Action) + assert.Equal(t, "notification.created", msg.Event) +} + +func TestOutgoingEventSerialization(t *testing.T) { + msg := OutgoingMessage{ + Event: "notification.created", + Data: map[string]string{"hello": "world"}, + } + data, err := json.Marshal(msg) + require.NoError(t, err) + assert.Contains(t, string(data), `"event":"notification.created"`) + assert.NotContains(t, string(data), `"topic"`) + assert.Contains(t, string(data), `"hello":"world"`) +} + +func TestOutgoingErrorSerialization(t *testing.T) { + msg := OutgoingMessage{ + Error: "forbidden", + Event: "project.tasks", + } + data, err := json.Marshal(msg) + require.NoError(t, err) + assert.Contains(t, string(data), `"error":"forbidden"`) + assert.Contains(t, string(data), `"event":"project.tasks"`) +} + +func TestOutgoingAuthSuccessSerialization(t *testing.T) { + msg := OutgoingMessage{ + Action: ActionAuthSuccess, + Success: true, + } + data, err := json.Marshal(msg) + require.NoError(t, err) + assert.Contains(t, string(data), `"action":"auth.success"`) + assert.Contains(t, string(data), `"success":true`) +} From 0139e9a2ab2e530337e1db12d13ec3f60b5eaa49 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 2 Apr 2026 18:18:13 +0200 Subject: [PATCH 017/101] feat(websocket): add HTTP upgrade handler and /api/v1/ws route Add the WebSocket upgrade endpoint at /api/v1/ws with CORS origin verification using the configured allowed origins. Includes nil hub guard returning 503 if the WebSocket system hasn't been initialized. Register the hub initialization in the app startup sequence and wire the upgrade handler into the Echo router. --- pkg/initialize/init.go | 5 +++ pkg/routes/routes.go | 4 +++ pkg/websocket/handler.go | 66 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 75 insertions(+) create mode 100644 pkg/websocket/handler.go diff --git a/pkg/initialize/init.go b/pkg/initialize/init.go index 7145b06d8..4296cfd12 100644 --- a/pkg/initialize/init.go +++ b/pkg/initialize/init.go @@ -37,6 +37,7 @@ import ( _ "code.vikunja.io/api/pkg/plugins/yaegi" // register yaegi plugin loader "code.vikunja.io/api/pkg/red" "code.vikunja.io/api/pkg/user" + ws "code.vikunja.io/api/pkg/websocket" ) // LightInit will only init config, redis, logger but no db connection. @@ -133,11 +134,15 @@ func FullInit() { openid.RegisterEmptyOpenIDTeamCleanupCron() models.RegisterAPITokenExpiryCheckCron() + // Initialize WebSocket hub + ws.InitHub() + // Start processing events go func() { models.RegisterListeners() user.RegisterListeners() migrationHandler.RegisterListeners() + ws.RegisterListeners() err := events.InitEvents() if err != nil { log.Fatal(err.Error()) diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index e6c956209..4d0d532e9 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -79,6 +79,7 @@ import ( "code.vikunja.io/api/pkg/routes/caldav" "code.vikunja.io/api/pkg/version" "code.vikunja.io/api/pkg/web/handler" + ws "code.vikunja.io/api/pkg/websocket" "github.com/getsentry/sentry-go" "github.com/labstack/echo/v5" @@ -352,6 +353,9 @@ func registerAPIRoutes(a *echo.Group) { n.GET("/docs", apiv1.RedocUI) n.GET("/docs/redoc.standalone.js", apiv1.RedocJS) + // WebSocket (auth happens after upgrade via first message) + n.GET("/ws", ws.UpgradeHandler) + // Prometheus endpoint setupMetrics(n) diff --git a/pkg/websocket/handler.go b/pkg/websocket/handler.go new file mode 100644 index 000000000..8a2cdc49f --- /dev/null +++ b/pkg/websocket/handler.go @@ -0,0 +1,66 @@ +// 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 License 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 License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package websocket + +import ( + "context" + "net/http" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/log" + + "github.com/coder/websocket" + "github.com/labstack/echo/v5" +) + +var globalHub *Hub + +// InitHub creates the global hub. Must be called once at startup. +func InitHub() { + globalHub = NewHub() +} + +// GetHub returns the global hub. +func GetHub() *Hub { + return globalHub +} + +// UpgradeHandler is the Echo handler for WebSocket upgrades at /api/v1/ws. +// The upgrade happens without authentication - auth is done via the first message. +func UpgradeHandler(c *echo.Context) error { + if globalHub == nil { + log.Errorf("WebSocket: hub not initialized") + return echo.NewHTTPError(http.StatusServiceUnavailable, "WebSocket hub not initialized") + } + + ws, err := websocket.Accept(c.Response(), c.Request(), &websocket.AcceptOptions{ + OriginPatterns: config.CorsOrigins.GetStringSlice(), + }) + if err != nil { + log.Errorf("WebSocket: upgrade failed: %v", err) + return nil // Accept already wrote the error response + } + + conn := NewConnection(ws, globalHub) + + ctx, cancel := context.WithCancel(context.Background()) + + go conn.WriteLoop(ctx, cancel) + go conn.ReadLoop(ctx, cancel) + + return nil +} From 55ea5bd9662a5975072e31bc0bec28a4607d8769 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 2 Apr 2026 18:18:20 +0200 Subject: [PATCH 018/101] refactor(auth): extract shared token validation into auth package Move JWT parsing (GetUserIDFromToken) and API token validation (ValidateAPITokenString) into pkg/modules/auth so both HTTP middleware and WebSocket auth use the same logic. This ensures consistent token validity checks including expiry and user status (disabled/locked). The HTTP API token middleware now delegates to the shared function, removing duplicated lookup/expiry logic. --- pkg/modules/auth/auth.go | 56 ++++++++++++++++++++++++++++++++++++++++ pkg/routes/api_tokens.go | 11 +++----- 2 files changed, 59 insertions(+), 8 deletions(-) diff --git a/pkg/modules/auth/auth.go b/pkg/modules/auth/auth.go index 2b6662031..402de54e9 100644 --- a/pkg/modules/auth/auth.go +++ b/pkg/modules/auth/auth.go @@ -192,6 +192,62 @@ func GetAuthFromClaims(c *echo.Context) (a web.Auth, err error) { return nil, echo.NewHTTPError(http.StatusBadRequest, "Invalid JWT token.") } +// ValidateAPITokenString looks up an API token by its raw string, checks expiry, +// and returns the token and its owner. This is the shared validation logic used +// by both the HTTP middleware and WebSocket auth. +func ValidateAPITokenString(tokenString string) (*models.APIToken, *user.User, error) { + s := db.NewSession() + defer s.Close() + + token, err := models.GetTokenFromTokenString(s, tokenString) + if err != nil { + return nil, nil, err + } + + if time.Now().After(token.ExpiresAt) { + return nil, nil, fmt.Errorf("API token %d expired on %s", token.ID, token.ExpiresAt.String()) + } + + u, err := user.GetUserByID(s, token.OwnerID) + if err != nil { + if user.IsErrUserStatusError(err) { + return nil, nil, fmt.Errorf("API token %d owner account is disabled or locked", token.ID) + } + return nil, nil, err + } + + return token, u, nil +} + +// GetUserIDFromToken parses a raw JWT token string and returns the user ID. +// Only regular user tokens are accepted (not link shares). +// Returns 0 and an error if the token is invalid. +func GetUserIDFromToken(tokenString string) (int64, error) { + token, err := jwt.Parse(tokenString, func(_ *jwt.Token) (any, error) { + return []byte(config.ServiceSecret.GetString()), nil + }) + if err != nil { + return 0, err + } + + claims, ok := token.Claims.(jwt.MapClaims) + if !ok || !token.Valid { + return 0, jwt.ErrTokenInvalidClaims + } + + typ, ok := claims["type"].(float64) + if !ok || int(typ) != AuthTypeUser { + return 0, jwt.ErrTokenInvalidClaims + } + + userIDFloat, ok := claims["id"].(float64) + if !ok { + return 0, jwt.ErrTokenInvalidClaims + } + + return int64(userIDFloat), nil +} + func CreateUserWithRandomUsername(s *xorm.Session, uu *user.User) (u *user.User, err error) { // Check if we actually have a preferred username and generate a random one right away if we don't for { diff --git a/pkg/routes/api_tokens.go b/pkg/routes/api_tokens.go index cc6c2f546..40f48fec7 100644 --- a/pkg/routes/api_tokens.go +++ b/pkg/routes/api_tokens.go @@ -21,9 +21,9 @@ import ( "strings" "code.vikunja.io/api/pkg/config" - "code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/modules/auth" "code.vikunja.io/api/pkg/web" echojwt "github.com/labstack/echo-jwt/v5" @@ -72,14 +72,9 @@ func SetupTokenMiddleware() echo.MiddlewareFunc { } func checkAPITokenAndPutItInContext(tokenHeaderValue string, c *echo.Context) error { - s := db.NewSession() - defer s.Close() - - token, u, err := models.ValidateTokenAndGetOwner(s, strings.TrimPrefix(tokenHeaderValue, "Bearer ")) + token, u, err := auth.ValidateAPITokenString(strings.TrimPrefix(tokenHeaderValue, "Bearer ")) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "Internal Server Error").Wrap(err) - } - if token == nil || u == nil { + log.Debugf("[auth] API token validation failed: %v", err) return echo.NewHTTPError(http.StatusUnauthorized, "Unauthorized") } From f5385c574e5dc26f4812bdf8e0813ca2718f3503 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 2 Apr 2026 18:18:27 +0200 Subject: [PATCH 019/101] feat(websocket): add notification event with XORM AfterInsert dispatch Add NotificationCreatedEvent that fires automatically when a DatabaseNotification is inserted, using XORM's AfterInsertProcessor interface. The AfterInsert hook dispatches the event after the row is persisted, without callers needing to manage DispatchOnCommit or DispatchPending. The WebSocket listener subscribes to this event, reloads the notification from the database (ensuring accurate timestamps), and pushes it to connected clients subscribed to the notification.created event. Dispatch errors are logged rather than propagated since the DB notification is already committed at that point. --- pkg/notifications/database.go | 28 ++++++++++++ pkg/notifications/events.go | 33 ++++++++++++++ pkg/notifications/main_test.go | 2 + pkg/notifications/notification.go | 2 +- pkg/websocket/listener.go | 76 +++++++++++++++++++++++++++++++ 5 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 pkg/notifications/events.go create mode 100644 pkg/websocket/listener.go diff --git a/pkg/notifications/database.go b/pkg/notifications/database.go index 83fb86874..b007d8a54 100644 --- a/pkg/notifications/database.go +++ b/pkg/notifications/database.go @@ -19,6 +19,9 @@ package notifications import ( "time" + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/log" + "xorm.io/xorm" ) @@ -43,6 +46,18 @@ type DatabaseNotification struct { Created time.Time `xorm:"created not null" json:"created"` } +// AfterInsert is called by XORM after the row is inserted. For transactional +// sessions this runs during Commit(), guaranteeing the row is persisted before +// the event fires. +func (d *DatabaseNotification) AfterInsert() { + if err := events.Dispatch(&NotificationCreatedEvent{ + NotificationID: d.ID, + UserID: d.NotifiableID, + }); err != nil { + log.Errorf("Failed to dispatch notification created event for notification %d: %v", d.ID, err) + } +} + // TableName resolves to a better table name for notifications func (d *DatabaseNotification) TableName() string { return "notifications" @@ -67,6 +82,19 @@ func GetNotificationsForUser(s *xorm.Session, notifiableID int64, limit, start i return notifications, len(notifications), total, err } +// GetNotificationByID returns a single notification by its ID. +func GetNotificationByID(s *xorm.Session, id int64) (*DatabaseNotification, error) { + n := &DatabaseNotification{} + has, err := s.ID(id).Get(n) + if err != nil { + return nil, err + } + if !has { + return nil, nil + } + return n, nil +} + func GetNotificationsForNameAndUser(s *xorm.Session, notifiableID int64, event string, subjectID int64) (notifications []*DatabaseNotification, err error) { notifications = []*DatabaseNotification{} err = s.Where("notifiable_id = ? AND name = ? AND subject_id = ?", notifiableID, event, subjectID). diff --git a/pkg/notifications/events.go b/pkg/notifications/events.go new file mode 100644 index 000000000..a65fd85ca --- /dev/null +++ b/pkg/notifications/events.go @@ -0,0 +1,33 @@ +// 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 License 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 License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package notifications + +import "code.vikunja.io/api/pkg/events" + +// NotificationCreatedEvent is dispatched after a notification is committed to the database. +// The listener reloads the full record from the DB to get accurate timestamps. +type NotificationCreatedEvent struct { + NotificationID int64 `json:"notification_id"` + UserID int64 `json:"user_id"` +} + +// Name returns the event name. +func (n *NotificationCreatedEvent) Name() string { + return "notification.created" +} + +var _ events.Event = (*NotificationCreatedEvent)(nil) diff --git a/pkg/notifications/main_test.go b/pkg/notifications/main_test.go index 89d1cd2fd..14949adc4 100644 --- a/pkg/notifications/main_test.go +++ b/pkg/notifications/main_test.go @@ -22,6 +22,7 @@ import ( "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/events" "code.vikunja.io/api/pkg/i18n" "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/mail" @@ -54,5 +55,6 @@ func TestMain(m *testing.M) { SetupTests() mail.Fake() + events.Fake() os.Exit(m.Run()) } diff --git a/pkg/notifications/notification.go b/pkg/notifications/notification.go index 7f0e1faef..d50735793 100644 --- a/pkg/notifications/notification.go +++ b/pkg/notifications/notification.go @@ -118,7 +118,7 @@ func notifyDB(notifiable Notifiable, notification Notification, existingSession dbNotification := &DatabaseNotification{ NotifiableID: notifiable.RouteForDB(), - Notification: content, + Notification: json.RawMessage(content), Name: notification.Name(), } diff --git a/pkg/websocket/listener.go b/pkg/websocket/listener.go new file mode 100644 index 000000000..3250d1c45 --- /dev/null +++ b/pkg/websocket/listener.go @@ -0,0 +1,76 @@ +// 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 License 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 License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package websocket + +import ( + "encoding/json" + + "code.vikunja.io/api/pkg/db" + "code.vikunja.io/api/pkg/events" + "code.vikunja.io/api/pkg/log" + "code.vikunja.io/api/pkg/notifications" + + "github.com/ThreeDotsLabs/watermill/message" +) + +// NotificationListener pushes new notifications to WebSocket clients. +type NotificationListener struct{} + +// Name returns the listener name. +func (n *NotificationListener) Name() string { + return "websocket.notification.push" +} + +// Handle processes a notification created event, reloads the notification +// from the database (to get accurate timestamps), and pushes it to the +// relevant WebSocket connections. +func (n *NotificationListener) Handle(msg *message.Message) error { + var event notifications.NotificationCreatedEvent + if err := json.Unmarshal(msg.Payload, &event); err != nil { + return err + } + + hub := GetHub() + if hub == nil { + log.Warningf("WebSocket: hub not initialized, skipping notification push") + return nil + } + + s := db.NewSession() + defer s.Close() + + dbNotification, err := notifications.GetNotificationByID(s, event.NotificationID) + if err != nil { + log.Errorf("WebSocket: failed to load notification %d: %v", event.NotificationID, err) + return nil + } + if dbNotification == nil { + log.Warningf("WebSocket: notification %d not found, skipping push", event.NotificationID) + return nil + } + + hub.PublishForUser(event.UserID, "notification.created", dbNotification) + return nil +} + +// RegisterListeners registers WebSocket event listeners. +func RegisterListeners() { + events.RegisterListener( + (¬ifications.NotificationCreatedEvent{}).Name(), + &NotificationListener{}, + ) +} From 09232ed880dda862ccbe4749db8a108652fc6fc5 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 2 Apr 2026 18:19:00 +0200 Subject: [PATCH 020/101] feat(websocket): add frontend WebSocket support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add useWebSocket composable with: - Auto-connect on login, disconnect on logout - Exponential backoff with ±25% jitter for reconnects - Auth failure detection to prevent reconnect loops - Trailing slash stripping from API_URL - Overlapping reconnect prevention - visibilityState check for fallback polling Replace notification polling with real-time WebSocket push in the Notifications component. Initial state is still loaded via REST on mount, with fallback polling when WebSocket is disconnected. Incoming notifications are deduplicated against already-loaded REST data. Notifications are reloaded via REST on WS disconnect to catch missed events. --- frontend/src/components/home/ContentAuth.vue | 4 + .../notifications/Notifications.vue | 70 ++++-- frontend/src/composables/useWebSocket.ts | 208 ++++++++++++++++++ frontend/src/stores/auth.ts | 4 + 4 files changed, 273 insertions(+), 13 deletions(-) create mode 100644 frontend/src/composables/useWebSocket.ts diff --git a/frontend/src/components/home/ContentAuth.vue b/frontend/src/components/home/ContentAuth.vue index cceb8a48d..e61cef332 100644 --- a/frontend/src/components/home/ContentAuth.vue +++ b/frontend/src/components/home/ContentAuth.vue @@ -86,6 +86,7 @@ import {useProjectStore} from '@/stores/projects' import {useRouteWithModal} from '@/composables/useRouteWithModal' import {useRenewTokenOnFocus} from '@/composables/useRenewTokenOnFocus' import {useSidebarResize} from '@/composables/useSidebarResize' +import {useWebSocket} from '@/composables/useWebSocket' import {useAuthStore} from '@/stores/auth' const authStore = useAuthStore() @@ -136,6 +137,9 @@ watch(() => route.name as string, (routeName) => { useRenewTokenOnFocus() +const {connect} = useWebSocket() +connect() + const labelStore = useLabelStore() labelStore.loadAllLabels() diff --git a/frontend/src/components/notifications/Notifications.vue b/frontend/src/components/notifications/Notifications.vue index ca4e5264e..c8e702142 100644 --- a/frontend/src/components/notifications/Notifications.vue +++ b/frontend/src/components/notifications/Notifications.vue @@ -83,10 +83,11 @@ - + + diff --git a/frontend/src/components/project/views/ProjectList.vue b/frontend/src/components/project/views/ProjectList.vue index ae889955d..114549081 100644 --- a/frontend/src/components/project/views/ProjectList.vue +++ b/frontend/src/components/project/views/ProjectList.vue @@ -7,12 +7,15 @@ > @@ -49,13 +52,13 @@ v-if="tasks && tasks.length > 0" v-model="tasks" :group="{name: 'tasks', put: false}" - :disabled="!canDragTasks" + :disabled="!canDragTasks || !isPositionSorting" item-key="id" tag="ul" :component-data="{ class: { tasks: true, - 'dragging-disabled': !canDragTasks || isAlphabeticalSorting + 'dragging-disabled': !canDragTasks || !isPositionSorting }, type: 'transition-group' }" @@ -71,14 +74,13 @@ @@ -109,7 +111,7 @@ import SingleTaskInProject from '@/components/tasks/partials/SingleTaskInProject import FilterPopup from '@/components/project/partials/FilterPopup.vue' import Nothing from '@/components/misc/Nothing.vue' import Pagination from '@/components/misc/Pagination.vue' -import {ALPHABETICAL_SORT} from '@/components/project/partials/Filters.vue' +import SortPopup from '@/components/project/partials/SortPopup.vue' import {useTaskList} from '@/composables/useTaskList' import {useTaskDragToProject} from '@/composables/useTaskDragToProject' @@ -172,9 +174,7 @@ watch( }, ) -const isAlphabeticalSorting = computed(() => { - return params.value.sort_by.find(sortBy => sortBy === ALPHABETICAL_SORT) !== undefined -}) +const isPositionSorting = computed(() => 'position' in sortByParam.value) const firstNewPosition = computed(() => { if (tasks.value.length === 0) { @@ -215,7 +215,7 @@ function focusNewTaskInput() { } function updateTaskList(task: ITask) { - if (isAlphabeticalSorting.value) { + if (!isPositionSorting.value) { // reload tasks with current filter and sorting loadTasks() } else { @@ -287,15 +287,6 @@ async function saveTaskPosition(e: { originalEvent?: MouseEvent, to: HTMLElement } } -function prepareFiltersAndLoadTasks() { - if (isAlphabeticalSorting.value) { - sortByParam.value = {} - sortByParam.value[ALPHABETICAL_SORT] = 'asc' - } - - loadTasks() -} - const taskRefs = ref<(InstanceType | null)[]>([]) const focusedIndex = ref(-1) @@ -365,6 +356,12 @@ onBeforeUnmount(() => { diff --git a/frontend/src/views/migrate/icons/csv.svg b/frontend/src/views/migrate/icons/csv.svg new file mode 100644 index 000000000..944ffe17d --- /dev/null +++ b/frontend/src/views/migrate/icons/csv.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/frontend/src/views/migrate/migrators.ts b/frontend/src/views/migrate/migrators.ts index 98f924fc1..82c43f161 100644 --- a/frontend/src/views/migrate/migrators.ts +++ b/frontend/src/views/migrate/migrators.ts @@ -5,11 +5,13 @@ import microsoftTodoIcon from './icons/microsoft-todo.svg?url' import vikunjaFileIcon from './icons/vikunja-file.png?url' import tickTickIcon from './icons/ticktick.svg?url' import wekanIcon from './icons/wekan.png?url' +import csvIcon from './icons/csv.svg?url' export interface Migrator { id: string name: string isFileMigrator?: boolean + isCSVMigrator?: boolean icon: string } @@ -56,4 +58,11 @@ export const MIGRATORS = { icon: wekanIcon, isFileMigrator: true, }, + csv: { + id: 'csv', + name: 'CSV', + icon: csvIcon as string, + isFileMigrator: true, + isCSVMigrator: true, + }, } as const satisfies IMigratorRecord diff --git a/pkg/modules/migration/csv/csv.go b/pkg/modules/migration/csv/csv.go new file mode 100644 index 000000000..f20221a01 --- /dev/null +++ b/pkg/modules/migration/csv/csv.go @@ -0,0 +1,704 @@ +// 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 License 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 License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package csv + +import ( + "bytes" + "encoding/csv" + "errors" + "io" + "sort" + "strconv" + "strings" + "time" + + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/modules/migration" + "code.vikunja.io/api/pkg/user" +) + +// Migrator is the CSV migrator +type Migrator struct{} + +// Name returns the name of this migrator +func (m *Migrator) Name() string { + return "csv" +} + +// SupportedDelimiters contains all supported CSV delimiters +var SupportedDelimiters = []string{",", ";", "\t", "|"} + +// SupportedQuoteChars contains all supported quote characters +var SupportedQuoteChars = []string{"\"", "'"} + +// SupportedDateFormats contains common date formats for parsing +var SupportedDateFormats = []string{ + "2006-01-02", // ISO date + "2006-01-02T15:04:05", // ISO datetime + "2006-01-02T15:04:05Z07:00", // RFC3339 + "2006-01-02T15:04:05-0700", // ISO with timezone + "02/01/2006", // DD/MM/YYYY + "01/02/2006", // MM/DD/YYYY + "02-01-2006", // DD-MM-YYYY + "01-02-2006", // MM-DD-YYYY + "Jan 2, 2006", // Month D, YYYY + "2 Jan 2006", // D Month YYYY + "02/01/2006 15:04", // DD/MM/YYYY HH:MM + "01/02/2006 15:04", // MM/DD/YYYY HH:MM + "2006-01-02 15:04:05", // MySQL datetime + "2006/01/02", // YYYY/MM/DD + "02.01.2006", // DD.MM.YYYY (European) + "02.01.2006 15:04", // DD.MM.YYYY HH:MM (European) + time.RFC1123, // RFC1123 + time.RFC1123Z, // RFC1123 with numeric zone + time.RFC822, // RFC822 + time.RFC822Z, // RFC822 with numeric zone + time.RFC850, // RFC850 + time.ANSIC, // ANSIC + time.UnixDate, // Unix date +} + +// TaskAttribute represents a task attribute that can be mapped from CSV +type TaskAttribute string + +const ( + AttrTitle TaskAttribute = "title" + AttrDescription TaskAttribute = "description" + AttrDueDate TaskAttribute = "due_date" + AttrStartDate TaskAttribute = "start_date" + AttrEndDate TaskAttribute = "end_date" + AttrDone TaskAttribute = "done" + AttrPriority TaskAttribute = "priority" + AttrLabels TaskAttribute = "labels" + AttrProject TaskAttribute = "project" + AttrReminder TaskAttribute = "reminder" + AttrIgnore TaskAttribute = "ignore" +) + +// AllTaskAttributes returns all available task attributes for mapping +var AllTaskAttributes = []TaskAttribute{ + AttrTitle, + AttrDescription, + AttrDueDate, + AttrStartDate, + AttrEndDate, + AttrDone, + AttrPriority, + AttrLabels, + AttrProject, + AttrReminder, + AttrIgnore, +} + +// ColumnMapping represents a mapping from a CSV column to a task attribute +type ColumnMapping struct { + ColumnIndex int `json:"column_index"` + ColumnName string `json:"column_name"` + Attribute TaskAttribute `json:"attribute"` +} + +// DetectionResult contains the auto-detected CSV structure +type DetectionResult struct { + Columns []string `json:"columns"` + Delimiter string `json:"delimiter"` + QuoteChar string `json:"quote_char"` + DateFormat string `json:"date_format"` + SuggestedMapping []ColumnMapping `json:"suggested_mapping"` + PreviewRows [][]string `json:"preview_rows"` +} + +// ImportConfig contains the configuration for CSV import +type ImportConfig struct { + Delimiter string `json:"delimiter"` + QuoteChar string `json:"quote_char"` + DateFormat string `json:"date_format"` + Mapping []ColumnMapping `json:"mapping"` +} + +// PreviewTask represents a task preview before import +type PreviewTask struct { + Title string `json:"title"` + Description string `json:"description"` + DueDate string `json:"due_date,omitempty"` + StartDate string `json:"start_date,omitempty"` + EndDate string `json:"end_date,omitempty"` + Done bool `json:"done"` + Priority int `json:"priority"` + Labels []string `json:"labels,omitempty"` + Project string `json:"project,omitempty"` +} + +// PreviewResult contains preview data before import +type PreviewResult struct { + Tasks []PreviewTask `json:"tasks"` + TotalRows int `json:"total_rows"` +} + +// stripBOM removes the UTF-8 BOM from the beginning of a reader +func stripBOM(data []byte) []byte { + if len(data) >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF { + return data[3:] + } + return data +} + +// detectDelimiter attempts to auto-detect the CSV delimiter +func detectDelimiter(data []byte) string { + content := string(data) + + // Count occurrences of each delimiter in the first few lines + lines := strings.SplitN(content, "\n", 5) + if len(lines) < 2 { + return "," // Default to comma + } + + delimiterCounts := make(map[string]int) + for _, delim := range SupportedDelimiters { + count := 0 + for _, line := range lines[:minInt(3, len(lines))] { + count += strings.Count(line, delim) + } + delimiterCounts[delim] = count + } + + // Find the delimiter with the most consistent count across lines + bestDelimiter := "," + maxCount := 0 + for delim, count := range delimiterCounts { + if count > maxCount { + maxCount = count + bestDelimiter = delim + } + } + + return bestDelimiter +} + +// detectQuoteChar attempts to auto-detect the quote character +func detectQuoteChar(data []byte) string { + content := string(data) + + doubleQuotes := strings.Count(content, "\"") + singleQuotes := strings.Count(content, "'") + + if singleQuotes > doubleQuotes { + return "'" + } + return "\"" +} + +// detectDateFormat attempts to detect the date format from sample data +func detectDateFormat(sampleDates []string) string { + if len(sampleDates) == 0 { + return SupportedDateFormats[0] // Default to ISO + } + + for _, format := range SupportedDateFormats { + matches := 0 + for _, dateStr := range sampleDates { + dateStr = strings.TrimSpace(dateStr) + if dateStr == "" { + continue + } + _, err := time.Parse(format, dateStr) + if err == nil { + matches++ + } + } + // If most dates match this format, use it + if matches > 0 && matches >= len(sampleDates)/2 { + return format + } + } + + return SupportedDateFormats[0] +} + +// suggestMapping suggests column mappings based on column names +func suggestMapping(columns []string) []ColumnMapping { + mappings := make([]ColumnMapping, len(columns)) + + // Common column name patterns for each attribute + patterns := map[TaskAttribute][]string{ + AttrTitle: {"title", "name", "task", "subject", "summary"}, + AttrDescription: {"description", "content", "notes", "details", "body", "text"}, + AttrDueDate: {"due", "due_date", "duedate", "deadline", "due date"}, + AttrStartDate: {"start", "start_date", "startdate", "begin", "start date"}, + AttrEndDate: {"end", "end_date", "enddate", "finish", "end date"}, + AttrDone: {"done", "completed", "complete", "finished", "status", "is_done"}, + AttrPriority: {"priority", "importance", "urgent", "prio"}, + AttrLabels: {"labels", "tags", "categories", "category", "label", "tag"}, + AttrProject: {"project", "list", "folder", "group", "project_name", "list_name"}, + AttrReminder: {"reminder", "remind", "alert", "notification"}, + } + + usedAttributes := make(map[TaskAttribute]bool) + + for i, col := range columns { + colLower := strings.ToLower(strings.TrimSpace(col)) + mappings[i] = ColumnMapping{ + ColumnIndex: i, + ColumnName: col, + Attribute: AttrIgnore, + } + + for attr, keywords := range patterns { + if usedAttributes[attr] && attr != AttrLabels { + continue // Don't map the same attribute twice (except labels) + } + for _, keyword := range keywords { + if strings.Contains(colLower, keyword) || colLower == keyword { + mappings[i].Attribute = attr + usedAttributes[attr] = true + break + } + } + if mappings[i].Attribute != AttrIgnore { + break + } + } + } + + return mappings +} + +// parseCSV parses CSV data with the given configuration +func parseCSV(data []byte, delimiter string) ([]string, [][]string, error) { + data = stripBOM(data) + + // Go's csv.Reader only supports double-quote as the quote character. + // LazyQuotes mode handles most edge cases including unescaped quotes + // in fields. We intentionally do not replace non-standard quote chars + // (e.g. single quotes) as that would corrupt apostrophes in text content. + reader := csv.NewReader(bytes.NewReader(data)) + + if len(delimiter) > 0 { + reader.Comma = rune(delimiter[0]) + } + reader.FieldsPerRecord = -1 // Allow variable field counts + reader.LazyQuotes = true + reader.TrimLeadingSpace = true + + records, err := reader.ReadAll() + if err != nil { + return nil, nil, err + } + + if len(records) == 0 { + return nil, nil, &migration.ErrFileIsEmpty{} + } + + headers := records[0] + var dataRows [][]string + if len(records) > 1 { + dataRows = records[1:] + } + + return headers, dataRows, nil +} + +// DetectCSVStructure analyzes a CSV file and returns detection results +func DetectCSVStructure(file io.ReaderAt, size int64) (*DetectionResult, error) { + if size == 0 { + return nil, &migration.ErrFileIsEmpty{} + } + + // Read the entire file + data := make([]byte, size) + _, err := file.ReadAt(data, 0) + if err != nil && !errors.Is(err, io.EOF) { + return nil, err + } + + // Detect delimiter and quote character + delimiter := detectDelimiter(data) + quoteChar := detectQuoteChar(data) + + // Parse CSV + headers, rows, err := parseCSV(data, delimiter) + if err != nil { + var emptyErr *migration.ErrFileIsEmpty + if errors.As(err, &emptyErr) { + return nil, err + } + return nil, &migration.ErrNotACSVFile{} + } + + // Suggest column mappings + suggestedMapping := suggestMapping(headers) + + // Collect sample dates for format detection + var sampleDates []string + for _, mapping := range suggestedMapping { + if mapping.Attribute == AttrDueDate || mapping.Attribute == AttrStartDate || mapping.Attribute == AttrEndDate { + for _, row := range rows { + if mapping.ColumnIndex < len(row) && row[mapping.ColumnIndex] != "" { + sampleDates = append(sampleDates, row[mapping.ColumnIndex]) + if len(sampleDates) >= 10 { + break + } + } + } + } + } + + dateFormat := detectDateFormat(sampleDates) + + // Get preview rows (first 5) + previewRows := rows + if len(previewRows) > 5 { + previewRows = previewRows[:5] + } + + return &DetectionResult{ + Columns: headers, + Delimiter: delimiter, + QuoteChar: quoteChar, + DateFormat: dateFormat, + SuggestedMapping: suggestedMapping, + PreviewRows: previewRows, + }, nil +} + +// PreviewImport generates a preview of the import based on current mapping +func PreviewImport(file io.ReaderAt, size int64, config *ImportConfig) (*PreviewResult, error) { + if size == 0 { + return nil, &migration.ErrFileIsEmpty{} + } + + data := make([]byte, size) + _, err := file.ReadAt(data, 0) + if err != nil && !errors.Is(err, io.EOF) { + return nil, err + } + + _, rows, err := parseCSV(data, config.Delimiter) + if err != nil { + var emptyErr *migration.ErrFileIsEmpty + if errors.As(err, &emptyErr) { + return nil, err + } + return nil, &migration.ErrNotACSVFile{} + } + + result := &PreviewResult{ + Tasks: make([]PreviewTask, 0, minInt(5, len(rows))), + TotalRows: len(rows), + } + + previewCount := minInt(5, len(rows)) + for i := 0; i < previewCount; i++ { + task := rowToPreviewTask(rows[i], config) + result.Tasks = append(result.Tasks, task) + } + + return result, nil +} + +// rowToPreviewTask converts a CSV row to a preview task +func rowToPreviewTask(row []string, config *ImportConfig) PreviewTask { + task := PreviewTask{} + + for _, mapping := range config.Mapping { + if mapping.ColumnIndex < 0 || mapping.ColumnIndex >= len(row) { + continue + } + + value := strings.TrimSpace(row[mapping.ColumnIndex]) + if value == "" { + continue + } + + switch mapping.Attribute { + case AttrTitle: + task.Title = value + case AttrDescription: + task.Description = value + case AttrDueDate: + task.DueDate = value + case AttrStartDate: + task.StartDate = value + case AttrEndDate: + task.EndDate = value + case AttrDone: + task.Done = parseBool(value) + case AttrPriority: + task.Priority = parsePriority(value) + case AttrLabels: + task.Labels = parseLabels(value) + case AttrProject: + task.Project = value + case AttrReminder: + // Reminders are not supported in preview tasks + case AttrIgnore: + // Ignored attributes are not processed + } + } + + return task +} + +// parseBool parses various boolean representations +func parseBool(value string) bool { + lower := strings.ToLower(strings.TrimSpace(value)) + return lower == "true" || lower == "yes" || lower == "1" || lower == "done" || lower == "completed" +} + +// parsePriority parses priority value +func parsePriority(value string) int { + // Try to parse as number + if p, err := strconv.Atoi(strings.TrimSpace(value)); err == nil { + // Vikunja uses 0-5 priority (0=unset, 1=low, 5=urgent) + if p < 0 { + return 0 + } + if p > 5 { + return 5 + } + return p + } + + // Try to parse text priority + lower := strings.ToLower(strings.TrimSpace(value)) + switch { + case strings.Contains(lower, "urgent") || strings.Contains(lower, "highest"): + return 5 + case strings.Contains(lower, "high"): + return 4 + case strings.Contains(lower, "medium") || strings.Contains(lower, "normal"): + return 3 + case strings.Contains(lower, "lowest"): + return 1 + case strings.Contains(lower, "low"): + return 2 + } + + return 0 +} + +// parseLabels parses comma-separated labels +func parseLabels(value string) []string { + parts := strings.Split(value, ",") + labels := make([]string, 0, len(parts)) + for _, part := range parts { + label := strings.TrimSpace(part) + if label != "" { + labels = append(labels, label) + } + } + return labels +} + +// parseDate parses a date string with the given format using the configured timezone +func parseDate(value, format string) time.Time { + if value == "" { + return time.Time{} + } + + loc := config.GetTimeZone() + trimmed := strings.TrimSpace(value) + + // Try the specified format first + if t, err := time.ParseInLocation(format, trimmed, loc); err == nil { + return t + } + + // Try all supported formats as fallback + for _, f := range SupportedDateFormats { + if t, err := time.ParseInLocation(f, trimmed, loc); err == nil { + return t + } + } + + return time.Time{} +} + +// Migrate imports CSV data into Vikunja +// @Summary Import all tasks from a CSV file +// @Description Imports tasks from a CSV file into Vikunja. Requires a mapping configuration. +// @tags migration +// @Accept multipart/form-data +// @Produce json +// @Security JWTKeyAuth +// @Param import formData file true "The CSV file to import" +// @Param config formData string true "The import configuration JSON" +// @Success 200 {object} models.Message "A message telling you everything was migrated successfully." +// @Failure 400 {object} models.Message "Invalid CSV file or configuration" +// @Failure 500 {object} models.Message "Internal server error" +// @Router /migration/csv/migrate [put] +func (m *Migrator) Migrate(_ *user.User, _ io.ReaderAt, _ int64) error { + return &migration.ErrCSVConfigRequired{} +} + +// MigrateWithConfig imports CSV data into Vikunja with the provided configuration +func MigrateWithConfig(u *user.User, file io.ReaderAt, size int64, config *ImportConfig) error { + if size == 0 { + return &migration.ErrFileIsEmpty{} + } + + data := make([]byte, size) + _, err := file.ReadAt(data, 0) + if err != nil && !errors.Is(err, io.EOF) { + return err + } + + _, rows, err := parseCSV(data, config.Delimiter) + if err != nil { + var emptyErr *migration.ErrFileIsEmpty + if errors.As(err, &emptyErr) { + return err + } + return &migration.ErrNotACSVFile{} + } + + if len(rows) == 0 { + return &migration.ErrFileIsEmpty{} + } + + // Convert rows to Vikunja structure + vikunjaTasks := convertToVikunja(rows, config) + + return migration.InsertFromStructure(vikunjaTasks, u) +} + +// convertToVikunja converts CSV rows to Vikunja project/task structure +func convertToVikunja(rows [][]string, config *ImportConfig) []*models.ProjectWithTasksAndBuckets { + var pseudoParentID int64 = 1 + result := []*models.ProjectWithTasksAndBuckets{ + { + Project: models.Project{ + ID: pseudoParentID, + Title: "Imported from CSV", + }, + }, + } + + projects := make(map[string]*models.ProjectWithTasksAndBuckets) + defaultProjectName := "Tasks" + + for i, row := range rows { + task := rowToTask(row, config, int64(i+1)) + + // Determine project name + projectName := defaultProjectName + for _, mapping := range config.Mapping { + if mapping.Attribute == AttrProject && mapping.ColumnIndex < len(row) { + if pn := strings.TrimSpace(row[mapping.ColumnIndex]); pn != "" { + projectName = pn + } + } + } + + // Get or create project + if _, exists := projects[projectName]; !exists { + projects[projectName] = &models.ProjectWithTasksAndBuckets{ + Project: models.Project{ + ID: int64(len(projects)+2) + pseudoParentID, + ParentProjectID: pseudoParentID, + Title: projectName, + }, + } + } + + // Add task to project + projects[projectName].Tasks = append(projects[projectName].Tasks, &models.TaskWithComments{Task: task}) + } + + // Collect all projects + for _, p := range projects { + result = append(result, p) + } + + // Sort projects by title for consistent ordering + sort.Slice(result[1:], func(i, j int) bool { + return result[i+1].Title < result[j+1].Title + }) + + return result +} + +// rowToTask converts a CSV row to a Vikunja task +func rowToTask(row []string, config *ImportConfig, taskID int64) models.Task { + task := models.Task{ + ID: taskID, + } + + for _, mapping := range config.Mapping { + if mapping.ColumnIndex < 0 || mapping.ColumnIndex >= len(row) { + continue + } + + value := strings.TrimSpace(row[mapping.ColumnIndex]) + if value == "" { + continue + } + + switch mapping.Attribute { + case AttrTitle: + task.Title = value + case AttrDescription: + task.Description = value + case AttrDueDate: + task.DueDate = parseDate(value, config.DateFormat) + case AttrStartDate: + task.StartDate = parseDate(value, config.DateFormat) + case AttrEndDate: + task.EndDate = parseDate(value, config.DateFormat) + case AttrDone: + task.Done = parseBool(value) + if task.Done { + task.DoneAt = time.Now() + } + case AttrPriority: + task.Priority = int64(parsePriority(value)) + case AttrLabels: + labels := parseLabels(value) + for _, labelTitle := range labels { + task.Labels = append(task.Labels, &models.Label{Title: labelTitle}) + } + case AttrReminder: + // Parse reminder as duration or date + reminderDate := parseDate(value, config.DateFormat) + if !reminderDate.IsZero() { + task.Reminders = []*models.TaskReminder{ + { + Reminder: reminderDate, + }, + } + } + case AttrProject: + // Project attribute is handled separately for task creation + case AttrIgnore: + // Ignored attributes are not processed + } + } + + // Ensure task has a title + if task.Title == "" { + task.Title = "Untitled Task" + } + + return task +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/pkg/modules/migration/csv/csv_test.go b/pkg/modules/migration/csv/csv_test.go new file mode 100644 index 000000000..040fbfc68 --- /dev/null +++ b/pkg/modules/migration/csv/csv_test.go @@ -0,0 +1,581 @@ +// 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 License 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 License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package csv + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestStripBOM(t *testing.T) { + tests := []struct { + name string + input []byte + expected []byte + }{ + { + name: "with BOM", + input: []byte{0xEF, 0xBB, 0xBF, 'H', 'e', 'l', 'l', 'o'}, + expected: []byte("Hello"), + }, + { + name: "without BOM", + input: []byte("Hello"), + expected: []byte("Hello"), + }, + { + name: "empty", + input: []byte{}, + expected: []byte{}, + }, + { + name: "only BOM", + input: []byte{0xEF, 0xBB, 0xBF}, + expected: []byte{}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := stripBOM(tc.input) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestDetectDelimiter(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "comma separated", + input: "name,email,phone\nJohn,john@test.com,123\nJane,jane@test.com,456", + expected: ",", + }, + { + name: "semicolon separated", + input: "name;email;phone\nJohn;john@test.com;123\nJane;jane@test.com;456", + expected: ";", + }, + { + name: "tab separated", + input: "name\temail\tphone\nJohn\tjohn@test.com\t123\nJane\tjane@test.com\t456", + expected: "\t", + }, + { + name: "pipe separated", + input: "name|email|phone\nJohn|john@test.com|123\nJane|jane@test.com|456", + expected: "|", + }, + { + name: "single line defaults to comma", + input: "just a single line", + expected: ",", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := detectDelimiter([]byte(tc.input)) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestDetectQuoteChar(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "double quotes", + input: `"name","email"\n"John","john@test.com"`, + expected: "\"", + }, + { + name: "single quotes", + input: `'name','email'\n'John','john@test.com'`, + expected: "'", + }, + { + name: "no quotes defaults to double", + input: "name,email\nJohn,john@test.com", + expected: "\"", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := detectQuoteChar([]byte(tc.input)) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestDetectDateFormat(t *testing.T) { + tests := []struct { + name string + sampleDates []string + expected string + }{ + { + name: "ISO date", + sampleDates: []string{"2024-01-15", "2024-02-20", "2024-03-25"}, + expected: "2006-01-02", + }, + { + name: "ISO datetime", + sampleDates: []string{"2024-01-15T10:30:00", "2024-02-20T14:45:00"}, + expected: "2006-01-02T15:04:05", + }, + { + name: "European format", + sampleDates: []string{"15.01.2024", "20.02.2024", "25.03.2024"}, + expected: "02.01.2006", + }, + { + name: "empty defaults to ISO", + sampleDates: []string{}, + expected: "2006-01-02", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := detectDateFormat(tc.sampleDates) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestSuggestMapping(t *testing.T) { + tests := []struct { + name string + columns []string + expected map[int]TaskAttribute + }{ + { + name: "standard column names", + columns: []string{"Title", "Description", "Due Date", "Priority", "Labels"}, + expected: map[int]TaskAttribute{ + 0: AttrTitle, + 1: AttrDescription, + 2: AttrDueDate, + 3: AttrPriority, + 4: AttrLabels, + }, + }, + { + name: "alternative column names", + columns: []string{"Task Name", "Notes", "Deadline", "Tags", "Project"}, + expected: map[int]TaskAttribute{ + 0: AttrTitle, + 1: AttrDescription, + 2: AttrDueDate, + 3: AttrLabels, + 4: AttrProject, + }, + }, + { + name: "unknown columns", + columns: []string{"ID", "Random Column", "Unknown"}, + expected: map[int]TaskAttribute{ + 0: AttrIgnore, + 1: AttrIgnore, + 2: AttrIgnore, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mappings := suggestMapping(tc.columns) + require.Len(t, mappings, len(tc.columns)) + + for idx, expectedAttr := range tc.expected { + assert.Equal(t, expectedAttr, mappings[idx].Attribute, "Column %d (%s)", idx, tc.columns[idx]) + } + }) + } +} + +func TestParseCSV(t *testing.T) { + tests := []struct { + name string + input string + delimiter string + quoteChar string + expectedCols []string + expectedRows int + expectedError bool + }{ + { + name: "simple comma CSV", + input: "name,email,phone\nJohn,john@test.com,123\nJane,jane@test.com,456", + delimiter: ",", + quoteChar: "\"", + expectedCols: []string{"name", "email", "phone"}, + expectedRows: 2, + }, + { + name: "semicolon CSV", + input: "name;email;phone\nJohn;john@test.com;123", + delimiter: ";", + quoteChar: "\"", + expectedCols: []string{"name", "email", "phone"}, + expectedRows: 1, + }, + { + name: "quoted fields", + input: "name,description\n\"John Doe\",\"A long, complicated description\"\nJane,Simple", + delimiter: ",", + quoteChar: "\"", + expectedCols: []string{"name", "description"}, + expectedRows: 2, + }, + { + name: "with BOM", + input: "\xEF\xBB\xBFname,email\nJohn,john@test.com", + delimiter: ",", + quoteChar: "\"", + expectedCols: []string{"name", "email"}, + expectedRows: 1, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + headers, rows, err := parseCSV([]byte(tc.input), tc.delimiter) + + if tc.expectedError { + require.Error(t, err) + return + } + + require.NoError(t, err) + assert.Equal(t, tc.expectedCols, headers) + assert.Len(t, rows, tc.expectedRows) + }) + } +} + +func TestParseBool(t *testing.T) { + tests := []struct { + input string + expected bool + }{ + {"true", true}, + {"True", true}, + {"TRUE", true}, + {"yes", true}, + {"Yes", true}, + {"1", true}, + {"done", true}, + {"completed", true}, + {"false", false}, + {"no", false}, + {"0", false}, + {"", false}, + {"random", false}, + } + + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + result := parseBool(tc.input) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestParsePriority(t *testing.T) { + tests := []struct { + input string + expected int + }{ + {"0", 0}, + {"1", 1}, + {"3", 3}, + {"5", 5}, + {"10", 5}, // capped at 5 + {"-1", 0}, // minimum 0 + {"low", 2}, + {"medium", 3}, + {"high", 4}, + {"urgent", 5}, + {"highest", 5}, + {"lowest", 1}, + {"normal", 3}, + {"random", 0}, + } + + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + result := parsePriority(tc.input) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestParseLabels(t *testing.T) { + tests := []struct { + input string + expected []string + }{ + {"work, personal, urgent", []string{"work", "personal", "urgent"}}, + {"single", []string{"single"}}, + {" spaced , labels ", []string{"spaced", "labels"}}, + {"", []string{}}, + {",,,", []string{}}, + } + + for _, tc := range tests { + t.Run(tc.input, func(t *testing.T) { + result := parseLabels(tc.input) + assert.Equal(t, tc.expected, result) + }) + } +} + +func TestDetectCSVStructure(t *testing.T) { + csvContent := `Title,Description,Due Date,Priority,Labels +Task 1,Description 1,2024-01-15,high,work +Task 2,Description 2,2024-01-20,low,"personal, urgent" +Task 3,Description 3,2024-01-25,medium,home` + + reader := bytes.NewReader([]byte(csvContent)) + + result, err := DetectCSVStructure(reader, int64(len(csvContent))) + require.NoError(t, err) + + assert.Equal(t, []string{"Title", "Description", "Due Date", "Priority", "Labels"}, result.Columns) + assert.Equal(t, ",", result.Delimiter) + assert.Len(t, result.SuggestedMapping, 5) + assert.Len(t, result.PreviewRows, 3) + + // Check suggested mappings + titleMapping := result.SuggestedMapping[0] + assert.Equal(t, AttrTitle, titleMapping.Attribute) + assert.Equal(t, "Title", titleMapping.ColumnName) + + descMapping := result.SuggestedMapping[1] + assert.Equal(t, AttrDescription, descMapping.Attribute) + + dueDateMapping := result.SuggestedMapping[2] + assert.Equal(t, AttrDueDate, dueDateMapping.Attribute) +} + +func TestPreviewImport(t *testing.T) { + csvContent := `Title,Description,Done,Priority +Task 1,Description 1,true,high +Task 2,Description 2,false,low +Task 3,Description 3,yes,medium +Task 4,Description 4,no,urgent +Task 5,Description 5,1,normal +Task 6,Description 6,0,low` + + config := ImportConfig{ + Delimiter: ",", + QuoteChar: "\"", + DateFormat: "2006-01-02", + Mapping: []ColumnMapping{ + {ColumnIndex: 0, ColumnName: "Title", Attribute: AttrTitle}, + {ColumnIndex: 1, ColumnName: "Description", Attribute: AttrDescription}, + {ColumnIndex: 2, ColumnName: "Done", Attribute: AttrDone}, + {ColumnIndex: 3, ColumnName: "Priority", Attribute: AttrPriority}, + }, + } + + reader := bytes.NewReader([]byte(csvContent)) + + result, err := PreviewImport(reader, int64(len(csvContent)), &config) + require.NoError(t, err) + + assert.Equal(t, 6, result.TotalRows) + assert.Len(t, result.Tasks, 5) // Preview limited to 5 + + // Check first task + assert.Equal(t, "Task 1", result.Tasks[0].Title) + assert.Equal(t, "Description 1", result.Tasks[0].Description) + assert.True(t, result.Tasks[0].Done) + assert.Equal(t, 4, result.Tasks[0].Priority) // "high" -> 4 + + // Check second task + assert.Equal(t, "Task 2", result.Tasks[1].Title) + assert.False(t, result.Tasks[1].Done) + assert.Equal(t, 2, result.Tasks[1].Priority) // "low" -> 2 +} + +func TestConvertToVikunja(t *testing.T) { + rows := [][]string{ + {"Task 1", "Description 1", "Project A"}, + {"Task 2", "Description 2", "Project A"}, + {"Task 3", "Description 3", "Project B"}, + {"Task 4", "Description 4", ""}, // No project -> default + } + + config := ImportConfig{ + Delimiter: ",", + QuoteChar: "\"", + DateFormat: "2006-01-02", + Mapping: []ColumnMapping{ + {ColumnIndex: 0, Attribute: AttrTitle}, + {ColumnIndex: 1, Attribute: AttrDescription}, + {ColumnIndex: 2, Attribute: AttrProject}, + }, + } + + result := convertToVikunja(rows, &config) + + // Should have parent project + child projects + require.GreaterOrEqual(t, len(result), 2) + + // First project should be the parent "Imported from CSV" + assert.Equal(t, "Imported from CSV", result[0].Title) + + // Find Project A + var projectA, projectB, tasksProject *struct { + title string + numTasks int + } + for _, p := range result[1:] { + switch p.Title { + case "Project A": + projectA = &struct { + title string + numTasks int + }{p.Title, len(p.Tasks)} + case "Project B": + projectB = &struct { + title string + numTasks int + }{p.Title, len(p.Tasks)} + case "Tasks": + tasksProject = &struct { + title string + numTasks int + }{p.Title, len(p.Tasks)} + } + } + + assert.NotNil(t, projectA, "Project A should exist") + assert.Equal(t, 2, projectA.numTasks, "Project A should have 2 tasks") + + assert.NotNil(t, projectB, "Project B should exist") + assert.Equal(t, 1, projectB.numTasks, "Project B should have 1 task") + + assert.NotNil(t, tasksProject, "Tasks project should exist for tasks without project") + assert.Equal(t, 1, tasksProject.numTasks, "Tasks project should have 1 task") +} + +func TestRowToTask(t *testing.T) { + row := []string{"My Task", "Task description", "2024-01-15", "high", "work, urgent"} + + config := ImportConfig{ + DateFormat: "2006-01-02", + Mapping: []ColumnMapping{ + {ColumnIndex: 0, Attribute: AttrTitle}, + {ColumnIndex: 1, Attribute: AttrDescription}, + {ColumnIndex: 2, Attribute: AttrDueDate}, + {ColumnIndex: 3, Attribute: AttrPriority}, + {ColumnIndex: 4, Attribute: AttrLabels}, + }, + } + + task := rowToTask(row, &config, 1) + + assert.Equal(t, "My Task", task.Title) + assert.Equal(t, "Task description", task.Description) + assert.Equal(t, 2024, task.DueDate.Year()) + assert.Equal(t, 1, int(task.DueDate.Month())) + assert.Equal(t, 15, task.DueDate.Day()) + assert.Equal(t, int64(4), task.Priority) // "high" -> 4 + require.Len(t, task.Labels, 2) + assert.Equal(t, "work", task.Labels[0].Title) + assert.Equal(t, "urgent", task.Labels[1].Title) +} + +func TestMigratorName(t *testing.T) { + m := &Migrator{} + assert.Equal(t, "csv", m.Name()) +} + +func TestEmptyFile(t *testing.T) { + reader := bytes.NewReader([]byte{}) + + _, err := DetectCSVStructure(reader, 0) + require.Error(t, err) +} + +func TestRowToTaskWithMissingColumns(t *testing.T) { + // Row with fewer columns than expected + row := []string{"My Task"} + + config := ImportConfig{ + Mapping: []ColumnMapping{ + {ColumnIndex: 0, Attribute: AttrTitle}, + {ColumnIndex: 1, Attribute: AttrDescription}, // Index 1 doesn't exist + {ColumnIndex: 2, Attribute: AttrDueDate}, // Index 2 doesn't exist + }, + } + + task := rowToTask(row, &config, 1) + + // Should still work with available columns + assert.Equal(t, "My Task", task.Title) + assert.Empty(t, task.Description) + assert.True(t, task.DueDate.IsZero()) +} + +func TestRowToTaskWithEmptyTitle(t *testing.T) { + row := []string{"", "Some description"} + + config := ImportConfig{ + Mapping: []ColumnMapping{ + {ColumnIndex: 0, Attribute: AttrTitle}, + {ColumnIndex: 1, Attribute: AttrDescription}, + }, + } + + task := rowToTask(row, &config, 1) + + // Should have default title + assert.Equal(t, "Untitled Task", task.Title) + assert.Equal(t, "Some description", task.Description) +} + +func TestDoneTask(t *testing.T) { + row := []string{"Done Task", "completed"} + + config := ImportConfig{ + Mapping: []ColumnMapping{ + {ColumnIndex: 0, Attribute: AttrTitle}, + {ColumnIndex: 1, Attribute: AttrDone}, + }, + } + + task := rowToTask(row, &config, 1) + + assert.Equal(t, "Done Task", task.Title) + assert.True(t, task.Done) + assert.False(t, task.DoneAt.IsZero()) // DoneAt should be set +} diff --git a/pkg/modules/migration/csv/handler.go b/pkg/modules/migration/csv/handler.go new file mode 100644 index 000000000..389c13573 --- /dev/null +++ b/pkg/modules/migration/csv/handler.go @@ -0,0 +1,206 @@ +// 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 License 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 License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +package csv + +import ( + "encoding/json" + "net/http" + + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/modules/migration" + user2 "code.vikunja.io/api/pkg/user" + "github.com/labstack/echo/v5" +) + +// MigratorWeb handles CSV migration HTTP routes +type MigratorWeb struct{} + +// RegisterRoutes registers all CSV migration routes +func (c *MigratorWeb) RegisterRoutes(g *echo.Group) { + g.GET("/csv/status", c.Status) + g.PUT("/csv/detect", c.Detect) + g.PUT("/csv/preview", c.Preview) + g.PUT("/csv/migrate", c.Migrate) +} + +// Status returns the migration status +// @Summary Get CSV migration status +// @Description Returns if the current user already did the CSV migration or not. +// @tags migration +// @Produce json +// @Security JWTKeyAuth +// @Success 200 {object} migration.Status "The migration status" +// @Failure 500 {object} models.Message "Internal server error" +// @Router /migration/csv/status [get] +func (c *MigratorWeb) Status(ctx *echo.Context) error { + u, err := user2.GetCurrentUser(ctx) + if err != nil { + return err + } + + m := &Migrator{} + s, err := migration.GetMigrationStatus(m, u) + if err != nil { + return err + } + + return ctx.JSON(http.StatusOK, s) +} + +// Detect analyzes a CSV file and returns detection results +// @Summary Detect CSV structure +// @Description Analyzes a CSV file and returns auto-detected columns, delimiter, quote character, and date format with suggested column mappings. +// @tags migration +// @Accept multipart/form-data +// @Produce json +// @Security JWTKeyAuth +// @Param import formData file true "The CSV file to analyze" +// @Success 200 {object} DetectionResult "Detection results with suggested mappings" +// @Failure 400 {object} models.Message "Invalid CSV file" +// @Failure 500 {object} models.Message "Internal server error" +// @Router /migration/csv/detect [put] +func (c *MigratorWeb) Detect(ctx *echo.Context) error { + _, err := user2.GetCurrentUser(ctx) + if err != nil { + return err + } + + file, err := ctx.FormFile("import") + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "No file provided") + } + + src, err := file.Open() + if err != nil { + return err + } + defer src.Close() + + result, err := DetectCSVStructure(src, file.Size) + if err != nil { + return err + } + + return ctx.JSON(http.StatusOK, result) +} + +// Preview generates a preview of the import +// @Summary Preview CSV import +// @Description Generates a preview of the first 5 tasks that would be imported with the given configuration. +// @tags migration +// @Accept multipart/form-data +// @Produce json +// @Security JWTKeyAuth +// @Param import formData file true "The CSV file to preview" +// @Param config formData string true "The import configuration JSON" +// @Success 200 {object} PreviewResult "Preview of tasks to import" +// @Failure 400 {object} models.Message "Invalid CSV file or configuration" +// @Failure 500 {object} models.Message "Internal server error" +// @Router /migration/csv/preview [put] +func (c *MigratorWeb) Preview(ctx *echo.Context) error { + _, err := user2.GetCurrentUser(ctx) + if err != nil { + return err + } + + file, err := ctx.FormFile("import") + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "No file provided") + } + + configStr := ctx.FormValue("config") + if configStr == "" { + return echo.NewHTTPError(http.StatusBadRequest, "No configuration provided") + } + + var config ImportConfig + if err := json.Unmarshal([]byte(configStr), &config); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid configuration: "+err.Error()) + } + + src, err := file.Open() + if err != nil { + return err + } + defer src.Close() + + result, err := PreviewImport(src, file.Size, &config) + if err != nil { + return err + } + + return ctx.JSON(http.StatusOK, result) +} + +// Migrate imports the CSV file +// @Summary Import CSV file +// @Description Imports tasks from a CSV file into Vikunja with the provided configuration. +// @tags migration +// @Accept multipart/form-data +// @Produce json +// @Security JWTKeyAuth +// @Param import formData file true "The CSV file to import" +// @Param config formData string true "The import configuration JSON" +// @Success 200 {object} models.Message "A message telling you everything was migrated successfully." +// @Failure 400 {object} models.Message "Invalid CSV file or configuration" +// @Failure 500 {object} models.Message "Internal server error" +// @Router /migration/csv/migrate [put] +func (c *MigratorWeb) Migrate(ctx *echo.Context) error { + u, err := user2.GetCurrentUser(ctx) + if err != nil { + return err + } + + file, err := ctx.FormFile("import") + if err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "No file provided") + } + + configStr := ctx.FormValue("config") + if configStr == "" { + return echo.NewHTTPError(http.StatusBadRequest, "No configuration provided") + } + + var config ImportConfig + if err := json.Unmarshal([]byte(configStr), &config); err != nil { + return echo.NewHTTPError(http.StatusBadRequest, "Invalid configuration: "+err.Error()) + } + + src, err := file.Open() + if err != nil { + return err + } + defer src.Close() + + m := &Migrator{} + status, err := migration.StartMigration(m, u) + if err != nil { + return err + } + + err = MigrateWithConfig(u, src, file.Size, &config) + if err != nil { + return err + } + + err = migration.FinishMigration(status) + if err != nil { + return err + } + + return ctx.JSON(http.StatusOK, models.Message{Message: "Everything was migrated successfully."}) +} diff --git a/pkg/modules/migration/errors.go b/pkg/modules/migration/errors.go index 6a2a4f780..3129c5da2 100644 --- a/pkg/modules/migration/errors.go +++ b/pkg/modules/migration/errors.go @@ -60,6 +60,28 @@ func (err *ErrFileIsEmpty) HTTPError() web.HTTPError { } } +// ErrCSVConfigRequired represents an error when the CSV migration endpoint +// is called without the required configuration. The CSV migrator requires +// a mapping configuration and must be used via /migration/csv/migrate with +// a config form field. +type ErrCSVConfigRequired struct{} + +func (err *ErrCSVConfigRequired) Error() string { + return "CSV import requires a configuration with column mappings. Use the /migration/csv/detect endpoint to get suggested mappings, then call /migration/csv/migrate with a config form field." +} + +// ErrCodeCSVConfigRequired holds the unique world-error code of this error +const ErrCodeCSVConfigRequired = 14004 + +// HTTPError holds the http error description +func (err *ErrCSVConfigRequired) HTTPError() web.HTTPError { + return web.HTTPError{ + HTTPCode: http.StatusBadRequest, + Code: ErrCodeCSVConfigRequired, + Message: "CSV import requires a configuration with column mappings. Use the /migration/csv/detect endpoint to get suggested mappings, then call /migration/csv/migrate with a config form field.", + } +} + // ErrNotACSVFile represents a "ErrNotACSVFile" kind of error. type ErrNotACSVFile struct{} diff --git a/pkg/routes/api/v1/info.go b/pkg/routes/api/v1/info.go index 2fa14e5f9..ed429f48a 100644 --- a/pkg/routes/api/v1/info.go +++ b/pkg/routes/api/v1/info.go @@ -22,6 +22,7 @@ import ( "code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/log" "code.vikunja.io/api/pkg/modules/auth/openid" + csvmigrator "code.vikunja.io/api/pkg/modules/migration/csv" microsofttodo "code.vikunja.io/api/pkg/modules/migration/microsoft-todo" "code.vikunja.io/api/pkg/modules/migration/ticktick" "code.vikunja.io/api/pkg/modules/migration/todoist" @@ -108,6 +109,7 @@ func Info(c *echo.Context) error { (&vikunja_file.FileMigrator{}).Name(), (&ticktick.Migrator{}).Name(), (&wekan.Migrator{}).Name(), + (&csvmigrator.Migrator{}).Name(), }, Legal: legalInfo{ ImprintURL: config.LegalImprintURL.GetString(), diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index a74ee3e82..98bd687bc 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -68,6 +68,7 @@ import ( "code.vikunja.io/api/pkg/modules/background/unsplash" "code.vikunja.io/api/pkg/modules/background/upload" "code.vikunja.io/api/pkg/modules/migration" + csvmigrator "code.vikunja.io/api/pkg/modules/migration/csv" migrationHandler "code.vikunja.io/api/pkg/modules/migration/handler" microsofttodo "code.vikunja.io/api/pkg/modules/migration/microsoft-todo" "code.vikunja.io/api/pkg/modules/migration/ticktick" @@ -861,6 +862,10 @@ func registerMigrations(m *echo.Group) { }, } wekanFileMigrator.RegisterRoutes(m) + + // CSV File Migrator (always enabled - generic import) + csvFileMigrator := &csvmigrator.MigratorWeb{} + csvFileMigrator.RegisterRoutes(m) } func registerCalDavRoutes(c *echo.Group) { From 3437f98dc36c7b10919d157137b41459e3e2f064 Mon Sep 17 00:00:00 2001 From: kolaente Date: Thu, 5 Mar 2026 12:24:11 +0100 Subject: [PATCH 095/101] feat(migration): add skip rows option to CSV import Allow users to skip the first N data rows when importing CSV files. This is useful when the CSV contains metadata rows before the actual task data begins. Adds skip_rows to ImportConfig (backend) and a number input in the parsing options UI (frontend). --- frontend/src/i18n/lang/en.json | 1 + .../src/services/migrator/csvMigration.ts | 1 + frontend/src/views/migrate/MigrationCSV.vue | 14 +++++++++++++ pkg/modules/migration/csv/csv.go | 21 +++++++++++++++++++ 4 files changed, 37 insertions(+) diff --git a/frontend/src/i18n/lang/en.json b/frontend/src/i18n/lang/en.json index 98e484a23..551349ef7 100644 --- a/frontend/src/i18n/lang/en.json +++ b/frontend/src/i18n/lang/en.json @@ -676,6 +676,7 @@ "parsingOptions": "Parsing Options", "delimiter": "Delimiter", "dateFormat": "Date Format", + "skipRows": "Skip Rows", "mapColumns": "Map Columns", "example": "e.g.", "preview": "Preview", diff --git a/frontend/src/services/migrator/csvMigration.ts b/frontend/src/services/migrator/csvMigration.ts index ecbc26561..7bcc25c03 100644 --- a/frontend/src/services/migrator/csvMigration.ts +++ b/frontend/src/services/migrator/csvMigration.ts @@ -46,6 +46,7 @@ export interface ImportConfig { delimiter: string quote_char: string date_format: string + skip_rows: number mapping: ColumnMapping[] } diff --git a/frontend/src/views/migrate/MigrationCSV.vue b/frontend/src/views/migrate/MigrationCSV.vue index 2aab57101..1318032ce 100644 --- a/frontend/src/views/migrate/MigrationCSV.vue +++ b/frontend/src/views/migrate/MigrationCSV.vue @@ -82,6 +82,17 @@ +
+ + +
@@ -219,6 +230,7 @@ const config = ref({ delimiter: ',', quote_char: '"', date_format: '2006-01-02', + skip_rows: 0, mapping: [], }) @@ -303,6 +315,7 @@ async function handleFileUpload() { delimiter: result.delimiter, quote_char: result.quote_char, date_format: result.date_format, + skip_rows: 0, mapping: result.suggested_mapping, } @@ -366,6 +379,7 @@ function resetToUpload() { delimiter: ',', quote_char: '"', date_format: '2006-01-02', + skip_rows: 0, mapping: [], } } diff --git a/pkg/modules/migration/csv/csv.go b/pkg/modules/migration/csv/csv.go index f20221a01..81e3dc00a 100644 --- a/pkg/modules/migration/csv/csv.go +++ b/pkg/modules/migration/csv/csv.go @@ -127,6 +127,7 @@ type ImportConfig struct { Delimiter string `json:"delimiter"` QuoteChar string `json:"quote_char"` DateFormat string `json:"date_format"` + SkipRows int `json:"skip_rows"` Mapping []ColumnMapping `json:"mapping"` } @@ -396,6 +397,17 @@ func PreviewImport(file io.ReaderAt, size int64, config *ImportConfig) (*Preview return nil, &migration.ErrNotACSVFile{} } + // Skip rows if configured + if config.SkipRows > 0 { + if config.SkipRows >= len(rows) { + return &PreviewResult{ + Tasks: []PreviewTask{}, + TotalRows: 0, + }, nil + } + rows = rows[config.SkipRows:] + } + result := &PreviewResult{ Tasks: make([]PreviewTask, 0, minInt(5, len(rows))), TotalRows: len(rows), @@ -566,6 +578,15 @@ func MigrateWithConfig(u *user.User, file io.ReaderAt, size int64, config *Impor return &migration.ErrNotACSVFile{} } + // Skip rows if configured + if config.SkipRows > 0 { + if config.SkipRows >= len(rows) { + rows = nil + } else { + rows = rows[config.SkipRows:] + } + } + if len(rows) == 0 { return &migration.ErrFileIsEmpty{} } From bc0bb556adb3e558df4fed180544cf12348e64ab Mon Sep 17 00:00:00 2001 From: kolaente Date: Tue, 7 Apr 2026 16:16:53 +0200 Subject: [PATCH 096/101] feat(migration): flatten project hierarchy for single-project imports --- pkg/modules/migration/csv/csv.go | 43 ++++++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/pkg/modules/migration/csv/csv.go b/pkg/modules/migration/csv/csv.go index 81e3dc00a..a0bee4f08 100644 --- a/pkg/modules/migration/csv/csv.go +++ b/pkg/modules/migration/csv/csv.go @@ -597,18 +597,36 @@ func MigrateWithConfig(u *user.User, file io.ReaderAt, size int64, config *Impor return migration.InsertFromStructure(vikunjaTasks, u) } +// hasProjectMapping returns true if any column is mapped to the project attribute +func hasProjectMapping(config *ImportConfig) bool { + for _, mapping := range config.Mapping { + if mapping.Attribute == AttrProject { + return true + } + } + return false +} + // convertToVikunja converts CSV rows to Vikunja project/task structure func convertToVikunja(rows [][]string, config *ImportConfig) []*models.ProjectWithTasksAndBuckets { var pseudoParentID int64 = 1 - result := []*models.ProjectWithTasksAndBuckets{ - { - Project: models.Project{ - ID: pseudoParentID, - Title: "Imported from CSV", - }, + parentProject := &models.ProjectWithTasksAndBuckets{ + Project: models.Project{ + ID: pseudoParentID, + Title: "Imported from CSV", }, } + // If no project column is mapped, put all tasks directly in the parent project + if !hasProjectMapping(config) { + for i, row := range rows { + task := rowToTask(row, config, int64(i+1)) + parentProject.Tasks = append(parentProject.Tasks, &models.TaskWithComments{Task: task}) + } + return []*models.ProjectWithTasksAndBuckets{parentProject} + } + + // Collect tasks by project name projects := make(map[string]*models.ProjectWithTasksAndBuckets) defaultProjectName := "Tasks" @@ -618,7 +636,7 @@ func convertToVikunja(rows [][]string, config *ImportConfig) []*models.ProjectWi // Determine project name projectName := defaultProjectName for _, mapping := range config.Mapping { - if mapping.Attribute == AttrProject && mapping.ColumnIndex < len(row) { + if mapping.Attribute == AttrProject && mapping.ColumnIndex >= 0 && mapping.ColumnIndex < len(row) { if pn := strings.TrimSpace(row[mapping.ColumnIndex]); pn != "" { projectName = pn } @@ -640,7 +658,16 @@ func convertToVikunja(rows [][]string, config *ImportConfig) []*models.ProjectWi projects[projectName].Tasks = append(projects[projectName].Tasks, &models.TaskWithComments{Task: task}) } - // Collect all projects + // If only one project exists, put all tasks directly in the parent project + if len(projects) == 1 { + for _, p := range projects { + parentProject.Tasks = p.Tasks + } + return []*models.ProjectWithTasksAndBuckets{parentProject} + } + + // Multiple projects: create sub-projects under the parent + result := []*models.ProjectWithTasksAndBuckets{parentProject} for _, p := range projects { result = append(result, p) } From a0dd7a7270924814c022bc7e4875c6e7ae08f2e6 Mon Sep 17 00:00:00 2001 From: "Frederick [Bot]" Date: Tue, 7 Apr 2026 15:45:50 +0000 Subject: [PATCH 097/101] [skip ci] Updated swagger docs --- pkg/swagger/docs.go | 319 +++++++++++++++++++++++++++++++++++++++ pkg/swagger/swagger.json | 319 +++++++++++++++++++++++++++++++++++++++ pkg/swagger/swagger.yaml | 214 ++++++++++++++++++++++++++ 3 files changed, 852 insertions(+) diff --git a/pkg/swagger/docs.go b/pkg/swagger/docs.go index 0d7ce800f..6061a40a2 100644 --- a/pkg/swagger/docs.go +++ b/pkg/swagger/docs.go @@ -765,6 +765,198 @@ const docTemplate = `{ } } }, + "/migration/csv/detect": { + "put": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Analyzes a CSV file and returns auto-detected columns, delimiter, quote character, and date format with suggested column mappings.", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "migration" + ], + "summary": "Detect CSV structure", + "parameters": [ + { + "type": "file", + "description": "The CSV file to analyze", + "name": "import", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "Detection results with suggested mappings", + "schema": { + "$ref": "#/definitions/csv.DetectionResult" + } + }, + "400": { + "description": "Invalid CSV file", + "schema": { + "$ref": "#/definitions/models.Message" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + } + }, + "/migration/csv/migrate": { + "put": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Imports tasks from a CSV file into Vikunja with the provided configuration.", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "migration" + ], + "summary": "Import CSV file", + "parameters": [ + { + "type": "file", + "description": "The CSV file to import", + "name": "import", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "The import configuration JSON", + "name": "config", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "A message telling you everything was migrated successfully.", + "schema": { + "$ref": "#/definitions/models.Message" + } + }, + "400": { + "description": "Invalid CSV file or configuration", + "schema": { + "$ref": "#/definitions/models.Message" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + } + }, + "/migration/csv/preview": { + "put": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Generates a preview of the first 5 tasks that would be imported with the given configuration.", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "migration" + ], + "summary": "Preview CSV import", + "parameters": [ + { + "type": "file", + "description": "The CSV file to preview", + "name": "import", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "The import configuration JSON", + "name": "config", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "Preview of tasks to import", + "schema": { + "$ref": "#/definitions/csv.PreviewResult" + } + }, + "400": { + "description": "Invalid CSV file or configuration", + "schema": { + "$ref": "#/definitions/models.Message" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + } + }, + "/migration/csv/status": { + "get": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Returns if the current user already did the CSV migration or not.", + "produces": [ + "application/json" + ], + "tags": [ + "migration" + ], + "summary": "Get CSV migration status", + "responses": { + "200": { + "description": "The migration status", + "schema": { + "$ref": "#/definitions/migration.Status" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + } + }, "/migration/microsoft-todo/auth": { "get": { "security": [ @@ -8268,6 +8460,133 @@ const docTemplate = `{ } } }, + "csv.ColumnMapping": { + "type": "object", + "properties": { + "attribute": { + "$ref": "#/definitions/csv.TaskAttribute" + }, + "column_index": { + "type": "integer" + }, + "column_name": { + "type": "string" + } + } + }, + "csv.DetectionResult": { + "type": "object", + "properties": { + "columns": { + "type": "array", + "items": { + "type": "string" + } + }, + "date_format": { + "type": "string" + }, + "delimiter": { + "type": "string" + }, + "preview_rows": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "quote_char": { + "type": "string" + }, + "suggested_mapping": { + "type": "array", + "items": { + "$ref": "#/definitions/csv.ColumnMapping" + } + } + } + }, + "csv.PreviewResult": { + "type": "object", + "properties": { + "tasks": { + "type": "array", + "items": { + "$ref": "#/definitions/csv.PreviewTask" + } + }, + "total_rows": { + "type": "integer" + } + } + }, + "csv.PreviewTask": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "done": { + "type": "boolean" + }, + "due_date": { + "type": "string" + }, + "end_date": { + "type": "string" + }, + "labels": { + "type": "array", + "items": { + "type": "string" + } + }, + "priority": { + "type": "integer" + }, + "project": { + "type": "string" + }, + "start_date": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, + "csv.TaskAttribute": { + "type": "string", + "enum": [ + "title", + "description", + "due_date", + "start_date", + "end_date", + "done", + "priority", + "labels", + "project", + "reminder", + "ignore" + ], + "x-enum-varnames": [ + "AttrTitle", + "AttrDescription", + "AttrDueDate", + "AttrStartDate", + "AttrEndDate", + "AttrDone", + "AttrPriority", + "AttrLabels", + "AttrProject", + "AttrReminder", + "AttrIgnore" + ] + }, "files.File": { "type": "object", "properties": { diff --git a/pkg/swagger/swagger.json b/pkg/swagger/swagger.json index 022d0a275..a23bd2231 100644 --- a/pkg/swagger/swagger.json +++ b/pkg/swagger/swagger.json @@ -757,6 +757,198 @@ } } }, + "/migration/csv/detect": { + "put": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Analyzes a CSV file and returns auto-detected columns, delimiter, quote character, and date format with suggested column mappings.", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "migration" + ], + "summary": "Detect CSV structure", + "parameters": [ + { + "type": "file", + "description": "The CSV file to analyze", + "name": "import", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "Detection results with suggested mappings", + "schema": { + "$ref": "#/definitions/csv.DetectionResult" + } + }, + "400": { + "description": "Invalid CSV file", + "schema": { + "$ref": "#/definitions/models.Message" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + } + }, + "/migration/csv/migrate": { + "put": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Imports tasks from a CSV file into Vikunja with the provided configuration.", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "migration" + ], + "summary": "Import CSV file", + "parameters": [ + { + "type": "file", + "description": "The CSV file to import", + "name": "import", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "The import configuration JSON", + "name": "config", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "A message telling you everything was migrated successfully.", + "schema": { + "$ref": "#/definitions/models.Message" + } + }, + "400": { + "description": "Invalid CSV file or configuration", + "schema": { + "$ref": "#/definitions/models.Message" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + } + }, + "/migration/csv/preview": { + "put": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Generates a preview of the first 5 tasks that would be imported with the given configuration.", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "migration" + ], + "summary": "Preview CSV import", + "parameters": [ + { + "type": "file", + "description": "The CSV file to preview", + "name": "import", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "The import configuration JSON", + "name": "config", + "in": "formData", + "required": true + } + ], + "responses": { + "200": { + "description": "Preview of tasks to import", + "schema": { + "$ref": "#/definitions/csv.PreviewResult" + } + }, + "400": { + "description": "Invalid CSV file or configuration", + "schema": { + "$ref": "#/definitions/models.Message" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + } + }, + "/migration/csv/status": { + "get": { + "security": [ + { + "JWTKeyAuth": [] + } + ], + "description": "Returns if the current user already did the CSV migration or not.", + "produces": [ + "application/json" + ], + "tags": [ + "migration" + ], + "summary": "Get CSV migration status", + "responses": { + "200": { + "description": "The migration status", + "schema": { + "$ref": "#/definitions/migration.Status" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/models.Message" + } + } + } + } + }, "/migration/microsoft-todo/auth": { "get": { "security": [ @@ -8260,6 +8452,133 @@ } } }, + "csv.ColumnMapping": { + "type": "object", + "properties": { + "attribute": { + "$ref": "#/definitions/csv.TaskAttribute" + }, + "column_index": { + "type": "integer" + }, + "column_name": { + "type": "string" + } + } + }, + "csv.DetectionResult": { + "type": "object", + "properties": { + "columns": { + "type": "array", + "items": { + "type": "string" + } + }, + "date_format": { + "type": "string" + }, + "delimiter": { + "type": "string" + }, + "preview_rows": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "quote_char": { + "type": "string" + }, + "suggested_mapping": { + "type": "array", + "items": { + "$ref": "#/definitions/csv.ColumnMapping" + } + } + } + }, + "csv.PreviewResult": { + "type": "object", + "properties": { + "tasks": { + "type": "array", + "items": { + "$ref": "#/definitions/csv.PreviewTask" + } + }, + "total_rows": { + "type": "integer" + } + } + }, + "csv.PreviewTask": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "done": { + "type": "boolean" + }, + "due_date": { + "type": "string" + }, + "end_date": { + "type": "string" + }, + "labels": { + "type": "array", + "items": { + "type": "string" + } + }, + "priority": { + "type": "integer" + }, + "project": { + "type": "string" + }, + "start_date": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, + "csv.TaskAttribute": { + "type": "string", + "enum": [ + "title", + "description", + "due_date", + "start_date", + "end_date", + "done", + "priority", + "labels", + "project", + "reminder", + "ignore" + ], + "x-enum-varnames": [ + "AttrTitle", + "AttrDescription", + "AttrDueDate", + "AttrStartDate", + "AttrEndDate", + "AttrDone", + "AttrPriority", + "AttrLabels", + "AttrProject", + "AttrReminder", + "AttrIgnore" + ] + }, "files.File": { "type": "object", "properties": { diff --git a/pkg/swagger/swagger.yaml b/pkg/swagger/swagger.yaml index f1bfadf51..4a1a5f504 100644 --- a/pkg/swagger/swagger.yaml +++ b/pkg/swagger/swagger.yaml @@ -41,6 +41,96 @@ definitions: username_fallback: type: boolean type: object + csv.ColumnMapping: + properties: + attribute: + $ref: '#/definitions/csv.TaskAttribute' + column_index: + type: integer + column_name: + type: string + type: object + csv.DetectionResult: + properties: + columns: + items: + type: string + type: array + date_format: + type: string + delimiter: + type: string + preview_rows: + items: + items: + type: string + type: array + type: array + quote_char: + type: string + suggested_mapping: + items: + $ref: '#/definitions/csv.ColumnMapping' + type: array + type: object + csv.PreviewResult: + properties: + tasks: + items: + $ref: '#/definitions/csv.PreviewTask' + type: array + total_rows: + type: integer + type: object + csv.PreviewTask: + properties: + description: + type: string + done: + type: boolean + due_date: + type: string + end_date: + type: string + labels: + items: + type: string + type: array + priority: + type: integer + project: + type: string + start_date: + type: string + title: + type: string + type: object + csv.TaskAttribute: + enum: + - title + - description + - due_date + - start_date + - end_date + - done + - priority + - labels + - project + - reminder + - ignore + type: string + x-enum-varnames: + - AttrTitle + - AttrDescription + - AttrDueDate + - AttrStartDate + - AttrEndDate + - AttrDone + - AttrPriority + - AttrLabels + - AttrProject + - AttrReminder + - AttrIgnore files.File: properties: created: @@ -2198,6 +2288,130 @@ paths: summary: Login tags: - auth + /migration/csv/detect: + put: + consumes: + - multipart/form-data + description: Analyzes a CSV file and returns auto-detected columns, delimiter, + quote character, and date format with suggested column mappings. + parameters: + - description: The CSV file to analyze + in: formData + name: import + required: true + type: file + produces: + - application/json + responses: + "200": + description: Detection results with suggested mappings + schema: + $ref: '#/definitions/csv.DetectionResult' + "400": + description: Invalid CSV file + schema: + $ref: '#/definitions/models.Message' + "500": + description: Internal server error + schema: + $ref: '#/definitions/models.Message' + security: + - JWTKeyAuth: [] + summary: Detect CSV structure + tags: + - migration + /migration/csv/migrate: + put: + consumes: + - multipart/form-data + description: Imports tasks from a CSV file into Vikunja with the provided configuration. + parameters: + - description: The CSV file to import + in: formData + name: import + required: true + type: file + - description: The import configuration JSON + in: formData + name: config + required: true + type: string + produces: + - application/json + responses: + "200": + description: A message telling you everything was migrated successfully. + schema: + $ref: '#/definitions/models.Message' + "400": + description: Invalid CSV file or configuration + schema: + $ref: '#/definitions/models.Message' + "500": + description: Internal server error + schema: + $ref: '#/definitions/models.Message' + security: + - JWTKeyAuth: [] + summary: Import CSV file + tags: + - migration + /migration/csv/preview: + put: + consumes: + - multipart/form-data + description: Generates a preview of the first 5 tasks that would be imported + with the given configuration. + parameters: + - description: The CSV file to preview + in: formData + name: import + required: true + type: file + - description: The import configuration JSON + in: formData + name: config + required: true + type: string + produces: + - application/json + responses: + "200": + description: Preview of tasks to import + schema: + $ref: '#/definitions/csv.PreviewResult' + "400": + description: Invalid CSV file or configuration + schema: + $ref: '#/definitions/models.Message' + "500": + description: Internal server error + schema: + $ref: '#/definitions/models.Message' + security: + - JWTKeyAuth: [] + summary: Preview CSV import + tags: + - migration + /migration/csv/status: + get: + description: Returns if the current user already did the CSV migration or not. + produces: + - application/json + responses: + "200": + description: The migration status + schema: + $ref: '#/definitions/migration.Status' + "500": + description: Internal server error + schema: + $ref: '#/definitions/models.Message' + security: + - JWTKeyAuth: [] + summary: Get CSV migration status + tags: + - migration /migration/microsoft-todo/auth: get: description: Returns the auth url where the user needs to get its auth code. From f528bcc2761d8b5be64c5ef23de1c378d8906707 Mon Sep 17 00:00:00 2001 From: "Frederick [Bot]" Date: Wed, 8 Apr 2026 01:25:14 +0000 Subject: [PATCH 098/101] chore(i18n): update translations via Crowdin --- frontend/src/i18n/lang/de-DE.json | 30 ++++++++++++++++++++++++++++ frontend/src/i18n/lang/de-swiss.json | 30 ++++++++++++++++++++++++++++ frontend/src/i18n/lang/ru-RU.json | 7 ++++++- pkg/i18n/lang/ru-RU.json | 13 ++++++++++++ 4 files changed, 79 insertions(+), 1 deletion(-) diff --git a/frontend/src/i18n/lang/de-DE.json b/frontend/src/i18n/lang/de-DE.json index a29d1680c..6bb898f65 100644 --- a/frontend/src/i18n/lang/de-DE.json +++ b/frontend/src/i18n/lang/de-DE.json @@ -5,9 +5,36 @@ }, "home": { "welcomeNight": "Gute Nacht, {username}!", + "welcomeNightOwl": "Hallo, Nacht-Eule {username}", + "welcomeNightBurning": "Machst du mal wieder die Nacht zum Tag, {username}?", + "welcomeNightQuiet": "Ruhezeit, {username}", + "welcomeNightLate": "Es ist spät, {username}", + "welcomeNightMoonlit": "Mondlicht-Planung, {username}?", "welcomeMorning": "Guten Morgen, {username}!", + "welcomeMorningHey": "Hey {username}, los geht's?", + "welcomeMorningFresh": "Frisch in den Tag, {username}", + "welcomeMorningCoffee": "Kaffee und Aufgaben, {username}?", + "welcomeMorningRise": "Morgenplan hat Gold im Mund, {username}", + "welcomeMorningBack": "Willkommen zurück, {username}", + "welcomeMondayFresh": "Frische Woche, {username}", + "welcomeTuesday": "Fröhlichen Dienstag, {username}", + "welcomeWednesdayMid": "Bergfest, {username}", + "welcomeThursday": "Fast geschafft, {username}", + "welcomeFridayPush": "Endspurt ins Wochenende, {username}?", + "welcomeSaturday": "Wochenendmodus, {username}", + "welcomeSundaySession": "Sonntagsschicht, {username}?", "welcomeDay": "Hallo {username}!", + "welcomeDayBack": "Wieder zurück, {username}", + "welcomeDayFocus": "Fokus, {username}", + "welcomeDayKeepGoing": "Weiter geht's, {username}", + "welcomeDayWhatsNext": "Was kommt als Nächstes, {username}?", + "welcomeDayGood": "Guten Nachmittag, {username}", "welcomeEvening": "Guten Abend, {username}!", + "welcomeEveningWind": "Feierabend, {username}?", + "welcomeEveningReturns": "{username} kehrt zurück", + "welcomeEveningWrap": "Feierabend in Sicht, {username}?", + "welcomeEveningOneMore": "Noch eine Sache, {username}?", + "welcomeEveningStill": "Immer noch da, {username}?", "lastViewed": "Zuletzt angesehen", "addToHomeScreen": "Füge diese App deinem Startbildschirm hinzu, um schneller darauf zuzugreifen und das Erlebnis zu verbessern.", "goToOverview": "Zur Übersicht", @@ -849,6 +876,7 @@ "addReminder": "Eine Erinnerung hinzufügen…", "doneSuccess": "Die Aufgabe wurde erfolgreich als erledigt markiert.", "undoneSuccess": "Die Aufgabe wurde erfolgreich als nicht-erledigt markiert.", + "readOnlyCheckbox": "Du hast nur Lesezugriff auf diese Aufgabe und kannst sie nicht als erledigt markieren.", "movedToProject": "Die Aufgabe wurde nach {project} verschoben.", "undo": "Rückgängig", "openDetail": "Aufgabe in der Detailansicht anzeigen", @@ -879,6 +907,8 @@ "updateSuccess": "Die Aufgabe wurde erfolgreich gespeichert.", "deleteSuccess": "Die Aufgabe wurde erfolgreich gelöscht.", "duplicateSuccess": "Die Aufgabe wurde erfolgreich dupliziert.", + "noBucket": "Keine Spalte", + "bucketChangedSuccess": "Die Spalte der Aufgabe wurde erfolgreich geändert.", "belongsToProject": "Diese Aufgabe gehört zum Projekt „{project}“", "back": "Zurück zum Projekt", "due": "Fällig {at}", diff --git a/frontend/src/i18n/lang/de-swiss.json b/frontend/src/i18n/lang/de-swiss.json index 8a60b440e..e7d3cdb3b 100644 --- a/frontend/src/i18n/lang/de-swiss.json +++ b/frontend/src/i18n/lang/de-swiss.json @@ -5,9 +5,36 @@ }, "home": { "welcomeNight": "Gute Nacht, {username}!", + "welcomeNightOwl": "Hallo, Nacht-Eule {username}", + "welcomeNightBurning": "Machst du mal wieder die Nacht zum Tag, {username}?", + "welcomeNightQuiet": "Ruhezeit, {username}", + "welcomeNightLate": "Es ist spät, {username}", + "welcomeNightMoonlit": "Mondlicht-Planung, {username}?", "welcomeMorning": "Guten Morgen, {username}!", + "welcomeMorningHey": "Hey {username}, los geht's?", + "welcomeMorningFresh": "Frisch in den Tag, {username}", + "welcomeMorningCoffee": "Kaffee und Aufgaben, {username}?", + "welcomeMorningRise": "Morgenplan hat Gold im Mund, {username}", + "welcomeMorningBack": "Willkommen zurück, {username}", + "welcomeMondayFresh": "Frische Woche, {username}", + "welcomeTuesday": "Fröhlichen Dienstag, {username}", + "welcomeWednesdayMid": "Bergfest, {username}", + "welcomeThursday": "Fast geschafft, {username}", + "welcomeFridayPush": "Endspurt ins Wochenende, {username}?", + "welcomeSaturday": "Wochenendmodus, {username}", + "welcomeSundaySession": "Sonntagsschicht, {username}?", "welcomeDay": "Hallo {username}!", + "welcomeDayBack": "Wieder zurück, {username}", + "welcomeDayFocus": "Fokus, {username}", + "welcomeDayKeepGoing": "Weiter geht's, {username}", + "welcomeDayWhatsNext": "Was kommt als Nächstes, {username}?", + "welcomeDayGood": "Guten Nachmittag, {username}", "welcomeEvening": "Guten Abend, {username}!", + "welcomeEveningWind": "Feierabend, {username}?", + "welcomeEveningReturns": "{username} kehrt zurück", + "welcomeEveningWrap": "Feierabend in Sicht, {username}?", + "welcomeEveningOneMore": "Noch eine Sache, {username}?", + "welcomeEveningStill": "Immer noch da, {username}?", "lastViewed": "Zletscht ahglueget", "addToHomeScreen": "Füge diese App deinem Startbildschirm hinzu, um schneller darauf zuzugreifen und das Erlebnis zu verbessern.", "goToOverview": "Zur Übersicht", @@ -849,6 +876,7 @@ "addReminder": "Eine Erinnerung hinzufügen…", "doneSuccess": "Die Uufgab isch erfolgriich als \"Fertig\" markiert wordä.", "undoneSuccess": "Die Uufgaab isch nüme als fertig markiert.", + "readOnlyCheckbox": "Du hast nur Lesezugriff auf diese Aufgabe und kannst sie nicht als erledigt markieren.", "movedToProject": "Die Aufgabe wurde nach {project} verschoben.", "undo": "Rückgängig", "openDetail": "Uufgab i de Detailaahsicht öffne", @@ -879,6 +907,8 @@ "updateSuccess": "Die Uufgab isch erfolgriich g'speichered wore.", "deleteSuccess": "Die Uufgab isch erfolgriich g'chüblet wore.", "duplicateSuccess": "Die Aufgabe wurde erfolgreich dupliziert.", + "noBucket": "Keine Spalte", + "bucketChangedSuccess": "Die Spalte der Aufgabe wurde erfolgreich geändert.", "belongsToProject": "Diese Aufgabe gehört zum Projekt „{project}“", "back": "Zurück zum Projekt", "due": "Fällig bis {at}", diff --git a/frontend/src/i18n/lang/ru-RU.json b/frontend/src/i18n/lang/ru-RU.json index 1f52124a2..3836ddce0 100644 --- a/frontend/src/i18n/lang/ru-RU.json +++ b/frontend/src/i18n/lang/ru-RU.json @@ -54,6 +54,8 @@ "authenticating": "Аутентификация…", "openIdStateError": "Состояние не совпадает, поэтому не продолжаем!", "openIdGeneralError": "Произошла ошибка при аутентификации через сторонний сервис.", + "oauthMissingParams": "Отсутствуют необходимые параметры OAuth: {params}", + "oauthRedirectedToApp": "Вы были перенаправлены в приложение. Теперь вы можете закрыть эту вкладку.", "logout": "Выйти", "emailInvalid": "Введите корректный email адрес.", "usernameRequired": "Введите имя пользователя.", @@ -155,7 +157,8 @@ "tokenCreated": "Ваш новый токен: {token}", "wontSeeItAgain": "Запишите его где-нибудь — у вас больше не будет возможности его увидеть.", "mustUseToken": "Вам необходимо создать токен CalDAV, если вы хотите использовать его со сторонним клиентом. Используйте его в качестве пароля.", - "usernameIs": "Имя пользователя для CalDAV: {0}" + "usernameIs": "Имя пользователя для CalDAV: {0}", + "apiTokenHint": "Вы также можете использовать токен API с разрешением CalDAV. Создайте его в {link}." }, "avatar": { "title": "Аватар", @@ -863,6 +866,8 @@ "updateSuccess": "Задача сохранена.", "deleteSuccess": "Задача удалена.", "duplicateSuccess": "Задача продублирована.", + "noBucket": "Нет колонки", + "bucketChangedSuccess": "Колонка задачи была успешно изменена.", "belongsToProject": "Задача принадлежит проекту «{project}»", "back": "Вернуться к проекту", "due": "Истекает {at}", diff --git a/pkg/i18n/lang/ru-RU.json b/pkg/i18n/lang/ru-RU.json index 1e0bce407..385e83db5 100644 --- a/pkg/i18n/lang/ru-RU.json +++ b/pkg/i18n/lang/ru-RU.json @@ -132,6 +132,19 @@ "working_on_it": "Мы получили сообщение об ошибке и изучим его в ближайшее время." } }, + "api_token": { + "expiring": { + "week": { + "subject": "Ваш API токен \"%[1]s\" скоро истекает", + "message": "Ваш API-токен \"%[1]s\" истекает %[2]. Если в нем есть необходимость, пожалуйста, создайте новый токен до истечения срока действия." + }, + "day": { + "subject": "Ваш API токен \"%[1]\" истекает завтра", + "message": "Ваш API-токен \"%[1]s\" истекает %[2]. Если в нем есть необходимость, пожалуйста, создайте новый токен до истечения срока действия." + }, + "action": "Управление API токенами" + } + }, "common": { "have_nice_day": "Хорошего дня!", "copy_url": "Если ссылка выше не работает, скопируйте и вставьте в адресную строку ссылку отсюда:", From a7bc3d6497e5e3dfa2e6832d34bdeafe9c097af7 Mon Sep 17 00:00:00 2001 From: kolaente Date: Wed, 8 Apr 2026 10:12:08 +0200 Subject: [PATCH 099/101] refactor: move plan file instead of copying in prepare-worktree --- magefile.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/magefile.go b/magefile.go index ebf0d7b3c..1446d9308 100644 --- a/magefile.go +++ b/magefile.go @@ -1645,7 +1645,7 @@ func (Generate) ConfigYAML(commented bool) { // PrepareWorktree creates a new git worktree for development. // The first argument is the name, which becomes both the folder name and branch name. -// The second argument is a path to a plan file that will be copied to the new worktree (pass "" to skip). +// The second argument is a path to a plan file that will be moved to the new worktree (pass "" to skip). // The worktree is created in the parent directory (../). // It also copies the current config.yml with an updated rootpath, and initializes the frontend. func (Dev) PrepareWorktree(ctx context.Context, name string, planPath string) error { @@ -1728,10 +1728,10 @@ func (Dev) PrepareWorktree(ctx context.Context, name string, planPath string) er } dstPlanPath := filepath.Join(plansDir, filepath.Base(planPath)) - if err := copyFile(srcPlanPath, dstPlanPath); err != nil { - return fmt.Errorf("failed to copy plan file: %w", err) + if err := os.Rename(srcPlanPath, dstPlanPath); err != nil { + return fmt.Errorf("failed to move plan file: %w", err) } - printSuccess("Plan file copied to %s!", dstPlanPath) + printSuccess("Plan file moved to %s!", dstPlanPath) } } From e898c01e3dece568f8dda8d092411776e4e447ec Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 03:49:37 +0000 Subject: [PATCH 100/101] chore(deps): update dev-dependencies --- frontend/package.json | 8 +- frontend/pnpm-lock.yaml | 740 ++++++++++++++++++++++------------------ 2 files changed, 418 insertions(+), 330 deletions(-) diff --git a/frontend/package.json b/frontend/package.json index dd0d79484..73df36b98 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -116,8 +116,8 @@ "@types/node": "24.12.2", "@types/sortablejs": "1.15.9", "@types/ws": "8.18.1", - "@typescript-eslint/eslint-plugin": "8.58.0", - "@typescript-eslint/parser": "8.58.0", + "@typescript-eslint/eslint-plugin": "8.58.1", + "@typescript-eslint/parser": "8.58.1", "@vitejs/plugin-vue": "6.0.5", "@vue/eslint-config-typescript": "14.7.0", "@vue/test-utils": "2.4.6", @@ -125,7 +125,7 @@ "@vueuse/shared": "14.2.1", "autoprefixer": "10.4.27", "browserslist": "4.28.2", - "caniuse-lite": "1.0.30001786", + "caniuse-lite": "1.0.30001787", "csstype": "3.2.3", "esbuild": "0.28.0", "eslint": "9.39.4", @@ -133,7 +133,7 @@ "eslint-plugin-vue": "10.8.0", "happy-dom": "20.8.9", "histoire": "1.0.0-beta.1", - "postcss": "8.5.8", + "postcss": "8.5.9", "postcss-easing-gradients": "3.0.1", "postcss-preset-env": "11.2.0", "rollup": "4.60.1", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 63c1de024..64e3be2aa 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -206,17 +206,17 @@ importers: specifier: 8.18.1 version: 8.18.1 '@typescript-eslint/eslint-plugin': - specifier: 8.58.0 - version: 8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + specifier: 8.58.1 + version: 8.58.1(@typescript-eslint/parser@8.58.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) '@typescript-eslint/parser': - specifier: 8.58.0 - version: 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + specifier: 8.58.1 + version: 8.58.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) '@vitejs/plugin-vue': specifier: 6.0.5 version: 6.0.5(vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3))(vue@3.5.27(typescript@5.9.3)) '@vue/eslint-config-typescript': specifier: 14.7.0 - version: 14.7.0(eslint-plugin-vue@10.8.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + version: 14.7.0(eslint-plugin-vue@10.8.0(@typescript-eslint/parser@8.58.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) '@vue/test-utils': specifier: 2.4.6 version: 2.4.6 @@ -228,13 +228,13 @@ importers: version: 14.2.1(vue@3.5.27(typescript@5.9.3)) autoprefixer: specifier: 10.4.27 - version: 10.4.27(postcss@8.5.8) + version: 10.4.27(postcss@8.5.9) browserslist: specifier: 4.28.2 version: 4.28.2 caniuse-lite: - specifier: 1.0.30001786 - version: 1.0.30001786 + specifier: 1.0.30001787 + version: 1.0.30001787 csstype: specifier: 3.2.3 version: 3.2.3 @@ -249,7 +249,7 @@ importers: version: 1.5.0(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-vue: specifier: 10.8.0 - version: 10.8.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))) + version: 10.8.0(@typescript-eslint/parser@8.58.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))) happy-dom: specifier: 20.8.9 version: 20.8.9 @@ -257,14 +257,14 @@ importers: specifier: 1.0.0-beta.1 version: 1.0.0-beta.1(@types/node@24.12.2)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3))(yaml@2.8.3) postcss: - specifier: 8.5.8 - version: 8.5.8 + specifier: 8.5.9 + version: 8.5.9 postcss-easing-gradients: specifier: 3.0.1 version: 3.0.1 postcss-preset-env: specifier: 11.2.0 - version: 11.2.0(postcss@8.5.8) + version: 11.2.0(postcss@8.5.9) rollup: specifier: 4.60.1 version: 4.60.1 @@ -285,7 +285,7 @@ importers: version: 1.6.1(postcss-html@1.8.0)(stylelint@17.6.0(typescript@5.9.3)) stylelint-config-standard-scss: specifier: 17.0.0 - version: 17.0.0(postcss@8.5.8)(stylelint@17.6.0(typescript@5.9.3)) + version: 17.0.0(postcss@8.5.9)(stylelint@17.6.0(typescript@5.9.3)) stylelint-use-logical: specifier: 2.1.3 version: 2.1.3(stylelint@17.6.0(typescript@5.9.3)) @@ -2831,11 +2831,11 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/eslint-plugin@8.58.0': - resolution: {integrity: sha512-RLkVSiNuUP1C2ROIWfqX+YcUfLaSnxGE/8M+Y57lopVwg9VTYYfhuz15Yf1IzCKgZj6/rIbYTmJCUSqr76r0Wg==} + '@typescript-eslint/eslint-plugin@8.58.1': + resolution: {integrity: sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: - '@typescript-eslint/parser': ^8.58.0 + '@typescript-eslint/parser': ^8.58.1 eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' @@ -2846,8 +2846,8 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/parser@8.58.0': - resolution: {integrity: sha512-rLoGZIf9afaRBYsPUMtvkDWykwXwUPL60HebR4JgTI8mxfFe2cQTu3AGitANp4b9B2QlVru6WzjgB2IzJKiCSA==} + '@typescript-eslint/parser@8.58.1': + resolution: {integrity: sha512-gGkiNMPqerb2cJSVcruigx9eHBlLG14fSdPdqMoOcBfh+vvn4iCq2C8MzUB89PrxOXk0y3GZ1yIWb9aOzL93bw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -2865,6 +2865,12 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/project-service@8.58.1': + resolution: {integrity: sha512-gfQ8fk6cxhtptek+/8ZIqw8YrRW5048Gug8Ts5IYcMLCw18iUgrZAEY/D7s4hkI0FxEfGakKuPK/XUMPzPxi5g==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/scope-manager@8.56.0': resolution: {integrity: sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2873,6 +2879,10 @@ packages: resolution: {integrity: sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/scope-manager@8.58.1': + resolution: {integrity: sha512-TPYUEqJK6avLcEjumWsIuTpuYODTTDAtoMdt8ZZa93uWMTX13Nb8L5leSje1NluammvU+oI3QRr5lLXPgihX3w==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/tsconfig-utils@8.56.0': resolution: {integrity: sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2885,6 +2895,12 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/tsconfig-utils@8.58.1': + resolution: {integrity: sha512-JAr2hOIct2Q+qk3G+8YFfqkqi7sC86uNryT+2i5HzMa2MPjw4qNFvtjnw1IiA1rP7QhNKVe21mSSLaSjwA1Olw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/type-utils@8.56.0': resolution: {integrity: sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2892,8 +2908,8 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.0.0' - '@typescript-eslint/type-utils@8.58.0': - resolution: {integrity: sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==} + '@typescript-eslint/type-utils@8.58.1': + resolution: {integrity: sha512-HUFxvTJVroT+0rXVJC7eD5zol6ID+Sn5npVPWoFuHGg9Ncq5Q4EYstqR+UOqaNRFXi5TYkpXXkLhoCHe3G0+7w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 @@ -2907,6 +2923,10 @@ packages: resolution: {integrity: sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/types@8.58.1': + resolution: {integrity: sha512-io/dV5Aw5ezwzfPBBWLoT+5QfVtP8O7q4Kftjn5azJ88bYyp/ZMCsyW1lpKK46EXJcaYMZ1JtYj+s/7TdzmQMw==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/typescript-estree@8.56.0': resolution: {integrity: sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2919,6 +2939,12 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/typescript-estree@8.58.1': + resolution: {integrity: sha512-w4w7WR7GHOjqqPnvAYbazq+Y5oS68b9CzasGtnd6jIeOIeKUzYzupGTB2T4LTPSv4d+WPeccbxuneTFHYgAAWg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/utils@8.56.0': resolution: {integrity: sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2933,6 +2959,13 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/utils@8.58.1': + resolution: {integrity: sha512-Ln8R0tmWC7pTtLOzgJzYTXSCjJ9rDNHAqTaVONF4FEi2qwce8mD9iSOxOpLFFvWp/wBFlew0mjM1L1ihYWfBdQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/visitor-keys@8.56.0': resolution: {integrity: sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2941,6 +2974,10 @@ packages: resolution: {integrity: sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/visitor-keys@8.58.1': + resolution: {integrity: sha512-y+vH7QE8ycjoa0bWciFg7OpFcipUuem1ujhrdLtq1gByKwfbC7bPeKsiny9e0urg93DqwGcHey+bGRKCnF1nZQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} @@ -3383,8 +3420,8 @@ packages: resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} engines: {node: '>=16'} - caniuse-lite@1.0.30001786: - resolution: {integrity: sha512-4oxTZEvqmLLrERwxO76yfKM7acZo310U+v4kqexI2TL1DkkUEMT8UijrxxcnVdxR3qkVf5awGRX+4Z6aPHVKrA==} + caniuse-lite@1.0.30001787: + resolution: {integrity: sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==} capture-website@4.2.0: resolution: {integrity: sha512-EmkSn36CXTC8tUsS6aNmvvsdpfVTYYkuRp7U5bV9gcJwcDbqqA5c0Op/iskYPKtDdOkuVp61mjn/LLywX0h7cw==} @@ -5439,8 +5476,8 @@ packages: resolution: {integrity: sha512-yioayjNbHn6z1/Bywyb2Y4s3yvDAeXGOyxqD+LnVOinq6Mdmd++SW2wUNVzavyyHxd6+DxzWGIuosg6P1Rj8uA==} engines: {node: '>=6.0.0'} - postcss@8.5.8: - resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} + postcss@8.5.9: + resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==} engines: {node: ^10 || ^12 || >=14} prelude-ls@1.2.1: @@ -7797,283 +7834,283 @@ snapshots: '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 - '@csstools/postcss-alpha-function@2.0.3(postcss@8.5.8)': + '@csstools/postcss-alpha-function@2.0.3(postcss@8.5.9)': dependencies: '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 - '@csstools/postcss-progressive-custom-properties': 5.0.0(postcss@8.5.8) - '@csstools/utilities': 3.0.0(postcss@8.5.8) - postcss: 8.5.8 + '@csstools/postcss-progressive-custom-properties': 5.0.0(postcss@8.5.9) + '@csstools/utilities': 3.0.0(postcss@8.5.9) + postcss: 8.5.9 - '@csstools/postcss-cascade-layers@6.0.0(postcss@8.5.8)': + '@csstools/postcss-cascade-layers@6.0.0(postcss@8.5.9)': dependencies: '@csstools/selector-specificity': 6.0.0(postcss-selector-parser@7.1.1) - postcss: 8.5.8 + postcss: 8.5.9 postcss-selector-parser: 7.1.1 - '@csstools/postcss-color-function-display-p3-linear@2.0.2(postcss@8.5.8)': + '@csstools/postcss-color-function-display-p3-linear@2.0.2(postcss@8.5.9)': dependencies: '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 - '@csstools/postcss-progressive-custom-properties': 5.0.0(postcss@8.5.8) - '@csstools/utilities': 3.0.0(postcss@8.5.8) - postcss: 8.5.8 + '@csstools/postcss-progressive-custom-properties': 5.0.0(postcss@8.5.9) + '@csstools/utilities': 3.0.0(postcss@8.5.9) + postcss: 8.5.9 - '@csstools/postcss-color-function@5.0.2(postcss@8.5.8)': + '@csstools/postcss-color-function@5.0.2(postcss@8.5.9)': dependencies: '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 - '@csstools/postcss-progressive-custom-properties': 5.0.0(postcss@8.5.8) - '@csstools/utilities': 3.0.0(postcss@8.5.8) - postcss: 8.5.8 + '@csstools/postcss-progressive-custom-properties': 5.0.0(postcss@8.5.9) + '@csstools/utilities': 3.0.0(postcss@8.5.9) + postcss: 8.5.9 - '@csstools/postcss-color-mix-function@4.0.2(postcss@8.5.8)': + '@csstools/postcss-color-mix-function@4.0.2(postcss@8.5.9)': dependencies: '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 - '@csstools/postcss-progressive-custom-properties': 5.0.0(postcss@8.5.8) - '@csstools/utilities': 3.0.0(postcss@8.5.8) - postcss: 8.5.8 + '@csstools/postcss-progressive-custom-properties': 5.0.0(postcss@8.5.9) + '@csstools/utilities': 3.0.0(postcss@8.5.9) + postcss: 8.5.9 - '@csstools/postcss-color-mix-variadic-function-arguments@2.0.2(postcss@8.5.8)': + '@csstools/postcss-color-mix-variadic-function-arguments@2.0.2(postcss@8.5.9)': dependencies: '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 - '@csstools/postcss-progressive-custom-properties': 5.0.0(postcss@8.5.8) - '@csstools/utilities': 3.0.0(postcss@8.5.8) - postcss: 8.5.8 + '@csstools/postcss-progressive-custom-properties': 5.0.0(postcss@8.5.9) + '@csstools/utilities': 3.0.0(postcss@8.5.9) + postcss: 8.5.9 - '@csstools/postcss-content-alt-text@3.0.0(postcss@8.5.8)': + '@csstools/postcss-content-alt-text@3.0.0(postcss@8.5.9)': dependencies: '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 - '@csstools/postcss-progressive-custom-properties': 5.0.0(postcss@8.5.8) - '@csstools/utilities': 3.0.0(postcss@8.5.8) - postcss: 8.5.8 + '@csstools/postcss-progressive-custom-properties': 5.0.0(postcss@8.5.9) + '@csstools/utilities': 3.0.0(postcss@8.5.9) + postcss: 8.5.9 - '@csstools/postcss-contrast-color-function@3.0.2(postcss@8.5.8)': + '@csstools/postcss-contrast-color-function@3.0.2(postcss@8.5.9)': dependencies: '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 - '@csstools/postcss-progressive-custom-properties': 5.0.0(postcss@8.5.8) - '@csstools/utilities': 3.0.0(postcss@8.5.8) - postcss: 8.5.8 + '@csstools/postcss-progressive-custom-properties': 5.0.0(postcss@8.5.9) + '@csstools/utilities': 3.0.0(postcss@8.5.9) + postcss: 8.5.9 - '@csstools/postcss-exponential-functions@3.0.1(postcss@8.5.8)': + '@csstools/postcss-exponential-functions@3.0.1(postcss@8.5.9)': dependencies: '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 - postcss: 8.5.8 + postcss: 8.5.9 - '@csstools/postcss-font-format-keywords@5.0.0(postcss@8.5.8)': + '@csstools/postcss-font-format-keywords@5.0.0(postcss@8.5.9)': dependencies: - '@csstools/utilities': 3.0.0(postcss@8.5.8) - postcss: 8.5.8 + '@csstools/utilities': 3.0.0(postcss@8.5.9) + postcss: 8.5.9 postcss-value-parser: 4.2.0 - '@csstools/postcss-font-width-property@1.0.0(postcss@8.5.8)': + '@csstools/postcss-font-width-property@1.0.0(postcss@8.5.9)': dependencies: - '@csstools/utilities': 3.0.0(postcss@8.5.8) - postcss: 8.5.8 + '@csstools/utilities': 3.0.0(postcss@8.5.9) + postcss: 8.5.9 - '@csstools/postcss-gamut-mapping@3.0.2(postcss@8.5.8)': + '@csstools/postcss-gamut-mapping@3.0.2(postcss@8.5.9)': dependencies: '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 - postcss: 8.5.8 + postcss: 8.5.9 - '@csstools/postcss-gradients-interpolation-method@6.0.2(postcss@8.5.8)': + '@csstools/postcss-gradients-interpolation-method@6.0.2(postcss@8.5.9)': dependencies: '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 - '@csstools/postcss-progressive-custom-properties': 5.0.0(postcss@8.5.8) - '@csstools/utilities': 3.0.0(postcss@8.5.8) - postcss: 8.5.8 + '@csstools/postcss-progressive-custom-properties': 5.0.0(postcss@8.5.9) + '@csstools/utilities': 3.0.0(postcss@8.5.9) + postcss: 8.5.9 - '@csstools/postcss-hwb-function@5.0.2(postcss@8.5.8)': + '@csstools/postcss-hwb-function@5.0.2(postcss@8.5.9)': dependencies: '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 - '@csstools/postcss-progressive-custom-properties': 5.0.0(postcss@8.5.8) - '@csstools/utilities': 3.0.0(postcss@8.5.8) - postcss: 8.5.8 + '@csstools/postcss-progressive-custom-properties': 5.0.0(postcss@8.5.9) + '@csstools/utilities': 3.0.0(postcss@8.5.9) + postcss: 8.5.9 - '@csstools/postcss-ic-unit@5.0.0(postcss@8.5.8)': + '@csstools/postcss-ic-unit@5.0.0(postcss@8.5.9)': dependencies: - '@csstools/postcss-progressive-custom-properties': 5.0.0(postcss@8.5.8) - '@csstools/utilities': 3.0.0(postcss@8.5.8) - postcss: 8.5.8 + '@csstools/postcss-progressive-custom-properties': 5.0.0(postcss@8.5.9) + '@csstools/utilities': 3.0.0(postcss@8.5.9) + postcss: 8.5.9 postcss-value-parser: 4.2.0 - '@csstools/postcss-initial@3.0.0(postcss@8.5.8)': + '@csstools/postcss-initial@3.0.0(postcss@8.5.9)': dependencies: - postcss: 8.5.8 + postcss: 8.5.9 - '@csstools/postcss-is-pseudo-class@6.0.0(postcss@8.5.8)': + '@csstools/postcss-is-pseudo-class@6.0.0(postcss@8.5.9)': dependencies: '@csstools/selector-specificity': 6.0.0(postcss-selector-parser@7.1.1) - postcss: 8.5.8 + postcss: 8.5.9 postcss-selector-parser: 7.1.1 - '@csstools/postcss-light-dark-function@3.0.0(postcss@8.5.8)': + '@csstools/postcss-light-dark-function@3.0.0(postcss@8.5.9)': dependencies: '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 - '@csstools/postcss-progressive-custom-properties': 5.0.0(postcss@8.5.8) - '@csstools/utilities': 3.0.0(postcss@8.5.8) - postcss: 8.5.8 + '@csstools/postcss-progressive-custom-properties': 5.0.0(postcss@8.5.9) + '@csstools/utilities': 3.0.0(postcss@8.5.9) + postcss: 8.5.9 - '@csstools/postcss-logical-float-and-clear@4.0.0(postcss@8.5.8)': + '@csstools/postcss-logical-float-and-clear@4.0.0(postcss@8.5.9)': dependencies: - postcss: 8.5.8 + postcss: 8.5.9 - '@csstools/postcss-logical-overflow@3.0.0(postcss@8.5.8)': + '@csstools/postcss-logical-overflow@3.0.0(postcss@8.5.9)': dependencies: - postcss: 8.5.8 + postcss: 8.5.9 - '@csstools/postcss-logical-overscroll-behavior@3.0.0(postcss@8.5.8)': + '@csstools/postcss-logical-overscroll-behavior@3.0.0(postcss@8.5.9)': dependencies: - postcss: 8.5.8 + postcss: 8.5.9 - '@csstools/postcss-logical-resize@4.0.0(postcss@8.5.8)': + '@csstools/postcss-logical-resize@4.0.0(postcss@8.5.9)': dependencies: - postcss: 8.5.8 + postcss: 8.5.9 postcss-value-parser: 4.2.0 - '@csstools/postcss-logical-viewport-units@4.0.0(postcss@8.5.8)': + '@csstools/postcss-logical-viewport-units@4.0.0(postcss@8.5.9)': dependencies: '@csstools/css-tokenizer': 4.0.0 - '@csstools/utilities': 3.0.0(postcss@8.5.8) - postcss: 8.5.8 + '@csstools/utilities': 3.0.0(postcss@8.5.9) + postcss: 8.5.9 - '@csstools/postcss-media-minmax@3.0.1(postcss@8.5.8)': + '@csstools/postcss-media-minmax@3.0.1(postcss@8.5.9)': dependencies: '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 '@csstools/media-query-list-parser': 5.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) - postcss: 8.5.8 + postcss: 8.5.9 - '@csstools/postcss-media-queries-aspect-ratio-number-values@4.0.0(postcss@8.5.8)': + '@csstools/postcss-media-queries-aspect-ratio-number-values@4.0.0(postcss@8.5.9)': dependencies: '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 '@csstools/media-query-list-parser': 5.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) - postcss: 8.5.8 + postcss: 8.5.9 - '@csstools/postcss-mixins@1.0.0(postcss@8.5.8)': + '@csstools/postcss-mixins@1.0.0(postcss@8.5.9)': dependencies: '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 - postcss: 8.5.8 + postcss: 8.5.9 - '@csstools/postcss-nested-calc@5.0.0(postcss@8.5.8)': + '@csstools/postcss-nested-calc@5.0.0(postcss@8.5.9)': dependencies: - '@csstools/utilities': 3.0.0(postcss@8.5.8) - postcss: 8.5.8 + '@csstools/utilities': 3.0.0(postcss@8.5.9) + postcss: 8.5.9 postcss-value-parser: 4.2.0 - '@csstools/postcss-normalize-display-values@5.0.1(postcss@8.5.8)': + '@csstools/postcss-normalize-display-values@5.0.1(postcss@8.5.9)': dependencies: - postcss: 8.5.8 + postcss: 8.5.9 postcss-value-parser: 4.2.0 - '@csstools/postcss-oklab-function@5.0.2(postcss@8.5.8)': + '@csstools/postcss-oklab-function@5.0.2(postcss@8.5.9)': dependencies: '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 - '@csstools/postcss-progressive-custom-properties': 5.0.0(postcss@8.5.8) - '@csstools/utilities': 3.0.0(postcss@8.5.8) - postcss: 8.5.8 + '@csstools/postcss-progressive-custom-properties': 5.0.0(postcss@8.5.9) + '@csstools/utilities': 3.0.0(postcss@8.5.9) + postcss: 8.5.9 - '@csstools/postcss-position-area-property@2.0.0(postcss@8.5.8)': + '@csstools/postcss-position-area-property@2.0.0(postcss@8.5.9)': dependencies: - postcss: 8.5.8 + postcss: 8.5.9 - '@csstools/postcss-progressive-custom-properties@5.0.0(postcss@8.5.8)': + '@csstools/postcss-progressive-custom-properties@5.0.0(postcss@8.5.9)': dependencies: - postcss: 8.5.8 + postcss: 8.5.9 postcss-value-parser: 4.2.0 - '@csstools/postcss-property-rule-prelude-list@2.0.0(postcss@8.5.8)': + '@csstools/postcss-property-rule-prelude-list@2.0.0(postcss@8.5.9)': dependencies: '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 - postcss: 8.5.8 + postcss: 8.5.9 - '@csstools/postcss-random-function@3.0.1(postcss@8.5.8)': + '@csstools/postcss-random-function@3.0.1(postcss@8.5.9)': dependencies: '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 - postcss: 8.5.8 + postcss: 8.5.9 - '@csstools/postcss-relative-color-syntax@4.0.2(postcss@8.5.8)': + '@csstools/postcss-relative-color-syntax@4.0.2(postcss@8.5.9)': dependencies: '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 - '@csstools/postcss-progressive-custom-properties': 5.0.0(postcss@8.5.8) - '@csstools/utilities': 3.0.0(postcss@8.5.8) - postcss: 8.5.8 + '@csstools/postcss-progressive-custom-properties': 5.0.0(postcss@8.5.9) + '@csstools/utilities': 3.0.0(postcss@8.5.9) + postcss: 8.5.9 - '@csstools/postcss-scope-pseudo-class@5.0.0(postcss@8.5.8)': + '@csstools/postcss-scope-pseudo-class@5.0.0(postcss@8.5.9)': dependencies: - postcss: 8.5.8 + postcss: 8.5.9 postcss-selector-parser: 7.1.1 - '@csstools/postcss-sign-functions@2.0.1(postcss@8.5.8)': + '@csstools/postcss-sign-functions@2.0.1(postcss@8.5.9)': dependencies: '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 - postcss: 8.5.8 + postcss: 8.5.9 - '@csstools/postcss-stepped-value-functions@5.0.1(postcss@8.5.8)': + '@csstools/postcss-stepped-value-functions@5.0.1(postcss@8.5.9)': dependencies: '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 - postcss: 8.5.8 + postcss: 8.5.9 - '@csstools/postcss-syntax-descriptor-syntax-production@2.0.0(postcss@8.5.8)': + '@csstools/postcss-syntax-descriptor-syntax-production@2.0.0(postcss@8.5.9)': dependencies: '@csstools/css-tokenizer': 4.0.0 - postcss: 8.5.8 + postcss: 8.5.9 - '@csstools/postcss-system-ui-font-family@2.0.0(postcss@8.5.8)': + '@csstools/postcss-system-ui-font-family@2.0.0(postcss@8.5.9)': dependencies: '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 - postcss: 8.5.8 + postcss: 8.5.9 - '@csstools/postcss-text-decoration-shorthand@5.0.3(postcss@8.5.8)': + '@csstools/postcss-text-decoration-shorthand@5.0.3(postcss@8.5.9)': dependencies: '@csstools/color-helpers': 6.0.2 - postcss: 8.5.8 + postcss: 8.5.9 postcss-value-parser: 4.2.0 - '@csstools/postcss-trigonometric-functions@5.0.1(postcss@8.5.8)': + '@csstools/postcss-trigonometric-functions@5.0.1(postcss@8.5.9)': dependencies: '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 - postcss: 8.5.8 + postcss: 8.5.9 - '@csstools/postcss-unset-value@5.0.0(postcss@8.5.8)': + '@csstools/postcss-unset-value@5.0.0(postcss@8.5.9)': dependencies: - postcss: 8.5.8 + postcss: 8.5.9 '@csstools/selector-resolve-nested@4.0.0(postcss-selector-parser@7.1.1)': dependencies: @@ -8083,9 +8120,9 @@ snapshots: dependencies: postcss-selector-parser: 7.1.1 - '@csstools/utilities@3.0.0(postcss@8.5.8)': + '@csstools/utilities@3.0.0(postcss@8.5.9)': dependencies: - postcss: 8.5.8 + postcss: 8.5.9 '@esbuild/aix-ppc64@0.25.12': optional: true @@ -9417,14 +9454,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/eslint-plugin@8.58.1(@typescript-eslint/parser@8.58.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/scope-manager': 8.58.0 - '@typescript-eslint/type-utils': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/utils': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.58.0 + '@typescript-eslint/parser': 8.58.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/scope-manager': 8.58.1 + '@typescript-eslint/type-utils': 8.58.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.58.1 eslint: 9.39.4(jiti@2.6.1) ignore: 7.0.5 natural-compare: 1.4.0 @@ -9445,12 +9482,12 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/parser@8.58.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/scope-manager': 8.58.0 - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) - '@typescript-eslint/visitor-keys': 8.58.0 + '@typescript-eslint/scope-manager': 8.58.1 + '@typescript-eslint/types': 8.58.1 + '@typescript-eslint/typescript-estree': 8.58.1(typescript@5.9.3) + '@typescript-eslint/visitor-keys': 8.58.1 debug: 4.4.3 eslint: 9.39.4(jiti@2.6.1) typescript: 5.9.3 @@ -9475,6 +9512,15 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/project-service@8.58.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.58.1(typescript@5.9.3) + '@typescript-eslint/types': 8.58.1 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/scope-manager@8.56.0': dependencies: '@typescript-eslint/types': 8.56.0 @@ -9485,6 +9531,11 @@ snapshots: '@typescript-eslint/types': 8.58.0 '@typescript-eslint/visitor-keys': 8.58.0 + '@typescript-eslint/scope-manager@8.58.1': + dependencies: + '@typescript-eslint/types': 8.58.1 + '@typescript-eslint/visitor-keys': 8.58.1 + '@typescript-eslint/tsconfig-utils@8.56.0(typescript@5.9.3)': dependencies: typescript: 5.9.3 @@ -9493,6 +9544,10 @@ snapshots: dependencies: typescript: 5.9.3 + '@typescript-eslint/tsconfig-utils@8.58.1(typescript@5.9.3)': + dependencies: + typescript: 5.9.3 + '@typescript-eslint/type-utils@8.56.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/types': 8.56.0 @@ -9505,11 +9560,11 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/type-utils@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@typescript-eslint/type-utils@8.58.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: - '@typescript-eslint/types': 8.58.0 - '@typescript-eslint/typescript-estree': 8.58.0(typescript@5.9.3) - '@typescript-eslint/utils': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/types': 8.58.1 + '@typescript-eslint/typescript-estree': 8.58.1(typescript@5.9.3) + '@typescript-eslint/utils': 8.58.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) debug: 4.4.3 eslint: 9.39.4(jiti@2.6.1) ts-api-utils: 2.5.0(typescript@5.9.3) @@ -9521,6 +9576,8 @@ snapshots: '@typescript-eslint/types@8.58.0': {} + '@typescript-eslint/types@8.58.1': {} + '@typescript-eslint/typescript-estree@8.56.0(typescript@5.9.3)': dependencies: '@typescript-eslint/project-service': 8.56.0(typescript@5.9.3) @@ -9551,6 +9608,21 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/typescript-estree@8.58.1(typescript@5.9.3)': + dependencies: + '@typescript-eslint/project-service': 8.58.1(typescript@5.9.3) + '@typescript-eslint/tsconfig-utils': 8.58.1(typescript@5.9.3) + '@typescript-eslint/types': 8.58.1 + '@typescript-eslint/visitor-keys': 8.58.1 + debug: 4.4.3 + minimatch: 10.2.4 + semver: 7.7.3 + tinyglobby: 0.2.15 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/utils@8.56.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) @@ -9573,6 +9645,17 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/utils@8.58.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) + '@typescript-eslint/scope-manager': 8.58.1 + '@typescript-eslint/types': 8.58.1 + '@typescript-eslint/typescript-estree': 8.58.1(typescript@5.9.3) + eslint: 9.39.4(jiti@2.6.1) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/visitor-keys@8.56.0': dependencies: '@typescript-eslint/types': 8.56.0 @@ -9583,6 +9666,11 @@ snapshots: '@typescript-eslint/types': 8.58.0 eslint-visitor-keys: 5.0.0 + '@typescript-eslint/visitor-keys@8.58.1': + dependencies: + '@typescript-eslint/types': 8.58.1 + eslint-visitor-keys: 5.0.0 + '@ungap/structured-clone@1.3.0': {} '@vitejs/plugin-vue@6.0.5(vite@7.3.2(@types/node@24.12.2)(jiti@2.6.1)(lightningcss@1.32.0)(sass-embedded@1.99.0)(sass@1.99.0)(terser@5.31.6)(yaml@2.8.3))(vue@3.5.27(typescript@5.9.3))': @@ -9696,7 +9784,7 @@ snapshots: '@vue/shared': 3.5.27 estree-walker: 2.0.2 magic-string: 0.30.21 - postcss: 8.5.8 + postcss: 8.5.9 source-map-js: 1.2.1 '@vue/compiler-ssr@3.5.27': @@ -9739,11 +9827,11 @@ snapshots: '@vue/devtools-shared@8.1.1': {} - '@vue/eslint-config-typescript@14.7.0(eslint-plugin-vue@10.8.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': + '@vue/eslint-config-typescript@14.7.0(eslint-plugin-vue@10.8.0(@typescript-eslint/parser@8.58.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)': dependencies: '@typescript-eslint/utils': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) eslint: 9.39.4(jiti@2.6.1) - eslint-plugin-vue: 10.8.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))) + eslint-plugin-vue: 10.8.0(@typescript-eslint/parser@8.58.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))) fast-glob: 3.3.3 typescript-eslint: 8.56.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) vue-eslint-parser: 10.4.0(eslint@9.39.4(jiti@2.6.1)) @@ -9917,13 +10005,13 @@ snapshots: stubborn-fs: 2.0.0 when-exit: 2.1.5 - autoprefixer@10.4.27(postcss@8.5.8): + autoprefixer@10.4.27(postcss@8.5.9): dependencies: browserslist: 4.28.2 - caniuse-lite: 1.0.30001786 + caniuse-lite: 1.0.30001787 fraction.js: 5.3.4 picocolors: 1.1.1 - postcss: 8.5.8 + postcss: 8.5.9 postcss-value-parser: 4.2.0 available-typed-arrays@1.0.7: @@ -10039,7 +10127,7 @@ snapshots: browserslist@4.28.2: dependencies: baseline-browser-mapping: 2.10.12 - caniuse-lite: 1.0.30001786 + caniuse-lite: 1.0.30001787 electron-to-chromium: 1.5.329 node-releases: 2.0.36 update-browserslist-db: 1.2.3(browserslist@4.28.2) @@ -10101,7 +10189,7 @@ snapshots: camelcase@8.0.0: {} - caniuse-lite@1.0.30001786: {} + caniuse-lite@1.0.30001787: {} capture-website@4.2.0(typescript@5.9.3): dependencies: @@ -10253,23 +10341,23 @@ snapshots: crypto-random-string@2.0.0: {} - css-blank-pseudo@8.0.1(postcss@8.5.8): + css-blank-pseudo@8.0.1(postcss@8.5.9): dependencies: - postcss: 8.5.8 + postcss: 8.5.9 postcss-selector-parser: 7.1.1 css-functions-list@3.3.3: {} - css-has-pseudo@8.0.0(postcss@8.5.8): + css-has-pseudo@8.0.0(postcss@8.5.9): dependencies: '@csstools/selector-specificity': 6.0.0(postcss-selector-parser@7.1.1) - postcss: 8.5.8 + postcss: 8.5.9 postcss-selector-parser: 7.1.1 postcss-value-parser: 4.2.0 - css-prefers-color-scheme@11.0.0(postcss@8.5.8): + css-prefers-color-scheme@11.0.0(postcss@8.5.9): dependencies: - postcss: 8.5.8 + postcss: 8.5.9 css-property-sort-order-smacss@2.2.0: {} @@ -10697,7 +10785,7 @@ snapshots: module-replacements: 2.11.0 semver: 7.7.3 - eslint-plugin-vue@10.8.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))): + eslint-plugin-vue@10.8.0(@typescript-eslint/parser@8.58.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(vue-eslint-parser@10.4.0(eslint@9.39.4(jiti@2.6.1))): dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.39.4(jiti@2.6.1)) eslint: 9.39.4(jiti@2.6.1) @@ -10708,7 +10796,7 @@ snapshots: vue-eslint-parser: 10.4.0(eslint@9.39.4(jiti@2.6.1)) xml-name-validator: 4.0.0 optionalDependencies: - '@typescript-eslint/parser': 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) + '@typescript-eslint/parser': 8.58.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) eslint-scope@8.4.0: dependencies: @@ -12062,72 +12150,72 @@ snapshots: possible-typed-array-names@1.0.0: {} - postcss-attribute-case-insensitive@8.0.0(postcss@8.5.8): + postcss-attribute-case-insensitive@8.0.0(postcss@8.5.9): dependencies: - postcss: 8.5.8 + postcss: 8.5.9 postcss-selector-parser: 7.1.1 - postcss-clamp@4.1.0(postcss@8.5.8): + postcss-clamp@4.1.0(postcss@8.5.9): dependencies: - postcss: 8.5.8 + postcss: 8.5.9 postcss-value-parser: 4.2.0 - postcss-color-functional-notation@8.0.2(postcss@8.5.8): + postcss-color-functional-notation@8.0.2(postcss@8.5.9): dependencies: '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 - '@csstools/postcss-progressive-custom-properties': 5.0.0(postcss@8.5.8) - '@csstools/utilities': 3.0.0(postcss@8.5.8) - postcss: 8.5.8 + '@csstools/postcss-progressive-custom-properties': 5.0.0(postcss@8.5.9) + '@csstools/utilities': 3.0.0(postcss@8.5.9) + postcss: 8.5.9 - postcss-color-hex-alpha@11.0.0(postcss@8.5.8): + postcss-color-hex-alpha@11.0.0(postcss@8.5.9): dependencies: - '@csstools/utilities': 3.0.0(postcss@8.5.8) - postcss: 8.5.8 + '@csstools/utilities': 3.0.0(postcss@8.5.9) + postcss: 8.5.9 postcss-value-parser: 4.2.0 - postcss-color-rebeccapurple@11.0.0(postcss@8.5.8): + postcss-color-rebeccapurple@11.0.0(postcss@8.5.9): dependencies: - '@csstools/utilities': 3.0.0(postcss@8.5.8) - postcss: 8.5.8 + '@csstools/utilities': 3.0.0(postcss@8.5.9) + postcss: 8.5.9 postcss-value-parser: 4.2.0 - postcss-custom-media@12.0.1(postcss@8.5.8): + postcss-custom-media@12.0.1(postcss@8.5.9): dependencies: '@csstools/cascade-layer-name-parser': 3.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 '@csstools/media-query-list-parser': 5.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) - postcss: 8.5.8 + postcss: 8.5.9 - postcss-custom-properties@15.0.1(postcss@8.5.8): + postcss-custom-properties@15.0.1(postcss@8.5.9): dependencies: '@csstools/cascade-layer-name-parser': 3.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 - '@csstools/utilities': 3.0.0(postcss@8.5.8) - postcss: 8.5.8 + '@csstools/utilities': 3.0.0(postcss@8.5.9) + postcss: 8.5.9 postcss-value-parser: 4.2.0 - postcss-custom-selectors@9.0.1(postcss@8.5.8): + postcss-custom-selectors@9.0.1(postcss@8.5.9): dependencies: '@csstools/cascade-layer-name-parser': 3.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 - postcss: 8.5.8 + postcss: 8.5.9 postcss-selector-parser: 7.1.1 - postcss-dir-pseudo-class@10.0.0(postcss@8.5.8): + postcss-dir-pseudo-class@10.0.0(postcss@8.5.9): dependencies: - postcss: 8.5.8 + postcss: 8.5.9 postcss-selector-parser: 7.1.1 - postcss-double-position-gradients@7.0.0(postcss@8.5.8): + postcss-double-position-gradients@7.0.0(postcss@8.5.9): dependencies: - '@csstools/postcss-progressive-custom-properties': 5.0.0(postcss@8.5.8) - '@csstools/utilities': 3.0.0(postcss@8.5.8) - postcss: 8.5.8 + '@csstools/postcss-progressive-custom-properties': 5.0.0(postcss@8.5.9) + '@csstools/utilities': 3.0.0(postcss@8.5.9) + postcss: 8.5.9 postcss-value-parser: 4.2.0 postcss-easing-gradients@3.0.1: @@ -12137,181 +12225,181 @@ snapshots: postcss: 7.0.39 postcss-value-parser: 3.3.1 - postcss-focus-visible@11.0.0(postcss@8.5.8): + postcss-focus-visible@11.0.0(postcss@8.5.9): dependencies: - postcss: 8.5.8 + postcss: 8.5.9 postcss-selector-parser: 7.1.1 - postcss-focus-within@10.0.0(postcss@8.5.8): + postcss-focus-within@10.0.0(postcss@8.5.9): dependencies: - postcss: 8.5.8 + postcss: 8.5.9 postcss-selector-parser: 7.1.1 - postcss-font-variant@5.0.0(postcss@8.5.8): + postcss-font-variant@5.0.0(postcss@8.5.9): dependencies: - postcss: 8.5.8 + postcss: 8.5.9 - postcss-gap-properties@7.0.0(postcss@8.5.8): + postcss-gap-properties@7.0.0(postcss@8.5.9): dependencies: - postcss: 8.5.8 + postcss: 8.5.9 postcss-html@1.8.0: dependencies: htmlparser2: 8.0.2 js-tokens: 9.0.1 - postcss: 8.5.8 - postcss-safe-parser: 6.0.0(postcss@8.5.8) + postcss: 8.5.9 + postcss-safe-parser: 6.0.0(postcss@8.5.9) - postcss-image-set-function@8.0.0(postcss@8.5.8): + postcss-image-set-function@8.0.0(postcss@8.5.9): dependencies: - '@csstools/utilities': 3.0.0(postcss@8.5.8) - postcss: 8.5.8 + '@csstools/utilities': 3.0.0(postcss@8.5.9) + postcss: 8.5.9 postcss-value-parser: 4.2.0 - postcss-lab-function@8.0.2(postcss@8.5.8): + postcss-lab-function@8.0.2(postcss@8.5.9): dependencies: '@csstools/css-color-parser': 4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 - '@csstools/postcss-progressive-custom-properties': 5.0.0(postcss@8.5.8) - '@csstools/utilities': 3.0.0(postcss@8.5.8) - postcss: 8.5.8 + '@csstools/postcss-progressive-custom-properties': 5.0.0(postcss@8.5.9) + '@csstools/utilities': 3.0.0(postcss@8.5.9) + postcss: 8.5.9 - postcss-logical@9.0.0(postcss@8.5.8): + postcss-logical@9.0.0(postcss@8.5.9): dependencies: - postcss: 8.5.8 + postcss: 8.5.9 postcss-value-parser: 4.2.0 postcss-media-query-parser@0.2.3: {} - postcss-nesting@14.0.0(postcss@8.5.8): + postcss-nesting@14.0.0(postcss@8.5.9): dependencies: '@csstools/selector-resolve-nested': 4.0.0(postcss-selector-parser@7.1.1) '@csstools/selector-specificity': 6.0.0(postcss-selector-parser@7.1.1) - postcss: 8.5.8 + postcss: 8.5.9 postcss-selector-parser: 7.1.1 - postcss-opacity-percentage@3.0.0(postcss@8.5.8): + postcss-opacity-percentage@3.0.0(postcss@8.5.9): dependencies: - postcss: 8.5.8 + postcss: 8.5.9 - postcss-overflow-shorthand@7.0.0(postcss@8.5.8): + postcss-overflow-shorthand@7.0.0(postcss@8.5.9): dependencies: - postcss: 8.5.8 + postcss: 8.5.9 postcss-value-parser: 4.2.0 - postcss-page-break@3.0.4(postcss@8.5.8): + postcss-page-break@3.0.4(postcss@8.5.9): dependencies: - postcss: 8.5.8 + postcss: 8.5.9 - postcss-place@11.0.0(postcss@8.5.8): + postcss-place@11.0.0(postcss@8.5.9): dependencies: - postcss: 8.5.8 + postcss: 8.5.9 postcss-value-parser: 4.2.0 - postcss-preset-env@11.2.0(postcss@8.5.8): + postcss-preset-env@11.2.0(postcss@8.5.9): dependencies: - '@csstools/postcss-alpha-function': 2.0.3(postcss@8.5.8) - '@csstools/postcss-cascade-layers': 6.0.0(postcss@8.5.8) - '@csstools/postcss-color-function': 5.0.2(postcss@8.5.8) - '@csstools/postcss-color-function-display-p3-linear': 2.0.2(postcss@8.5.8) - '@csstools/postcss-color-mix-function': 4.0.2(postcss@8.5.8) - '@csstools/postcss-color-mix-variadic-function-arguments': 2.0.2(postcss@8.5.8) - '@csstools/postcss-content-alt-text': 3.0.0(postcss@8.5.8) - '@csstools/postcss-contrast-color-function': 3.0.2(postcss@8.5.8) - '@csstools/postcss-exponential-functions': 3.0.1(postcss@8.5.8) - '@csstools/postcss-font-format-keywords': 5.0.0(postcss@8.5.8) - '@csstools/postcss-font-width-property': 1.0.0(postcss@8.5.8) - '@csstools/postcss-gamut-mapping': 3.0.2(postcss@8.5.8) - '@csstools/postcss-gradients-interpolation-method': 6.0.2(postcss@8.5.8) - '@csstools/postcss-hwb-function': 5.0.2(postcss@8.5.8) - '@csstools/postcss-ic-unit': 5.0.0(postcss@8.5.8) - '@csstools/postcss-initial': 3.0.0(postcss@8.5.8) - '@csstools/postcss-is-pseudo-class': 6.0.0(postcss@8.5.8) - '@csstools/postcss-light-dark-function': 3.0.0(postcss@8.5.8) - '@csstools/postcss-logical-float-and-clear': 4.0.0(postcss@8.5.8) - '@csstools/postcss-logical-overflow': 3.0.0(postcss@8.5.8) - '@csstools/postcss-logical-overscroll-behavior': 3.0.0(postcss@8.5.8) - '@csstools/postcss-logical-resize': 4.0.0(postcss@8.5.8) - '@csstools/postcss-logical-viewport-units': 4.0.0(postcss@8.5.8) - '@csstools/postcss-media-minmax': 3.0.1(postcss@8.5.8) - '@csstools/postcss-media-queries-aspect-ratio-number-values': 4.0.0(postcss@8.5.8) - '@csstools/postcss-mixins': 1.0.0(postcss@8.5.8) - '@csstools/postcss-nested-calc': 5.0.0(postcss@8.5.8) - '@csstools/postcss-normalize-display-values': 5.0.1(postcss@8.5.8) - '@csstools/postcss-oklab-function': 5.0.2(postcss@8.5.8) - '@csstools/postcss-position-area-property': 2.0.0(postcss@8.5.8) - '@csstools/postcss-progressive-custom-properties': 5.0.0(postcss@8.5.8) - '@csstools/postcss-property-rule-prelude-list': 2.0.0(postcss@8.5.8) - '@csstools/postcss-random-function': 3.0.1(postcss@8.5.8) - '@csstools/postcss-relative-color-syntax': 4.0.2(postcss@8.5.8) - '@csstools/postcss-scope-pseudo-class': 5.0.0(postcss@8.5.8) - '@csstools/postcss-sign-functions': 2.0.1(postcss@8.5.8) - '@csstools/postcss-stepped-value-functions': 5.0.1(postcss@8.5.8) - '@csstools/postcss-syntax-descriptor-syntax-production': 2.0.0(postcss@8.5.8) - '@csstools/postcss-system-ui-font-family': 2.0.0(postcss@8.5.8) - '@csstools/postcss-text-decoration-shorthand': 5.0.3(postcss@8.5.8) - '@csstools/postcss-trigonometric-functions': 5.0.1(postcss@8.5.8) - '@csstools/postcss-unset-value': 5.0.0(postcss@8.5.8) - autoprefixer: 10.4.27(postcss@8.5.8) + '@csstools/postcss-alpha-function': 2.0.3(postcss@8.5.9) + '@csstools/postcss-cascade-layers': 6.0.0(postcss@8.5.9) + '@csstools/postcss-color-function': 5.0.2(postcss@8.5.9) + '@csstools/postcss-color-function-display-p3-linear': 2.0.2(postcss@8.5.9) + '@csstools/postcss-color-mix-function': 4.0.2(postcss@8.5.9) + '@csstools/postcss-color-mix-variadic-function-arguments': 2.0.2(postcss@8.5.9) + '@csstools/postcss-content-alt-text': 3.0.0(postcss@8.5.9) + '@csstools/postcss-contrast-color-function': 3.0.2(postcss@8.5.9) + '@csstools/postcss-exponential-functions': 3.0.1(postcss@8.5.9) + '@csstools/postcss-font-format-keywords': 5.0.0(postcss@8.5.9) + '@csstools/postcss-font-width-property': 1.0.0(postcss@8.5.9) + '@csstools/postcss-gamut-mapping': 3.0.2(postcss@8.5.9) + '@csstools/postcss-gradients-interpolation-method': 6.0.2(postcss@8.5.9) + '@csstools/postcss-hwb-function': 5.0.2(postcss@8.5.9) + '@csstools/postcss-ic-unit': 5.0.0(postcss@8.5.9) + '@csstools/postcss-initial': 3.0.0(postcss@8.5.9) + '@csstools/postcss-is-pseudo-class': 6.0.0(postcss@8.5.9) + '@csstools/postcss-light-dark-function': 3.0.0(postcss@8.5.9) + '@csstools/postcss-logical-float-and-clear': 4.0.0(postcss@8.5.9) + '@csstools/postcss-logical-overflow': 3.0.0(postcss@8.5.9) + '@csstools/postcss-logical-overscroll-behavior': 3.0.0(postcss@8.5.9) + '@csstools/postcss-logical-resize': 4.0.0(postcss@8.5.9) + '@csstools/postcss-logical-viewport-units': 4.0.0(postcss@8.5.9) + '@csstools/postcss-media-minmax': 3.0.1(postcss@8.5.9) + '@csstools/postcss-media-queries-aspect-ratio-number-values': 4.0.0(postcss@8.5.9) + '@csstools/postcss-mixins': 1.0.0(postcss@8.5.9) + '@csstools/postcss-nested-calc': 5.0.0(postcss@8.5.9) + '@csstools/postcss-normalize-display-values': 5.0.1(postcss@8.5.9) + '@csstools/postcss-oklab-function': 5.0.2(postcss@8.5.9) + '@csstools/postcss-position-area-property': 2.0.0(postcss@8.5.9) + '@csstools/postcss-progressive-custom-properties': 5.0.0(postcss@8.5.9) + '@csstools/postcss-property-rule-prelude-list': 2.0.0(postcss@8.5.9) + '@csstools/postcss-random-function': 3.0.1(postcss@8.5.9) + '@csstools/postcss-relative-color-syntax': 4.0.2(postcss@8.5.9) + '@csstools/postcss-scope-pseudo-class': 5.0.0(postcss@8.5.9) + '@csstools/postcss-sign-functions': 2.0.1(postcss@8.5.9) + '@csstools/postcss-stepped-value-functions': 5.0.1(postcss@8.5.9) + '@csstools/postcss-syntax-descriptor-syntax-production': 2.0.0(postcss@8.5.9) + '@csstools/postcss-system-ui-font-family': 2.0.0(postcss@8.5.9) + '@csstools/postcss-text-decoration-shorthand': 5.0.3(postcss@8.5.9) + '@csstools/postcss-trigonometric-functions': 5.0.1(postcss@8.5.9) + '@csstools/postcss-unset-value': 5.0.0(postcss@8.5.9) + autoprefixer: 10.4.27(postcss@8.5.9) browserslist: 4.28.2 - css-blank-pseudo: 8.0.1(postcss@8.5.8) - css-has-pseudo: 8.0.0(postcss@8.5.8) - css-prefers-color-scheme: 11.0.0(postcss@8.5.8) + css-blank-pseudo: 8.0.1(postcss@8.5.9) + css-has-pseudo: 8.0.0(postcss@8.5.9) + css-prefers-color-scheme: 11.0.0(postcss@8.5.9) cssdb: 8.8.0 - postcss: 8.5.8 - postcss-attribute-case-insensitive: 8.0.0(postcss@8.5.8) - postcss-clamp: 4.1.0(postcss@8.5.8) - postcss-color-functional-notation: 8.0.2(postcss@8.5.8) - postcss-color-hex-alpha: 11.0.0(postcss@8.5.8) - postcss-color-rebeccapurple: 11.0.0(postcss@8.5.8) - postcss-custom-media: 12.0.1(postcss@8.5.8) - postcss-custom-properties: 15.0.1(postcss@8.5.8) - postcss-custom-selectors: 9.0.1(postcss@8.5.8) - postcss-dir-pseudo-class: 10.0.0(postcss@8.5.8) - postcss-double-position-gradients: 7.0.0(postcss@8.5.8) - postcss-focus-visible: 11.0.0(postcss@8.5.8) - postcss-focus-within: 10.0.0(postcss@8.5.8) - postcss-font-variant: 5.0.0(postcss@8.5.8) - postcss-gap-properties: 7.0.0(postcss@8.5.8) - postcss-image-set-function: 8.0.0(postcss@8.5.8) - postcss-lab-function: 8.0.2(postcss@8.5.8) - postcss-logical: 9.0.0(postcss@8.5.8) - postcss-nesting: 14.0.0(postcss@8.5.8) - postcss-opacity-percentage: 3.0.0(postcss@8.5.8) - postcss-overflow-shorthand: 7.0.0(postcss@8.5.8) - postcss-page-break: 3.0.4(postcss@8.5.8) - postcss-place: 11.0.0(postcss@8.5.8) - postcss-pseudo-class-any-link: 11.0.0(postcss@8.5.8) - postcss-replace-overflow-wrap: 4.0.0(postcss@8.5.8) - postcss-selector-not: 9.0.0(postcss@8.5.8) + postcss: 8.5.9 + postcss-attribute-case-insensitive: 8.0.0(postcss@8.5.9) + postcss-clamp: 4.1.0(postcss@8.5.9) + postcss-color-functional-notation: 8.0.2(postcss@8.5.9) + postcss-color-hex-alpha: 11.0.0(postcss@8.5.9) + postcss-color-rebeccapurple: 11.0.0(postcss@8.5.9) + postcss-custom-media: 12.0.1(postcss@8.5.9) + postcss-custom-properties: 15.0.1(postcss@8.5.9) + postcss-custom-selectors: 9.0.1(postcss@8.5.9) + postcss-dir-pseudo-class: 10.0.0(postcss@8.5.9) + postcss-double-position-gradients: 7.0.0(postcss@8.5.9) + postcss-focus-visible: 11.0.0(postcss@8.5.9) + postcss-focus-within: 10.0.0(postcss@8.5.9) + postcss-font-variant: 5.0.0(postcss@8.5.9) + postcss-gap-properties: 7.0.0(postcss@8.5.9) + postcss-image-set-function: 8.0.0(postcss@8.5.9) + postcss-lab-function: 8.0.2(postcss@8.5.9) + postcss-logical: 9.0.0(postcss@8.5.9) + postcss-nesting: 14.0.0(postcss@8.5.9) + postcss-opacity-percentage: 3.0.0(postcss@8.5.9) + postcss-overflow-shorthand: 7.0.0(postcss@8.5.9) + postcss-page-break: 3.0.4(postcss@8.5.9) + postcss-place: 11.0.0(postcss@8.5.9) + postcss-pseudo-class-any-link: 11.0.0(postcss@8.5.9) + postcss-replace-overflow-wrap: 4.0.0(postcss@8.5.9) + postcss-selector-not: 9.0.0(postcss@8.5.9) - postcss-pseudo-class-any-link@11.0.0(postcss@8.5.8): + postcss-pseudo-class-any-link@11.0.0(postcss@8.5.9): dependencies: - postcss: 8.5.8 + postcss: 8.5.9 postcss-selector-parser: 7.1.1 - postcss-replace-overflow-wrap@4.0.0(postcss@8.5.8): + postcss-replace-overflow-wrap@4.0.0(postcss@8.5.9): dependencies: - postcss: 8.5.8 + postcss: 8.5.9 postcss-resolve-nested-selector@0.1.6: {} - postcss-safe-parser@6.0.0(postcss@8.5.8): + postcss-safe-parser@6.0.0(postcss@8.5.9): dependencies: - postcss: 8.5.8 + postcss: 8.5.9 - postcss-safe-parser@7.0.1(postcss@8.5.8): + postcss-safe-parser@7.0.1(postcss@8.5.9): dependencies: - postcss: 8.5.8 + postcss: 8.5.9 - postcss-scss@4.0.9(postcss@8.5.8): + postcss-scss@4.0.9(postcss@8.5.9): dependencies: - postcss: 8.5.8 + postcss: 8.5.9 - postcss-selector-not@9.0.0(postcss@8.5.8): + postcss-selector-not@9.0.0(postcss@8.5.9): dependencies: - postcss: 8.5.8 + postcss: 8.5.9 postcss-selector-parser: 7.1.1 postcss-selector-parser@7.1.1: @@ -12319,9 +12407,9 @@ snapshots: cssesc: 3.0.0 util-deprecate: 1.0.2 - postcss-sorting@8.0.2(postcss@8.5.8): + postcss-sorting@8.0.2(postcss@8.5.9): dependencies: - postcss: 8.5.8 + postcss: 8.5.9 postcss-value-parser@3.3.1: {} @@ -12332,7 +12420,7 @@ snapshots: picocolors: 0.2.1 source-map: 0.6.1 - postcss@8.5.8: + postcss@8.5.9: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 @@ -13091,14 +13179,14 @@ snapshots: stylelint: 17.6.0(typescript@5.9.3) stylelint-order: 6.0.4(stylelint@17.6.0(typescript@5.9.3)) - stylelint-config-recommended-scss@17.0.0(postcss@8.5.8)(stylelint@17.6.0(typescript@5.9.3)): + stylelint-config-recommended-scss@17.0.0(postcss@8.5.9)(stylelint@17.6.0(typescript@5.9.3)): dependencies: - postcss-scss: 4.0.9(postcss@8.5.8) + postcss-scss: 4.0.9(postcss@8.5.9) stylelint: 17.6.0(typescript@5.9.3) stylelint-config-recommended: 18.0.0(stylelint@17.6.0(typescript@5.9.3)) stylelint-scss: 7.0.0(stylelint@17.6.0(typescript@5.9.3)) optionalDependencies: - postcss: 8.5.8 + postcss: 8.5.9 stylelint-config-recommended-vue@1.6.1(postcss-html@1.8.0)(stylelint@17.6.0(typescript@5.9.3)): dependencies: @@ -13112,13 +13200,13 @@ snapshots: dependencies: stylelint: 17.6.0(typescript@5.9.3) - stylelint-config-standard-scss@17.0.0(postcss@8.5.8)(stylelint@17.6.0(typescript@5.9.3)): + stylelint-config-standard-scss@17.0.0(postcss@8.5.9)(stylelint@17.6.0(typescript@5.9.3)): dependencies: stylelint: 17.6.0(typescript@5.9.3) - stylelint-config-recommended-scss: 17.0.0(postcss@8.5.8)(stylelint@17.6.0(typescript@5.9.3)) + stylelint-config-recommended-scss: 17.0.0(postcss@8.5.9)(stylelint@17.6.0(typescript@5.9.3)) stylelint-config-standard: 40.0.0(stylelint@17.6.0(typescript@5.9.3)) optionalDependencies: - postcss: 8.5.8 + postcss: 8.5.9 stylelint-config-standard@40.0.0(stylelint@17.6.0(typescript@5.9.3)): dependencies: @@ -13127,8 +13215,8 @@ snapshots: stylelint-order@6.0.4(stylelint@17.6.0(typescript@5.9.3)): dependencies: - postcss: 8.5.8 - postcss-sorting: 8.0.2(postcss@8.5.8) + postcss: 8.5.9 + postcss-sorting: 8.0.2(postcss@8.5.9) stylelint: 17.6.0(typescript@5.9.3) stylelint-scss@7.0.0(stylelint@17.6.0(typescript@5.9.3)): @@ -13176,8 +13264,8 @@ snapshots: micromatch: 4.0.8 normalize-path: 3.0.0 picocolors: 1.1.1 - postcss: 8.5.8 - postcss-safe-parser: 7.0.1(postcss@8.5.8) + postcss: 8.5.9 + postcss-safe-parser: 7.0.1(postcss@8.5.9) postcss-selector-parser: 7.1.1 postcss-value-parser: 4.2.0 string-width: 8.2.0 @@ -13651,7 +13739,7 @@ snapshots: esbuild: 0.27.5 fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 - postcss: 8.5.8 + postcss: 8.5.9 rollup: 4.60.1 tinyglobby: 0.2.15 optionalDependencies: From 71378fd0b23ff127e54f660acb9548c5dc609528 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 03:48:08 +0000 Subject: [PATCH 101/101] chore(deps): bump github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream Bumps [github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream](https://github.com/aws/aws-sdk-go-v2) from 1.7.5 to 1.7.8. - [Release notes](https://github.com/aws/aws-sdk-go-v2/releases) - [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/m2/v1.7.5...service/m2/v1.7.8) --- updated-dependencies: - dependency-name: github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream dependency-version: 1.7.8 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- go.mod | 4 ++-- go.sum | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/go.mod b/go.mod index 32726bd4f..cf5b35c00 100644 --- a/go.mod +++ b/go.mod @@ -29,7 +29,7 @@ require ( github.com/aws/aws-sdk-go-v2/config v1.32.10 github.com/aws/aws-sdk-go-v2/credentials v1.19.10 github.com/aws/aws-sdk-go-v2/service/s3 v1.96.2 - github.com/aws/smithy-go v1.24.1 + github.com/aws/smithy-go v1.24.2 github.com/bbrks/go-blurhash v1.1.1 github.com/c2h5oh/datasize v0.0.0-20231215233829-aa82cc1e6500 github.com/coder/websocket v1.8.14 @@ -97,7 +97,7 @@ require ( github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect github.com/KyleBanks/depth v1.2.1 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 // indirect + github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.18 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.18 // indirect diff --git a/go.sum b/go.sum index cb2223038..eefbd966c 100644 --- a/go.sum +++ b/go.sum @@ -34,8 +34,8 @@ github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3d github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/aws/aws-sdk-go-v2 v1.41.2 h1:LuT2rzqNQsauaGkPK/7813XxcZ3o3yePY0Iy891T2ls= github.com/aws/aws-sdk-go-v2 v1.41.2/go.mod h1:IvvlAZQXvTXznUPfRVfryiG1fbzE2NGK6m9u39YQ+S4= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5 h1:zWFmPmgw4sveAYi1mRqG+E/g0461cJ5M4bJ8/nc6d3Q= -github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.5/go.mod h1:nVUlMLVV8ycXSb7mSkcNu9e3v/1TJq2RTlrPwhYWr5c= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI= github.com/aws/aws-sdk-go-v2/config v1.32.10 h1:9DMthfO6XWZYLfzZglAgW5Fyou2nRI5CuV44sTedKBI= github.com/aws/aws-sdk-go-v2/config v1.32.10/go.mod h1:2rUIOnA2JaiqYmSKYmRJlcMWy6qTj1vuRFscppSBMcw= github.com/aws/aws-sdk-go-v2/credentials v1.19.10 h1:EEhmEUFCE1Yhl7vDhNOI5OCL/iKMdkkYFTRpZXNw7m8= @@ -68,8 +68,8 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15 h1:edCcNp9eGIUDUCrzoCu1jWA github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.15/go.mod h1:lyRQKED9xWfgkYC/wmmYfv7iVIM68Z5OQ88ZdcV1QbU= github.com/aws/aws-sdk-go-v2/service/sts v1.41.7 h1:NITQpgo9A5NrDZ57uOWj+abvXSb83BbyggcUBVksN7c= github.com/aws/aws-sdk-go-v2/service/sts v1.41.7/go.mod h1:sks5UWBhEuWYDPdwlnRFn1w7xWdH29Jcpe+/PJQefEs= -github.com/aws/smithy-go v1.24.1 h1:VbyeNfmYkWoxMVpGUAbQumkODcYmfMRfZ8yQiH30SK0= -github.com/aws/smithy-go v1.24.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= +github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/bbrks/go-blurhash v1.1.1 h1:uoXOxRPDca9zHYabUTwvS4KnY++KKUbwFo+Yxb8ME4M=