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.
This commit is contained in:
kolaente 2026-06-28 00:01:08 +02:00
parent 71639a3dc5
commit fa0c9a8584
9 changed files with 472 additions and 28 deletions

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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 <https://www.gnu.org/licenses/>.
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":"<p>Hello <strong>world</strong></p>","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, "<p>Hello <strong>world</strong></p>", 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(), "<strong>world</strong>")
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 = "<p>a <strong>bold</strong> note</p>"
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":"<p>kanban <strong>md</strong></p>"}`, 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(), "<strong>md</strong>", "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":"<p>a <strong>bold</strong> comment</p>"}`, 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":"<p>sub <strong>desc</strong></p>"}`, 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, "<strong>", "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, "<p>Hello <strong>world</strong></p>", 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, `<mention-user data-id="user1"`)
})
t.Run("markdown round trip is stable", func(t *testing.T) {
rec := humaRequest(t, e, http.MethodPost, "/api/v2/labels?format=markdown",
`{"title":"w4","description":"- [x] done\n- [ ] todo","hex_color":"112233"}`, token, "")
require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body.String())
id, _ := decodeLabel(t, rec.Body.Bytes())
// GET as markdown → PUT it back as markdown → GET as markdown must match.
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())
_, md1 := decodeLabel(t, rec.Body.Bytes())
put := fmt.Sprintf(`{"title":"w4","description":%s,"hex_color":"112233"}`, mustJSON(md1))
rec = humaRequest(t, e, http.MethodPut, fmt.Sprintf("/api/v2/labels/%d?format=markdown", id), put, token, "")
require.Equal(t, http.StatusOK, rec.Code, "body: %s", rec.Body.String())
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())
_, md2 := decodeLabel(t, rec.Body.Bytes())
assert.Equal(t, md1, md2, "markdown projection must be stable across a round trip")
})
t.Run("patch honours markdown via header", func(t *testing.T) {
rec := humaRequest(t, e, http.MethodPost, "/api/v2/labels",
`{"title":"w5","description":"<p>old</p>","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, "<p>new <strong>bold</strong></p>", desc)
})
}