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:
parent
292c815000
commit
e89e6d47d4
9 changed files with 1123 additions and 1 deletions
|
@ -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…
Reference in a new issue