fix(migration): support space-separated date format in TickTick importer

Fixes https://github.com/go-vikunja/vikunja/issues/2324
This commit is contained in:
kolaente 2026-03-02 08:32:46 +01:00
parent 15aa773212
commit a7e4a4f4af
No known key found for this signature in database
GPG Key ID: F40E70337AB24C9B
3 changed files with 111 additions and 2 deletions

View File

@ -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 space dates","TEXT","","A task exported with space-separated dates","N","2026-02-20 10:00:00","2026-02-27 14:59:52","","","0","0","2026-02-15 09:30:00","","-1099511627776","Europe/Berlin",,"false",,,"list","1",""
"Work","Project Beta","Completed task with space dates","TEXT","","This task was completed","N","","","","","0","1","2026-02-10 08:00:00","2026-02-25 16:45:30","0","Europe/Berlin","false","false",,,"list","2",""
Can't render this file because it has a wrong number of fields in line 7.

View File

@ -34,7 +34,11 @@ import (
"github.com/gocarina/gocsv"
)
const timeISO = "2006-01-02T15:04:05-0700"
var timeFormats = []string{
"2006-01-02T15:04:05-0700",
"2006-01-02 15:04:05",
"2006-01-02T15:04:05Z",
}
type Migrator struct {
}
@ -71,7 +75,12 @@ func (date *tickTickTime) UnmarshalCSV(csv string) (err error) {
if csv == "" {
return nil
}
date.Time, err = time.Parse(timeISO, csv)
for _, format := range timeFormats {
date.Time, err = time.Parse(format, csv)
if err == nil {
return nil
}
}
return err
}

View File

@ -459,6 +459,97 @@ func assertLabelsMatch(t *testing.T, vikunjaTask *models.TaskWithComments, expec
}
}
func TestUnmarshalCSVTimeFormats(t *testing.T) {
tests := []struct {
name string
input string
expected time.Time
}{
{
name: "ISO format with timezone offset",
input: "2022-10-09T15:09:48+0000",
expected: time.Date(2022, 10, 9, 15, 9, 48, 0, time.UTC),
},
{
name: "Space-separated without timezone",
input: "2026-02-27 14:59:52",
expected: time.Date(2026, 2, 27, 14, 59, 52, 0, time.UTC),
},
{
name: "ISO format with Z suffix",
input: "2022-10-09T15:09:48Z",
expected: time.Date(2022, 10, 9, 15, 9, 48, 0, time.UTC),
},
{
name: "ISO format with positive offset",
input: "2018-12-11T23:00:00+0100",
expected: time.Date(2018, 12, 11, 23, 0, 0, 0, time.FixedZone("", 3600)),
},
{
name: "empty string",
input: "",
expected: time.Time{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var ttt tickTickTime
err := ttt.UnmarshalCSV(tt.input)
require.NoError(t, err)
assert.True(t, tt.expected.Equal(ttt.Time), "expected %v, got %v", tt.expected, ttt.Time)
})
}
t.Run("invalid format returns error", func(t *testing.T) {
var ttt tickTickTime
err := ttt.UnmarshalCSV("not-a-date")
require.Error(t, err)
})
}
func TestSpaceSeparatedDatesCSV(t *testing.T) {
file, err := os.Open("testdata_ticktick_space_dates.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)
assert.Equal(t, 6, lines)
_, 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)
require.Len(t, tasks, 2)
// First task: has start date, due date, and created time in space-separated format
assert.Equal(t, "Task with space dates", tasks[0].Title)
assert.Equal(t, time.Date(2026, 2, 20, 10, 0, 0, 0, time.UTC), tasks[0].StartDate.Time)
assert.Equal(t, time.Date(2026, 2, 27, 14, 59, 52, 0, time.UTC), tasks[0].DueDate.Time)
assert.Equal(t, time.Date(2026, 2, 15, 9, 30, 0, 0, time.UTC), tasks[0].CreatedTime.Time)
assert.True(t, tasks[0].CompletedTime.IsZero())
// Second task: completed, has created time and completed time
assert.Equal(t, "Completed task with space dates", tasks[1].Title)
assert.Equal(t, time.Date(2026, 2, 10, 8, 0, 0, 0, time.UTC), tasks[1].CreatedTime.Time)
assert.Equal(t, time.Date(2026, 2, 25, 16, 45, 30, 0, time.UTC), tasks[1].CompletedTime.Time)
// Verify the tasks convert to Vikunja format without error
for _, task := range tasks {
task.Tags = strings.Split(task.TagsList, ", ")
}
vikunjaTasks := convertTickTickToVikunja(tasks)
require.Greater(t, len(vikunjaTasks), 0)
}
func TestMultilineDescriptions(t *testing.T) {
// Test with a CSV fixture that contains actual multiline content in quoted fields
file, err := os.Open("testdata_ticktick_multiline.csv")