diff --git a/pkg/modules/migration/ticktick/testdata_ticktick_invalid_numbers.csv b/pkg/modules/migration/ticktick/testdata_ticktick_invalid_numbers.csv new file mode 100644 index 000000000..b38329f61 --- /dev/null +++ b/pkg/modules/migration/ticktick/testdata_ticktick_invalid_numbers.csv @@ -0,0 +1,9 @@ +"Date: 2026-02-27+0000" +"Version: 7.1" +"Status: +0 Normal +1 Completed +2 Archived" +"Folder Name","List Name","Title","Kind","Tags","Content","Is Check list","Start Date","Due Date","Reminder","Repeat","Priority","Status","Created Time","Completed Time","Order","Timezone","Is All Day","Is Floating","Column Name","Column Order","View Mode","taskId","parentId" +"Work","Project Beta","Task with non-numeric priority","TEXT","","A task whose priority column is p1","N","","","","","p1","0","2026-02-15 09:30:00","","-1099511627776","Europe/Berlin",,"false",,,"list","1","" +"Work","Project Beta","Task with non-numeric ids","TEXT","","A task with garbage in the id columns","N","","","","","0","0","2026-02-10 08:00:00","","0","Europe/Berlin","false","false",,,"list","abc","xyz" diff --git a/pkg/modules/migration/ticktick/ticktick.go b/pkg/modules/migration/ticktick/ticktick.go index 48b74938c..d16307735 100644 --- a/pkg/modules/migration/ticktick/ticktick.go +++ b/pkg/modules/migration/ticktick/ticktick.go @@ -23,6 +23,7 @@ import ( "errors" "io" "sort" + "strconv" "strings" "time" @@ -44,32 +45,84 @@ type Migrator struct { } type tickTickTask struct { - FolderName string `csv:"Folder Name"` - ProjectName 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"` + FolderName string `csv:"Folder Name"` + ProjectName 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 tickTickPriority `csv:"Priority"` + Status string `csv:"Status"` + CreatedTime tickTickTime `csv:"Created Time"` + CompletedTime tickTickTime `csv:"Completed Time"` + Order float64 `csv:"Order"` + TaskID tickTickNumber `csv:"taskId"` + ParentID tickTickNumber `csv:"parentId"` } type tickTickTime struct { time.Time } +// tickTickNumber is an int64 that tolerates non-numeric or malformed values in +// the numeric ID columns (taskId, parentId) of TickTick exports. Such values can +// occur through column misalignment caused by unescaped delimiters, which would +// otherwise make gocsv fail the entire import with an internal server error +// (go-vikunja/vikunja#2822). Rather than aborting the import, we fall back to 0 +// for any value we cannot parse. +type tickTickNumber int64 + +func (n *tickTickNumber) UnmarshalCSV(csv string) error { + csv = strings.TrimSpace(csv) + if csv == "" { + *n = 0 + return nil + } + parsed, err := strconv.ParseInt(csv, 10, 64) + if err != nil { + // Deliberately ignore the parse error and fall back to 0 so a single + // malformed value does not abort the whole import. + *n = 0 + return nil //nolint:nilerr + } + *n = tickTickNumber(parsed) + return nil +} + +// tickTickPriority parses the TickTick "Priority" column. TickTick exports the +// priority either as a plain number (0, 1, 3, 5) or, in some exports, prefixed +// with "p" (p1, p2, p3). We accept both forms and fall back to 0 (no priority) +// for anything we cannot parse, so a stray value never fails the whole import +// (go-vikunja/vikunja#2822). Vikunja's task priority is a free-form sortable +// integer, so the parsed value is carried over as-is. +type tickTickPriority int64 + +func (p *tickTickPriority) UnmarshalCSV(csv string) error { + csv = strings.TrimSpace(csv) + csv = strings.TrimPrefix(csv, "p") + csv = strings.TrimPrefix(csv, "P") + if csv == "" { + *p = 0 + return nil + } + parsed, err := strconv.ParseInt(csv, 10, 64) + if err != nil { + // Deliberately ignore the parse error and fall back to 0 so a single + // malformed value does not abort the whole import. + *p = 0 + return nil //nolint:nilerr + } + *p = tickTickPriority(parsed) + return nil +} + func (date *tickTickTime) UnmarshalCSV(csv string) (err error) { date.Time = time.Time{} if csv == "" { @@ -88,12 +141,12 @@ func (date *tickTickTime) UnmarshalCSV(csv string) (err error) { // appears before any of its children. Tasks without a parent come first. // The relative order of siblings / unrelated tasks is preserved. func sortParentsBeforeChildren(tasks []*tickTickTask) []*tickTickTask { - tasksByID := make(map[int64]*tickTickTask, len(tasks)) + tasksByID := make(map[tickTickNumber]*tickTickTask, len(tasks)) for _, t := range tasks { tasksByID[t.TaskID] = t } - placed := make(map[int64]bool, len(tasks)) + placed := make(map[tickTickNumber]bool, len(tasks)) result := make([]*tickTickTask, 0, len(tasks)) var place func(t *tickTickTask) @@ -161,7 +214,7 @@ func convertTickTickToVikunja(tasks []*tickTickTask) (result []*models.ProjectWi task := &models.TaskWithComments{ Task: models.Task{ - ID: t.TaskID, + ID: int64(t.TaskID), Title: t.Title, Description: t.Content, StartDate: t.StartDate.Time, @@ -170,6 +223,7 @@ func convertTickTickToVikunja(tasks []*tickTickTask) (result []*models.ProjectWi Done: t.Status == "1" || t.Status == "2", DoneAt: t.CompletedTime.Time, Position: t.Order, + Priority: int64(t.Priority), Labels: labels, }, } @@ -185,7 +239,7 @@ func convertTickTickToVikunja(tasks []*tickTickTask) (result []*models.ProjectWi if t.ParentID != 0 { task.RelatedTasks = map[models.RelationKind][]*models.Task{ - models.RelationKindParenttask: {{ID: t.ParentID}}, + models.RelationKindParenttask: {{ID: int64(t.ParentID)}}, } } diff --git a/pkg/modules/migration/ticktick/ticktick_test.go b/pkg/modules/migration/ticktick/ticktick_test.go index 47ab622b5..52d35fd24 100644 --- a/pkg/modules/migration/ticktick/ticktick_test.go +++ b/pkg/modules/migration/ticktick/ticktick_test.go @@ -131,7 +131,7 @@ func TestConvertTicktickTasksToVikunja(t *testing.T) { assert.Equal(t, models.RelatedTaskMap{ models.RelationKindParenttask: []*models.Task{ { - ID: tickTickTasks[1].ParentID, + ID: int64(tickTickTasks[1].ParentID), }, }, }, vikunjaTasks[1].Tasks[1].RelatedTasks) @@ -770,3 +770,79 @@ func TestEmptyLabelHandlingWithRealCSV(t *testing.T) { t.Logf("Successfully processed %d tasks with %d total labels, no empty labels created", len(tasks), totalLabels) }) } + +func TestTickTickPriorityParsing(t *testing.T) { + cases := []struct { + input string + expected int64 + }{ + {"", 0}, + {"0", 0}, + {"1", 1}, + {"3", 3}, + {"5", 5}, + {"p1", 1}, + {"P2", 2}, + {"p3", 3}, + {" p5 ", 5}, + {"garbage", 0}, + } + for _, c := range cases { + t.Run(c.input, func(t *testing.T) { + var p tickTickPriority + require.NoError(t, p.UnmarshalCSV(c.input)) + assert.Equal(t, tickTickPriority(c.expected), p) + }) + } +} + +// TestNonNumericNumberColumns ensures that exports containing non-numeric values +// in columns Vikunja parses as integers (Priority, taskId, parentId) do not fail +// the whole import. See go-vikunja/vikunja#2822. +func TestNonNumericNumberColumns(t *testing.T) { + file, err := os.Open("testdata_ticktick_invalid_numbers.csv") + require.NoError(t, err) + defer file.Close() + + stat, err := file.Stat() + require.NoError(t, err) + + lines, err := linesToSkipBeforeHeader(file, stat.Size()) + require.NoError(t, err) + + _, err = file.Seek(0, io.SeekStart) + require.NoError(t, err) + + dec, err := newLineSkipDecoder(file, lines) + require.NoError(t, err) + tasks := []*tickTickTask{} + err = gocsv.UnmarshalDecoder(dec, &tasks) + require.NoError(t, err, "non-numeric values in numeric columns should not fail the import") + require.Len(t, tasks, 2) + + // "p1" in the Priority column is parsed as priority 1 instead of crashing. + assert.Equal(t, tickTickPriority(1), tasks[0].Priority) + assert.Equal(t, tickTickNumber(1), tasks[0].TaskID) + + // Non-numeric taskId/parentId fall back to 0 as well. + assert.Equal(t, tickTickNumber(0), tasks[1].TaskID) + assert.Equal(t, tickTickNumber(0), tasks[1].ParentID) + + // And the tasks still convert to the Vikunja structure, carrying the parsed + // priority over to the resulting task. + for _, task := range tasks { + task.Tags = strings.Split(task.TagsList, ", ") + } + vikunjaTasks := convertTickTickToVikunja(tasks) + require.Greater(t, len(vikunjaTasks), 0) + + var priority int64 + for _, project := range vikunjaTasks { + for _, task := range project.Tasks { + if task.Title == "Task with non-numeric priority" { + priority = task.Priority + } + } + } + assert.Equal(t, int64(1), priority) +}