From fa0c9a8584c4ad4f6e8294870c65bb80964829c3 Mon Sep 17 00:00:00 2001 From: kolaente Date: Sun, 28 Jun 2026 00:01:08 +0200 Subject: [PATCH] feat(api): exchange rich-text fields as markdown on v2 Wire the conversion helpers into every rich-text handler: read/list/echo convert HTML to markdown, create/update convert markdown to HTML before persisting, and each op documents the format query field. Opt-in via ?format=markdown or the X-Vikunja-Format header. --- pkg/routes/api/v2/bulk_task.go | 11 +- pkg/routes/api/v2/labels.go | 28 ++- pkg/routes/api/v2/projects.go | 27 ++- pkg/routes/api/v2/saved_filters.go | 20 +- pkg/routes/api/v2/task_collection.go | 7 + pkg/routes/api/v2/task_comments.go | 26 ++- pkg/routes/api/v2/tasks.go | 20 +- pkg/routes/api/v2/teams.go | 26 ++- pkg/webtests/huma_richtext_test.go | 335 +++++++++++++++++++++++++++ 9 files changed, 472 insertions(+), 28 deletions(-) create mode 100644 pkg/webtests/huma_richtext_test.go diff --git a/pkg/routes/api/v2/bulk_task.go b/pkg/routes/api/v2/bulk_task.go index be5e4b31e..f3751825f 100644 --- a/pkg/routes/api/v2/bulk_task.go +++ b/pkg/routes/api/v2/bulk_task.go @@ -47,15 +47,24 @@ func RegisterBulkTaskRoutes(api huma.API) { func init() { AddRouteRegistrar(RegisterBulkTaskRoutes) } func tasksBulkUpdate(ctx context.Context, in *struct { - Body models.BulkTask + Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."` + Body models.BulkTask }) (*singleBody[models.BulkTask], error) { a, err := authFromCtx(ctx) if err != nil { return nil, err } bt := &in.Body + if bt.Values != nil { + if err := convertToHTML(ctx, &bt.Values.Description); err != nil { + return nil, translateDomainError(err) + } + } if err := handler.DoUpdate(ctx, bt, a); err != nil { return nil, translateDomainError(err) } + // Echo values + updated tasks back in the requested format (values.description + // was converted to HTML above for persistence). + convertTasksToMarkdown(ctx, append([]*models.Task{bt.Values}, bt.Tasks...)...) return &singleBody[models.BulkTask]{Body: bt}, nil } diff --git a/pkg/routes/api/v2/labels.go b/pkg/routes/api/v2/labels.go index a9576bc50..f4181fbad 100644 --- a/pkg/routes/api/v2/labels.go +++ b/pkg/routes/api/v2/labels.go @@ -87,7 +87,10 @@ func RegisterLabelRoutes(api huma.API) { func init() { AddRouteRegistrar(RegisterLabelRoutes) } -func labelsList(ctx context.Context, in *ListParams) (*labelListBody, error) { +func labelsList(ctx context.Context, in *struct { + ListParams + Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."` +}) (*labelListBody, error) { a, err := authFromCtx(ctx) if err != nil { return nil, err @@ -100,6 +103,9 @@ func labelsList(ctx context.Context, in *ListParams) (*labelListBody, error) { if !ok { return nil, fmt.Errorf("labels.ReadAll returned unexpected type %T (expected []*models.LabelWithTaskID)", result) } + for _, l := range items { + convertToMarkdown(ctx, &l.Description) + } return &labelListBody{Body: NewPaginated(items, total, in.Page, in.PerPage)}, nil } @@ -109,7 +115,8 @@ type labelReadBody struct { } func labelsRead(ctx context.Context, in *struct { - ID int64 `path:"id"` + ID int64 `path:"id"` + Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."` conditional.Params }) (*singleReadBody[labelReadBody], error) { a, err := authFromCtx(ctx) @@ -122,26 +129,33 @@ func labelsRead(ctx context.Context, in *struct { return nil, translateDomainError(err) } body := &labelReadBody{Label: *label, MaxPermission: models.Permission(maxPermission)} + convertToMarkdown(ctx, &body.Description) return conditionalReadResponse(&in.Params, body, label.Updated, maxPermission) } func labelsCreate(ctx context.Context, in *struct { - Body models.Label + Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."` + Body models.Label }) (*singleBody[models.Label], error) { a, err := authFromCtx(ctx) if err != nil { return nil, err } + if err := convertToHTML(ctx, &in.Body.Description); err != nil { + return nil, translateDomainError(err) + } if err := handler.DoCreate(ctx, &in.Body, a); err != nil { return nil, translateDomainError(err) } + convertToMarkdown(ctx, &in.Body.Description) return &singleBody[models.Label]{Body: &in.Body}, nil } // Body matches the read shape so AutoPatch's GET→PUT echo of max_permission validates. func labelsUpdate(ctx context.Context, in *struct { - ID int64 `path:"id"` - Body labelReadBody + ID int64 `path:"id"` + Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."` + Body labelReadBody }) (*singleBody[models.Label], error) { a, err := authFromCtx(ctx) if err != nil { @@ -149,9 +163,13 @@ func labelsUpdate(ctx context.Context, in *struct { } label := &in.Body.Label label.ID = in.ID // URL wins over body + if err := convertToHTML(ctx, &label.Description); err != nil { + return nil, translateDomainError(err) + } if err := handler.DoUpdate(ctx, label, a); err != nil { return nil, translateDomainError(err) } + convertToMarkdown(ctx, &label.Description) return &singleBody[models.Label]{Body: label}, nil } diff --git a/pkg/routes/api/v2/projects.go b/pkg/routes/api/v2/projects.go index 27a40ea20..c6d4e7c52 100644 --- a/pkg/routes/api/v2/projects.go +++ b/pkg/routes/api/v2/projects.go @@ -89,6 +89,7 @@ func projectsList(ctx context.Context, in *struct { ListParams Expand string `query:"expand" enum:"permissions" doc:"If set to \"permissions\", each returned project includes the max permission the requesting user has on it (max_permission). Currently only \"permissions\" is supported."` IsArchived bool `query:"is_archived" doc:"If true, also returns archived projects."` + Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."` }) (*projectListBody, error) { a, err := authFromCtx(ctx) if err != nil { @@ -106,6 +107,9 @@ func projectsList(ctx context.Context, in *struct { if !ok { return nil, fmt.Errorf("projects.ReadAll returned unexpected type %T (expected []*models.Project)", result) } + for _, p := range items { + convertToMarkdown(ctx, &p.Description) + } return &projectListBody{Body: NewPaginated(items, total, in.Page, in.PerPage)}, nil } @@ -117,7 +121,8 @@ type projectReadBody struct { } func projectsRead(ctx context.Context, in *struct { - ID int64 `path:"id"` + ID int64 `path:"id"` + Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."` }) (*singleBody[projectReadBody], error) { a, err := authFromCtx(ctx) if err != nil { @@ -132,22 +137,29 @@ func projectsRead(ctx context.Context, in *struct { // the Favorites pseudo-project and saved-filter-backed ones), so the field // is always meaningful here — surfaced unconditionally like labels/views. project.MaxPermission = models.Permission(maxPermission) + body := &projectReadBody{Project: *project} + convertToMarkdown(ctx, &body.Description) // No ETag/conditional read: a project response carries user-scoped, derived // state (subscription, favorite, views, computed archived state) that // changes without bumping project.Updated, so it's always served fresh. - return &singleBody[projectReadBody]{Body: &projectReadBody{Project: *project}}, nil + return &singleBody[projectReadBody]{Body: body}, nil } func projectsCreate(ctx context.Context, in *struct { - Body models.Project + Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."` + Body models.Project }) (*singleBody[models.Project], error) { a, err := authFromCtx(ctx) if err != nil { return nil, err } + if err := convertToHTML(ctx, &in.Body.Description); err != nil { + return nil, translateDomainError(err) + } if err := handler.DoCreate(ctx, &in.Body, a); err != nil { return nil, translateDomainError(err) } + convertToMarkdown(ctx, &in.Body.Description) // Create/Update don't compute the caller's permission; null says "read it" // rather than echoing the zero value (0 = read), misleading for the owner. in.Body.MaxPermission = models.PermissionUnknown @@ -156,8 +168,9 @@ func projectsCreate(ctx context.Context, in *struct { // Body matches the read shape so AutoPatch's GET→PUT echo of max_permission validates. func projectsUpdate(ctx context.Context, in *struct { - ID int64 `path:"id"` - Body projectReadBody + ID int64 `path:"id"` + Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."` + Body projectReadBody }) (*singleBody[models.Project], error) { a, err := authFromCtx(ctx) if err != nil { @@ -165,9 +178,13 @@ func projectsUpdate(ctx context.Context, in *struct { } project := &in.Body.Project project.ID = in.ID // URL wins over body + if err := convertToHTML(ctx, &project.Description); err != nil { + return nil, translateDomainError(err) + } if err := handler.DoUpdate(ctx, project, a); err != nil { return nil, translateDomainError(err) } + convertToMarkdown(ctx, &project.Description) project.MaxPermission = models.PermissionUnknown // see projectsCreate return &singleBody[models.Project]{Body: project}, nil } diff --git a/pkg/routes/api/v2/saved_filters.go b/pkg/routes/api/v2/saved_filters.go index bc82776f5..6fe284094 100644 --- a/pkg/routes/api/v2/saved_filters.go +++ b/pkg/routes/api/v2/saved_filters.go @@ -77,7 +77,8 @@ type savedFilterReadBody struct { } func savedFiltersRead(ctx context.Context, in *struct { - ID int64 `path:"filter"` + ID int64 `path:"filter"` + Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."` conditional.Params }) (*singleReadBody[savedFilterReadBody], error) { a, err := authFromCtx(ctx) @@ -90,26 +91,33 @@ func savedFiltersRead(ctx context.Context, in *struct { return nil, translateDomainError(err) } body := &savedFilterReadBody{SavedFilter: *filter, MaxPermission: models.Permission(maxPermission)} + convertToMarkdown(ctx, &body.Description) return conditionalReadResponse(&in.Params, body, filter.Updated, maxPermission) } func savedFiltersCreate(ctx context.Context, in *struct { - Body models.SavedFilter + Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."` + Body models.SavedFilter }) (*singleBody[models.SavedFilter], error) { a, err := authFromCtx(ctx) if err != nil { return nil, err } + if err := convertToHTML(ctx, &in.Body.Description); err != nil { + return nil, translateDomainError(err) + } if err := handler.DoCreate(ctx, &in.Body, a); err != nil { return nil, translateDomainError(err) } + convertToMarkdown(ctx, &in.Body.Description) return &singleBody[models.SavedFilter]{Body: &in.Body}, nil } // Body matches the read shape so AutoPatch's GET→PUT echo of max_permission validates. func savedFiltersUpdate(ctx context.Context, in *struct { - ID int64 `path:"filter"` - Body savedFilterReadBody + ID int64 `path:"filter"` + Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."` + Body savedFilterReadBody }) (*singleBody[models.SavedFilter], error) { a, err := authFromCtx(ctx) if err != nil { @@ -117,9 +125,13 @@ func savedFiltersUpdate(ctx context.Context, in *struct { } filter := &in.Body.SavedFilter filter.ID = in.ID // URL wins over body + if err := convertToHTML(ctx, &filter.Description); err != nil { + return nil, translateDomainError(err) + } if err := handler.DoUpdate(ctx, filter, a); err != nil { return nil, translateDomainError(err) } + convertToMarkdown(ctx, &filter.Description) return &singleBody[models.SavedFilter]{Body: filter}, nil } diff --git a/pkg/routes/api/v2/task_collection.go b/pkg/routes/api/v2/task_collection.go index 1a379dbe6..563ac83b1 100644 --- a/pkg/routes/api/v2/task_collection.go +++ b/pkg/routes/api/v2/task_collection.go @@ -61,6 +61,7 @@ type TaskListQueryParams struct { SortBy []string `query:"sort_by,explode" doc:"Fields to sort by (e.g. done, priority). Repeatable; pair positionally with order_by."` OrderBy []string `query:"order_by,explode" doc:"Sort order per sort_by field, asc or desc. Repeatable; defaults to asc."` Expand []string `query:"expand,explode" enum:"subtasks,buckets,reactions,comments,comment_count,time_entries_count,is_unread" doc:"Embed extra, more expensive data per task. Repeatable."` + Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."` } type taskListAllInput struct { @@ -201,6 +202,7 @@ func readFlatTasks(ctx context.Context, f taskListFilters, page, perPage int, pr if !ok { return nil, fmt.Errorf("taskCollection.ReadAll returned unexpected type %T (expected []*models.Task)", result) } + convertTasksToMarkdown(ctx, tasks...) return &taskListBody{Body: NewPaginated(tasks, total, page, perPage)}, nil } @@ -228,6 +230,11 @@ func projectViewBucketsTasksList(ctx context.Context, in *taskListViewInput) (*b } return nil, fmt.Errorf("taskCollection.ReadAll returned unexpected type %T (expected []*models.Bucket)", result) } + var bucketTasks []*models.Task + for _, bucket := range buckets { + bucketTasks = append(bucketTasks, bucket.Tasks...) + } + convertTasksToMarkdown(ctx, bucketTasks...) out := &bucketsWithTasksBody{} out.Body.Items = buckets out.Body.Total = total diff --git a/pkg/routes/api/v2/task_comments.go b/pkg/routes/api/v2/task_comments.go index c4fafd564..6e76c572b 100644 --- a/pkg/routes/api/v2/task_comments.go +++ b/pkg/routes/api/v2/task_comments.go @@ -96,6 +96,7 @@ func init() { AddRouteRegistrar(RegisterTaskCommentRoutes) } func taskCommentsList(ctx context.Context, in *struct { TaskID int64 `path:"task"` OrderBy string `query:"order_by" enum:"asc,desc" default:"asc" doc:"Sort order by creation time: 'asc' (oldest first, default) or 'desc' (newest first)."` + Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."` ListParams }) (*taskCommentListBody, error) { a, err := authFromCtx(ctx) @@ -110,6 +111,9 @@ func taskCommentsList(ctx context.Context, in *struct { if !ok { return nil, fmt.Errorf("taskComments.ReadAll returned unexpected type %T (expected []*models.TaskComment)", result) } + for _, c := range items { + convertToMarkdown(ctx, &c.Comment) + } return &taskCommentListBody{Body: NewPaginated(items, total, in.Page, in.PerPage)}, nil } @@ -121,8 +125,9 @@ type taskCommentReadBody struct { } func taskCommentsRead(ctx context.Context, in *struct { - TaskID int64 `path:"task"` - ID int64 `path:"commentid"` + TaskID int64 `path:"task"` + ID int64 `path:"commentid"` + Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."` conditional.Params }) (*singleReadBody[taskCommentReadBody], error) { a, err := authFromCtx(ctx) @@ -137,11 +142,13 @@ func taskCommentsRead(ctx context.Context, in *struct { return nil, translateDomainError(err) } body := &taskCommentReadBody{TaskComment: *comment, MaxPermission: models.Permission(maxPermission)} + convertToMarkdown(ctx, &body.Comment) return conditionalReadResponse(&in.Params, body, comment.Updated, maxPermission) } func taskCommentsCreate(ctx context.Context, in *struct { - TaskID int64 `path:"task"` + TaskID int64 `path:"task"` + Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."` Body models.TaskComment }) (*singleBody[models.TaskComment], error) { a, err := authFromCtx(ctx) @@ -149,16 +156,21 @@ func taskCommentsCreate(ctx context.Context, in *struct { return nil, err } in.Body.TaskID = in.TaskID // URL wins over body + if err := convertToHTML(ctx, &in.Body.Comment); err != nil { + return nil, translateDomainError(err) + } if err := handler.DoCreate(ctx, &in.Body, a); err != nil { return nil, translateDomainError(err) } + convertToMarkdown(ctx, &in.Body.Comment) return &singleBody[models.TaskComment]{Body: &in.Body}, nil } // Body matches the read shape so AutoPatch's GET→PUT echo of max_permission validates. func taskCommentsUpdate(ctx context.Context, in *struct { - TaskID int64 `path:"task"` - ID int64 `path:"commentid"` + TaskID int64 `path:"task"` + ID int64 `path:"commentid"` + Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."` Body taskCommentReadBody }) (*singleBody[models.TaskComment], error) { a, err := authFromCtx(ctx) @@ -168,9 +180,13 @@ func taskCommentsUpdate(ctx context.Context, in *struct { comment := &in.Body.TaskComment comment.ID = in.ID // URL wins over body comment.TaskID = in.TaskID // parent from the path scopes the update + if err := convertToHTML(ctx, &comment.Comment); err != nil { + return nil, translateDomainError(err) + } if err := handler.DoUpdate(ctx, comment, a); err != nil { return nil, translateDomainError(err) } + convertToMarkdown(ctx, &comment.Comment) return &singleBody[models.TaskComment]{Body: comment}, nil } diff --git a/pkg/routes/api/v2/tasks.go b/pkg/routes/api/v2/tasks.go index 49fa21910..63cd46f6d 100644 --- a/pkg/routes/api/v2/tasks.go +++ b/pkg/routes/api/v2/tasks.go @@ -112,6 +112,7 @@ type taskReadOneBody struct { func tasksRead(ctx context.Context, in *struct { ID int64 `path:"projecttask" doc:"The numeric id of the task."` Expand []string `query:"expand,explode" enum:"subtasks,buckets,reactions,comments,comment_count,time_entries_count,is_unread" doc:"Embed extra data per task. Repeatable."` + Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."` conditional.Params }) (*singleReadBody[taskReadOneBody], error) { a, err := authFromCtx(ctx) @@ -128,6 +129,7 @@ func tasksRead(ctx context.Context, in *struct { return nil, translateDomainError(err) } body := &taskReadOneBody{Task: *task, MaxPermission: models.Permission(maxPermission)} + convertTasksToMarkdown(ctx, &body.Task) return conditionalReadResponse(&in.Params, body, task.Updated, maxPermission) } @@ -135,6 +137,7 @@ func tasksReadByIndex(ctx context.Context, in *struct { Project string `path:"project" doc:"A numeric project id or a textual project identifier (e.g. \"PROJ\")."` Index int64 `path:"index" doc:"The per-project task index."` Expand []string `query:"expand,explode" enum:"subtasks,buckets,reactions,comments,comment_count,time_entries_count,is_unread" doc:"Embed extra data per task. Repeatable."` + Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."` conditional.Params }) (*singleReadBody[taskReadOneBody], error) { a, err := authFromCtx(ctx) @@ -158,11 +161,13 @@ func tasksReadByIndex(ctx context.Context, in *struct { return nil, translateDomainError(err) } body := &taskReadOneBody{Task: *task, MaxPermission: models.Permission(maxPermission)} + convertTasksToMarkdown(ctx, &body.Task) return conditionalReadResponse(&in.Params, body, task.Updated, maxPermission) } func tasksCreate(ctx context.Context, in *struct { - Project int64 `path:"project" doc:"The numeric id of the project to create the task in."` + Project int64 `path:"project" doc:"The numeric id of the project to create the task in."` + Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."` Body models.Task }) (*singleBody[models.Task], error) { a, err := authFromCtx(ctx) @@ -171,16 +176,21 @@ func tasksCreate(ctx context.Context, in *struct { } task := &in.Body task.ProjectID = in.Project // URL wins over body + if err := convertToHTML(ctx, &task.Description); err != nil { + return nil, translateDomainError(err) + } if err := handler.DoCreate(ctx, task, a); err != nil { return nil, translateDomainError(err) } + convertTasksToMarkdown(ctx, task) return &singleBody[models.Task]{Body: task}, nil } // Body matches the read shape so AutoPatch's GET→PUT echo of max_permission validates. func tasksUpdate(ctx context.Context, in *struct { - ID int64 `path:"projecttask"` - Body taskReadOneBody + ID int64 `path:"projecttask"` + Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."` + Body taskReadOneBody }) (*singleBody[models.Task], error) { a, err := authFromCtx(ctx) if err != nil { @@ -188,9 +198,13 @@ func tasksUpdate(ctx context.Context, in *struct { } task := &in.Body.Task task.ID = in.ID // URL wins over body + if err := convertToHTML(ctx, &task.Description); err != nil { + return nil, translateDomainError(err) + } if err := handler.DoUpdate(ctx, task, a); err != nil { return nil, translateDomainError(err) } + convertTasksToMarkdown(ctx, task) return &singleBody[models.Task]{Body: task}, nil } diff --git a/pkg/routes/api/v2/teams.go b/pkg/routes/api/v2/teams.go index 481bf2a7e..29a3ebddb 100644 --- a/pkg/routes/api/v2/teams.go +++ b/pkg/routes/api/v2/teams.go @@ -90,7 +90,8 @@ func teamsList(ctx context.Context, in *struct { // IncludePublic mirrors the model's include_public query param; bound // onto the model below so ReadAll can honor it (gated by the instance // public-teams setting). - IncludePublic bool `query:"include_public" doc:"Also include public teams the user is not a member of. Only honored when public teams are enabled on the instance."` + IncludePublic bool `query:"include_public" doc:"Also include public teams the user is not a member of. Only honored when public teams are enabled on the instance."` + Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."` }) (*teamListBody, error) { a, err := authFromCtx(ctx) if err != nil { @@ -104,6 +105,9 @@ func teamsList(ctx context.Context, in *struct { if !ok { return nil, fmt.Errorf("teams.ReadAll returned unexpected type %T (expected []*models.Team)", result) } + for _, team := range items { + convertToMarkdown(ctx, &team.Description) + } return &teamListBody{Body: NewPaginated(items, total, in.Page, in.PerPage)}, nil } @@ -113,7 +117,8 @@ type teamReadBody struct { } func teamsRead(ctx context.Context, in *struct { - ID int64 `path:"id"` + ID int64 `path:"id"` + Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."` conditional.Params }) (*singleReadBody[teamReadBody], error) { a, err := authFromCtx(ctx) @@ -126,26 +131,33 @@ func teamsRead(ctx context.Context, in *struct { return nil, translateDomainError(err) } body := &teamReadBody{Team: *team, MaxPermission: models.Permission(maxPermission)} + convertToMarkdown(ctx, &body.Description) return conditionalReadResponse(&in.Params, body, team.Updated, maxPermission) } func teamsCreate(ctx context.Context, in *struct { - Body models.Team + Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."` + Body models.Team }) (*singleBody[models.Team], error) { a, err := authFromCtx(ctx) if err != nil { return nil, err } + if err := convertToHTML(ctx, &in.Body.Description); err != nil { + return nil, translateDomainError(err) + } if err := handler.DoCreate(ctx, &in.Body, a); err != nil { return nil, translateDomainError(err) } + convertToMarkdown(ctx, &in.Body.Description) return &singleBody[models.Team]{Body: &in.Body}, nil } // Body matches the read shape so AutoPatch's GET→PUT echo of max_permission validates. func teamsUpdate(ctx context.Context, in *struct { - ID int64 `path:"id"` - Body teamReadBody + ID int64 `path:"id"` + Format string `query:"format" enum:"html,markdown" doc:"How rich-text fields are exchanged. See the API description."` + Body teamReadBody }) (*singleBody[models.Team], error) { a, err := authFromCtx(ctx) if err != nil { @@ -153,9 +165,13 @@ func teamsUpdate(ctx context.Context, in *struct { } team := &in.Body.Team team.ID = in.ID // URL wins over body + if err := convertToHTML(ctx, &team.Description); err != nil { + return nil, translateDomainError(err) + } if err := handler.DoUpdate(ctx, team, a); err != nil { return nil, translateDomainError(err) } + convertToMarkdown(ctx, &team.Description) return &singleBody[models.Team]{Body: team}, nil } diff --git a/pkg/webtests/huma_richtext_test.go b/pkg/webtests/huma_richtext_test.go new file mode 100644 index 000000000..b22908c68 --- /dev/null +++ b/pkg/webtests/huma_richtext_test.go @@ -0,0 +1,335 @@ +// 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 webtests + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func mustJSON(s string) string { + b, err := json.Marshal(s) + if err != nil { + panic(err) + } + return string(b) +} + +func decodeLabel(t *testing.T, raw []byte) (id int64, description string) { + t.Helper() + var l struct { + ID int64 `json:"id"` + Description string `json:"description"` + } + require.NoError(t, json.Unmarshal(raw, &l)) + return l.ID, l.Description +} + +func TestHumaRichText_FormatDocumented(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + + rec := humaRequest(t, e, http.MethodGet, "/api/v2/openapi.json", "", "", "") + require.Equal(t, http.StatusOK, rec.Code) + + type param struct { + Name string `json:"name"` + In string `json:"in"` + } + var spec struct { + Info struct { + Description string `json:"description"` + } `json:"info"` + Paths map[string]map[string]struct { + Parameters []param `json:"parameters"` + } `json:"paths"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &spec)) + + hasParam := func(path, method, name, in string) bool { + op, ok := spec.Paths[path][method] + if !ok { + return false + } + for _, p := range op.Parameters { + if p.Name == name && p.In == in { + return true + } + } + return false + } + + // Query param on the ops where it works (GET/POST/PUT), per entity. + assert.True(t, hasParam("/labels/{id}", "get", "format", "query"), "labels read must document ?format") + assert.True(t, hasParam("/labels", "post", "format", "query"), "labels create must document ?format") + assert.True(t, hasParam("/tasks/{projecttask}", "put", "format", "query"), "tasks update must document ?format") + + // PATCH must NOT advertise ?format — AutoPatch strips the query at runtime, so + // it would be a trap (markdown stored as HTML). Stripped by stripPatchFormatQuery. + assert.False(t, hasParam("/labels/{id}", "patch", "format", "query"), "PATCH must not advertise ?format") + + // The X-Vikunja-Format header is documented centrally, not as a per-op param. + assert.False(t, hasParam("/labels/{id}", "get", "X-Vikunja-Format", "header")) + assert.False(t, hasParam("/labels/{id}", "patch", "X-Vikunja-Format", "header")) + + // Non-rich-text ops carry no format param. + assert.False(t, hasParam("/tasks/{task}/comments/{commentid}", "delete", "format", "query")) + + // The cross-cutting behavior, including the PATCH header, is in the API description. + assert.Contains(t, spec.Info.Description, "Rich-text fields") + assert.Contains(t, spec.Info.Description, "CalDAV always exchanges") + assert.Contains(t, spec.Info.Description, "X-Vikunja-Format") +} + +func TestHumaRichText_Read(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // Store a label with HTML directly (no format → verbatim). + rec := humaRequest(t, e, http.MethodPost, "/api/v2/labels", + `{"title":"rt","description":"

Hello world

","hex_color":"112233"}`, token, "") + require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String()) + id, _ := decodeLabel(t, rec.Body.Bytes()) + + t.Run("read as markdown converts html", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, fmt.Sprintf("/api/v2/labels/%d?format=markdown", id), "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + _, desc := decodeLabel(t, rec.Body.Bytes()) + assert.Equal(t, "Hello **world**", desc) + }) + + t.Run("read without param keeps html", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, fmt.Sprintf("/api/v2/labels/%d", id), "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + _, desc := decodeLabel(t, rec.Body.Bytes()) + assert.Equal(t, "

Hello world

", desc) + }) + + t.Run("list converts every item", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodGet, "/api/v2/labels?format=markdown", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + // The freshly created label's HTML must not appear; its markdown must. + assert.NotContains(t, rec.Body.String(), "world") + assert.Contains(t, rec.Body.String(), "Hello **world**") + }) +} + +func decodeField(t *testing.T, raw []byte, field string) (id int64, value string) { + t.Helper() + var m map[string]json.RawMessage + require.NoError(t, json.Unmarshal(raw, &m)) + if v, ok := m["id"]; ok { + _ = json.Unmarshal(v, &id) + } + if v, ok := m[field]; ok { + _ = json.Unmarshal(v, &value) + } + return id, value +} + +// TestHumaRichText_EveryEntity drives every rich-text entity through the real v2 +// API: each is created with a markdown body and read back as both HTML and +// markdown. A handler that stops converting fails its row here. +func TestHumaRichText_EveryEntity(t *testing.T) { + const md = "a **bold** note" + const html = "

a bold note

" + + entities := []struct { + name string + createPath string + createBody string + readPath string // fmt verb %d for the created id + field string + }{ + {"label", "/api/v2/labels", `{"title":"e-label","description":"a **bold** note"}`, "/api/v2/labels/%d", "description"}, + {"project", "/api/v2/projects", `{"title":"e-project","description":"a **bold** note"}`, "/api/v2/projects/%d", "description"}, + {"team", "/api/v2/teams", `{"name":"e-team","description":"a **bold** note"}`, "/api/v2/teams/%d", "description"}, + {"saved filter", "/api/v2/filters", `{"title":"e-filter","description":"a **bold** note","filters":{"filter":"done = true"}}`, "/api/v2/filters/%d", "description"}, + {"task", "/api/v2/projects/1/tasks", `{"title":"e-task","description":"a **bold** note"}`, "/api/v2/tasks/%d", "description"}, + {"task comment", "/api/v2/tasks/1/comments", `{"comment":"a **bold** note"}`, "/api/v2/tasks/1/comments/%d", "comment"}, + } + + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + for _, ent := range entities { + t.Run(ent.name, func(t *testing.T) { + // Markdown body converted to HTML on create. + rec := humaRequest(t, e, http.MethodPost, ent.createPath+"?format=markdown", ent.createBody, token, "") + require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String()) + id, _ := decodeField(t, rec.Body.Bytes(), ent.field) + require.NotZero(t, id) + + // Stored as canonical HTML (default read). + rec = humaRequest(t, e, http.MethodGet, fmt.Sprintf(ent.readPath, id), "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + _, stored := decodeField(t, rec.Body.Bytes(), ent.field) + assert.Equal(t, html, stored, "%s write seam did not convert markdown to HTML", ent.name) + + // Read back as markdown. + rec = humaRequest(t, e, http.MethodGet, fmt.Sprintf(ent.readPath, id)+"?format=markdown", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + _, asMarkdown := decodeField(t, rec.Body.Bytes(), ent.field) + assert.Equal(t, md, asMarkdown, "%s read transformer did not convert HTML to markdown", ent.name) + }) + } +} + +// TestHumaRichText_KanbanNested proves the read conversion reaches tasks nested +// inside kanban buckets (Body.Items[].Tasks[].Description), which the explicit +// handler converts by looping the buckets. +func TestHumaRichText_KanbanNested(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // Store a task with HTML directly (no format → verbatim) in project 1. + rec := humaRequest(t, e, http.MethodPost, "/api/v2/projects/1/tasks", + `{"title":"kanban task","description":"

kanban md

"}`, token, "") + require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String()) + + // View 4 is project 1's kanban view; its buckets/tasks response nests tasks. + rec = humaRequest(t, e, http.MethodGet, "/api/v2/projects/1/views/4/buckets/tasks?format=markdown", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + assert.Contains(t, rec.Body.String(), "kanban **md**", "nested task description must be converted to markdown") + assert.NotContains(t, rec.Body.String(), "md", "no HTML should leak from a nested task") +} + +// TestHumaRichText_TaskExpandedNested proves expanded comments and related tasks +// are converted too, not just the top-level task description. +func TestHumaRichText_TaskExpandedNested(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + // A comment with HTML on task 1. + rec := humaRequest(t, e, http.MethodPost, "/api/v2/tasks/1/comments", + `{"comment":"

a bold comment

"}`, token, "") + require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String()) + + // A subtask (related task) with an HTML description. + rec = humaRequest(t, e, http.MethodPost, "/api/v2/projects/1/tasks", + `{"title":"sub","description":"

sub desc

"}`, token, "") + require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String()) + subID, _ := decodeField(t, rec.Body.Bytes(), "title") + rec = humaRequest(t, e, http.MethodPost, "/api/v2/tasks/1/relations", + fmt.Sprintf(`{"other_task_id":%d,"relation_kind":"subtask"}`, subID), token, "") + require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String()) + + rec = humaRequest(t, e, http.MethodGet, "/api/v2/tasks/1?expand=comments&expand=subtasks&format=markdown", "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + body := rec.Body.String() + assert.Contains(t, body, "a **bold** comment", "expanded comment must be markdown") + assert.Contains(t, body, "sub **desc**", "related task description must be markdown") + assert.NotContains(t, body, "", "no nested HTML should leak") +} + +func TestHumaRichText_Write(t *testing.T) { + e, err := setupTestEnv() + require.NoError(t, err) + token := humaTokenFor(t, &testuser1) + + t.Run("markdown write is stored as html", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPost, "/api/v2/labels?format=markdown", + `{"title":"w1","description":"Hello **world**","hex_color":"112233"}`, token, "") + require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String()) + id, _ := decodeLabel(t, rec.Body.Bytes()) + + // Read back without format → canonical HTML. + rec = humaRequest(t, e, http.MethodGet, fmt.Sprintf("/api/v2/labels/%d", id), "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + _, desc := decodeLabel(t, rec.Body.Bytes()) + assert.Equal(t, "

Hello world

", desc) + }) + + t.Run("default write stores body verbatim", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPost, "/api/v2/labels", + `{"title":"w2","description":"Hello **world**","hex_color":"112233"}`, token, "") + require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String()) + id, _ := decodeLabel(t, rec.Body.Bytes()) + + rec = humaRequest(t, e, http.MethodGet, fmt.Sprintf("/api/v2/labels/%d", id), "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + _, desc := decodeLabel(t, rec.Body.Bytes()) + assert.Equal(t, "Hello **world**", desc, "without the param the body is stored unconverted") + }) + + t.Run("mention is rebuilt on markdown write", func(t *testing.T) { + rec := humaRequest(t, e, http.MethodPost, "/api/v2/labels?format=markdown", + `{"title":"w3","description":"ping @user1","hex_color":"112233"}`, token, "") + require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String()) + id, _ := decodeLabel(t, rec.Body.Bytes()) + + rec = humaRequest(t, e, http.MethodGet, fmt.Sprintf("/api/v2/labels/%d", id), "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + _, desc := decodeLabel(t, rec.Body.Bytes()) + assert.Contains(t, desc, `old

","hex_color":"112233"}`, token, "") + require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String()) + id, _ := decodeLabel(t, rec.Body.Bytes()) + + // AutoPatch strips the query string but forwards headers, so PATCH markdown + // support rides on X-Vikunja-Format. + req := httptest.NewRequest(http.MethodPatch, fmt.Sprintf("/api/v2/labels/%d", id), + strings.NewReader(`{"description":"new **bold**"}`)) + req.Header.Set("Content-Type", "application/merge-patch+json") + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("X-Vikunja-Format", "markdown") + rec = httptest.NewRecorder() + e.ServeHTTP(rec, req) + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + + rec = humaRequest(t, e, http.MethodGet, fmt.Sprintf("/api/v2/labels/%d", id), "", token, "") + require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String()) + _, desc := decodeLabel(t, rec.Body.Bytes()) + assert.Equal(t, "

new bold

", desc) + }) +}