2020-02-07 17:27:45 +01:00
// Vikunja is a to-do list application to facilitate your life.
2021-02-02 20:19:13 +01:00
// Copyright 2018-2021 Vikunja and contributors. All rights reserved.
2020-01-19 17:52:16 +01:00
//
// This program is free software: you can redistribute it and/or modify
2020-12-23 16:41:52 +01:00
// it under the terms of the GNU Affero General Public Licensee as published by
2020-01-19 17:52:16 +01:00
// 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
2020-12-23 16:41:52 +01:00
// GNU Affero General Public Licensee for more details.
2020-01-19 17:52:16 +01:00
//
2020-12-23 16:41:52 +01:00
// You should have received a copy of the GNU Affero General Public Licensee
2020-01-19 17:52:16 +01:00
// along with this program. If not, see <https://www.gnu.org/licenses/>.
package wunderlist
import (
"bytes"
2020-10-11 22:10:03 +02:00
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"time"
2020-01-19 17:52:16 +01:00
"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"
2020-01-26 18:08:06 +01:00
"code.vikunja.io/api/pkg/user"
2020-01-19 17:52:16 +01:00
"code.vikunja.io/api/pkg/utils"
)
// Migration represents the implementation of the migration for wunderlist
type Migration struct {
// Code is the code used to get a user api token
Code string ` query:"code" json:"code" `
}
// This represents all necessary fields for getting an api token for the wunderlist api from a code
type wunderlistAuthRequest struct {
ClientID string ` json:"client_id" `
ClientSecret string ` json:"client_secret" `
Code string ` json:"code" `
}
type wunderlistAuthToken struct {
AccessToken string ` json:"access_token" `
}
type task struct {
AssigneeID int ` json:"assignee_id" `
CreatedAt time . Time ` json:"created_at" `
CreatedByID int ` json:"created_by_id" `
2020-10-11 22:10:03 +02:00
Completed bool ` json:"completed" `
CompletedAt time . Time ` json:"completed_at" `
2020-01-19 17:52:16 +01:00
DueDate string ` json:"due_date" `
2020-10-11 22:10:03 +02:00
ID int ` json:"id" `
2020-01-19 17:52:16 +01:00
ListID int ` json:"list_id" `
Revision int ` json:"revision" `
Starred bool ` json:"starred" `
Title string ` json:"title" `
}
type list struct {
ID int ` json:"id" `
CreatedAt time . Time ` json:"created_at" `
Title string ` json:"title" `
ListType string ` json:"list_type" `
Type string ` json:"type" `
Revision int ` json:"revision" `
Migrated bool ` json:"-" `
}
type folder struct {
ID int ` json:"id" `
Title string ` json:"title" `
ListIds [ ] int ` json:"list_ids" `
CreatedAt time . Time ` json:"created_at" `
CreatedByRequestID string ` json:"created_by_request_id" `
UpdatedAt time . Time ` json:"updated_at" `
Type string ` json:"type" `
Revision int ` json:"revision" `
}
type note struct {
ID int ` json:"id" `
TaskID int ` json:"task_id" `
Content string ` json:"content" `
CreatedAt time . Time ` json:"created_at" `
UpdatedAt time . Time ` json:"updated_at" `
Revision int ` json:"revision" `
}
type file struct {
ID int ` json:"id" `
URL string ` json:"url" `
TaskID int ` json:"task_id" `
ListID int ` json:"list_id" `
UserID int ` json:"user_id" `
FileName string ` json:"file_name" `
ContentType string ` json:"content_type" `
FileSize int ` json:"file_size" `
LocalCreatedAt time . Time ` json:"local_created_at" `
CreatedAt time . Time ` json:"created_at" `
UpdatedAt time . Time ` json:"updated_at" `
Type string ` json:"type" `
Revision int ` json:"revision" `
}
type reminder struct {
ID int ` json:"id" `
Date time . Time ` json:"date" `
TaskID int ` json:"task_id" `
Revision int ` json:"revision" `
Type string ` json:"type" `
CreatedAt time . Time ` json:"created_at" `
UpdatedAt time . Time ` json:"updated_at" `
}
type subtask struct {
ID int ` json:"id" `
TaskID int ` json:"task_id" `
CreatedAt time . Time ` json:"created_at" `
CreatedByID int ` json:"created_by_id" `
Revision int ` json:"revision" `
Title string ` json:"title" `
}
type wunderlistContents struct {
tasks [ ] * task
lists [ ] * list
folders [ ] * folder
notes [ ] * note
files [ ] * file
reminders [ ] * reminder
subtasks [ ] * subtask
}
2021-09-04 21:26:31 +02:00
func convertListForFolder ( listID int , list * list , content * wunderlistContents ) ( * models . ListWithTasksAndBuckets , error ) {
2020-01-19 17:52:16 +01:00
2021-09-04 21:26:31 +02:00
l := & models . ListWithTasksAndBuckets {
List : models . List {
Title : list . Title ,
Created : list . CreatedAt ,
} ,
2020-01-19 17:52:16 +01:00
}
// Find all tasks belonging to this list and put them in
for _ , t := range content . tasks {
if t . ListID == listID {
newTask := & models . Task {
2020-05-16 12:17:44 +02:00
Title : t . Title ,
2020-06-27 19:04:01 +02:00
Created : t . CreatedAt ,
2020-01-19 17:52:16 +01:00
Done : t . Completed ,
}
// Set Done At
if newTask . Done {
2020-06-27 19:04:01 +02:00
newTask . DoneAt = t . CompletedAt . In ( config . GetTimeZone ( ) )
2020-01-19 17:52:16 +01:00
}
// Parse the due date
if t . DueDate != "" {
dueDate , err := time . Parse ( "2006-01-02" , t . DueDate )
if err != nil {
return nil , err
}
2020-06-27 19:04:01 +02:00
newTask . DueDate = dueDate . In ( config . GetTimeZone ( ) )
2020-01-19 17:52:16 +01:00
}
// Find related notes
for _ , n := range content . notes {
if n . TaskID == t . ID {
newTask . Description = n . Content
}
}
2022-06-19 16:21:11 +02:00
// Embeds
2020-01-19 17:52:16 +01:00
for _ , f := range content . files {
if f . TaskID == t . ID {
// Download the attachment and put it in the file
2020-10-11 22:10:03 +02:00
req , err := http . NewRequestWithContext ( context . Background ( ) , http . MethodGet , f . URL , nil )
if err != nil {
return nil , err
}
resp , err := http . DefaultClient . Do ( req )
2020-01-19 17:52:16 +01:00
if err != nil {
return nil , err
}
defer resp . Body . Close ( )
buf := & bytes . Buffer { }
_ , err = buf . ReadFrom ( resp . Body )
if err != nil {
return nil , err
}
newTask . Attachments = append ( newTask . Attachments , & models . TaskAttachment {
File : & files . File {
2020-06-27 19:04:01 +02:00
Name : f . FileName ,
Mime : f . ContentType ,
Size : uint64 ( f . FileSize ) ,
Created : f . CreatedAt ,
2020-01-19 17:52:16 +01:00
// 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 ( ) ,
} ,
2020-06-27 19:04:01 +02:00
Created : f . CreatedAt ,
2020-01-19 17:52:16 +01:00
} )
}
}
// Subtasks
for _ , s := range content . subtasks {
if s . TaskID == t . ID {
if newTask . RelatedTasks [ models . RelationKindSubtask ] == nil {
newTask . RelatedTasks = make ( models . RelatedTaskMap )
}
newTask . RelatedTasks [ models . RelationKindSubtask ] = append ( newTask . RelatedTasks [ models . RelationKindSubtask ] , & models . Task {
2020-05-16 12:17:44 +02:00
Title : s . Title ,
2020-01-19 17:52:16 +01:00
} )
}
}
// Reminders
for _ , r := range content . reminders {
if r . TaskID == t . ID {
2020-06-27 19:04:01 +02:00
newTask . Reminders = append ( newTask . Reminders , r . Date . In ( config . GetTimeZone ( ) ) )
2020-01-19 17:52:16 +01:00
}
}
2021-09-04 21:26:31 +02:00
l . Tasks = append ( l . Tasks , & models . TaskWithComments { Task : * newTask } )
2020-01-19 17:52:16 +01:00
}
}
return l , nil
}
2021-09-04 21:26:31 +02:00
func convertWunderlistToVikunja ( content * wunderlistContents ) ( fullVikunjaHierachie [ ] * models . NamespaceWithListsAndTasks , err error ) {
2020-01-19 17:52:16 +01:00
// Make a map from the list with the key being list id for easier handling
listMap := make ( map [ int ] * list , len ( content . lists ) )
for _ , l := range content . lists {
listMap [ l . ID ] = l
}
// First, we look through all folders and create namespaces for them.
for _ , folder := range content . folders {
2021-09-04 21:26:31 +02:00
namespace := & models . NamespaceWithListsAndTasks {
2020-01-19 17:52:16 +01:00
Namespace : models . Namespace {
2020-05-16 12:17:44 +02:00
Title : folder . Title ,
2020-06-27 19:04:01 +02:00
Created : folder . CreatedAt ,
Updated : folder . UpdatedAt ,
2020-01-19 17:52:16 +01:00
} ,
}
// Then find all lists for that folder
for _ , listID := range folder . ListIds {
if list , exists := listMap [ listID ] ; exists {
l , err := convertListForFolder ( listID , list , content )
if err != nil {
return nil , err
}
namespace . Lists = append ( namespace . Lists , l )
// And mark the list as migrated so we don't iterate over it again
list . Migrated = true
}
}
// And then finally put the namespace (which now has all the details) back in the full array.
fullVikunjaHierachie = append ( fullVikunjaHierachie , namespace )
}
// At the end, loop over all lists which don't belong to a namespace and put them in a default namespace
if len ( listMap ) > 0 {
2021-09-04 21:26:31 +02:00
newNamespace := & models . NamespaceWithListsAndTasks {
2020-01-19 17:52:16 +01:00
Namespace : models . Namespace {
2020-05-16 12:17:44 +02:00
Title : "Migrated from wunderlist" ,
2020-01-19 17:52:16 +01:00
} ,
}
for _ , list := range listMap {
if list . Migrated {
continue
}
l , err := convertListForFolder ( list . ID , list , content )
if err != nil {
return nil , err
}
newNamespace . Lists = append ( newNamespace . Lists , l )
}
fullVikunjaHierachie = append ( fullVikunjaHierachie , newNamespace )
}
return
}
func makeAuthGetRequest ( token * wunderlistAuthToken , urlPart string , v interface { } , urlParams url . Values ) error {
2020-10-11 22:10:03 +02:00
req , err := http . NewRequestWithContext ( context . Background ( ) , http . MethodGet , "https://a.wunderlist.com/api/v1/" + urlPart , nil )
2020-01-19 17:52:16 +01:00
if err != nil {
return err
}
req . Header . Set ( "X-Access-Token" , token . AccessToken )
req . Header . Set ( "X-Client-ID" , config . MigrationWunderlistClientID . GetString ( ) )
req . URL . RawQuery = urlParams . Encode ( )
resp , err := http . DefaultClient . Do ( req )
if err != nil {
return err
}
defer resp . Body . Close ( )
buf := & bytes . Buffer { }
_ , err = buf . ReadFrom ( resp . Body )
if err != nil {
return err
}
if resp . StatusCode > 399 {
return fmt . Errorf ( "wunderlist API Error: Status Code: %d, Response was: %s" , resp . StatusCode , buf . String ( ) )
}
// If the response is an empty json array, we need to exit here, otherwise this breaks the json parser since it
// expects a null for an empty slice
str := buf . String ( )
if str == "[]" {
return nil
}
return json . Unmarshal ( buf . Bytes ( ) , v )
}
// Migrate migrates a user's wunderlist lists, tasks, etc.
// @Summary Migrate all lists, tasks etc. from wunderlist
// @Description Migrates all folders, lists, tasks, notes, reminders, subtasks and files from wunderlist to vikunja.
// @tags migration
// @Accept json
// @Produce json
// @Security JWTKeyAuth
// @Param migrationCode body wunderlist.Migration true "The auth code previously obtained from the auth url. See the docs for /migration/wunderlist/auth."
// @Success 200 {object} models.Message "A message telling you everything was migrated successfully."
// @Failure 500 {object} models.Message "Internal server error"
// @Router /migration/wunderlist/migrate [post]
2020-01-26 18:08:06 +01:00
func ( w * Migration ) Migrate ( user * user . User ) ( err error ) {
2020-01-19 17:52:16 +01:00
log . Debugf ( "[Wunderlist migration] Starting wunderlist migration for user %d" , user . ID )
// Struct init
wContent := & wunderlistContents {
tasks : [ ] * task { } ,
lists : [ ] * list { } ,
folders : [ ] * folder { } ,
notes : [ ] * note { } ,
files : [ ] * file { } ,
reminders : [ ] * reminder { } ,
subtasks : [ ] * subtask { } ,
}
// 0. Get api token from oauth user token
authRequest := wunderlistAuthRequest {
ClientID : config . MigrationWunderlistClientID . GetString ( ) ,
ClientSecret : config . MigrationWunderlistClientSecret . GetString ( ) ,
Code : w . Code ,
}
jsonAuth , err := json . Marshal ( authRequest )
if err != nil {
return
}
2020-10-11 22:10:03 +02:00
req , err := http . NewRequestWithContext ( context . Background ( ) , http . MethodPost , "https://www.wunderlist.com/oauth/access_token" , bytes . NewBuffer ( jsonAuth ) )
if err != nil {
return err
}
req . Header . Add ( "Content-Type" , "application/json" )
resp , err := http . DefaultClient . Do ( req )
2020-01-19 17:52:16 +01:00
if err != nil {
return
}
2020-10-11 22:10:03 +02:00
defer resp . Body . Close ( )
2020-01-19 17:52:16 +01:00
authToken := & wunderlistAuthToken { }
err = json . NewDecoder ( resp . Body ) . Decode ( authToken )
if err != nil {
return
}
log . Debugf ( "[Wunderlist migration] Start getting all data from wunderlist for user %d" , user . ID )
// 1. Get all folders
err = makeAuthGetRequest ( authToken , "folders" , & wContent . folders , nil )
if err != nil {
return
}
// 2. Get all lists
err = makeAuthGetRequest ( authToken , "lists" , & wContent . lists , nil )
if err != nil {
return
}
for _ , l := range wContent . lists {
listQueryParam := url . Values { "list_id" : [ ] string { strconv . Itoa ( l . ID ) } }
// 3. Get all tasks for each list
tasks := [ ] * task { }
err = makeAuthGetRequest ( authToken , "tasks" , & tasks , listQueryParam )
if err != nil {
return
}
wContent . tasks = append ( wContent . tasks , tasks ... )
// 3. Get all done tasks for each list
doneTasks := [ ] * task { }
err = makeAuthGetRequest ( authToken , "tasks" , & doneTasks , url . Values { "list_id" : [ ] string { strconv . Itoa ( l . ID ) } , "completed" : [ ] string { "true" } } )
if err != nil {
return
}
wContent . tasks = append ( wContent . tasks , doneTasks ... )
// 4. Get all notes for all lists
notes := [ ] * note { }
err = makeAuthGetRequest ( authToken , "notes" , & notes , listQueryParam )
if err != nil {
return
}
wContent . notes = append ( wContent . notes , notes ... )
// 5. Get all files for all lists
fils := [ ] * file { }
err = makeAuthGetRequest ( authToken , "files" , & fils , listQueryParam )
if err != nil {
return
}
wContent . files = append ( wContent . files , fils ... )
// 6. Get all reminders for all lists
reminders := [ ] * reminder { }
err = makeAuthGetRequest ( authToken , "reminders" , & reminders , listQueryParam )
if err != nil {
return
}
wContent . reminders = append ( wContent . reminders , reminders ... )
// 7. Get all subtasks for all lists
subtasks := [ ] * subtask { }
err = makeAuthGetRequest ( authToken , "subtasks" , & subtasks , listQueryParam )
if err != nil {
return
}
wContent . subtasks = append ( wContent . subtasks , subtasks ... )
}
log . Debugf ( "[Wunderlist migration] Got all data from wunderlist for user %d" , user . ID )
log . Debugf ( "[Wunderlist migration] Migrating data to vikunja format for user %d" , user . ID )
// Convert + Insert everything
fullVikunjaHierachie , err := convertWunderlistToVikunja ( wContent )
if err != nil {
return
}
log . Debugf ( "[Wunderlist migration] Done migrating data to vikunja format for user %d" , user . ID )
log . Debugf ( "[Wunderlist migration] Insert data into db for user %d" , user . ID )
err = migration . InsertFromStructure ( fullVikunjaHierachie , user )
2020-02-18 23:00:54 +01:00
if err != nil {
return err
}
2020-01-19 17:52:16 +01:00
log . Debugf ( "[Wunderlist migration] Done inserting data into db for user %d" , user . ID )
log . Debugf ( "[Wunderlist migration] Wunderlist migration for user %d done" , user . ID )
2020-02-18 23:00:54 +01:00
return nil
2020-01-19 17:52:16 +01:00
}
// AuthURL returns the url users need to authenticate against
// @Summary Get the auth url from wunderlist
// @Description Returns the auth url where the user needs to get its auth code. This code can then be used to migrate everything from wunderlist 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/wunderlist/auth [get]
func ( w * Migration ) AuthURL ( ) string {
return "https://www.wunderlist.com/oauth/authorize?client_id=" +
config . MigrationWunderlistClientID . GetString ( ) +
"&redirect_uri=" +
config . MigrationWunderlistRedirectURL . GetString ( ) +
"&state=" + utils . MakeRandomString ( 32 )
}
2020-01-20 20:48:46 +01:00
// Name is used to get the name of the wunderlist migration
// @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/wunderlist/status [get]
func ( w * Migration ) Name ( ) string {
return "wunderlist"
}