2022-10-09 16:56:29 +00:00
// Vikunja is a to-do list application to facilitate your life.
2023-09-01 06:32:28 +00:00
// Copyright 2018-present Vikunja and contributors. All rights reserved.
2022-10-09 16:56:29 +00:00
//
// 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 ticktick
import (
"encoding/csv"
2022-10-09 17:23:23 +00:00
"errors"
2022-10-09 16:56:29 +00:00
"io"
"sort"
"strings"
"time"
2022-10-09 17:23:23 +00:00
"code.vikunja.io/api/pkg/log"
2022-10-09 16:56:29 +00:00
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/modules/migration"
"code.vikunja.io/api/pkg/user"
2023-04-01 11:09:11 +00:00
"code.vikunja.io/api/pkg/utils"
2023-01-24 13:58:18 +00:00
"github.com/gocarina/gocsv"
2022-10-09 16:56:29 +00:00
)
const timeISO = "2006-01-02T15:04:05-0700"
type Migrator struct {
}
type tickTickTask struct {
2023-03-14 14:35:03 +00:00
FolderName string ` csv:"Folder Name" `
ProjectName string ` csv:"List Name" `
Title string ` csv:"Title" `
2023-01-24 13:58:18 +00:00
TagsList string ` csv:"Tags" `
2023-03-14 14:35:03 +00:00
Tags [ ] string ` csv:"-" `
Content string ` csv:"Content" `
2023-01-24 13:58:18 +00:00
IsChecklistString string ` csv:"Is Check list" `
2023-03-14 14:35:03 +00:00
IsChecklist bool ` csv:"-" `
StartDate tickTickTime ` csv:"Start Date" `
DueDate tickTickTime ` csv:"Due Date" `
2023-01-24 13:58:18 +00:00
ReminderDuration string ` csv:"Reminder" `
2023-03-14 14:35:03 +00:00
Reminder time . Duration ` csv:"-" `
Repeat string ` csv:"Repeat" `
Priority int ` csv:"Priority" `
Status string ` csv:"Status" `
CreatedTime tickTickTime ` csv:"Created Time" `
CompletedTime tickTickTime ` csv:"Completed Time" `
Order float64 ` csv:"Order" `
TaskID int64 ` csv:"taskId" `
ParentID int64 ` csv:"parentId" `
2023-01-24 13:58:18 +00:00
}
type tickTickTime struct {
time . Time
}
func ( date * tickTickTime ) UnmarshalCSV ( csv string ) ( err error ) {
date . Time = time . Time { }
if csv == "" {
return nil
}
date . Time , err = time . Parse ( timeISO , csv )
return err
2022-10-09 16:56:29 +00:00
}
2022-12-29 17:11:15 +00:00
func convertTickTickToVikunja ( tasks [ ] * tickTickTask ) ( result [ ] * models . ProjectWithTasksAndBuckets ) {
2023-11-08 21:56:10 +00:00
var pseudoParentID int64 = 1
result = [ ] * models . ProjectWithTasksAndBuckets {
{
Project : models . Project {
ID : pseudoParentID ,
Title : "Migrated from TickTick" ,
} ,
2022-10-09 16:56:29 +00:00
} ,
}
2022-11-13 16:07:01 +00:00
projects := make ( map [ string ] * models . ProjectWithTasksAndBuckets )
2023-11-08 21:56:10 +00:00
for index , t := range tasks {
2022-11-13 16:07:01 +00:00
_ , has := projects [ t . ProjectName ]
2022-10-09 16:56:29 +00:00
if ! has {
2022-11-13 16:07:01 +00:00
projects [ t . ProjectName ] = & models . ProjectWithTasksAndBuckets {
2022-11-13 16:07:01 +00:00
Project : models . Project {
2023-11-08 21:56:10 +00:00
ID : int64 ( index + 1 ) + pseudoParentID ,
ParentProjectID : pseudoParentID ,
Title : t . ProjectName ,
2022-10-09 16:56:29 +00:00
} ,
}
}
labels := make ( [ ] * models . Label , 0 , len ( t . Tags ) )
for _ , tag := range t . Tags {
labels = append ( labels , & models . Label {
Title : tag ,
} )
}
task := & models . TaskWithComments {
Task : models . Task {
ID : t . TaskID ,
Title : t . Title ,
Description : t . Content ,
2023-01-24 13:58:18 +00:00
StartDate : t . StartDate . Time ,
EndDate : t . DueDate . Time ,
DueDate : t . DueDate . Time ,
Done : t . Status == "1" ,
DoneAt : t . CompletedTime . Time ,
Position : t . Order ,
Labels : labels ,
2022-10-09 16:56:29 +00:00
} ,
}
2023-01-24 13:58:18 +00:00
if ! t . DueDate . IsZero ( ) && t . Reminder > 0 {
2023-03-27 20:07:06 +00:00
task . Task . Reminders = [ ] * models . TaskReminder {
{
RelativeTo : models . ReminderRelationDueDate ,
RelativePeriod : int64 ( ( t . Reminder * - 1 ) . Seconds ( ) ) ,
} ,
2023-01-24 13:58:18 +00:00
}
}
2022-10-09 16:56:29 +00:00
if t . ParentID != 0 {
task . RelatedTasks = map [ models . RelationKind ] [ ] * models . Task {
models . RelationKindParenttask : { { ID : t . ParentID } } ,
}
}
2022-11-13 16:07:01 +00:00
projects [ t . ProjectName ] . Tasks = append ( projects [ t . ProjectName ] . Tasks , task )
2022-10-09 16:56:29 +00:00
}
2022-11-13 16:07:01 +00:00
for _ , l := range projects {
2023-11-08 21:56:10 +00:00
result = append ( result , l )
2022-10-09 16:56:29 +00:00
}
2023-11-08 21:56:10 +00:00
sort . Slice ( result , func ( i , j int ) bool {
return result [ i ] . Title < result [ j ] . Title
2022-10-09 16:56:29 +00:00
} )
2023-11-08 21:56:10 +00:00
return
2022-10-09 16:56:29 +00:00
}
// Name is used to get the name of the ticktick 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/ticktick/status [get]
func ( m * Migrator ) Name ( ) string {
return "ticktick"
}
2023-01-24 13:58:18 +00:00
func newLineSkipDecoder ( r io . Reader , linesToSkip int ) gocsv . SimpleDecoder {
reader := csv . NewReader ( r )
// reader.FieldsPerRecord = -1
for i := 0 ; i < linesToSkip ; i ++ {
_ , err := reader . Read ( )
if err != nil {
if errors . Is ( err , io . EOF ) {
break
}
log . Debugf ( "[TickTick Migration] CSV parse error: %s" , err )
}
}
reader . FieldsPerRecord = 0
return gocsv . NewSimpleDecoderFromCSVReader ( reader )
}
2022-10-09 16:56:29 +00:00
// Migrate takes a ticktick export, parses it and imports everything in it into Vikunja.
2022-11-13 16:07:01 +00:00
// @Summary Import all projects, tasks etc. from a TickTick backup export
2022-10-09 16:56:29 +00:00
// @Description Imports all projects, tasks, notes, reminders, subtasks and files from a TickTick backup export into Vikunja.
// @tags migration
2023-04-03 00:17:09 +00:00
// @Accept x-www-form-urlencoded
2022-10-09 16:56:29 +00:00
// @Produce json
// @Security JWTKeyAuth
// @Param import formData string true "The TickTick backup csv file."
// @Success 200 {object} models.Message "A message telling you everything was migrated successfully."
// @Failure 500 {object} models.Message "Internal server error"
// @Router /migration/ticktick/migrate [post]
func ( m * Migrator ) Migrate ( user * user . User , file io . ReaderAt , size int64 ) error {
2022-10-09 17:23:23 +00:00
fr := io . NewSectionReader ( file , 0 , size )
2023-01-24 13:58:18 +00:00
//r := csv.NewReader(fr)
2022-10-09 16:56:29 +00:00
2022-10-09 17:23:23 +00:00
allTasks := [ ] * tickTickTask { }
2023-01-24 13:58:18 +00:00
decode := newLineSkipDecoder ( fr , 3 )
err := gocsv . UnmarshalDecoder ( decode , & allTasks )
if err != nil {
return err
}
2022-10-09 16:56:29 +00:00
2023-01-24 13:58:18 +00:00
for _ , task := range allTasks {
if task . IsChecklistString == "Y" {
task . IsChecklist = true
2022-10-09 17:23:23 +00:00
}
2023-04-01 11:09:11 +00:00
reminder := utils . ParseISO8601Duration ( task . ReminderDuration )
2023-01-24 13:58:18 +00:00
if reminder > 0 {
task . Reminder = reminder
2022-10-09 17:23:23 +00:00
}
2023-01-24 13:58:18 +00:00
task . Tags = strings . Split ( task . TagsList , ", " )
2022-10-09 16:56:29 +00:00
}
vikunjaTasks := convertTickTickToVikunja ( allTasks )
return migration . InsertFromStructure ( vikunjaTasks , user )
}