Todoist Migration (#566)

Add swagger docs + fix lint

Add parsing logic + fix fixtures

Fix test init

Add logging to creating labels and debug logs

Add creating labels when migrating

Finish test fixtures

Started adding fixtures for testing

Add method and test structures to convert todoist to vikunja

Add basic structure to migrate everything

Add all structs for todoist api

Add docs for config options

Add routes for todoist migrator

Add api token exchange

Add basic structure for todoist migration

Co-authored-by: kolaente <k@knt.li>
Reviewed-on: https://kolaente.dev/vikunja/api/pulls/566
This commit is contained in:
konrad 2020-05-23 20:50:54 +00:00
parent 292c815000
commit e89e6d47d4
9 changed files with 1123 additions and 1 deletions

View file

@ -156,6 +156,20 @@ migration:
# with the code obtained from the wunderlist api. # with the code obtained from the wunderlist api.
# Note that the vikunja frontend expects this to be /migrate/wunderlist # Note that the vikunja frontend expects this to be /migrate/wunderlist
redirecturl: 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: avatar:
# Switch between avatar providers. Possible values are gravatar and default. # Switch between avatar providers. Possible values are gravatar and default.

View file

@ -199,6 +199,20 @@ migration:
# with the code obtained from the wunderlist api. # with the code obtained from the wunderlist api.
# Note that the vikunja frontend expects this to be /migrate/wunderlist # Note that the vikunja frontend expects this to be /migrate/wunderlist
redirecturl: 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: avatar:
# Switch between avatar providers. Possible values are gravatar and default. # Switch between avatar providers. Possible values are gravatar and default.

View file

@ -101,6 +101,10 @@ const (
MigrationWunderlistClientID Key = `migration.wunderlist.clientid` MigrationWunderlistClientID Key = `migration.wunderlist.clientid`
MigrationWunderlistClientSecret Key = `migration.wunderlist.clientsecret` MigrationWunderlistClientSecret Key = `migration.wunderlist.clientsecret`
MigrationWunderlistRedirectURL Key = `migration.wunderlist.redirecturl` 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` CorsEnable Key = `cors.enable`
CorsOrigins Key = `cors.origins` CorsOrigins Key = `cors.origins`
@ -235,6 +239,7 @@ func InitDefaultConfig() {
CorsMaxAge.setDefault(0) CorsMaxAge.setDefault(0)
// Migration // Migration
MigrationWunderlistEnable.setDefault(false) MigrationWunderlistEnable.setDefault(false)
MigrationTodoistEnable.setDefault(false)
// Avatar // Avatar
AvatarProvider.setDefault("gravatar") AvatarProvider.setDefault("gravatar")
AvatarGravaterExpiration.setDefault(3600) AvatarGravaterExpiration.setDefault(3600)

View file

@ -30,6 +30,8 @@ func InsertFromStructure(str []*models.NamespaceWithLists, user *user.User) (err
log.Debugf("[creating structure] Creating %d namespaces", len(str)) log.Debugf("[creating structure] Creating %d namespaces", len(str))
labels := make(map[string]*models.Label)
// Create all namespaces // Create all namespaces
for _, n := range str { for _, n := range str {
err = n.Create(user) 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) 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)
}
} }
} }
} }

View file

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

View file

@ -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 <https://www.gnu.org/licenses/>.
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
}

View file

@ -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 <https://www.gnu.org/licenses/>.
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)
}
}

View file

@ -348,6 +348,6 @@ func TestWunderlistParsing(t *testing.T) {
assert.NoError(t, err) assert.NoError(t, err)
assert.NotNil(t, hierachie) assert.NotNil(t, hierachie)
if diff, equal := messagediff.PrettyDiff(hierachie, expectedHierachie); !equal { 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)
} }
} }

View file

@ -49,6 +49,7 @@ import (
"code.vikunja.io/api/pkg/models" "code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/modules/migration" "code.vikunja.io/api/pkg/modules/migration"
migrationHandler "code.vikunja.io/api/pkg/modules/migration/handler" 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" "code.vikunja.io/api/pkg/modules/migration/wunderlist"
apiv1 "code.vikunja.io/api/pkg/routes/api/v1" apiv1 "code.vikunja.io/api/pkg/routes/api/v1"
"code.vikunja.io/api/pkg/routes/caldav" "code.vikunja.io/api/pkg/routes/caldav"
@ -433,6 +434,16 @@ func registerAPIRoutes(a *echo.Group) {
} }
wunderlistMigrationHandler.RegisterRoutes(m) 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) { func registerCalDavRoutes(c *echo.Group) {