Skip to content

Commit ebb89ba

Browse files
tink-botkolaente
authored andcommitted
fix(migration): tolerate non-numeric values in TickTick CSV exports
TickTick exports could contain non-numeric values in columns Vikunja parses as integers (Priority, taskId, parentId). gocsv's strconv.ParseInt then failed, aborting the entire import and surfacing as an internal server error reported to Sentry (e.g. parsing "p1": invalid syntax). Numeric ID columns now fall back to 0 for unparseable values instead of failing the import. The Priority column, which was previously parsed but never carried over to the imported task, is now mapped onto the task and accepts both the plain numeric form (0, 1, 3, 5) and the "pN" form (p1, p2, p3). Closes #2822
1 parent e1c9ab5 commit ebb89ba

3 files changed

Lines changed: 164 additions & 25 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
"Date: 2026-02-27+0000"
2+
"Version: 7.1"
3+
"Status:
4+
0 Normal
5+
1 Completed
6+
2 Archived"
7+
"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"
8+
"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",""
9+
"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"

pkg/modules/migration/ticktick/ticktick.go

Lines changed: 78 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"errors"
2424
"io"
2525
"sort"
26+
"strconv"
2627
"strings"
2728
"time"
2829

@@ -44,32 +45,84 @@ type Migrator struct {
4445
}
4546

4647
type tickTickTask struct {
47-
FolderName string `csv:"Folder Name"`
48-
ProjectName string `csv:"List Name"`
49-
Title string `csv:"Title"`
50-
TagsList string `csv:"Tags"`
51-
Tags []string `csv:"-"`
52-
Content string `csv:"Content"`
53-
IsChecklistString string `csv:"Is Check list"`
54-
IsChecklist bool `csv:"-"`
55-
StartDate tickTickTime `csv:"Start Date"`
56-
DueDate tickTickTime `csv:"Due Date"`
57-
ReminderDuration string `csv:"Reminder"`
58-
Reminder time.Duration `csv:"-"`
59-
Repeat string `csv:"Repeat"`
60-
Priority int `csv:"Priority"`
61-
Status string `csv:"Status"`
62-
CreatedTime tickTickTime `csv:"Created Time"`
63-
CompletedTime tickTickTime `csv:"Completed Time"`
64-
Order float64 `csv:"Order"`
65-
TaskID int64 `csv:"taskId"`
66-
ParentID int64 `csv:"parentId"`
48+
FolderName string `csv:"Folder Name"`
49+
ProjectName string `csv:"List Name"`
50+
Title string `csv:"Title"`
51+
TagsList string `csv:"Tags"`
52+
Tags []string `csv:"-"`
53+
Content string `csv:"Content"`
54+
IsChecklistString string `csv:"Is Check list"`
55+
IsChecklist bool `csv:"-"`
56+
StartDate tickTickTime `csv:"Start Date"`
57+
DueDate tickTickTime `csv:"Due Date"`
58+
ReminderDuration string `csv:"Reminder"`
59+
Reminder time.Duration `csv:"-"`
60+
Repeat string `csv:"Repeat"`
61+
Priority tickTickPriority `csv:"Priority"`
62+
Status string `csv:"Status"`
63+
CreatedTime tickTickTime `csv:"Created Time"`
64+
CompletedTime tickTickTime `csv:"Completed Time"`
65+
Order float64 `csv:"Order"`
66+
TaskID tickTickNumber `csv:"taskId"`
67+
ParentID tickTickNumber `csv:"parentId"`
6768
}
6869

6970
type tickTickTime struct {
7071
time.Time
7172
}
7273

74+
// tickTickNumber is an int64 that tolerates non-numeric or malformed values in
75+
// the numeric ID columns (taskId, parentId) of TickTick exports. Such values can
76+
// occur through column misalignment caused by unescaped delimiters, which would
77+
// otherwise make gocsv fail the entire import with an internal server error
78+
// (go-vikunja/vikunja#2822). Rather than aborting the import, we fall back to 0
79+
// for any value we cannot parse.
80+
type tickTickNumber int64
81+
82+
func (n *tickTickNumber) UnmarshalCSV(csv string) error {
83+
csv = strings.TrimSpace(csv)
84+
if csv == "" {
85+
*n = 0
86+
return nil
87+
}
88+
parsed, err := strconv.ParseInt(csv, 10, 64)
89+
if err != nil {
90+
// Deliberately ignore the parse error and fall back to 0 so a single
91+
// malformed value does not abort the whole import.
92+
*n = 0
93+
return nil //nolint:nilerr
94+
}
95+
*n = tickTickNumber(parsed)
96+
return nil
97+
}
98+
99+
// tickTickPriority parses the TickTick "Priority" column. TickTick exports the
100+
// priority either as a plain number (0, 1, 3, 5) or, in some exports, prefixed
101+
// with "p" (p1, p2, p3). We accept both forms and fall back to 0 (no priority)
102+
// for anything we cannot parse, so a stray value never fails the whole import
103+
// (go-vikunja/vikunja#2822). Vikunja's task priority is a free-form sortable
104+
// integer, so the parsed value is carried over as-is.
105+
type tickTickPriority int64
106+
107+
func (p *tickTickPriority) UnmarshalCSV(csv string) error {
108+
csv = strings.TrimSpace(csv)
109+
csv = strings.TrimPrefix(csv, "p")
110+
csv = strings.TrimPrefix(csv, "P")
111+
if csv == "" {
112+
*p = 0
113+
return nil
114+
}
115+
parsed, err := strconv.ParseInt(csv, 10, 64)
116+
if err != nil {
117+
// Deliberately ignore the parse error and fall back to 0 so a single
118+
// malformed value does not abort the whole import.
119+
*p = 0
120+
return nil //nolint:nilerr
121+
}
122+
*p = tickTickPriority(parsed)
123+
return nil
124+
}
125+
73126
func (date *tickTickTime) UnmarshalCSV(csv string) (err error) {
74127
date.Time = time.Time{}
75128
if csv == "" {
@@ -88,12 +141,12 @@ func (date *tickTickTime) UnmarshalCSV(csv string) (err error) {
88141
// appears before any of its children. Tasks without a parent come first.
89142
// The relative order of siblings / unrelated tasks is preserved.
90143
func sortParentsBeforeChildren(tasks []*tickTickTask) []*tickTickTask {
91-
tasksByID := make(map[int64]*tickTickTask, len(tasks))
144+
tasksByID := make(map[tickTickNumber]*tickTickTask, len(tasks))
92145
for _, t := range tasks {
93146
tasksByID[t.TaskID] = t
94147
}
95148

96-
placed := make(map[int64]bool, len(tasks))
149+
placed := make(map[tickTickNumber]bool, len(tasks))
97150
result := make([]*tickTickTask, 0, len(tasks))
98151

99152
var place func(t *tickTickTask)
@@ -161,7 +214,7 @@ func convertTickTickToVikunja(tasks []*tickTickTask) (result []*models.ProjectWi
161214

162215
task := &models.TaskWithComments{
163216
Task: models.Task{
164-
ID: t.TaskID,
217+
ID: int64(t.TaskID),
165218
Title: t.Title,
166219
Description: t.Content,
167220
StartDate: t.StartDate.Time,
@@ -170,6 +223,7 @@ func convertTickTickToVikunja(tasks []*tickTickTask) (result []*models.ProjectWi
170223
Done: t.Status == "1" || t.Status == "2",
171224
DoneAt: t.CompletedTime.Time,
172225
Position: t.Order,
226+
Priority: int64(t.Priority),
173227
Labels: labels,
174228
},
175229
}
@@ -185,7 +239,7 @@ func convertTickTickToVikunja(tasks []*tickTickTask) (result []*models.ProjectWi
185239

186240
if t.ParentID != 0 {
187241
task.RelatedTasks = map[models.RelationKind][]*models.Task{
188-
models.RelationKindParenttask: {{ID: t.ParentID}},
242+
models.RelationKindParenttask: {{ID: int64(t.ParentID)}},
189243
}
190244
}
191245

pkg/modules/migration/ticktick/ticktick_test.go

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,7 +131,7 @@ func TestConvertTicktickTasksToVikunja(t *testing.T) {
131131
assert.Equal(t, models.RelatedTaskMap{
132132
models.RelationKindParenttask: []*models.Task{
133133
{
134-
ID: tickTickTasks[1].ParentID,
134+
ID: int64(tickTickTasks[1].ParentID),
135135
},
136136
},
137137
}, vikunjaTasks[1].Tasks[1].RelatedTasks)
@@ -770,3 +770,79 @@ func TestEmptyLabelHandlingWithRealCSV(t *testing.T) {
770770
t.Logf("Successfully processed %d tasks with %d total labels, no empty labels created", len(tasks), totalLabels)
771771
})
772772
}
773+
774+
func TestTickTickPriorityParsing(t *testing.T) {
775+
cases := []struct {
776+
input string
777+
expected int64
778+
}{
779+
{"", 0},
780+
{"0", 0},
781+
{"1", 1},
782+
{"3", 3},
783+
{"5", 5},
784+
{"p1", 1},
785+
{"P2", 2},
786+
{"p3", 3},
787+
{" p5 ", 5},
788+
{"garbage", 0},
789+
}
790+
for _, c := range cases {
791+
t.Run(c.input, func(t *testing.T) {
792+
var p tickTickPriority
793+
require.NoError(t, p.UnmarshalCSV(c.input))
794+
assert.Equal(t, tickTickPriority(c.expected), p)
795+
})
796+
}
797+
}
798+
799+
// TestNonNumericNumberColumns ensures that exports containing non-numeric values
800+
// in columns Vikunja parses as integers (Priority, taskId, parentId) do not fail
801+
// the whole import. See go-vikunja/vikunja#2822.
802+
func TestNonNumericNumberColumns(t *testing.T) {
803+
file, err := os.Open("testdata_ticktick_invalid_numbers.csv")
804+
require.NoError(t, err)
805+
defer file.Close()
806+
807+
stat, err := file.Stat()
808+
require.NoError(t, err)
809+
810+
lines, err := linesToSkipBeforeHeader(file, stat.Size())
811+
require.NoError(t, err)
812+
813+
_, err = file.Seek(0, io.SeekStart)
814+
require.NoError(t, err)
815+
816+
dec, err := newLineSkipDecoder(file, lines)
817+
require.NoError(t, err)
818+
tasks := []*tickTickTask{}
819+
err = gocsv.UnmarshalDecoder(dec, &tasks)
820+
require.NoError(t, err, "non-numeric values in numeric columns should not fail the import")
821+
require.Len(t, tasks, 2)
822+
823+
// "p1" in the Priority column is parsed as priority 1 instead of crashing.
824+
assert.Equal(t, tickTickPriority(1), tasks[0].Priority)
825+
assert.Equal(t, tickTickNumber(1), tasks[0].TaskID)
826+
827+
// Non-numeric taskId/parentId fall back to 0 as well.
828+
assert.Equal(t, tickTickNumber(0), tasks[1].TaskID)
829+
assert.Equal(t, tickTickNumber(0), tasks[1].ParentID)
830+
831+
// And the tasks still convert to the Vikunja structure, carrying the parsed
832+
// priority over to the resulting task.
833+
for _, task := range tasks {
834+
task.Tags = strings.Split(task.TagsList, ", ")
835+
}
836+
vikunjaTasks := convertTickTickToVikunja(tasks)
837+
require.Greater(t, len(vikunjaTasks), 0)
838+
839+
var priority int64
840+
for _, project := range vikunjaTasks {
841+
for _, task := range project.Tasks {
842+
if task.Title == "Task with non-numeric priority" {
843+
priority = task.Priority
844+
}
845+
}
846+
}
847+
assert.Equal(t, int64(1), priority)
848+
}

0 commit comments

Comments
 (0)