diff --git a/pkg/migration/20240314214802.go b/pkg/migration/20240314214802.go new file mode 100644 index 000000000..6797528ad --- /dev/null +++ b/pkg/migration/20240314214802.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 Licensee as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public Licensee for more details. +// +// You should have received a copy of the GNU Affero General Public Licensee +// along with this program. If not, see . + +package migration + +import ( + "code.vikunja.io/api/pkg/config" + "src.techknowlogick.com/xormigrate" + "xorm.io/xorm" +) + +type taskPositions20240314214802 struct { + TaskID int64 `xorm:"bigint not null index" json:"task_id"` + ProjectViewID int64 `xorm:"bigint not null index" json:"project_view_id"` + Position float64 `xorm:"double not null" json:"position"` +} + +func (taskPositions20240314214802) TableName() string { + return "task_positions" +} + +type task20240314214802 struct { + ID int64 `xorm:"bigint autoincr not null unique pk"` + ProjectID int64 `xorm:"bigint INDEX not null"` + Position float64 `xorm:"double not null"` + KanbanPosition float64 `xorm:"double not null"` +} + +func (task20240314214802) TableName() string { + return "tasks" +} + +func init() { + migrations = append(migrations, &xormigrate.Migration{ + ID: "20240314214802", + Description: "make task position seperate", + Migrate: func(tx *xorm.Engine) error { + err := tx.Sync2(taskPositions20240314214802{}) + if err != nil { + return err + } + + tasks := []*task20240314214802{} + err = tx.Find(&tasks) + if err != nil { + return err + } + + views := []*projectView20240313230538{} + err = tx.Find(&views) + if err != nil { + return err + } + + viewMap := make(map[int64][]*projectView20240313230538) + for _, view := range views { + if _, has := viewMap[view.ProjectID]; !has { + viewMap[view.ProjectID] = []*projectView20240313230538{} + } + + viewMap[view.ProjectID] = append(viewMap[view.ProjectID], view) + } + + for _, task := range tasks { + for _, view := range viewMap[task.ProjectID] { + if view.ViewKind == 0 { // List view + position := &taskPositions20240314214802{ + TaskID: task.ID, + Position: task.Position, + ProjectViewID: view.ID, + } + _, err = tx.Insert(position) + if err != nil { + return err + } + } + if view.ViewKind == 3 { // Kanban view + position := &taskPositions20240314214802{ + TaskID: task.ID, + Position: task.KanbanPosition, + ProjectViewID: view.ID, + } + _, err = tx.Insert(position) + if err != nil { + return err + } + } + } + } + + if config.DatabaseType.GetString() == "sqlite" { + _, err = tx.Exec(` +create table tasks_dg_tmp +( + id INTEGER not null + primary key autoincrement, + title TEXT not null, + description TEXT, + done INTEGER, + done_at DATETIME, + due_date DATETIME, + project_id INTEGER not null, + repeat_after INTEGER, + repeat_mode INTEGER default 0 not null, + priority INTEGER, + start_date DATETIME, + end_date DATETIME, + hex_color TEXT, + percent_done REAL, + "index" INTEGER default 0 not null, + uid TEXT, + cover_image_attachment_id INTEGER default 0, + created DATETIME not null, + updated DATETIME not null, + bucket_id INTEGER, + created_by_id INTEGER not null +); + +insert into tasks_dg_tmp(id, title, description, done, done_at, due_date, project_id, repeat_after, repeat_mode, + priority, start_date, end_date, hex_color, percent_done, "index", uid, + cover_image_attachment_id, created, updated, bucket_id, created_by_id) +select id, + title, + description, + done, + done_at, + due_date, + project_id, + repeat_after, + repeat_mode, + priority, + start_date, + end_date, + hex_color, + percent_done, + "index", + uid, + cover_image_attachment_id, + created, + updated, + bucket_id, + created_by_id +from tasks; + +drop table tasks; + +alter table tasks_dg_tmp + rename to tasks; + +create index IDX_tasks_done + on tasks (done); + +create index IDX_tasks_done_at + on tasks (done_at); + +create index IDX_tasks_due_date + on tasks (due_date); + +create index IDX_tasks_end_date + on tasks (end_date); + +create index IDX_tasks_project_id + on tasks (project_id); + +create index IDX_tasks_repeat_after + on tasks (repeat_after); + +create index IDX_tasks_start_date + on tasks (start_date); + +create unique index UQE_tasks_id + on tasks (id); +`) + return err + } + + err = dropTableColum(tx, "tasks", "position") + if err != nil { + return err + } + return dropTableColum(tx, "tasks", "kanban_position") + }, + Rollback: func(tx *xorm.Engine) error { + return nil + }, + }) +} diff --git a/pkg/models/kanban.go b/pkg/models/kanban.go index 2672ce132..97e734a15 100644 --- a/pkg/models/kanban.go +++ b/pkg/models/kanban.go @@ -201,7 +201,7 @@ func GetTasksInBucketsForView(s *xorm.Session, view *ProjectView, opts *taskSear opts.sortby = []*sortParam{ { orderBy: orderAscending, - sortBy: taskPropertyKanbanPosition, + sortBy: taskPropertyPosition, }, } diff --git a/pkg/models/task_collection.go b/pkg/models/task_collection.go index 9163746b2..9b3a2d73d 100644 --- a/pkg/models/task_collection.go +++ b/pkg/models/task_collection.go @@ -67,7 +67,6 @@ func validateTaskField(fieldName string) error { taskPropertyCreated, taskPropertyUpdated, taskPropertyPosition, - taskPropertyKanbanPosition, taskPropertyBucketID, taskPropertyIndex: return nil diff --git a/pkg/models/task_collection_sort.go b/pkg/models/task_collection_sort.go index 8b6a2f06b..4777b3bfe 100644 --- a/pkg/models/task_collection_sort.go +++ b/pkg/models/task_collection_sort.go @@ -26,27 +26,26 @@ type ( ) const ( - taskPropertyID string = "id" - taskPropertyTitle string = "title" - taskPropertyDescription string = "description" - taskPropertyDone string = "done" - taskPropertyDoneAt string = "done_at" - taskPropertyDueDate string = "due_date" - taskPropertyCreatedByID string = "created_by_id" - taskPropertyProjectID string = "project_id" - taskPropertyRepeatAfter string = "repeat_after" - taskPropertyPriority string = "priority" - taskPropertyStartDate string = "start_date" - taskPropertyEndDate string = "end_date" - taskPropertyHexColor string = "hex_color" - taskPropertyPercentDone string = "percent_done" - taskPropertyUID string = "uid" - taskPropertyCreated string = "created" - taskPropertyUpdated string = "updated" - taskPropertyPosition string = "position" - taskPropertyKanbanPosition string = "kanban_position" - taskPropertyBucketID string = "bucket_id" - taskPropertyIndex string = "index" + taskPropertyID string = "id" + taskPropertyTitle string = "title" + taskPropertyDescription string = "description" + taskPropertyDone string = "done" + taskPropertyDoneAt string = "done_at" + taskPropertyDueDate string = "due_date" + taskPropertyCreatedByID string = "created_by_id" + taskPropertyProjectID string = "project_id" + taskPropertyRepeatAfter string = "repeat_after" + taskPropertyPriority string = "priority" + taskPropertyStartDate string = "start_date" + taskPropertyEndDate string = "end_date" + taskPropertyHexColor string = "hex_color" + taskPropertyPercentDone string = "percent_done" + taskPropertyUID string = "uid" + taskPropertyCreated string = "created" + taskPropertyUpdated string = "updated" + taskPropertyPosition string = "position" + taskPropertyBucketID string = "bucket_id" + taskPropertyIndex string = "index" ) const ( diff --git a/pkg/models/task_position.go b/pkg/models/task_position.go new file mode 100644 index 000000000..87f8f4845 --- /dev/null +++ b/pkg/models/task_position.go @@ -0,0 +1,115 @@ +// 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 Licensee as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public Licensee for more details. +// +// You should have received a copy of the GNU Affero General Public Licensee +// along with this program. If not, see . + +package models + +import ( + "code.vikunja.io/web" + "math" + "xorm.io/xorm" +) + +type TaskPosition struct { + // The ID of the task this position is for + TaskID int64 `xorm:"bigint not null index" json:"task_id" param:"task"` + // The project view this task is related to + ProjectViewID int64 `xorm:"bigint not null index" json:"project_view_id"` + // The position of the task - any task project can be sorted as usual by this parameter. + // When accessing tasks via kanban buckets, this is primarily used to sort them based on a range + // We're using a float64 here to make it possible to put any task within any two other tasks (by changing the number). + // You would calculate the new position between two tasks with something like task3.position = (task2.position - task1.position) / 2. + // A 64-Bit float leaves plenty of room to initially give tasks a position with 2^16 difference to the previous task + // which also leaves a lot of room for rearranging and sorting later. + // Positions are always saved per view. They will automatically be set if you request the tasks through a view + // endpoint, otherwise they will always be 0. To update them, take a look at the Task Position endpoint. + Position float64 `xorm:"double not null" json:"position"` + + web.CRUDable `xorm:"-" json:"-"` + web.Rights `xorm:"-" json:"-"` +} + +func (tp *TaskPosition) TableName() string { + return "task_positions" +} + +func (tp *TaskPosition) CanUpdate(s *xorm.Session, a web.Auth) (bool, error) { + pv := &ProjectView{ID: tp.ProjectViewID} + return pv.CanUpdate(s, a) +} + +// Update is the handler to update a task position +// @Summary Updates a task position +// @Description Updates a task position. +// @tags task +// @Accept json +// @Produce json +// @Security JWTKeyAuth +// @Param id path int true "Task ID" +// @Param view body models.TaskPosition true "The task position with updated values you want to change." +// @Success 200 {object} models.TaskPosition "The updated task position." +// @Failure 400 {object} web.HTTPError "Invalid task position object provided." +// @Failure 500 {object} models.Message "Internal error" +// @Router /tasks/{id}/position [post] +func (tp *TaskPosition) Update(s *xorm.Session, _ web.Auth) (err error) { + exists, err := s. + Where("task_id = ? AND project_view_id = ?", tp.TaskID, tp.ProjectViewID). + Get(&TaskPosition{}) + if err != nil { + return err + } + + if !exists { + _, err = s.Insert(tp) + return + } + + _, err = s. + Where("task_id = ?", tp.TaskID). + Cols("project_view_id", "position"). + Update(tp) + return +} + +func RecalculateTaskPositions(s *xorm.Session, view *ProjectView) (err error) { + + allTasks := []*Task{} + err = s. + Select("tasks.*, task_positions.position AS position"). + Join("LEFT", "task_positions", "task_positions.task_id = tasks.id AND task_positions.project_view_id = ?", view.ID). + Where("project_id = ?", view.ProjectID). + OrderBy("position asc"). + Find(&allTasks) + if err != nil { + return + } + + maxPosition := math.Pow(2, 32) + newPositions := make([]*TaskPosition, 0, len(allTasks)) + + for i, task := range allTasks { + + currentPosition := maxPosition / float64(len(allTasks)) * (float64(i + 1)) + + newPositions = append(newPositions, &TaskPosition{ + TaskID: task.ID, + ProjectViewID: view.ID, + Position: currentPosition, + }) + } + + _, err = s.Insert(newPositions) + return +} diff --git a/pkg/models/tasks.go b/pkg/models/tasks.go index 4a3b28da7..48b5e1faf 100644 --- a/pkg/models/tasks.go +++ b/pkg/models/tasks.go @@ -121,9 +121,9 @@ type Task struct { // You would calculate the new position between two tasks with something like task3.position = (task2.position - task1.position) / 2. // A 64-Bit float leaves plenty of room to initially give tasks a position with 2^16 difference to the previous task // which also leaves a lot of room for rearranging and sorting later. - Position float64 `xorm:"double null" json:"position"` - // The position of tasks in the kanban board. See the docs for the `position` property on how to use this. - KanbanPosition float64 `xorm:"double null" json:"kanban_position"` + // Positions are always saved per view. They will automatically be set if you request the tasks through a view + // endpoint, otherwise they will always be 0. To update them, take a look at the Task Position endpoint. + Position float64 `xorm:"-" json:"position"` // Reactions on that task. Reactions ReactionMap `xorm:"-" json:"reactions"` @@ -785,7 +785,6 @@ func createTask(s *xorm.Session, t *Task, a web.Auth, updateAssignees bool) (err // If no position was supplied, set a default one t.Position = calculateDefaultPosition(t.Index, t.Position) - t.KanbanPosition = calculateDefaultPosition(t.Index, t.KanbanPosition) t.HexColor = utils.NormalizeHex(t.HexColor) @@ -912,7 +911,6 @@ func (t *Task) Update(s *xorm.Session, a web.Auth) (err error) { "bucket_id", "position", "repeat_mode", - "kanban_position", "cover_image_attachment_id", } @@ -1028,9 +1026,6 @@ func (t *Task) Update(s *xorm.Session, a web.Auth) (err error) { if t.Position == 0 { ot.Position = 0 } - if t.KanbanPosition == 0 { - ot.KanbanPosition = 0 - } // Repeat from current date if t.RepeatMode == TaskRepeatModeDefault { ot.RepeatMode = TaskRepeatModeDefault @@ -1059,12 +1054,6 @@ func (t *Task) Update(s *xorm.Session, a web.Auth) (err error) { return err } } - if ot.KanbanPosition < 0.1 { - err = recalculateTaskKanbanPositions(s, t.BucketID) - if err != nil { - return err - } - } // Get the task updated timestamp in a new struct - if we'd just try to put it into t which we already have, it // would still contain the old updated date. @@ -1075,7 +1064,6 @@ func (t *Task) Update(s *xorm.Session, a web.Auth) (err error) { } t.Updated = nt.Updated t.Position = nt.Position - t.KanbanPosition = nt.KanbanPosition doer, _ := user.GetFromAuth(a) err = events.Dispatch(&TaskUpdatedEvent{ @@ -1089,39 +1077,6 @@ func (t *Task) Update(s *xorm.Session, a web.Auth) (err error) { return updateProjectLastUpdated(s, &Project{ID: t.ProjectID}) } -func recalculateTaskKanbanPositions(s *xorm.Session, bucketID int64) (err error) { - - allTasks := []*Task{} - err = s. - Where("bucket_id = ?", bucketID). - OrderBy("kanban_position asc"). - Find(&allTasks) - if err != nil { - return - } - - maxPosition := math.Pow(2, 32) - - for i, task := range allTasks { - - currentPosition := maxPosition / float64(len(allTasks)) * (float64(i + 1)) - - // Here we use "NoAutoTime() to prevent the ORM from updating column "updated" automatically. - // Otherwise, this signals to CalDAV clients that the task has changed, which is not the case. - // Consequence: when synchronizing a list of tasks, the first one immediately changes the date of all the - // following ones from the same batch, which are then unable to be updated. - _, err = s.Cols("kanban_position"). - Where("id = ?", task.ID). - NoAutoTime(). - Update(&Task{KanbanPosition: currentPosition}) - if err != nil { - return - } - } - - return -} - func recalculateTaskPositions(s *xorm.Session, projectID int64) (err error) { allTasks := []*Task{} diff --git a/pkg/models/tasks_test.go b/pkg/models/tasks_test.go index 1849936f3..558ef8838 100644 --- a/pkg/models/tasks_test.go +++ b/pkg/models/tasks_test.go @@ -253,12 +253,11 @@ func TestTask_Update(t *testing.T) { defer s.Close() task := &Task{ - ID: 4, - Title: "test10000", - Description: "Lorem Ipsum Dolor", - KanbanPosition: 10, - ProjectID: 1, - BucketID: 2, // Bucket 2 already has 3 tasks and a limit of 3 + ID: 4, + Title: "test10000", + Description: "Lorem Ipsum Dolor", + ProjectID: 1, + BucketID: 2, // Bucket 2 already has 3 tasks and a limit of 3 } err := task.Update(s, u) require.NoError(t, err) diff --git a/pkg/models/typesense.go b/pkg/models/typesense.go index 368da9e8c..0afa9be85 100644 --- a/pkg/models/typesense.go +++ b/pkg/models/typesense.go @@ -153,10 +153,6 @@ func CreateTypesenseCollections() error { Name: "position", Type: "float", }, - { - Name: "kanban_position", - Type: "float", - }, { Name: "created_by_id", Type: "int64", @@ -417,7 +413,6 @@ type typesenseTask struct { Updated int64 `json:"updated"` BucketID int64 `json:"bucket_id"` Position float64 `json:"position"` - KanbanPosition float64 `json:"kanban_position"` CreatedByID int64 `json:"created_by_id"` Reminders interface{} `json:"reminders"` Assignees interface{} `json:"assignees"` @@ -451,7 +446,6 @@ func convertTaskToTypesenseTask(task *Task) *typesenseTask { Updated: task.Updated.UTC().Unix(), BucketID: task.BucketID, Position: task.Position, - KanbanPosition: task.KanbanPosition, CreatedByID: task.CreatedByID, Reminders: task.Reminders, Assignees: task.Assignees, diff --git a/pkg/modules/migration/trello/trello.go b/pkg/modules/migration/trello/trello.go index d19fd48b7..b96ed35a8 100644 --- a/pkg/modules/migration/trello/trello.go +++ b/pkg/modules/migration/trello/trello.go @@ -253,9 +253,8 @@ func convertTrelloDataToVikunja(trelloData []*trello.Board, token string) (fullV // The usual stuff: Title, description, position, bucket id task := &models.Task{ - Title: card.Name, - KanbanPosition: card.Pos, - BucketID: bucketID, + Title: card.Name, + BucketID: bucketID, } task.Description, err = convertMarkdownToHTML(card.Desc) diff --git a/pkg/modules/migration/trello/trello_test.go b/pkg/modules/migration/trello/trello_test.go index cc7757ca3..4099a2250 100644 --- a/pkg/modules/migration/trello/trello_test.go +++ b/pkg/modules/migration/trello/trello_test.go @@ -228,11 +228,10 @@ func TestConvertTrelloToVikunja(t *testing.T) { Tasks: []*models.TaskWithComments{ { Task: models.Task{ - Title: "Test Card 1", - Description: "

Card Description bold

\n", - BucketID: 1, - KanbanPosition: 123, - DueDate: time1, + Title: "Test Card 1", + Description: "

Card Description bold

\n", + BucketID: 1, + DueDate: time1, Labels: []*models.Label{ { Title: "Label 1", @@ -271,22 +270,19 @@ func TestConvertTrelloToVikunja(t *testing.T) { `, - BucketID: 1, - KanbanPosition: 124, + BucketID: 1, }, }, { Task: models.Task{ - Title: "Test Card 3", - BucketID: 1, - KanbanPosition: 126, + Title: "Test Card 3", + BucketID: 1, }, }, { Task: models.Task{ - Title: "Test Card 4", - BucketID: 1, - KanbanPosition: 127, + Title: "Test Card 4", + BucketID: 1, Labels: []*models.Label{ { Title: "Label 2", @@ -297,9 +293,8 @@ func TestConvertTrelloToVikunja(t *testing.T) { }, { Task: models.Task{ - Title: "Test Card 5", - BucketID: 2, - KanbanPosition: 111, + Title: "Test Card 5", + BucketID: 2, Labels: []*models.Label{ { Title: "Label 3", @@ -318,24 +313,21 @@ func TestConvertTrelloToVikunja(t *testing.T) { }, { Task: models.Task{ - Title: "Test Card 6", - BucketID: 2, - KanbanPosition: 222, - DueDate: time1, + Title: "Test Card 6", + BucketID: 2, + DueDate: time1, }, }, { Task: models.Task{ - Title: "Test Card 7", - BucketID: 2, - KanbanPosition: 333, + Title: "Test Card 7", + BucketID: 2, }, }, { Task: models.Task{ - Title: "Test Card 8", - BucketID: 2, - KanbanPosition: 444, + Title: "Test Card 8", + BucketID: 2, }, }, }, @@ -355,9 +347,8 @@ func TestConvertTrelloToVikunja(t *testing.T) { Tasks: []*models.TaskWithComments{ { Task: models.Task{ - Title: "Test Card 634", - BucketID: 3, - KanbanPosition: 123, + Title: "Test Card 634", + BucketID: 3, }, }, }, @@ -378,9 +369,8 @@ func TestConvertTrelloToVikunja(t *testing.T) { Tasks: []*models.TaskWithComments{ { Task: models.Task{ - Title: "Test Card 63423", - BucketID: 4, - KanbanPosition: 123, + Title: "Test Card 63423", + BucketID: 4, }, }, }, diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index 18e05ae2a..b9bf28961 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -386,6 +386,13 @@ func registerAPIRoutes(a *echo.Group) { a.DELETE("/tasks/:projecttask", taskHandler.DeleteWeb) a.POST("/tasks/:projecttask", taskHandler.UpdateWeb) + taskPositionHandler := &handler.WebHandler{ + EmptyStruct: func() handler.CObject { + return &models.TaskPosition{} + }, + } + a.POST("/tasks/:task/position", taskPositionHandler.UpdateWeb) + bulkTaskHandler := &handler.WebHandler{ EmptyStruct: func() handler.CObject { return &models.BulkTask{}