Todoist Migration #566
@ -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.
|
||||
|
@ -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.
|
||||
|
@ -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)
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
476
pkg/modules/migration/todoist/todoist.go
Normal file
476
pkg/modules/migration/todoist/todoist.go
Normal 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
|
||||
}
|
550
pkg/modules/migration/todoist/todoist_test.go
Normal file
550
pkg/modules/migration/todoist/todoist_test.go
Normal 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)
|
||||
}
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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) {
|
||||
|
Loading…
x
Reference in New Issue
Block a user