2022-10-09 16:56:29 +00:00
// 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 ticktick
import (
"encoding/csv"
2022-10-09 17:23:23 +00:00
"errors"
2022-10-09 16:56:29 +00:00
"io"
2022-10-09 17:03:00 +00:00
"regexp"
2022-10-09 16:56:29 +00:00
"sort"
"strconv"
"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-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-01-24 13:58:18 +00:00
FolderName string ` csv:"Folder Name" `
ListName string ` csv:"List Name" `
Title string ` csv:"Title" `
TagsList string ` csv:"Tags" `
Tags [ ] string ` csv:"-" `
Content string ` csv:"Content" `
IsChecklistString string ` csv:"Is Check list" `
IsChecklist bool ` csv:"-" `
StartDate tickTickTime ` csv:"Start Date" `
DueDate tickTickTime ` csv:"Due Date" `
ReminderDuration string ` csv:"Reminder" `
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" `
}
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-10-09 17:03:00 +00:00
// Copied from https://stackoverflow.com/a/57617885
var durationRegex = regexp . MustCompile ( ` P([\d\.]+Y)?([\d\.]+M)?([\d\.]+D)?T?([\d\.]+H)?([\d\.]+M)?([\d\.]+?S)? ` )
// ParseDuration converts a ISO8601 duration into a time.Duration
func parseDuration ( str string ) time . Duration {
matches := durationRegex . FindStringSubmatch ( str )
2022-10-09 17:23:23 +00:00
if len ( matches ) == 0 {
return 0
}
2022-10-09 17:03:00 +00:00
years := parseDurationPart ( matches [ 1 ] , time . Hour * 24 * 365 )
months := parseDurationPart ( matches [ 2 ] , time . Hour * 24 * 30 )
days := parseDurationPart ( matches [ 3 ] , time . Hour * 24 )
hours := parseDurationPart ( matches [ 4 ] , time . Hour )
minutes := parseDurationPart ( matches [ 5 ] , time . Second * 60 )
seconds := parseDurationPart ( matches [ 6 ] , time . Second )
2022-10-09 17:23:23 +00:00
return years + months + days + hours + minutes + seconds
2022-10-09 17:03:00 +00:00
}
func parseDurationPart ( value string , unit time . Duration ) time . Duration {
if len ( value ) != 0 {
if parsed , err := strconv . ParseFloat ( value [ : len ( value ) - 1 ] , 64 ) ; err == nil {
return time . Duration ( float64 ( unit ) * parsed )
}
}
return 0
}
2022-11-13 16:07:01 +00:00
func convertTickTickToVikunja ( tasks [ ] * tickTickTask ) ( result [ ] * models . NamespaceWithProjectsAndTasks ) {
namespace := & models . NamespaceWithProjectsAndTasks {
2022-10-09 16:56:29 +00:00
Namespace : models . Namespace {
Title : "Migrated from TickTick" ,
} ,
2022-11-13 16:07:01 +00:00
Projects : [ ] * models . ProjectWithTasksAndBuckets { } ,
2022-10-09 16:56:29 +00:00
}
2022-11-13 16:07:01 +00:00
projects := make ( map [ string ] * models . ProjectWithTasksAndBuckets )
2022-10-09 16:56:29 +00:00
for _ , 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 {
Project : models . Project {
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 {
task . Task . Reminders = [ ] time . Time {
t . DueDate . Add ( t . Reminder * - 1 ) ,
}
}
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 {
namespace . Projects = append ( namespace . Projects , l )
2022-10-09 16:56:29 +00:00
}
2022-11-13 16:07:01 +00:00
sort . Slice ( namespace . Projects , func ( i , j int ) bool {
return namespace . Projects [ i ] . Title < namespace . Projects [ j ] . Title
2022-10-09 16:56:29 +00:00
} )
2022-11-13 16:07:01 +00:00
return [ ] * models . NamespaceWithProjectsAndTasks { namespace }
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
// @Accept json
// @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-01-24 13:58:18 +00:00
reminder := parseDuration ( task . ReminderDuration )
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 )
}