User Data Export and import (#967)
Co-authored-by: kolaente <k@knt.li> Reviewed-on: https://kolaente.dev/vikunja/api/pulls/967 Co-authored-by: konrad <k@knt.li> Co-committed-by: konrad <k@knt.li>
This commit is contained in:
@ -21,19 +21,15 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"code.vikunja.io/api/pkg/db"
|
||||
"code.vikunja.io/api/pkg/files"
|
||||
"code.vikunja.io/api/pkg/log"
|
||||
"code.vikunja.io/api/pkg/utils"
|
||||
"code.vikunja.io/api/pkg/version"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// Change to deflate to gain better compression
|
||||
// see http://golang.org/pkg/archive/zip/#pkg-constants
|
||||
const compressionUsed = zip.Deflate
|
||||
|
||||
// Dump creates a zip file with all vikunja files at filename
|
||||
func Dump(filename string) error {
|
||||
dumpFile, err := os.Create(filename)
|
||||
@ -55,7 +51,7 @@ func Dump(filename string) error {
|
||||
|
||||
// Version
|
||||
log.Info("Start dumping version file...")
|
||||
err = writeBytesToZip("VERSION", []byte(version.Version), dumpWriter)
|
||||
err = utils.WriteBytesToZip("VERSION", []byte(version.Version), dumpWriter)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error saving version: %s", err)
|
||||
}
|
||||
@ -68,7 +64,7 @@ func Dump(filename string) error {
|
||||
return fmt.Errorf("error saving database data: %s", err)
|
||||
}
|
||||
for t, d := range data {
|
||||
err = writeBytesToZip("database/"+t+".json", d, dumpWriter)
|
||||
err = utils.WriteBytesToZip("database/"+t+".json", d, dumpWriter)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error writing database table %s: %s", t, err)
|
||||
}
|
||||
@ -81,21 +77,12 @@ func Dump(filename string) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("error saving file: %s", err)
|
||||
}
|
||||
for fid, file := range allFiles {
|
||||
header := &zip.FileHeader{
|
||||
Name: "files/" + strconv.FormatInt(fid, 10),
|
||||
Method: compressionUsed,
|
||||
}
|
||||
w, err := dumpWriter.CreateHeader(header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = io.Copy(w, file)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error writing file %d: %s", fid, err)
|
||||
}
|
||||
_ = file.Close()
|
||||
|
||||
err = utils.WriteFilesToZip(allFiles, dumpWriter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Infof("Dumped files")
|
||||
|
||||
log.Info("Done creating dump")
|
||||
@ -123,7 +110,7 @@ func writeFileToZip(filename string, writer *zip.Writer) error {
|
||||
}
|
||||
|
||||
header.Name = info.Name()
|
||||
header.Method = compressionUsed
|
||||
header.Method = utils.CompressionUsed
|
||||
|
||||
w, err := writer.CreateHeader(header)
|
||||
if err != nil {
|
||||
@ -132,16 +119,3 @@ func writeFileToZip(filename string, writer *zip.Writer) error {
|
||||
_, err = io.Copy(w, fileToZip)
|
||||
return err
|
||||
}
|
||||
|
||||
func writeBytesToZip(filename string, data []byte, writer *zip.Writer) (err error) {
|
||||
header := &zip.FileHeader{
|
||||
Name: filename,
|
||||
Method: compressionUsed,
|
||||
}
|
||||
w, err := writer.CreateHeader(header)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = w.Write(data)
|
||||
return
|
||||
}
|
||||
|
@ -33,6 +33,7 @@ import (
|
||||
"code.vikunja.io/api/pkg/initialize"
|
||||
"code.vikunja.io/api/pkg/log"
|
||||
"code.vikunja.io/api/pkg/migration"
|
||||
|
||||
"src.techknowlogick.com/xormigrate"
|
||||
)
|
||||
|
||||
@ -194,6 +195,7 @@ func Restore(filename string) error {
|
||||
///////
|
||||
// Done
|
||||
log.Infof("Done restoring dump.")
|
||||
log.Infof("Restart Vikunja to make sure the new configuration file is applied.")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
@ -31,7 +31,7 @@ import (
|
||||
|
||||
// InsertFromStructure takes a fully nested Vikunja data structure and a user and then creates everything for this user
|
||||
// (Namespaces, tasks, etc. Even attachments and relations.)
|
||||
func InsertFromStructure(str []*models.NamespaceWithLists, user *user.User) (err error) {
|
||||
func InsertFromStructure(str []*models.NamespaceWithListsAndTasks, user *user.User) (err error) {
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
@ -45,7 +45,7 @@ func InsertFromStructure(str []*models.NamespaceWithLists, user *user.User) (err
|
||||
return s.Commit()
|
||||
}
|
||||
|
||||
func insertFromStructure(s *xorm.Session, str []*models.NamespaceWithLists, user *user.User) (err error) {
|
||||
func insertFromStructure(s *xorm.Session, str []*models.NamespaceWithListsAndTasks, user *user.User) (err error) {
|
||||
|
||||
log.Debugf("[creating structure] Creating %d namespaces", len(str))
|
||||
|
||||
@ -129,7 +129,7 @@ func insertFromStructure(s *xorm.Session, str []*models.NamespaceWithLists, user
|
||||
|
||||
// Create all tasks
|
||||
for _, t := range tasks {
|
||||
setBucketOrDefault(t)
|
||||
setBucketOrDefault(&t.Task)
|
||||
|
||||
t.ListID = l.ID
|
||||
err = t.Create(s, user)
|
||||
@ -221,6 +221,15 @@ func insertFromStructure(s *xorm.Session, str []*models.NamespaceWithLists, user
|
||||
}
|
||||
log.Debugf("[creating structure] Associated task %d with label %d", t.ID, lb.ID)
|
||||
}
|
||||
|
||||
for _, comment := range t.Comments {
|
||||
comment.TaskID = t.ID
|
||||
err = comment.Create(s, user)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
log.Debugf("[creating structure] Created new comment %d", comment.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// All tasks brought their own bucket with them, therefore the newly created default bucket is just extra space
|
||||
|
@ -32,79 +32,95 @@ func TestInsertFromStructure(t *testing.T) {
|
||||
}
|
||||
t.Run("normal", func(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
testStructure := []*models.NamespaceWithLists{
|
||||
testStructure := []*models.NamespaceWithListsAndTasks{
|
||||
{
|
||||
Namespace: models.Namespace{
|
||||
Title: "Test1",
|
||||
Description: "Lorem Ipsum",
|
||||
},
|
||||
Lists: []*models.List{
|
||||
Lists: []*models.ListWithTasksAndBuckets{
|
||||
{
|
||||
Title: "Testlist1",
|
||||
Description: "Something",
|
||||
List: models.List{
|
||||
Title: "Testlist1",
|
||||
Description: "Something",
|
||||
},
|
||||
Buckets: []*models.Bucket{
|
||||
{
|
||||
ID: 1234,
|
||||
Title: "Test Bucket",
|
||||
},
|
||||
},
|
||||
Tasks: []*models.Task{
|
||||
Tasks: []*models.TaskWithComments{
|
||||
{
|
||||
Title: "Task1",
|
||||
Description: "Lorem",
|
||||
Task: models.Task{
|
||||
Title: "Task1",
|
||||
Description: "Lorem",
|
||||
},
|
||||
},
|
||||
{
|
||||
Title: "Task with related tasks",
|
||||
RelatedTasks: map[models.RelationKind][]*models.Task{
|
||||
models.RelationKindSubtask: {
|
||||
Task: models.Task{
|
||||
Title: "Task with related tasks",
|
||||
RelatedTasks: map[models.RelationKind][]*models.Task{
|
||||
models.RelationKindSubtask: {
|
||||
{
|
||||
Title: "Related to task with related task",
|
||||
Description: "As subtask",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Task: models.Task{
|
||||
Title: "Task with attachments",
|
||||
Attachments: []*models.TaskAttachment{
|
||||
{
|
||||
Title: "Related to task with related task",
|
||||
Description: "As subtask",
|
||||
File: &files.File{
|
||||
Name: "testfile",
|
||||
Size: 4,
|
||||
FileContent: []byte{1, 2, 3, 4},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Title: "Task with attachments",
|
||||
Attachments: []*models.TaskAttachment{
|
||||
{
|
||||
File: &files.File{
|
||||
Name: "testfile",
|
||||
Size: 4,
|
||||
FileContent: []byte{1, 2, 3, 4},
|
||||
Task: models.Task{
|
||||
Title: "Task with labels",
|
||||
Labels: []*models.Label{
|
||||
{
|
||||
Title: "Label1",
|
||||
HexColor: "ff00ff",
|
||||
},
|
||||
{
|
||||
Title: "Label2",
|
||||
HexColor: "ff00ff",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Title: "Task with labels",
|
||||
Labels: []*models.Label{
|
||||
{
|
||||
Title: "Label1",
|
||||
HexColor: "ff00ff",
|
||||
},
|
||||
{
|
||||
Title: "Label2",
|
||||
HexColor: "ff00ff",
|
||||
Task: models.Task{
|
||||
Title: "Task with same label",
|
||||
Labels: []*models.Label{
|
||||
{
|
||||
Title: "Label1",
|
||||
HexColor: "ff00ff",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Title: "Task with same label",
|
||||
Labels: []*models.Label{
|
||||
{
|
||||
Title: "Label1",
|
||||
HexColor: "ff00ff",
|
||||
},
|
||||
Task: models.Task{
|
||||
Title: "Task in a bucket",
|
||||
BucketID: 1234,
|
||||
},
|
||||
},
|
||||
{
|
||||
Title: "Task in a bucket",
|
||||
BucketID: 1234,
|
||||
},
|
||||
{
|
||||
Title: "Task in a nonexisting bucket",
|
||||
BucketID: 1111,
|
||||
Task: models.Task{
|
||||
Title: "Task in a nonexisting bucket",
|
||||
BucketID: 1111,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
40
pkg/modules/migration/handler/common.go
Normal file
40
pkg/modules/migration/handler/common.go
Normal file
@ -0,0 +1,40 @@
|
||||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-2021 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 Affero General Public Licensee 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 Affero General Public Licensee for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public Licensee
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"code.vikunja.io/api/pkg/modules/migration"
|
||||
user2 "code.vikunja.io/api/pkg/user"
|
||||
"code.vikunja.io/web/handler"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
func status(ms migration.MigratorName, c echo.Context) error {
|
||||
user, err := user2.GetCurrentUser(c)
|
||||
if err != nil {
|
||||
return handler.HandleHTTPError(err, c)
|
||||
}
|
||||
|
||||
status, err := migration.GetMigrationStatus(ms, user)
|
||||
if err != nil {
|
||||
return handler.HandleHTTPError(err, c)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, status)
|
||||
}
|
@ -84,15 +84,5 @@ func (mw *MigrationWeb) Migrate(c echo.Context) error {
|
||||
func (mw *MigrationWeb) Status(c echo.Context) error {
|
||||
ms := mw.MigrationStruct()
|
||||
|
||||
user, err := user2.GetCurrentUser(c)
|
||||
if err != nil {
|
||||
return handler.HandleHTTPError(err, c)
|
||||
}
|
||||
|
||||
status, err := migration.GetMigrationStatus(ms, user)
|
||||
if err != nil {
|
||||
return handler.HandleHTTPError(err, c)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, status)
|
||||
return status(ms, c)
|
||||
}
|
||||
|
79
pkg/modules/migration/handler/handler_file.go
Normal file
79
pkg/modules/migration/handler/handler_file.go
Normal file
@ -0,0 +1,79 @@
|
||||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-2021 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 Affero General Public Licensee 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 Affero General Public Licensee for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public Licensee
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/api/pkg/modules/migration"
|
||||
user2 "code.vikunja.io/api/pkg/user"
|
||||
"code.vikunja.io/web/handler"
|
||||
"github.com/labstack/echo/v4"
|
||||
)
|
||||
|
||||
type FileMigratorWeb struct {
|
||||
MigrationStruct func() migration.FileMigrator
|
||||
}
|
||||
|
||||
// RegisterRoutes registers all routes for migration
|
||||
func (fw *FileMigratorWeb) RegisterRoutes(g *echo.Group) {
|
||||
ms := fw.MigrationStruct()
|
||||
g.GET("/"+ms.Name()+"/status", fw.Status)
|
||||
g.PUT("/"+ms.Name()+"/migrate", fw.Migrate)
|
||||
}
|
||||
|
||||
// Migrate calls the migration method
|
||||
func (fw *FileMigratorWeb) Migrate(c echo.Context) error {
|
||||
ms := fw.MigrationStruct()
|
||||
|
||||
// Get the user from context
|
||||
user, err := user2.GetCurrentUser(c)
|
||||
if err != nil {
|
||||
return handler.HandleHTTPError(err, c)
|
||||
}
|
||||
|
||||
file, err := c.FormFile("import")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
// Do the migration
|
||||
err = ms.Migrate(user, src, file.Size)
|
||||
if err != nil {
|
||||
return handler.HandleHTTPError(err, c)
|
||||
}
|
||||
|
||||
err = migration.SetMigrationStatus(ms, user)
|
||||
if err != nil {
|
||||
return handler.HandleHTTPError(err, c)
|
||||
}
|
||||
|
||||
return c.JSON(http.StatusOK, models.Message{Message: "Everything was migrated successfully."})
|
||||
}
|
||||
|
||||
// Status returns whether or not a user has already done this migration
|
||||
func (fw *FileMigratorWeb) Status(c echo.Context) error {
|
||||
ms := fw.MigrationStruct()
|
||||
|
||||
return status(ms, c)
|
||||
}
|
@ -243,15 +243,15 @@ func getMicrosoftTodoData(token string) (microsoftTodoData []*list, err error) {
|
||||
return
|
||||
}
|
||||
|
||||
func convertMicrosoftTodoData(todoData []*list) (vikunjsStructure []*models.NamespaceWithLists, err error) {
|
||||
func convertMicrosoftTodoData(todoData []*list) (vikunjsStructure []*models.NamespaceWithListsAndTasks, err error) {
|
||||
|
||||
// One namespace with all lists
|
||||
vikunjsStructure = []*models.NamespaceWithLists{
|
||||
vikunjsStructure = []*models.NamespaceWithListsAndTasks{
|
||||
{
|
||||
Namespace: models.Namespace{
|
||||
Title: "Migrated from Microsoft Todo",
|
||||
},
|
||||
Lists: []*models.List{},
|
||||
Lists: []*models.ListWithTasksAndBuckets{},
|
||||
},
|
||||
}
|
||||
|
||||
@ -262,8 +262,10 @@ func convertMicrosoftTodoData(todoData []*list) (vikunjsStructure []*models.Name
|
||||
log.Debugf("[Microsoft Todo Migration] Converting list %s", l.ID)
|
||||
|
||||
// Lists only with title
|
||||
list := &models.List{
|
||||
Title: l.DisplayName,
|
||||
list := &models.ListWithTasksAndBuckets{
|
||||
List: models.List{
|
||||
Title: l.DisplayName,
|
||||
},
|
||||
}
|
||||
|
||||
log.Debugf("[Microsoft Todo Migration] Converting %d tasks", len(l.Tasks))
|
||||
@ -340,7 +342,7 @@ func convertMicrosoftTodoData(todoData []*list) (vikunjsStructure []*models.Name
|
||||
}
|
||||
}
|
||||
|
||||
list.Tasks = append(list.Tasks, task)
|
||||
list.Tasks = append(list.Tasks, &models.TaskWithComments{Task: *task})
|
||||
log.Debugf("[Microsoft Todo Migration] Done converted %d tasks", len(l.Tasks))
|
||||
}
|
||||
|
||||
|
@ -102,57 +102,79 @@ func TestConverting(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
expectedHierachie := []*models.NamespaceWithLists{
|
||||
expectedHierachie := []*models.NamespaceWithListsAndTasks{
|
||||
{
|
||||
Namespace: models.Namespace{
|
||||
Title: "Migrated from Microsoft Todo",
|
||||
},
|
||||
Lists: []*models.List{
|
||||
Lists: []*models.ListWithTasksAndBuckets{
|
||||
{
|
||||
Title: "List 1",
|
||||
Tasks: []*models.Task{
|
||||
List: models.List{
|
||||
Title: "List 1",
|
||||
},
|
||||
Tasks: []*models.TaskWithComments{
|
||||
{
|
||||
Title: "Task 1",
|
||||
Description: "This is a description",
|
||||
},
|
||||
{
|
||||
Title: "Task 2",
|
||||
Done: true,
|
||||
DoneAt: testtimeTime,
|
||||
},
|
||||
{
|
||||
Title: "Task 3",
|
||||
Priority: 1,
|
||||
},
|
||||
{
|
||||
Title: "Task 4",
|
||||
Priority: 3,
|
||||
},
|
||||
{
|
||||
Title: "Task 5",
|
||||
Reminders: []time.Time{
|
||||
testtimeTime,
|
||||
Task: models.Task{
|
||||
Title: "Task 1",
|
||||
Description: "This is a description",
|
||||
},
|
||||
},
|
||||
{
|
||||
Title: "Task 6",
|
||||
DueDate: testtimeTime,
|
||||
Task: models.Task{
|
||||
Title: "Task 2",
|
||||
Done: true,
|
||||
DoneAt: testtimeTime,
|
||||
},
|
||||
},
|
||||
{
|
||||
Title: "Task 7",
|
||||
DueDate: testtimeTime,
|
||||
RepeatAfter: 60 * 60 * 24 * 7, // The amount of seconds in a week
|
||||
Task: models.Task{
|
||||
Title: "Task 3",
|
||||
Priority: 1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Task: models.Task{
|
||||
Title: "Task 4",
|
||||
Priority: 3,
|
||||
},
|
||||
},
|
||||
{
|
||||
Task: models.Task{
|
||||
Title: "Task 5",
|
||||
Reminders: []time.Time{
|
||||
testtimeTime,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Task: models.Task{
|
||||
Title: "Task 6",
|
||||
DueDate: testtimeTime,
|
||||
},
|
||||
},
|
||||
{
|
||||
Task: models.Task{
|
||||
Title: "Task 7",
|
||||
DueDate: testtimeTime,
|
||||
RepeatAfter: 60 * 60 * 24 * 7, // The amount of seconds in a week
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Title: "List 2",
|
||||
Tasks: []*models.Task{
|
||||
List: models.List{
|
||||
Title: "List 2",
|
||||
},
|
||||
Tasks: []*models.TaskWithComments{
|
||||
{
|
||||
Title: "Task 1",
|
||||
Task: models.Task{
|
||||
Title: "Task 1",
|
||||
},
|
||||
},
|
||||
{
|
||||
Title: "Task 2",
|
||||
Task: models.Task{
|
||||
Title: "Task 2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -37,7 +37,7 @@ func (s *Status) TableName() string {
|
||||
}
|
||||
|
||||
// SetMigrationStatus sets the migration status for a user
|
||||
func SetMigrationStatus(m Migrator, u *user.User) (err error) {
|
||||
func SetMigrationStatus(m MigratorName, u *user.User) (err error) {
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
@ -50,7 +50,7 @@ func SetMigrationStatus(m Migrator, u *user.User) (err error) {
|
||||
}
|
||||
|
||||
// GetMigrationStatus returns the migration status for a migration and a user
|
||||
func GetMigrationStatus(m Migrator, u *user.User) (status *Status, err error) {
|
||||
func GetMigrationStatus(m MigratorName, u *user.User) (status *Status, err error) {
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
|
@ -17,11 +17,20 @@
|
||||
package migration
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
)
|
||||
|
||||
type MigratorName interface {
|
||||
// Name holds the name of the migration.
|
||||
// This is used to show the name to users and to keep track of users who already migrated.
|
||||
Name() string
|
||||
}
|
||||
|
||||
// Migrator is the basic migrator interface which is shared among all migrators
|
||||
type Migrator interface {
|
||||
MigratorName
|
||||
// Migrate is the interface used to migrate a user's tasks from another platform to vikunja.
|
||||
// The user object is the user who's tasks will be migrated.
|
||||
Migrate(user *user.User) error
|
||||
@ -29,7 +38,12 @@ type Migrator interface {
|
||||
// The use case for this are Oauth flows, where the server token should remain hidden and not
|
||||
// known to the frontend.
|
||||
AuthURL() string
|
||||
// Title holds the name of the migration.
|
||||
// This is used to show the name to users and to keep track of users who already migrated.
|
||||
Name() string
|
||||
}
|
||||
|
||||
// FileMigrator handles importing Vikunja data from a file. The implementation of it determines the format.
|
||||
type FileMigrator interface {
|
||||
MigratorName
|
||||
// Migrate is the interface used to migrate a user's tasks, list and other things from a file to vikunja.
|
||||
// The user object is the user who's tasks will be migrated.
|
||||
Migrate(user *user.User, file io.ReaderAt, size int64) error
|
||||
}
|
||||
|
@ -252,28 +252,30 @@ func parseDate(dateString string) (date time.Time, err error) {
|
||||
return date, err
|
||||
}
|
||||
|
||||
func convertTodoistToVikunja(sync *sync, doneItems map[int64]*doneItem) (fullVikunjaHierachie []*models.NamespaceWithLists, err error) {
|
||||
func convertTodoistToVikunja(sync *sync, doneItems map[int64]*doneItem) (fullVikunjaHierachie []*models.NamespaceWithListsAndTasks, err error) {
|
||||
|
||||
newNamespace := &models.NamespaceWithLists{
|
||||
newNamespace := &models.NamespaceWithListsAndTasks{
|
||||
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[int64]*models.List, len(sync.Projects))
|
||||
lists := make(map[int64]*models.ListWithTasksAndBuckets, 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[int64]*models.Task, len(sync.Items))
|
||||
tasks := make(map[int64]*models.TaskWithComments, len(sync.Items))
|
||||
|
||||
// A map for all vikunja labels with the todoist id as key to find them easier
|
||||
labels := make(map[int64]*models.Label, len(sync.Labels))
|
||||
|
||||
for _, p := range sync.Projects {
|
||||
list := &models.List{
|
||||
Title: p.Name,
|
||||
HexColor: todoistColors[p.Color],
|
||||
IsArchived: p.IsArchived == 1,
|
||||
list := &models.ListWithTasksAndBuckets{
|
||||
List: models.List{
|
||||
Title: p.Name,
|
||||
HexColor: todoistColors[p.Color],
|
||||
IsArchived: p.IsArchived == 1,
|
||||
},
|
||||
}
|
||||
|
||||
lists[p.ID] = list
|
||||
@ -305,11 +307,13 @@ func convertTodoistToVikunja(sync *sync, doneItems map[int64]*doneItem) (fullVik
|
||||
}
|
||||
|
||||
for _, i := range sync.Items {
|
||||
task := &models.Task{
|
||||
Title: i.Content,
|
||||
Created: i.DateAdded.In(config.GetTimeZone()),
|
||||
Done: i.Checked == 1,
|
||||
BucketID: i.SectionID,
|
||||
task := &models.TaskWithComments{
|
||||
Task: models.Task{
|
||||
Title: i.Content,
|
||||
Created: i.DateAdded.In(config.GetTimeZone()),
|
||||
Done: i.Checked == 1,
|
||||
BucketID: i.SectionID,
|
||||
},
|
||||
}
|
||||
|
||||
// Only try to parse the task done at date if the task is actually done
|
||||
@ -365,7 +369,7 @@ func convertTodoistToVikunja(sync *sync, doneItems map[int64]*doneItem) (fullVik
|
||||
tasks[i.ParentID].RelatedTasks = make(models.RelatedTaskMap)
|
||||
}
|
||||
|
||||
tasks[i.ParentID].RelatedTasks[models.RelationKindSubtask] = append(tasks[i.ParentID].RelatedTasks[models.RelationKindSubtask], tasks[i.ID])
|
||||
tasks[i.ParentID].RelatedTasks[models.RelationKindSubtask] = append(tasks[i.ParentID].RelatedTasks[models.RelationKindSubtask], &tasks[i.ID].Task)
|
||||
|
||||
// Remove the task from the top level structure, otherwise it is added twice
|
||||
outer:
|
||||
@ -449,7 +453,7 @@ func convertTodoistToVikunja(sync *sync, doneItems map[int64]*doneItem) (fullVik
|
||||
tasks[r.ItemID].Reminders = append(tasks[r.ItemID].Reminders, date.In(config.GetTimeZone()))
|
||||
}
|
||||
|
||||
return []*models.NamespaceWithLists{
|
||||
return []*models.NamespaceWithListsAndTasks{
|
||||
newNamespace,
|
||||
}, err
|
||||
}
|
||||
|
@ -375,210 +375,258 @@ func TestConvertTodoistToVikunja(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
expectedHierachie := []*models.NamespaceWithLists{
|
||||
expectedHierachie := []*models.NamespaceWithListsAndTasks{
|
||||
{
|
||||
Namespace: models.Namespace{
|
||||
Title: "Migrated from todoist",
|
||||
},
|
||||
Lists: []*models.List{
|
||||
Lists: []*models.ListWithTasksAndBuckets{
|
||||
{
|
||||
Title: "Project1",
|
||||
Description: "Lorem Ipsum dolor sit amet\nLorem Ipsum dolor sit amet 2\nLorem Ipsum dolor sit amet 3",
|
||||
HexColor: todoistColors[30],
|
||||
List: 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],
|
||||
},
|
||||
Buckets: []*models.Bucket{
|
||||
{
|
||||
ID: 1234,
|
||||
Title: "Some Bucket",
|
||||
},
|
||||
},
|
||||
Tasks: []*models.Task{
|
||||
Tasks: []*models.TaskWithComments{
|
||||
{
|
||||
Title: "Task400000000",
|
||||
Description: "Lorem Ipsum dolor sit amet",
|
||||
Done: false,
|
||||
Created: time1,
|
||||
Reminders: []time.Time{
|
||||
time.Date(2020, time.June, 15, 0, 0, 0, 0, time.UTC).In(config.GetTimeZone()),
|
||||
time.Date(2020, time.June, 16, 7, 0, 0, 0, time.UTC).In(config.GetTimeZone()),
|
||||
Task: models.Task{
|
||||
Title: "Task400000000",
|
||||
Description: "Lorem Ipsum dolor sit amet",
|
||||
Done: false,
|
||||
Created: time1,
|
||||
Reminders: []time.Time{
|
||||
time.Date(2020, time.June, 15, 0, 0, 0, 0, time.UTC).In(config.GetTimeZone()),
|
||||
time.Date(2020, time.June, 16, 7, 0, 0, 0, time.UTC).In(config.GetTimeZone()),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Title: "Task400000001",
|
||||
Description: "Lorem Ipsum dolor sit amet",
|
||||
Done: false,
|
||||
Created: time1,
|
||||
},
|
||||
{
|
||||
Title: "Task400000002",
|
||||
Done: false,
|
||||
Created: time1,
|
||||
Reminders: []time.Time{
|
||||
time.Date(2020, time.July, 15, 7, 0, 0, 0, time.UTC).In(config.GetTimeZone()),
|
||||
Task: models.Task{
|
||||
Title: "Task400000001",
|
||||
Description: "Lorem Ipsum dolor sit amet",
|
||||
Done: false,
|
||||
Created: time1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Title: "Task400000003",
|
||||
Description: "Lorem Ipsum dolor sit amet",
|
||||
Done: true,
|
||||
DueDate: dueTime,
|
||||
Created: time1,
|
||||
DoneAt: time3,
|
||||
Labels: vikunjaLabels,
|
||||
Reminders: []time.Time{
|
||||
time.Date(2020, time.June, 15, 7, 0, 0, 0, time.UTC).In(config.GetTimeZone()),
|
||||
Task: models.Task{
|
||||
Title: "Task400000002",
|
||||
Done: false,
|
||||
Created: time1,
|
||||
Reminders: []time.Time{
|
||||
time.Date(2020, time.July, 15, 7, 0, 0, 0, time.UTC).In(config.GetTimeZone()),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Title: "Task400000004",
|
||||
Done: false,
|
||||
Created: time1,
|
||||
Labels: vikunjaLabels,
|
||||
},
|
||||
{
|
||||
Title: "Task400000005",
|
||||
Done: true,
|
||||
DueDate: dueTime,
|
||||
Created: time1,
|
||||
DoneAt: time3,
|
||||
Reminders: []time.Time{
|
||||
time.Date(2020, time.June, 15, 7, 0, 0, 0, time.UTC).In(config.GetTimeZone()),
|
||||
Task: models.Task{
|
||||
Title: "Task400000003",
|
||||
Description: "Lorem Ipsum dolor sit amet",
|
||||
Done: true,
|
||||
DueDate: dueTime,
|
||||
Created: time1,
|
||||
DoneAt: time3,
|
||||
Labels: vikunjaLabels,
|
||||
Reminders: []time.Time{
|
||||
time.Date(2020, time.June, 15, 7, 0, 0, 0, time.UTC).In(config.GetTimeZone()),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Title: "Task400000006",
|
||||
Done: true,
|
||||
DueDate: dueTime,
|
||||
Created: time1,
|
||||
DoneAt: time3,
|
||||
RelatedTasks: map[models.RelationKind][]*models.Task{
|
||||
models.RelationKindSubtask: {
|
||||
Task: models.Task{
|
||||
Title: "Task400000004",
|
||||
Done: false,
|
||||
Created: time1,
|
||||
Labels: vikunjaLabels,
|
||||
},
|
||||
},
|
||||
{
|
||||
Task: models.Task{
|
||||
Title: "Task400000005",
|
||||
Done: true,
|
||||
DueDate: dueTime,
|
||||
Created: time1,
|
||||
DoneAt: time3,
|
||||
Reminders: []time.Time{
|
||||
time.Date(2020, time.June, 15, 7, 0, 0, 0, time.UTC).In(config.GetTimeZone()),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Task: models.Task{
|
||||
Title: "Task400000006",
|
||||
Done: true,
|
||||
DueDate: dueTime,
|
||||
Created: time1,
|
||||
DoneAt: time3,
|
||||
RelatedTasks: map[models.RelationKind][]*models.Task{
|
||||
models.RelationKindSubtask: {
|
||||
{
|
||||
Title: "Task with parent",
|
||||
Done: false,
|
||||
Priority: 2,
|
||||
Created: time1,
|
||||
DoneAt: nilTime,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Task: models.Task{
|
||||
Title: "Task400000106",
|
||||
Done: true,
|
||||
DueDate: dueTimeWithTime,
|
||||
Created: time1,
|
||||
DoneAt: time3,
|
||||
Labels: vikunjaLabels,
|
||||
},
|
||||
},
|
||||
{
|
||||
Task: models.Task{
|
||||
Title: "Task400000107",
|
||||
Done: true,
|
||||
Created: time1,
|
||||
DoneAt: time3,
|
||||
},
|
||||
},
|
||||
{
|
||||
Task: models.Task{
|
||||
Title: "Task400000108",
|
||||
Done: true,
|
||||
Created: time1,
|
||||
DoneAt: time3,
|
||||
},
|
||||
},
|
||||
{
|
||||
Task: models.Task{
|
||||
Title: "Task400000109",
|
||||
Done: true,
|
||||
Created: time1,
|
||||
DoneAt: time3,
|
||||
BucketID: 1234,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
List: models.List{
|
||||
Title: "Project2",
|
||||
Description: "Lorem Ipsum dolor sit amet 4\nLorem Ipsum dolor sit amet 5",
|
||||
HexColor: todoistColors[37],
|
||||
},
|
||||
Tasks: []*models.TaskWithComments{
|
||||
{
|
||||
Task: models.Task{
|
||||
Title: "Task400000007",
|
||||
Done: false,
|
||||
DueDate: dueTime,
|
||||
Created: time1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Task: models.Task{
|
||||
Title: "Task400000008",
|
||||
Done: false,
|
||||
DueDate: dueTime,
|
||||
Created: time1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Task: models.Task{
|
||||
Title: "Task400000009",
|
||||
Done: false,
|
||||
Created: time1,
|
||||
Reminders: []time.Time{
|
||||
time.Date(2020, time.June, 15, 7, 0, 0, 0, time.UTC).In(config.GetTimeZone()),
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Task: models.Task{
|
||||
Title: "Task400000010",
|
||||
Description: "Lorem Ipsum dolor sit amet",
|
||||
Done: true,
|
||||
Created: time1,
|
||||
DoneAt: time3,
|
||||
},
|
||||
},
|
||||
{
|
||||
Task: models.Task{
|
||||
Title: "Task400000101",
|
||||
Description: "Lorem Ipsum dolor sit amet",
|
||||
Done: false,
|
||||
Created: time1,
|
||||
Attachments: []*models.TaskAttachment{
|
||||
{
|
||||
Title: "Task with parent",
|
||||
Done: false,
|
||||
Priority: 2,
|
||||
Created: time1,
|
||||
DoneAt: nilTime,
|
||||
File: &files.File{
|
||||
Name: "file.md",
|
||||
Mime: "text/plain",
|
||||
Size: 12345,
|
||||
Created: time1,
|
||||
FileContent: exampleFile,
|
||||
},
|
||||
Created: time1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Title: "Task400000106",
|
||||
Done: true,
|
||||
DueDate: dueTimeWithTime,
|
||||
Created: time1,
|
||||
DoneAt: time3,
|
||||
Labels: vikunjaLabels,
|
||||
Task: models.Task{
|
||||
Title: "Task400000102",
|
||||
Done: false,
|
||||
DueDate: dueTime,
|
||||
Created: time1,
|
||||
Labels: vikunjaLabels,
|
||||
},
|
||||
},
|
||||
{
|
||||
Title: "Task400000107",
|
||||
Done: true,
|
||||
Created: time1,
|
||||
DoneAt: time3,
|
||||
Task: models.Task{
|
||||
Title: "Task400000103",
|
||||
Done: false,
|
||||
Created: time1,
|
||||
Labels: vikunjaLabels,
|
||||
},
|
||||
},
|
||||
{
|
||||
Title: "Task400000108",
|
||||
Done: true,
|
||||
Created: time1,
|
||||
DoneAt: time3,
|
||||
Task: models.Task{
|
||||
Title: "Task400000104",
|
||||
Done: false,
|
||||
Created: time1,
|
||||
Labels: vikunjaLabels,
|
||||
},
|
||||
},
|
||||
{
|
||||
Title: "Task400000109",
|
||||
Done: true,
|
||||
Created: time1,
|
||||
DoneAt: time3,
|
||||
BucketID: 1234,
|
||||
Task: models.Task{
|
||||
Title: "Task400000105",
|
||||
Done: false,
|
||||
DueDate: dueTime,
|
||||
Created: time1,
|
||||
Labels: vikunjaLabels,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
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: dueTime,
|
||||
Created: time1,
|
||||
},
|
||||
{
|
||||
Title: "Task400000008",
|
||||
Done: false,
|
||||
DueDate: dueTime,
|
||||
Created: time1,
|
||||
},
|
||||
{
|
||||
Title: "Task400000009",
|
||||
Done: false,
|
||||
Created: time1,
|
||||
Reminders: []time.Time{
|
||||
time.Date(2020, time.June, 15, 7, 0, 0, 0, time.UTC).In(config.GetTimeZone()),
|
||||
},
|
||||
},
|
||||
{
|
||||
Title: "Task400000010",
|
||||
Description: "Lorem Ipsum dolor sit amet",
|
||||
Done: true,
|
||||
Created: time1,
|
||||
DoneAt: time3,
|
||||
},
|
||||
{
|
||||
Title: "Task400000101",
|
||||
Description: "Lorem Ipsum dolor sit amet",
|
||||
Done: false,
|
||||
Created: time1,
|
||||
Attachments: []*models.TaskAttachment{
|
||||
{
|
||||
File: &files.File{
|
||||
Name: "file.md",
|
||||
Mime: "text/plain",
|
||||
Size: 12345,
|
||||
Created: time1,
|
||||
FileContent: exampleFile,
|
||||
},
|
||||
Created: time1,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Title: "Task400000102",
|
||||
Done: false,
|
||||
DueDate: dueTime,
|
||||
Created: time1,
|
||||
Labels: vikunjaLabels,
|
||||
},
|
||||
{
|
||||
Title: "Task400000103",
|
||||
Done: false,
|
||||
Created: time1,
|
||||
Labels: vikunjaLabels,
|
||||
},
|
||||
{
|
||||
Title: "Task400000104",
|
||||
Done: false,
|
||||
Created: time1,
|
||||
Labels: vikunjaLabels,
|
||||
},
|
||||
{
|
||||
Title: "Task400000105",
|
||||
Done: false,
|
||||
DueDate: dueTime,
|
||||
Created: time1,
|
||||
Labels: vikunjaLabels,
|
||||
},
|
||||
List: models.List{
|
||||
Title: "Project3 - Archived",
|
||||
HexColor: todoistColors[37],
|
||||
IsArchived: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
Title: "Project3 - Archived",
|
||||
HexColor: todoistColors[37],
|
||||
IsArchived: true,
|
||||
Tasks: []*models.Task{
|
||||
Tasks: []*models.TaskWithComments{
|
||||
{
|
||||
Title: "Task400000111",
|
||||
Done: true,
|
||||
Created: time1,
|
||||
DoneAt: time3,
|
||||
Task: models.Task{
|
||||
Title: "Task400000111",
|
||||
Done: true,
|
||||
Created: time1,
|
||||
DoneAt: time3,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -144,16 +144,16 @@ func getTrelloData(token string) (trelloData []*trello.Board, err error) {
|
||||
|
||||
// Converts all previously obtained data from trello into the vikunja format.
|
||||
// `trelloData` should contain all boards with their lists and cards respectively.
|
||||
func convertTrelloDataToVikunja(trelloData []*trello.Board) (fullVikunjaHierachie []*models.NamespaceWithLists, err error) {
|
||||
func convertTrelloDataToVikunja(trelloData []*trello.Board) (fullVikunjaHierachie []*models.NamespaceWithListsAndTasks, err error) {
|
||||
|
||||
log.Debugf("[Trello Migration] ")
|
||||
|
||||
fullVikunjaHierachie = []*models.NamespaceWithLists{
|
||||
fullVikunjaHierachie = []*models.NamespaceWithListsAndTasks{
|
||||
{
|
||||
Namespace: models.Namespace{
|
||||
Title: "Imported from Trello",
|
||||
},
|
||||
Lists: []*models.List{},
|
||||
Lists: []*models.ListWithTasksAndBuckets{},
|
||||
},
|
||||
}
|
||||
|
||||
@ -162,10 +162,12 @@ func convertTrelloDataToVikunja(trelloData []*trello.Board) (fullVikunjaHierachi
|
||||
log.Debugf("[Trello Migration] Converting %d boards to vikunja lists", len(trelloData))
|
||||
|
||||
for _, board := range trelloData {
|
||||
list := &models.List{
|
||||
Title: board.Name,
|
||||
Description: board.Desc,
|
||||
IsArchived: board.Closed,
|
||||
list := &models.ListWithTasksAndBuckets{
|
||||
List: models.List{
|
||||
Title: board.Name,
|
||||
Description: board.Desc,
|
||||
IsArchived: board.Closed,
|
||||
},
|
||||
}
|
||||
|
||||
// Background
|
||||
@ -269,7 +271,7 @@ func convertTrelloDataToVikunja(trelloData []*trello.Board) (fullVikunjaHierachi
|
||||
log.Debugf("[Trello Migration] Downloaded card attachment %s", attachment.ID)
|
||||
}
|
||||
|
||||
list.Tasks = append(list.Tasks, task)
|
||||
list.Tasks = append(list.Tasks, &models.TaskWithComments{Task: *task})
|
||||
}
|
||||
|
||||
list.Buckets = append(list.Buckets, bucket)
|
||||
|
@ -187,16 +187,18 @@ func TestConvertTrelloToVikunja(t *testing.T) {
|
||||
}
|
||||
trelloData[0].Prefs.BackgroundImage = "https://vikunja.io/testimage.jpg" // Using an image which we are hosting, so it'll still be up
|
||||
|
||||
expectedHierachie := []*models.NamespaceWithLists{
|
||||
expectedHierachie := []*models.NamespaceWithListsAndTasks{
|
||||
{
|
||||
Namespace: models.Namespace{
|
||||
Title: "Imported from Trello",
|
||||
},
|
||||
Lists: []*models.List{
|
||||
Lists: []*models.ListWithTasksAndBuckets{
|
||||
{
|
||||
Title: "TestBoard",
|
||||
Description: "This is a description",
|
||||
BackgroundInformation: bytes.NewBuffer(exampleFile),
|
||||
List: models.List{
|
||||
Title: "TestBoard",
|
||||
Description: "This is a description",
|
||||
BackgroundInformation: bytes.NewBuffer(exampleFile),
|
||||
},
|
||||
Buckets: []*models.Bucket{
|
||||
{
|
||||
ID: 1,
|
||||
@ -207,37 +209,40 @@ func TestConvertTrelloToVikunja(t *testing.T) {
|
||||
Title: "Test List 2",
|
||||
},
|
||||
},
|
||||
Tasks: []*models.Task{
|
||||
Tasks: []*models.TaskWithComments{
|
||||
{
|
||||
Title: "Test Card 1",
|
||||
Description: "Card Description",
|
||||
BucketID: 1,
|
||||
KanbanPosition: 123,
|
||||
DueDate: time1,
|
||||
Labels: []*models.Label{
|
||||
{
|
||||
Title: "Label 1",
|
||||
HexColor: trelloColorMap["green"],
|
||||
Task: models.Task{
|
||||
Title: "Test Card 1",
|
||||
Description: "Card Description",
|
||||
BucketID: 1,
|
||||
KanbanPosition: 123,
|
||||
DueDate: time1,
|
||||
Labels: []*models.Label{
|
||||
{
|
||||
Title: "Label 1",
|
||||
HexColor: trelloColorMap["green"],
|
||||
},
|
||||
{
|
||||
Title: "Label 2",
|
||||
HexColor: trelloColorMap["orange"],
|
||||
},
|
||||
},
|
||||
{
|
||||
Title: "Label 2",
|
||||
HexColor: trelloColorMap["orange"],
|
||||
},
|
||||
},
|
||||
Attachments: []*models.TaskAttachment{
|
||||
{
|
||||
File: &files.File{
|
||||
Name: "Testimage.jpg",
|
||||
Mime: "image/jpg",
|
||||
Size: uint64(len(exampleFile)),
|
||||
FileContent: exampleFile,
|
||||
Attachments: []*models.TaskAttachment{
|
||||
{
|
||||
File: &files.File{
|
||||
Name: "Testimage.jpg",
|
||||
Mime: "image/jpg",
|
||||
Size: uint64(len(exampleFile)),
|
||||
FileContent: exampleFile,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Title: "Test Card 2",
|
||||
Description: `
|
||||
Task: models.Task{
|
||||
Title: "Test Card 2",
|
||||
Description: `
|
||||
|
||||
## Checklist 1
|
||||
|
||||
@ -248,84 +253,105 @@ func TestConvertTrelloToVikunja(t *testing.T) {
|
||||
|
||||
* [ ] Pending Task
|
||||
* [ ] Another Pending Task`,
|
||||
BucketID: 1,
|
||||
KanbanPosition: 124,
|
||||
BucketID: 1,
|
||||
KanbanPosition: 124,
|
||||
},
|
||||
},
|
||||
{
|
||||
Title: "Test Card 3",
|
||||
BucketID: 1,
|
||||
KanbanPosition: 126,
|
||||
Task: models.Task{
|
||||
Title: "Test Card 3",
|
||||
BucketID: 1,
|
||||
KanbanPosition: 126,
|
||||
},
|
||||
},
|
||||
{
|
||||
Title: "Test Card 4",
|
||||
BucketID: 1,
|
||||
KanbanPosition: 127,
|
||||
Labels: []*models.Label{
|
||||
{
|
||||
Title: "Label 2",
|
||||
HexColor: trelloColorMap["orange"],
|
||||
Task: models.Task{
|
||||
Title: "Test Card 4",
|
||||
BucketID: 1,
|
||||
KanbanPosition: 127,
|
||||
Labels: []*models.Label{
|
||||
{
|
||||
Title: "Label 2",
|
||||
HexColor: trelloColorMap["orange"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Title: "Test Card 5",
|
||||
BucketID: 2,
|
||||
KanbanPosition: 111,
|
||||
Labels: []*models.Label{
|
||||
{
|
||||
Title: "Label 3",
|
||||
HexColor: trelloColorMap["blue"],
|
||||
Task: models.Task{
|
||||
Title: "Test Card 5",
|
||||
BucketID: 2,
|
||||
KanbanPosition: 111,
|
||||
Labels: []*models.Label{
|
||||
{
|
||||
Title: "Label 3",
|
||||
HexColor: trelloColorMap["blue"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Title: "Test Card 6",
|
||||
BucketID: 2,
|
||||
KanbanPosition: 222,
|
||||
DueDate: time1,
|
||||
Task: models.Task{
|
||||
Title: "Test Card 6",
|
||||
BucketID: 2,
|
||||
KanbanPosition: 222,
|
||||
DueDate: time1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Title: "Test Card 7",
|
||||
BucketID: 2,
|
||||
KanbanPosition: 333,
|
||||
Task: models.Task{
|
||||
Title: "Test Card 7",
|
||||
BucketID: 2,
|
||||
KanbanPosition: 333,
|
||||
},
|
||||
},
|
||||
{
|
||||
Title: "Test Card 8",
|
||||
BucketID: 2,
|
||||
KanbanPosition: 444,
|
||||
Task: models.Task{
|
||||
Title: "Test Card 8",
|
||||
BucketID: 2,
|
||||
KanbanPosition: 444,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Title: "TestBoard 2",
|
||||
List: models.List{
|
||||
Title: "TestBoard 2",
|
||||
},
|
||||
Buckets: []*models.Bucket{
|
||||
{
|
||||
ID: 3,
|
||||
Title: "Test List 4",
|
||||
},
|
||||
},
|
||||
Tasks: []*models.Task{
|
||||
Tasks: []*models.TaskWithComments{
|
||||
{
|
||||
Title: "Test Card 634",
|
||||
BucketID: 3,
|
||||
KanbanPosition: 123,
|
||||
Task: models.Task{
|
||||
Title: "Test Card 634",
|
||||
BucketID: 3,
|
||||
KanbanPosition: 123,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Title: "TestBoard Archived",
|
||||
IsArchived: true,
|
||||
List: models.List{
|
||||
Title: "TestBoard Archived",
|
||||
IsArchived: true,
|
||||
},
|
||||
Buckets: []*models.Bucket{
|
||||
{
|
||||
ID: 4,
|
||||
Title: "Test List 5",
|
||||
},
|
||||
},
|
||||
Tasks: []*models.Task{
|
||||
Tasks: []*models.TaskWithComments{
|
||||
{
|
||||
Title: "Test Card 63423",
|
||||
BucketID: 4,
|
||||
KanbanPosition: 123,
|
||||
Task: models.Task{
|
||||
Title: "Test Card 63423",
|
||||
BucketID: 4,
|
||||
KanbanPosition: 123,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
BIN
pkg/modules/migration/vikunja-file/export.zip
Normal file
BIN
pkg/modules/migration/vikunja-file/export.zip
Normal file
Binary file not shown.
44
pkg/modules/migration/vikunja-file/main_test.go
Normal file
44
pkg/modules/migration/vikunja-file/main_test.go
Normal file
@ -0,0 +1,44 @@
|
||||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-2021 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 Affero General Public Licensee 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 Affero General Public Licensee for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public Licensee
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package vikunjafile
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"code.vikunja.io/api/pkg/events"
|
||||
|
||||
"code.vikunja.io/api/pkg/config"
|
||||
"code.vikunja.io/api/pkg/files"
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
)
|
||||
|
||||
// TestMain is the main test function used to bootstrap the test env
|
||||
func TestMain(m *testing.M) {
|
||||
// Set default config
|
||||
config.InitDefaultConfig()
|
||||
// We need to set the root path even if we're not using the config, otherwise fixtures are not loaded correctly
|
||||
config.ServiceRootpath.Set(os.Getenv("VIKUNJA_SERVICE_ROOTPATH"))
|
||||
|
||||
// Some tests use the file engine, so we'll need to initialize that
|
||||
files.InitTests()
|
||||
user.InitTests()
|
||||
models.SetupTests()
|
||||
events.Fake()
|
||||
os.Exit(m.Run())
|
||||
}
|
204
pkg/modules/migration/vikunja-file/vikunja.go
Normal file
204
pkg/modules/migration/vikunja-file/vikunja.go
Normal file
@ -0,0 +1,204 @@
|
||||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-2021 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 Affero General Public Licensee 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 Affero General Public Licensee for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public Licensee
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package vikunjafile
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"code.vikunja.io/api/pkg/db"
|
||||
"code.vikunja.io/api/pkg/log"
|
||||
"code.vikunja.io/api/pkg/models"
|
||||
"code.vikunja.io/api/pkg/modules/migration"
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
)
|
||||
|
||||
const logPrefix = "[Vikunja File Import] "
|
||||
|
||||
type FileMigrator struct {
|
||||
}
|
||||
|
||||
// Name is used to get the name of the vikunja-file 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/vikunja-file/status [get]
|
||||
func (v *FileMigrator) Name() string {
|
||||
return "vikunja-file"
|
||||
}
|
||||
|
||||
// Migrate takes a vikunja file export, parses it and imports everything in it into Vikunja.
|
||||
// @Summary Import all lists, tasks etc. from a Vikunja data export
|
||||
// @Description Imports all projects, tasks, notes, reminders, subtasks and files from a Vikunjda data export into Vikunja.
|
||||
// @tags migration
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security JWTKeyAuth
|
||||
// @Param import formData string true "The Vikunja export zip file."
|
||||
// @Success 200 {object} models.Message "A message telling you everything was migrated successfully."
|
||||
// @Failure 500 {object} models.Message "Internal server error"
|
||||
// @Router /migration/vikunja-file/migrate [post]
|
||||
func (v *FileMigrator) Migrate(user *user.User, file io.ReaderAt, size int64) error {
|
||||
r, err := zip.NewReader(file, size)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not open import file: %s", err)
|
||||
}
|
||||
|
||||
log.Debugf(logPrefix+"Importing a zip file containing %d files", len(r.File))
|
||||
|
||||
var dataFile *zip.File
|
||||
var filterFile *zip.File
|
||||
storedFiles := make(map[int64]*zip.File)
|
||||
for _, f := range r.File {
|
||||
if strings.HasPrefix(f.Name, "files/") {
|
||||
fname := strings.ReplaceAll(f.Name, "files/", "")
|
||||
id, err := strconv.ParseInt(fname, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not convert file id: %s", err)
|
||||
}
|
||||
storedFiles[id] = f
|
||||
log.Debugf(logPrefix + "Found a blob file")
|
||||
continue
|
||||
}
|
||||
if f.Name == "data.json" {
|
||||
dataFile = f
|
||||
log.Debugf(logPrefix + "Found a data file")
|
||||
continue
|
||||
}
|
||||
if f.Name == "filters.json" {
|
||||
filterFile = f
|
||||
log.Debugf(logPrefix + "Found a filter file")
|
||||
}
|
||||
}
|
||||
|
||||
if dataFile == nil {
|
||||
return fmt.Errorf("no data file provided")
|
||||
}
|
||||
|
||||
log.Debugf(logPrefix + "")
|
||||
|
||||
//////
|
||||
// Import the bulk of Vikunja data
|
||||
df, err := dataFile.Open()
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not open data file: %s", err)
|
||||
}
|
||||
defer df.Close()
|
||||
|
||||
var bufData bytes.Buffer
|
||||
if _, err := bufData.ReadFrom(df); err != nil {
|
||||
return fmt.Errorf("could not read data file: %s", err)
|
||||
}
|
||||
|
||||
namespaces := []*models.NamespaceWithListsAndTasks{}
|
||||
if err := json.Unmarshal(bufData.Bytes(), &namespaces); err != nil {
|
||||
return fmt.Errorf("could not read data: %s", err)
|
||||
}
|
||||
|
||||
for _, n := range namespaces {
|
||||
for _, l := range n.Lists {
|
||||
if b, exists := storedFiles[l.BackgroundFileID]; exists {
|
||||
bf, err := b.Open()
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not open list background file %d for reading: %s", l.BackgroundFileID, err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if _, err := buf.ReadFrom(bf); err != nil {
|
||||
return fmt.Errorf("could not read list background file %d: %s", l.BackgroundFileID, err)
|
||||
}
|
||||
|
||||
l.BackgroundInformation = &buf
|
||||
}
|
||||
|
||||
for _, t := range l.Tasks {
|
||||
for _, label := range t.Labels {
|
||||
label.ID = 0
|
||||
}
|
||||
for _, comment := range t.Comments {
|
||||
comment.ID = 0
|
||||
}
|
||||
for _, attachment := range t.Attachments {
|
||||
af, err := storedFiles[attachment.File.ID].Open()
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not open attachment %d for reading: %s", attachment.ID, err)
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if _, err := buf.ReadFrom(af); err != nil {
|
||||
return fmt.Errorf("could not read attachment %d: %s", attachment.ID, err)
|
||||
}
|
||||
|
||||
attachment.ID = 0
|
||||
attachment.File.ID = 0
|
||||
attachment.File.FileContent = buf.Bytes()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
err = migration.InsertFromStructure(namespaces, user)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not insert data: %s", err)
|
||||
}
|
||||
|
||||
if filterFile == nil {
|
||||
log.Debugf(logPrefix + "No filter file found")
|
||||
return nil
|
||||
}
|
||||
|
||||
///////
|
||||
// Import filters
|
||||
ff, err := filterFile.Open()
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not open filters file: %s", err)
|
||||
}
|
||||
defer ff.Close()
|
||||
|
||||
var bufFilter bytes.Buffer
|
||||
if _, err := bufFilter.ReadFrom(ff); err != nil {
|
||||
return fmt.Errorf("could not read filters file: %s", err)
|
||||
}
|
||||
|
||||
filters := []*models.SavedFilter{}
|
||||
if err := json.Unmarshal(bufFilter.Bytes(), &filters); err != nil {
|
||||
return fmt.Errorf("could not read filter data: %s", err)
|
||||
}
|
||||
|
||||
log.Debugf(logPrefix+"Importing %d saved filters", len(filters))
|
||||
|
||||
s := db.NewSession()
|
||||
defer s.Close()
|
||||
|
||||
for _, f := range filters {
|
||||
f.ID = 0
|
||||
err = f.Create(s, user)
|
||||
if err != nil {
|
||||
_ = s.Rollback()
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return s.Commit()
|
||||
}
|
79
pkg/modules/migration/vikunja-file/vikunja_test.go
Normal file
79
pkg/modules/migration/vikunja-file/vikunja_test.go
Normal file
@ -0,0 +1,79 @@
|
||||
// Vikunja is a to-do list application to facilitate your life.
|
||||
// Copyright 2018-2021 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 Affero General Public Licensee 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 Affero General Public Licensee for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public Licensee
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
package vikunjafile
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"code.vikunja.io/api/pkg/config"
|
||||
"code.vikunja.io/api/pkg/db"
|
||||
"code.vikunja.io/api/pkg/user"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestVikunjaFileMigrator_Migrate(t *testing.T) {
|
||||
db.LoadAndAssertFixtures(t)
|
||||
|
||||
m := &FileMigrator{}
|
||||
u := &user.User{ID: 1}
|
||||
|
||||
f, err := os.Open(config.ServiceRootpath.GetString() + "/pkg/modules/migration/vikunja-file/export.zip")
|
||||
if err != nil {
|
||||
t.Fatalf("Could not open file: %s", err)
|
||||
}
|
||||
defer f.Close()
|
||||
s, err := f.Stat()
|
||||
if err != nil {
|
||||
t.Fatalf("Could not stat file: %s", err)
|
||||
}
|
||||
|
||||
err = m.Migrate(u, f, s.Size())
|
||||
assert.NoError(t, err)
|
||||
db.AssertExists(t, "namespaces", map[string]interface{}{
|
||||
"title": "test",
|
||||
"owner_id": u.ID,
|
||||
}, false)
|
||||
db.AssertExists(t, "lists", map[string]interface{}{
|
||||
"title": "Test list",
|
||||
"owner_id": u.ID,
|
||||
}, false)
|
||||
db.AssertExists(t, "lists", map[string]interface{}{
|
||||
"title": "A list with a background",
|
||||
"owner_id": u.ID,
|
||||
}, false)
|
||||
db.AssertExists(t, "tasks", map[string]interface{}{
|
||||
"title": "Some other task",
|
||||
"created_by_id": u.ID,
|
||||
}, false)
|
||||
db.AssertExists(t, "task_comments", map[string]interface{}{
|
||||
"comment": "This is a comment",
|
||||
"author_id": u.ID,
|
||||
}, false)
|
||||
db.AssertExists(t, "files", map[string]interface{}{
|
||||
"name": "cristiano-mozzillo-v3d5uBB26yA-unsplash.jpg",
|
||||
"created_by_id": u.ID,
|
||||
}, false)
|
||||
db.AssertExists(t, "labels", map[string]interface{}{
|
||||
"title": "test",
|
||||
"created_by_id": u.ID,
|
||||
}, false)
|
||||
db.AssertExists(t, "buckets", map[string]interface{}{
|
||||
"title": "Test Bucket",
|
||||
"created_by_id": u.ID,
|
||||
}, false)
|
||||
}
|
@ -142,11 +142,13 @@ type wunderlistContents struct {
|
||||
subtasks []*subtask
|
||||
}
|
||||
|
||||
func convertListForFolder(listID int, list *list, content *wunderlistContents) (*models.List, error) {
|
||||
func convertListForFolder(listID int, list *list, content *wunderlistContents) (*models.ListWithTasksAndBuckets, error) {
|
||||
|
||||
l := &models.List{
|
||||
Title: list.Title,
|
||||
Created: list.CreatedAt,
|
||||
l := &models.ListWithTasksAndBuckets{
|
||||
List: models.List{
|
||||
Title: list.Title,
|
||||
Created: list.CreatedAt,
|
||||
},
|
||||
}
|
||||
|
||||
// Find all tasks belonging to this list and put them in
|
||||
@ -233,13 +235,13 @@ func convertListForFolder(listID int, list *list, content *wunderlistContents) (
|
||||
}
|
||||
}
|
||||
|
||||
l.Tasks = append(l.Tasks, newTask)
|
||||
l.Tasks = append(l.Tasks, &models.TaskWithComments{Task: *newTask})
|
||||
}
|
||||
}
|
||||
return l, nil
|
||||
}
|
||||
|
||||
func convertWunderlistToVikunja(content *wunderlistContents) (fullVikunjaHierachie []*models.NamespaceWithLists, err error) {
|
||||
func convertWunderlistToVikunja(content *wunderlistContents) (fullVikunjaHierachie []*models.NamespaceWithListsAndTasks, err error) {
|
||||
|
||||
// Make a map from the list with the key being list id for easier handling
|
||||
listMap := make(map[int]*list, len(content.lists))
|
||||
@ -249,7 +251,7 @@ func convertWunderlistToVikunja(content *wunderlistContents) (fullVikunjaHierach
|
||||
|
||||
// First, we look through all folders and create namespaces for them.
|
||||
for _, folder := range content.folders {
|
||||
namespace := &models.NamespaceWithLists{
|
||||
namespace := &models.NamespaceWithListsAndTasks{
|
||||
Namespace: models.Namespace{
|
||||
Title: folder.Title,
|
||||
Created: folder.CreatedAt,
|
||||
@ -276,7 +278,7 @@ func convertWunderlistToVikunja(content *wunderlistContents) (fullVikunjaHierach
|
||||
|
||||
// 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 {
|
||||
newNamespace := &models.NamespaceWithLists{
|
||||
newNamespace := &models.NamespaceWithListsAndTasks{
|
||||
Namespace: models.Namespace{
|
||||
Title: "Migrated from wunderlist",
|
||||
},
|
||||
|
@ -194,49 +194,55 @@ func TestWunderlistParsing(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
expectedHierachie := []*models.NamespaceWithLists{
|
||||
expectedHierachie := []*models.NamespaceWithListsAndTasks{
|
||||
{
|
||||
Namespace: models.Namespace{
|
||||
Title: "Lorem Ipsum",
|
||||
Created: time1,
|
||||
Updated: time2,
|
||||
},
|
||||
Lists: []*models.List{
|
||||
Lists: []*models.ListWithTasksAndBuckets{
|
||||
{
|
||||
Created: time1,
|
||||
Title: "Lorem1",
|
||||
Tasks: []*models.Task{
|
||||
List: models.List{
|
||||
Created: time1,
|
||||
Title: "Lorem1",
|
||||
},
|
||||
Tasks: []*models.TaskWithComments{
|
||||
{
|
||||
Title: "Ipsum1",
|
||||
DueDate: time.Unix(1378339200, 0).In(config.GetTimeZone()),
|
||||
Created: time1,
|
||||
Description: "Lorem Ipsum dolor sit amet",
|
||||
Attachments: []*models.TaskAttachment{
|
||||
{
|
||||
File: &files.File{
|
||||
Name: "file.md",
|
||||
Mime: "text/plain",
|
||||
Size: 12345,
|
||||
Created: time2,
|
||||
FileContent: exampleFile,
|
||||
Task: models.Task{
|
||||
Title: "Ipsum1",
|
||||
DueDate: time.Unix(1378339200, 0).In(config.GetTimeZone()),
|
||||
Created: time1,
|
||||
Description: "Lorem Ipsum dolor sit amet",
|
||||
Attachments: []*models.TaskAttachment{
|
||||
{
|
||||
File: &files.File{
|
||||
Name: "file.md",
|
||||
Mime: "text/plain",
|
||||
Size: 12345,
|
||||
Created: time2,
|
||||
FileContent: exampleFile,
|
||||
},
|
||||
Created: time2,
|
||||
},
|
||||
Created: time2,
|
||||
},
|
||||
Reminders: []time.Time{time4},
|
||||
},
|
||||
Reminders: []time.Time{time4},
|
||||
},
|
||||
{
|
||||
Title: "Ipsum2",
|
||||
DueDate: time.Unix(1378339200, 0).In(config.GetTimeZone()),
|
||||
Created: time1,
|
||||
Description: "Lorem Ipsum dolor sit amet",
|
||||
RelatedTasks: map[models.RelationKind][]*models.Task{
|
||||
models.RelationKindSubtask: {
|
||||
{
|
||||
Title: "LoremSub1",
|
||||
},
|
||||
{
|
||||
Title: "LoremSub2",
|
||||
Task: models.Task{
|
||||
Title: "Ipsum2",
|
||||
DueDate: time.Unix(1378339200, 0).In(config.GetTimeZone()),
|
||||
Created: time1,
|
||||
Description: "Lorem Ipsum dolor sit amet",
|
||||
RelatedTasks: map[models.RelationKind][]*models.Task{
|
||||
models.RelationKindSubtask: {
|
||||
{
|
||||
Title: "LoremSub1",
|
||||
},
|
||||
{
|
||||
Title: "LoremSub2",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -244,38 +250,44 @@ func TestWunderlistParsing(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Created: time1,
|
||||
Title: "Lorem2",
|
||||
Tasks: []*models.Task{
|
||||
List: models.List{
|
||||
Created: time1,
|
||||
Title: "Lorem2",
|
||||
},
|
||||
Tasks: []*models.TaskWithComments{
|
||||
{
|
||||
Title: "Ipsum3",
|
||||
Done: true,
|
||||
DoneAt: time1,
|
||||
DueDate: time.Unix(1378339200, 0).In(config.GetTimeZone()),
|
||||
Created: time1,
|
||||
Description: "Lorem Ipsum dolor sit amet",
|
||||
Attachments: []*models.TaskAttachment{
|
||||
{
|
||||
File: &files.File{
|
||||
Name: "file2.md",
|
||||
Mime: "text/plain",
|
||||
Size: 12345,
|
||||
Created: time3,
|
||||
FileContent: exampleFile,
|
||||
},
|
||||
Created: time3,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Title: "Ipsum4",
|
||||
DueDate: time.Unix(1378339200, 0).In(config.GetTimeZone()),
|
||||
Created: time1,
|
||||
Reminders: []time.Time{time3},
|
||||
RelatedTasks: map[models.RelationKind][]*models.Task{
|
||||
models.RelationKindSubtask: {
|
||||
Task: models.Task{
|
||||
Title: "Ipsum3",
|
||||
Done: true,
|
||||
DoneAt: time1,
|
||||
DueDate: time.Unix(1378339200, 0).In(config.GetTimeZone()),
|
||||
Created: time1,
|
||||
Description: "Lorem Ipsum dolor sit amet",
|
||||
Attachments: []*models.TaskAttachment{
|
||||
{
|
||||
Title: "LoremSub3",
|
||||
File: &files.File{
|
||||
Name: "file2.md",
|
||||
Mime: "text/plain",
|
||||
Size: 12345,
|
||||
Created: time3,
|
||||
FileContent: exampleFile,
|
||||
},
|
||||
Created: time3,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Task: models.Task{
|
||||
Title: "Ipsum4",
|
||||
DueDate: time.Unix(1378339200, 0).In(config.GetTimeZone()),
|
||||
Created: time1,
|
||||
Reminders: []time.Time{time3},
|
||||
RelatedTasks: map[models.RelationKind][]*models.Task{
|
||||
models.RelationKindSubtask: {
|
||||
{
|
||||
Title: "LoremSub3",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -283,52 +295,68 @@ func TestWunderlistParsing(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Created: time1,
|
||||
Title: "Lorem3",
|
||||
Tasks: []*models.Task{
|
||||
List: models.List{
|
||||
Created: time1,
|
||||
Title: "Lorem3",
|
||||
},
|
||||
Tasks: []*models.TaskWithComments{
|
||||
{
|
||||
Title: "Ipsum5",
|
||||
DueDate: time.Unix(1378339200, 0).In(config.GetTimeZone()),
|
||||
Created: time1,
|
||||
Task: models.Task{
|
||||
Title: "Ipsum5",
|
||||
DueDate: time.Unix(1378339200, 0).In(config.GetTimeZone()),
|
||||
Created: time1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Title: "Ipsum6",
|
||||
DueDate: time.Unix(1378339200, 0).In(config.GetTimeZone()),
|
||||
Created: time1,
|
||||
Done: true,
|
||||
DoneAt: time1,
|
||||
Task: models.Task{
|
||||
Title: "Ipsum6",
|
||||
DueDate: time.Unix(1378339200, 0).In(config.GetTimeZone()),
|
||||
Created: time1,
|
||||
Done: true,
|
||||
DoneAt: time1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Title: "Ipsum7",
|
||||
DueDate: time.Unix(1378339200, 0).In(config.GetTimeZone()),
|
||||
Created: time1,
|
||||
Done: true,
|
||||
DoneAt: time1,
|
||||
Task: models.Task{
|
||||
Title: "Ipsum7",
|
||||
DueDate: time.Unix(1378339200, 0).In(config.GetTimeZone()),
|
||||
Created: time1,
|
||||
Done: true,
|
||||
DoneAt: time1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Title: "Ipsum8",
|
||||
DueDate: time.Unix(1378339200, 0).In(config.GetTimeZone()),
|
||||
Created: time1,
|
||||
Task: models.Task{
|
||||
Title: "Ipsum8",
|
||||
DueDate: time.Unix(1378339200, 0).In(config.GetTimeZone()),
|
||||
Created: time1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Created: time1,
|
||||
Title: "Lorem4",
|
||||
Tasks: []*models.Task{
|
||||
List: models.List{
|
||||
Created: time1,
|
||||
Title: "Lorem4",
|
||||
},
|
||||
Tasks: []*models.TaskWithComments{
|
||||
{
|
||||
Title: "Ipsum9",
|
||||
DueDate: time.Unix(1378339200, 0).In(config.GetTimeZone()),
|
||||
Created: time1,
|
||||
Done: true,
|
||||
DoneAt: time1,
|
||||
Task: models.Task{
|
||||
Title: "Ipsum9",
|
||||
DueDate: time.Unix(1378339200, 0).In(config.GetTimeZone()),
|
||||
Created: time1,
|
||||
Done: true,
|
||||
DoneAt: time1,
|
||||
},
|
||||
},
|
||||
{
|
||||
Title: "Ipsum10",
|
||||
DueDate: time.Unix(1378339200, 0).In(config.GetTimeZone()),
|
||||
Created: time1,
|
||||
Done: true,
|
||||
DoneAt: time1,
|
||||
Task: models.Task{
|
||||
Title: "Ipsum10",
|
||||
DueDate: time.Unix(1378339200, 0).In(config.GetTimeZone()),
|
||||
Created: time1,
|
||||
Done: true,
|
||||
DoneAt: time1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@ -338,10 +366,12 @@ func TestWunderlistParsing(t *testing.T) {
|
||||
Namespace: models.Namespace{
|
||||
Title: "Migrated from wunderlist",
|
||||
},
|
||||
Lists: []*models.List{
|
||||
Lists: []*models.ListWithTasksAndBuckets{
|
||||
{
|
||||
Created: time4,
|
||||
Title: "List without a namespace",
|
||||
List: models.List{
|
||||
Created: time4,
|
||||
Title: "List without a namespace",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
Reference in New Issue
Block a user