diff --git a/config.yml.sample b/config.yml.sample index a5c99f0182..dfe765b6ea 100644 --- a/config.yml.sample +++ b/config.yml.sample @@ -156,6 +156,20 @@ migration: # with the code obtained from the wunderlist api. # Note that the vikunja frontend expects this to be /migrate/wunderlist redirecturl: + todoist: + # Wheter to enable the todoist migrator or not + enable: false + # The client id, required for making requests to the wunderlist api + # You need to register your vikunja instance at https://developer.todoist.com/appconsole.html to get this + clientid: + # The client secret, also required for making requests to the todoist api + clientsecret: + # The url where clients are redirected after they authorized Vikunja to access their todoist items. + # This needs to match the url you entered when registering your Vikunja instance at todoist. + # This is usually the frontend url where the frontend then makes a request to /migration/todoist/migrate + # with the code obtained from the todoist api. + # Note that the vikunja frontend expects this to be /migrate/todoist + redirecturl: avatar: # Switch between avatar providers. Possible values are gravatar and default. diff --git a/docs/content/doc/setup/config.md b/docs/content/doc/setup/config.md index 105b6ec22f..9270461424 100644 --- a/docs/content/doc/setup/config.md +++ b/docs/content/doc/setup/config.md @@ -199,6 +199,20 @@ migration: # with the code obtained from the wunderlist api. # Note that the vikunja frontend expects this to be /migrate/wunderlist redirecturl: + todoist: + # Wheter to enable the todoist migrator or not + enable: false + # The client id, required for making requests to the wunderlist api + # You need to register your vikunja instance at https://developer.todoist.com/appconsole.html to get this + clientid: + # The client secret, also required for making requests to the todoist api + clientsecret: + # The url where clients are redirected after they authorized Vikunja to access their todoist items. + # This needs to match the url you entered when registering your Vikunja instance at todoist. + # This is usually the frontend url where the frontend then makes a request to /migration/todoist/migrate + # with the code obtained from the todoist api. + # Note that the vikunja frontend expects this to be /migrate/todoist + redirecturl: avatar: # Switch between avatar providers. Possible values are gravatar and default. diff --git a/pkg/config/config.go b/pkg/config/config.go index f1708f522f..e1c38e9922 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -101,6 +101,10 @@ const ( MigrationWunderlistClientID Key = `migration.wunderlist.clientid` MigrationWunderlistClientSecret Key = `migration.wunderlist.clientsecret` MigrationWunderlistRedirectURL Key = `migration.wunderlist.redirecturl` + MigrationTodoistEnable Key = `migration.todoist.enable` + MigrationTodoistClientID Key = `migration.todoist.clientid` + MigrationTodoistClientSecret Key = `migration.todoist.clientsecret` + MigrationTodoistRedirectURL Key = `migration.todoist.redirecturl` CorsEnable Key = `cors.enable` CorsOrigins Key = `cors.origins` @@ -235,6 +239,7 @@ func InitDefaultConfig() { CorsMaxAge.setDefault(0) // Migration MigrationWunderlistEnable.setDefault(false) + MigrationTodoistEnable.setDefault(false) // Avatar AvatarProvider.setDefault("gravatar") AvatarGravaterExpiration.setDefault(3600) diff --git a/pkg/modules/migration/create_from_structure.go b/pkg/modules/migration/create_from_structure.go index 903ee08a2e..571e4bfcbd 100644 --- a/pkg/modules/migration/create_from_structure.go +++ b/pkg/modules/migration/create_from_structure.go @@ -30,6 +30,8 @@ func InsertFromStructure(str []*models.NamespaceWithLists, user *user.User) (err log.Debugf("[creating structure] Creating %d namespaces", len(str)) + labels := make(map[string]*models.Label) + // Create all namespaces for _, n := range str { err = n.Create(user) @@ -118,6 +120,34 @@ func InsertFromStructure(str []*models.NamespaceWithLists, user *user.User) (err log.Debugf("[creating structure] Created new attachment %d", a.ID) } } + + // Create all labels + for _, label := range t.Labels { + // Check if we already have a label with that name + color combination and use it + // If not, create one and save it for later + var lb *models.Label + var exists bool + lb, exists = labels[label.Title+label.HexColor] + if !exists { + err = label.Create(user) + if err != nil { + return err + } + log.Debugf("[creating structure] Created new label %d", label.ID) + labels[label.Title+label.HexColor] = label + lb = label + } + + lt := &models.LabelTask{ + LabelID: lb.ID, + TaskID: t.ID, + } + err = lt.Create(user) + if err != nil { + return err + } + log.Debugf("[creating structure] Associated task %d with label %d", t.ID, lb.ID) + } } } } diff --git a/pkg/modules/migration/create_from_structure_test.go b/pkg/modules/migration/create_from_structure_test.go index 2b235da3fc..75fea4fbe9 100644 --- a/pkg/modules/migration/create_from_structure_test.go +++ b/pkg/modules/migration/create_from_structure_test.go @@ -69,6 +69,28 @@ func TestInsertFromStructure(t *testing.T) { }, }, }, + { + Title: "Task with labels", + Labels: []*models.Label{ + { + Title: "Label1", + HexColor: "ff00ff", + }, + { + Title: "Label2", + HexColor: "ff00ff", + }, + }, + }, + { + Title: "Task with same label", + Labels: []*models.Label{ + { + Title: "Label1", + HexColor: "ff00ff", + }, + }, + }, }, }, }, diff --git a/pkg/modules/migration/todoist/todoist.go b/pkg/modules/migration/todoist/todoist.go new file mode 100644 index 0000000000..9d2dc859dc --- /dev/null +++ b/pkg/modules/migration/todoist/todoist.go @@ -0,0 +1,476 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-2020 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 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 General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package todoist + +import ( + "bytes" + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/files" + "code.vikunja.io/api/pkg/log" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/modules/migration" + "code.vikunja.io/api/pkg/timeutil" + "code.vikunja.io/api/pkg/user" + "code.vikunja.io/api/pkg/utils" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + "time" +) + +// Migration is the todoist migration struct +type Migration struct { + Code string `json:"code"` +} + +type apiTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` +} + +type label struct { + ID int `json:"id"` + Name string `json:"name"` + Color int `json:"color"` + ItemOrder int `json:"item_order"` + IsDeleted int `json:"is_deleted"` + IsFavorite int `json:"is_favorite"` +} + +type project struct { + ID int `json:"id"` + LegacyID int `json:"legacy_id"` + Name string `json:"name"` + Color int `json:"color"` + ParentID int `json:"parent_id"` + ChildOrder int `json:"child_order"` + Collapsed int `json:"collapsed"` + Shared bool `json:"shared"` + LegacyParentID int `json:"legacy_parent_id"` + SyncID int `json:"sync_id"` + IsDeleted int `json:"is_deleted"` + IsArchived int `json:"is_archived"` + IsFavorite int `json:"is_favorite"` +} + +type dueDate struct { + Date string `json:"date"` + Timezone interface{} `json:"timezone"` + String string `json:"string"` + Lang string `json:"lang"` + IsRecurring bool `json:"is_recurring"` +} + +type item struct { + ID int `json:"id"` + LegacyID int `json:"legacy_id"` + UserID int `json:"user_id"` + ProjectID int `json:"project_id"` + LegacyProjectID int `json:"legacy_project_id"` + Content string `json:"content"` + Priority int `json:"priority"` + Due *dueDate `json:"due"` + ParentID int `json:"parent_id"` + LegacyParentID int `json:"legacy_parent_id"` + ChildOrder int `json:"child_order"` + SectionID int `json:"section_id"` + DayOrder int `json:"day_order"` + Collapsed int `json:"collapsed"` + Children interface{} `json:"children"` + Labels []int `json:"labels"` + AddedByUID int `json:"added_by_uid"` + AssignedByUID int `json:"assigned_by_uid"` + ResponsibleUID int `json:"responsible_uid"` + Checked int `json:"checked"` + InHistory int `json:"in_history"` + IsDeleted int `json:"is_deleted"` + DateAdded time.Time `json:"date_added"` + HasMoreNotes bool `json:"has_more_notes"` + DateCompleted time.Time `json:"date_completed"` +} + +type fileAttachment struct { + FileType string `json:"file_type"` + FileName string `json:"file_name"` + FileSize int `json:"file_size"` + FileURL string `json:"file_url"` + UploadState string `json:"upload_state"` +} + +type note struct { + ID int `json:"id"` + LegacyID int `json:"legacy_id"` + PostedUID int `json:"posted_uid"` + ProjectID int `json:"project_id"` + LegacyProjectID int `json:"legacy_project_id"` + ItemID int `json:"item_id"` + LegacyItemID int `json:"legacy_item_id"` + Content string `json:"content"` + FileAttachment *fileAttachment `json:"file_attachment"` + UidsToNotify []int `json:"uids_to_notify"` + IsDeleted int `json:"is_deleted"` + Posted time.Time `json:"posted"` +} + +type projectNote struct { + Content string `json:"content"` + FileAttachment *fileAttachment `json:"file_attachment"` + ID int64 `json:"id"` + IsDeleted int `json:"is_deleted"` + Posted time.Time `json:"posted"` + PostedUID int `json:"posted_uid"` + ProjectID int `json:"project_id"` + UidsToNotify []int `json:"uids_to_notify"` +} + +type reminder struct { + ID int `json:"id"` + NotifyUID int `json:"notify_uid"` + ItemID int `json:"item_id"` + Service string `json:"service"` + Type string `json:"type"` + Due *dueDate `json:"due"` + MmOffset int `json:"mm_offset"` + IsDeleted int `json:"is_deleted"` +} + +type sync struct { + Projects []*project `json:"projects"` + Items []*item `json:"items"` + Labels []*label `json:"labels"` + Notes []*note `json:"notes"` + ProjectNotes []*projectNote `json:"project_notes"` + Reminders []*reminder `json:"reminders"` +} + +var todoistColors = map[int]string{} + +func init() { + todoistColors = make(map[int]string, 19) + // The todoists colors are static, taken from https://developer.todoist.com/sync/v8/#colors + todoistColors = map[int]string{ + 30: "b8256f", + 31: "db4035", + 32: "ff9933", + 33: "fad000", + 34: "afb83b", + 35: "7ecc49", + 36: "299438", + 37: "6accbc", + 38: "158fad", + 39: "14aaf5", + 40: "96c3eb", + 41: "4073ff", + 42: "884dff", + 43: "af38eb", + 44: "eb96eb", + 45: "e05194", + 46: "ff8d85", + 47: "808080", + 48: "b8b8b8", + 49: "ccac93", + } +} + +// Name is used to get the name of the todoist migration - we're using the docs here to annotate the status route. +// @Summary Get migration status +// @Description Returns if the current user already did the migation or not. This is useful to show a confirmation message in the frontend if the user is trying to do the same migration again. +// @tags migration +// @Produce json +// @Security JWTKeyAuth +// @Success 200 {object} migration.Status "The migration status" +// @Failure 500 {object} models.Message "Internal server error" +// @Router /migration/todoist/status [get] +func (m *Migration) Name() string { + return "todoist" +} + +// AuthURL returns the url users need to authenticate against +// @Summary Get the auth url from todoist +// @Description Returns the auth url where the user needs to get its auth code. This code can then be used to migrate everything from todoist to Vikunja. +// @tags migration +// @Produce json +// @Security JWTKeyAuth +// @Success 200 {object} handler.AuthURL "The auth url." +// @Failure 500 {object} models.Message "Internal server error" +// @Router /migration/todoist/auth [get] +func (m *Migration) AuthURL() string { + return "https://todoist.com/oauth/authorize" + + "?client_id=" + config.MigrationTodoistClientID.GetString() + + "&scope=data:read" + + "&state=" + utils.MakeRandomString(32) +} + +func doPost(url string, form url.Values) (resp *http.Response, err error) { + req, err := http.NewRequest("POST", url, strings.NewReader(form.Encode())) + if err != nil { + return + } + + req.Header.Add("Content-Type", "application/x-www-form-urlencoded") + hc := http.Client{} + return hc.Do(req) +} + +func convertTodoistToVikunja(sync *sync) (fullVikunjaHierachie []*models.NamespaceWithLists, err error) { + + newNamespace := &models.NamespaceWithLists{ + Namespace: models.Namespace{ + Title: "Migrated from todoist", + }, + } + + // A map for all vikunja lists with the project id they're coming from as key + lists := make(map[int]*models.List, len(sync.Projects)) + + // A map for all vikunja tasks with the todoist task id as key to find them easily and add more data + tasks := make(map[int]*models.Task, len(sync.Items)) + + // A map for all vikunja labels with the todoist id as key to find them easier + labels := make(map[int]*models.Label, len(sync.Labels)) + + for _, p := range sync.Projects { + list := &models.List{ + Title: p.Name, + HexColor: todoistColors[p.Color], + IsArchived: p.IsArchived == 1, + } + + lists[p.ID] = list + + newNamespace.Lists = append(newNamespace.Lists, list) + } + + for _, label := range sync.Labels { + labels[label.ID] = &models.Label{ + Title: label.Name, + HexColor: todoistColors[label.Color], + } + } + + for _, i := range sync.Items { + task := &models.Task{ + Title: i.Content, + Created: timeutil.FromTime(i.DateAdded), + Done: i.Checked == 1, + } + + // Only try to parse the task done at date if the task is actually done + // Sometimes weired things happen if we try to parse nil dates. + if task.Done { + task.DoneAt = timeutil.FromTime(i.DateCompleted) + } + + // Todoist priorities only range from 1 (lowest) and max 4 (highest), so we need to make slight adjustments + if i.Priority > 1 { + task.Priority = int64(i.Priority) + } + + // Put the due date together + if i.Due != nil { + dueDate, err := time.Parse("2006-01-02", i.Due.Date) + if err != nil { + return nil, err + } + task.DueDate = timeutil.FromTime(dueDate) + } + + // Put all labels together from earlier + for _, lID := range i.Labels { + task.Labels = append(task.Labels, labels[lID]) + } + + tasks[i.ID] = task + + lists[i.ProjectID].Tasks = append(lists[i.ProjectID].Tasks, task) + } + + // If the parenId of a task is not 0, create a task relation + // We're looping again here to make sure we have seem all tasks before and have them in our map + for _, i := range sync.Items { + if i.ParentID == 0 { + continue + } + + // Prevent all those nil errors + if tasks[i.ParentID].RelatedTasks == nil { + tasks[i.ParentID].RelatedTasks = make(models.RelatedTaskMap) + } + + tasks[i.ParentID].RelatedTasks[models.RelationKindSubtask] = append(tasks[i.ParentID].RelatedTasks[models.RelationKindSubtask], tasks[i.ID]) + } + + // Task Notes -> Task Descriptions + for _, n := range sync.Notes { + if tasks[n.ItemID].Description != "" { + tasks[n.ItemID].Description += "\n" + } + tasks[n.ItemID].Description += n.Content + + if n.FileAttachment == nil { + continue + } + + // Download the attachment and put it in the file + resp, err := http.Get(n.FileAttachment.FileURL) + if err != nil { + return nil, err + } + defer resp.Body.Close() + buf := &bytes.Buffer{} + _, err = buf.ReadFrom(resp.Body) + if err != nil { + return nil, err + } + + tasks[n.ItemID].Attachments = append(tasks[n.ItemID].Attachments, &models.TaskAttachment{ + File: &files.File{ + Name: n.FileAttachment.FileName, + Mime: n.FileAttachment.FileType, + Size: uint64(n.FileAttachment.FileSize), + Created: n.Posted, + CreatedUnix: timeutil.FromTime(n.Posted), + // We directly pass the file contents here to have a way to link the attachment to the file later. + // Because we don't have an ID for our task at this point of the migration, we cannot just throw all + // attachments in a slice and do the work of downloading and properly storing them later. + FileContent: buf.Bytes(), + }, + Created: timeutil.FromTime(n.Posted), + }) + } + + // Project Notes -> List Descriptions + for _, pn := range sync.ProjectNotes { + if lists[pn.ProjectID].Description != "" { + lists[pn.ProjectID].Description += "\n" + } + + lists[pn.ProjectID].Description += pn.Content + } + + // Reminders -> vikunja reminders + for _, r := range sync.Reminders { + if r.Due == nil { + continue + } + + date, err := time.Parse("2006-01-02", r.Due.Date) + if err != nil { + return nil, err + } + + tasks[r.ItemID].Reminders = append(tasks[r.ItemID].Reminders, timeutil.FromTime(date)) + } + + return []*models.NamespaceWithLists{ + newNamespace, + }, err +} + +func getAccessTokenFromAuthToken(authToken string) (accessToken string, err error) { + + form := url.Values{ + "client_id": []string{config.MigrationTodoistClientID.GetString()}, + "client_secret": []string{config.MigrationTodoistClientSecret.GetString()}, + "code": []string{authToken}, + "redirect_uri": []string{config.MigrationTodoistRedirectURL.GetString()}, + } + resp, err := doPost("https://todoist.com/oauth/access_token", form) + if err != nil { + return + } + + if resp.StatusCode > 399 { + buf := &bytes.Buffer{} + _, _ = buf.ReadFrom(resp.Body) + return "", fmt.Errorf("got http status %d while trying to get token, error was %s", resp.StatusCode, buf.String()) + } + + token := &apiTokenResponse{} + err = json.NewDecoder(resp.Body).Decode(token) + return token.AccessToken, err +} + +// Migrate gets all tasks from todoist for a user and puts them into vikunja +// @Summary Migrate all lists, tasks etc. from todoist +// @Description Migrates all projects, tasks, notes, reminders, subtasks and files from todoist to vikunja. +// @tags migration +// @Accept json +// @Produce json +// @Security JWTKeyAuth +// @Param migrationCode body todoist.Migration true "The auth code previously obtained from the auth url. See the docs for /migration/todoist/auth." +// @Success 200 {object} models.Message "A message telling you everything was migrated successfully." +// @Failure 500 {object} models.Message "Internal server error" +// @Router /migration/todoist/migrate [post] +func (m *Migration) Migrate(u *user.User) (err error) { + + log.Debugf("[Todoist Migration] Starting migration for user %d", u.ID) + + // 0. Get an api token from the obtained auth token + token, err := getAccessTokenFromAuthToken(m.Code) + if err != nil { + return + } + + if token == "" { + log.Debugf("[Todoist Migration] Could not get token") + return + } + + log.Debugf("[Todoist Migration] Got user token for user %d", u.ID) + log.Debugf("[Todoist Migration] Getting todoist data for user %d", u.ID) + + // Get everything with the sync api + form := url.Values{ + "token": []string{token}, + "sync_token": []string{"*"}, + "resource_types": []string{"[\"all\"]"}, + } + resp, err := doPost("https://api.todoist.com/sync/v8/sync", form) + if err != nil { + return + } + + syncResponse := &sync{} + err = json.NewDecoder(resp.Body).Decode(syncResponse) + if err != nil { + return + } + + log.Debugf("[Todoist Migration] Got all todoist user data for user %d", u.ID) + log.Debugf("[Todoist Migration] Start converting data for user %d", u.ID) + + fullVikunjaHierachie, err := convertTodoistToVikunja(syncResponse) + if err != nil { + return + } + + log.Debugf("[Todoist Migration] Done converting data for user %d", u.ID) + log.Debugf("[Todoist Migration] Start inserting data for user %d", u.ID) + + err = migration.InsertFromStructure(fullVikunjaHierachie, u) + if err != nil { + return + } + + log.Debugf("[Todoist Migration] Done inserting data for user %d", u.ID) + log.Debugf("[Todoist Migration] Todoist migration done for user %d", u.ID) + + return nil +} diff --git a/pkg/modules/migration/todoist/todoist_test.go b/pkg/modules/migration/todoist/todoist_test.go new file mode 100644 index 0000000000..19625a232d --- /dev/null +++ b/pkg/modules/migration/todoist/todoist_test.go @@ -0,0 +1,550 @@ +// Vikunja is a to-do list application to facilitate your life. +// Copyright 2018-2020 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 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 General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +package todoist + +import ( + "code.vikunja.io/api/pkg/config" + "code.vikunja.io/api/pkg/files" + "code.vikunja.io/api/pkg/models" + "code.vikunja.io/api/pkg/timeutil" + "github.com/stretchr/testify/assert" + "gopkg.in/d4l3k/messagediff.v1" + "io/ioutil" + "strconv" + "testing" + "time" +) + +func TestConvertTodoistToVikunja(t *testing.T) { + + config.InitConfig() + + time1, err := time.Parse(time.RFC3339Nano, "2014-09-26T08:25:05Z") + assert.NoError(t, err) + time3, err := time.Parse(time.RFC3339Nano, "2014-10-21T08:25:05Z") + assert.NoError(t, err) + dueTime, err := time.Parse(time.RFC3339Nano, "2020-05-31T00:00:00Z") + assert.NoError(t, err) + nilTime, err := time.Parse(time.RFC3339Nano, "1970-01-01T00:00:00Z") + assert.NoError(t, err) + exampleFile, err := ioutil.ReadFile(config.ServiceRootpath.GetString() + "/pkg/modules/migration/wunderlist/testimage.jpg") + assert.NoError(t, err) + + makeTestItem := func(id, projectId int, hasDueDate, hasLabels, done bool) *item { + item := &item{ + ID: id, + UserID: 1855589, + ProjectID: projectId, + Content: "Task" + strconv.Itoa(id), + Priority: 1, + ParentID: 0, + ChildOrder: 1, + DateAdded: time1, + DateCompleted: nilTime, + } + + if done { + item.Checked = 1 + item.DateCompleted = time3 + } + + if hasLabels { + item.Labels = []int{ + 80000, + 80001, + 80002, + 80003, + } + } + + if hasDueDate { + item.Due = &dueDate{ + Date: "2020-05-31", + Timezone: nil, + IsRecurring: false, + } + } + + return item + } + + testSync := &sync{ + Projects: []*project{ + { + ID: 396936926, + Name: "Project1", + Color: 30, + ChildOrder: 1, + Collapsed: 0, + Shared: false, + IsDeleted: 0, + IsArchived: 0, + IsFavorite: 0, + }, + { + ID: 396936927, + Name: "Project2", + Color: 37, + ChildOrder: 1, + Collapsed: 0, + Shared: false, + IsDeleted: 0, + IsArchived: 0, + IsFavorite: 0, + }, + { + ID: 396936928, + Name: "Project3 - Archived", + Color: 37, + ChildOrder: 1, + Collapsed: 0, + Shared: false, + IsDeleted: 0, + IsArchived: 1, + IsFavorite: 0, + }, + }, + Items: []*item{ + makeTestItem(400000000, 396936926, false, false, false), + makeTestItem(400000001, 396936926, false, false, false), + makeTestItem(400000002, 396936926, false, false, false), + makeTestItem(400000003, 396936926, true, true, true), + makeTestItem(400000004, 396936926, false, true, false), + makeTestItem(400000005, 396936926, true, false, true), + makeTestItem(400000006, 396936926, true, false, true), + { + ID: 400000110, + UserID: 1855589, + ProjectID: 396936926, + Content: "Task with parent", + Priority: 2, + ParentID: 400000006, + ChildOrder: 1, + Checked: 0, + DateAdded: time1, + }, + makeTestItem(400000106, 396936926, true, true, true), + makeTestItem(400000107, 396936926, false, false, true), + makeTestItem(400000108, 396936926, false, false, true), + makeTestItem(400000109, 396936926, false, false, true), + + makeTestItem(400000007, 396936927, true, false, false), + makeTestItem(400000008, 396936927, true, false, false), + makeTestItem(400000009, 396936927, false, false, false), + makeTestItem(400000010, 396936927, false, false, true), + makeTestItem(400000101, 396936927, false, false, false), + makeTestItem(400000102, 396936927, true, true, false), + makeTestItem(400000103, 396936927, false, true, false), + makeTestItem(400000104, 396936927, false, true, false), + makeTestItem(400000105, 396936927, true, true, false), + + makeTestItem(400000111, 396936928, false, false, true), + }, + Labels: []*label{ + { + ID: 80000, + Name: "Label1", + Color: 30, + }, + { + ID: 80001, + Name: "Label2", + Color: 31, + }, + { + ID: 80002, + Name: "Label3", + Color: 32, + }, + { + ID: 80003, + Name: "Label4", + Color: 33, + }, + }, + Notes: []*note{ + { + ID: 101476, + PostedUID: 1855589, + ItemID: 400000000, + Content: "Lorem Ipsum dolor sit amet", + Posted: time1, + }, + { + ID: 101477, + PostedUID: 1855589, + ItemID: 400000001, + Content: "Lorem Ipsum dolor sit amet", + Posted: time1, + }, + { + ID: 101478, + PostedUID: 1855589, + ItemID: 400000003, + Content: "Lorem Ipsum dolor sit amet", + Posted: time1, + }, + { + ID: 101479, + PostedUID: 1855589, + ItemID: 400000010, + Content: "Lorem Ipsum dolor sit amet", + Posted: time1, + }, + { + ID: 101480, + PostedUID: 1855589, + ItemID: 400000101, + Content: "Lorem Ipsum dolor sit amet", + FileAttachment: &fileAttachment{ + FileName: "file.md", + FileType: "text/plain", + FileSize: 12345, + FileURL: "https://vikunja.io/testimage.jpg", // Using an image which we are hosting, so it'll still be up + UploadState: "completed", + }, + Posted: time1, + }, + }, + ProjectNotes: []*projectNote{ + { + ID: 102000, + Content: "Lorem Ipsum dolor sit amet", + ProjectID: 396936926, + Posted: time3, + PostedUID: 1855589, + }, + { + ID: 102001, + Content: "Lorem Ipsum dolor sit amet 2", + ProjectID: 396936926, + Posted: time3, + PostedUID: 1855589, + }, + { + ID: 102002, + Content: "Lorem Ipsum dolor sit amet 3", + ProjectID: 396936926, + Posted: time3, + PostedUID: 1855589, + }, + { + ID: 102003, + Content: "Lorem Ipsum dolor sit amet 4", + ProjectID: 396936927, + Posted: time3, + PostedUID: 1855589, + }, + { + ID: 102004, + Content: "Lorem Ipsum dolor sit amet 5", + ProjectID: 396936927, + Posted: time3, + PostedUID: 1855589, + }, + }, + Reminders: []*reminder{ + { + ID: 103000, + ItemID: 400000000, + Due: &dueDate{ + Date: "2020-06-15", + IsRecurring: false, + }, + MmOffset: 180, + }, + { + ID: 103001, + ItemID: 400000000, + Due: &dueDate{ + Date: "2020-06-16", + IsRecurring: false, + }, + }, + { + ID: 103002, + ItemID: 400000002, + Due: &dueDate{ + Date: "2020-07-15", + IsRecurring: true, + }, + }, + { + ID: 103003, + ItemID: 400000003, + Due: &dueDate{ + Date: "2020-06-15", + IsRecurring: false, + }, + }, + { + ID: 103004, + ItemID: 400000005, + Due: &dueDate{ + Date: "2020-06-15", + IsRecurring: false, + }, + }, + { + ID: 103006, + ItemID: 400000009, + Due: &dueDate{ + Date: "2020-06-15", + IsRecurring: false, + }, + }, + }, + } + + vikunjaLabels := []*models.Label{ + { + Title: "Label1", + HexColor: todoistColors[30], + }, + { + Title: "Label2", + HexColor: todoistColors[31], + }, + { + Title: "Label3", + HexColor: todoistColors[32], + }, + { + Title: "Label4", + HexColor: todoistColors[33], + }, + } + + expectedHierachie := []*models.NamespaceWithLists{ + { + Namespace: models.Namespace{ + Title: "Migrated from todoist", + }, + Lists: []*models.List{ + { + Title: "Project1", + Description: "Lorem Ipsum dolor sit amet\nLorem Ipsum dolor sit amet 2\nLorem Ipsum dolor sit amet 3", + HexColor: todoistColors[30], + Tasks: []*models.Task{ + { + Title: "Task400000000", + Description: "Lorem Ipsum dolor sit amet", + Done: false, + Created: timeutil.FromTime(time1), + Reminders: []timeutil.TimeStamp{ + timeutil.FromTime(time.Date(2020, time.June, 15, 0, 0, 0, 0, time.UTC)), + timeutil.FromTime(time.Date(2020, time.June, 16, 0, 0, 0, 0, time.UTC)), + }, + }, + { + Title: "Task400000001", + Description: "Lorem Ipsum dolor sit amet", + Done: false, + Created: timeutil.FromTime(time1), + }, + { + Title: "Task400000002", + Done: false, + Created: timeutil.FromTime(time1), + Reminders: []timeutil.TimeStamp{ + timeutil.FromTime(time.Date(2020, time.July, 15, 0, 0, 0, 0, time.UTC)), + }, + }, + { + Title: "Task400000003", + Description: "Lorem Ipsum dolor sit amet", + Done: true, + DueDate: timeutil.FromTime(dueTime), + Created: timeutil.FromTime(time1), + DoneAt: timeutil.FromTime(time3), + Labels: vikunjaLabels, + Reminders: []timeutil.TimeStamp{ + timeutil.FromTime(time.Date(2020, time.June, 15, 0, 0, 0, 0, time.UTC)), + }, + }, + { + Title: "Task400000004", + Done: false, + Created: timeutil.FromTime(time1), + Labels: vikunjaLabels, + }, + { + Title: "Task400000005", + Done: true, + DueDate: timeutil.FromTime(dueTime), + Created: timeutil.FromTime(time1), + DoneAt: timeutil.FromTime(time3), + Reminders: []timeutil.TimeStamp{ + timeutil.FromTime(time.Date(2020, time.June, 15, 0, 0, 0, 0, time.UTC)), + }, + }, + { + Title: "Task400000006", + Done: true, + DueDate: timeutil.FromTime(dueTime), + Created: timeutil.FromTime(time1), + DoneAt: timeutil.FromTime(time3), + RelatedTasks: map[models.RelationKind][]*models.Task{ + models.RelationKindSubtask: { + { + Title: "Task with parent", + Done: false, + Priority: 2, + Created: timeutil.FromTime(time1), + DoneAt: timeutil.FromTime(nilTime), + }, + }, + }, + }, + { + Title: "Task with parent", + Done: false, + Priority: 2, + Created: timeutil.FromTime(time1), + DoneAt: timeutil.FromTime(nilTime), + }, + { + Title: "Task400000106", + Done: true, + DueDate: timeutil.FromTime(dueTime), + Created: timeutil.FromTime(time1), + DoneAt: timeutil.FromTime(time3), + Labels: vikunjaLabels, + }, + { + Title: "Task400000107", + Done: true, + Created: timeutil.FromTime(time1), + DoneAt: timeutil.FromTime(time3), + }, + { + Title: "Task400000108", + Done: true, + Created: timeutil.FromTime(time1), + DoneAt: timeutil.FromTime(time3), + }, + { + Title: "Task400000109", + Done: true, + Created: timeutil.FromTime(time1), + DoneAt: timeutil.FromTime(time3), + }, + }, + }, + { + Title: "Project2", + Description: "Lorem Ipsum dolor sit amet 4\nLorem Ipsum dolor sit amet 5", + HexColor: todoistColors[37], + Tasks: []*models.Task{ + { + Title: "Task400000007", + Done: false, + DueDate: timeutil.FromTime(dueTime), + Created: timeutil.FromTime(time1), + }, + { + Title: "Task400000008", + Done: false, + DueDate: timeutil.FromTime(dueTime), + Created: timeutil.FromTime(time1), + }, + { + Title: "Task400000009", + Done: false, + Created: timeutil.FromTime(time1), + Reminders: []timeutil.TimeStamp{ + timeutil.FromTime(time.Date(2020, time.June, 15, 0, 0, 0, 0, time.UTC)), + }, + }, + { + Title: "Task400000010", + Description: "Lorem Ipsum dolor sit amet", + Done: true, + Created: timeutil.FromTime(time1), + DoneAt: timeutil.FromTime(time3), + }, + { + Title: "Task400000101", + Description: "Lorem Ipsum dolor sit amet", + Done: false, + Created: timeutil.FromTime(time1), + Attachments: []*models.TaskAttachment{ + { + File: &files.File{ + Name: "file.md", + Mime: "text/plain", + Size: 12345, + Created: time1, + CreatedUnix: timeutil.FromTime(time1), + FileContent: exampleFile, + }, + Created: timeutil.FromTime(time1), + }, + }, + }, + { + Title: "Task400000102", + Done: false, + DueDate: timeutil.FromTime(dueTime), + Created: timeutil.FromTime(time1), + Labels: vikunjaLabels, + }, + { + Title: "Task400000103", + Done: false, + Created: timeutil.FromTime(time1), + Labels: vikunjaLabels, + }, + { + Title: "Task400000104", + Done: false, + Created: timeutil.FromTime(time1), + Labels: vikunjaLabels, + }, + { + Title: "Task400000105", + Done: false, + DueDate: timeutil.FromTime(dueTime), + Created: timeutil.FromTime(time1), + Labels: vikunjaLabels, + }, + }, + }, + { + Title: "Project3 - Archived", + HexColor: todoistColors[37], + IsArchived: true, + Tasks: []*models.Task{ + { + Title: "Task400000111", + Done: true, + Created: timeutil.FromTime(time1), + DoneAt: timeutil.FromTime(time3), + }, + }, + }, + }, + }, + } + + hierachie, err := convertTodoistToVikunja(testSync) + assert.NoError(t, err) + assert.NotNil(t, hierachie) + if diff, equal := messagediff.PrettyDiff(hierachie, expectedHierachie); !equal { + t.Errorf("converted todoist data = %v, want %v, diff: %v", hierachie, expectedHierachie, diff) + } +} diff --git a/pkg/modules/migration/wunderlist/wunderlist_test.go b/pkg/modules/migration/wunderlist/wunderlist_test.go index 7b3fc6d003..11c1d8c2fa 100644 --- a/pkg/modules/migration/wunderlist/wunderlist_test.go +++ b/pkg/modules/migration/wunderlist/wunderlist_test.go @@ -348,6 +348,6 @@ func TestWunderlistParsing(t *testing.T) { assert.NoError(t, err) assert.NotNil(t, hierachie) if diff, equal := messagediff.PrettyDiff(hierachie, expectedHierachie); !equal { - t.Errorf("ListUser.ReadAll() = %v, want %v, diff: %v", hierachie, expectedHierachie, diff) + t.Errorf("converted wunderlist data = %v, want %v, diff: %v", hierachie, expectedHierachie, diff) } } diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index af5c53a048..5fe208151a 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -49,6 +49,7 @@ import ( "code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/modules/migration" migrationHandler "code.vikunja.io/api/pkg/modules/migration/handler" + "code.vikunja.io/api/pkg/modules/migration/todoist" "code.vikunja.io/api/pkg/modules/migration/wunderlist" apiv1 "code.vikunja.io/api/pkg/routes/api/v1" "code.vikunja.io/api/pkg/routes/caldav" @@ -433,6 +434,16 @@ func registerAPIRoutes(a *echo.Group) { } wunderlistMigrationHandler.RegisterRoutes(m) } + + // Todoist + if config.MigrationTodoistEnable.GetBool() { + todoistMigrationHandler := &migrationHandler.MigrationWeb{ + MigrationStruct: func() migration.Migrator { + return &todoist.Migration{} + }, + } + todoistMigrationHandler.RegisterRoutes(m) + } } func registerCalDavRoutes(c *echo.Group) {