diff --git a/config.yml.sample b/config.yml.sample
index a5c99f01..dfe765b6 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 105b6ec2..92704614 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 f1708f52..e1c38e99 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 903ee08a..571e4bfc 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 2b235da3..75fea4fb 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 00000000..9d2dc859
--- /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 00000000..19625a23
--- /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 7b3fc6d0..11c1d8c2 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 af5c53a0..5fe20815 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) {