mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-07-16 14:52:35 -05:00
Collapsing unparseable taskIds to 0 meant sortParentsBeforeChildren, which tracked placement by TaskID, treated every zero-id task after the first as already placed and silently dropped it. Track placement by task identity instead so duplicate or zero ids never conflate distinct tasks.
872 lines
28 KiB
Go
872 lines
28 KiB
Go
// Vikunja is a to-do list application to facilitate your life.
|
|
// Copyright 2018-present 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 License 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 License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
package ticktick
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"encoding/csv"
|
|
"io"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"code.vikunja.io/api/pkg/models"
|
|
"github.com/gocarina/gocsv"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestConvertTicktickTasksToVikunja(t *testing.T) {
|
|
t1, err := time.Parse(time.RFC3339Nano, "2022-11-18T03:00:00.4770000Z")
|
|
require.NoError(t, err)
|
|
time1 := tickTickTime{Time: t1}
|
|
t2, err := time.Parse(time.RFC3339Nano, "2022-12-18T03:00:00.4770000Z")
|
|
require.NoError(t, err)
|
|
time2 := tickTickTime{Time: t2}
|
|
t3, err := time.Parse(time.RFC3339Nano, "2022-12-10T03:00:00.4770000Z")
|
|
require.NoError(t, err)
|
|
time3 := tickTickTime{Time: t3}
|
|
duration, err := time.ParseDuration("24h")
|
|
require.NoError(t, err)
|
|
|
|
tickTickTasks := []*tickTickTask{
|
|
{
|
|
TaskID: 1,
|
|
ParentID: 0,
|
|
ProjectName: "Project 1",
|
|
Title: "Test task 1",
|
|
Tags: []string{"label1", "label2"},
|
|
Content: "Lorem Ipsum Dolor sit amet",
|
|
StartDate: time1,
|
|
DueDate: time2,
|
|
Reminder: duration,
|
|
Repeat: "FREQ=WEEKLY;INTERVAL=1;UNTIL=20190117T210000Z",
|
|
Status: "0",
|
|
Order: -1099511627776,
|
|
},
|
|
{
|
|
TaskID: 2,
|
|
ParentID: 1,
|
|
ProjectName: "Project 1",
|
|
Title: "Test task 2",
|
|
Status: "1",
|
|
CompletedTime: time3,
|
|
Order: -1099511626,
|
|
},
|
|
{
|
|
TaskID: 3,
|
|
ParentID: 0,
|
|
ProjectName: "Project 1",
|
|
Title: "Test task 3",
|
|
Tags: []string{"label1", "label2", "other label"},
|
|
StartDate: time1,
|
|
DueDate: time2,
|
|
Reminder: duration,
|
|
Status: "0",
|
|
Order: -109951627776,
|
|
},
|
|
{
|
|
TaskID: 4,
|
|
ParentID: 0,
|
|
ProjectName: "Project 1",
|
|
Title: "Test task 4 - archived",
|
|
Status: "2",
|
|
CompletedTime: time3,
|
|
Order: -109951627777,
|
|
},
|
|
{
|
|
TaskID: 5,
|
|
ParentID: 0,
|
|
ProjectName: "Project 2",
|
|
Title: "Test task 5",
|
|
Status: "0",
|
|
Order: -109951627778,
|
|
},
|
|
}
|
|
|
|
vikunjaTasks := convertTickTickToVikunja(tickTickTasks)
|
|
|
|
assert.Len(t, vikunjaTasks, 3)
|
|
|
|
assert.Equal(t, vikunjaTasks[1].ParentProjectID, vikunjaTasks[0].ID)
|
|
assert.Equal(t, vikunjaTasks[2].ParentProjectID, vikunjaTasks[0].ID)
|
|
|
|
assert.Len(t, vikunjaTasks[1].Tasks, 4)
|
|
assert.Equal(t, vikunjaTasks[1].Title, tickTickTasks[0].ProjectName)
|
|
|
|
assert.Equal(t, vikunjaTasks[1].Tasks[0].Title, tickTickTasks[0].Title)
|
|
assert.Equal(t, vikunjaTasks[1].Tasks[0].Description, tickTickTasks[0].Content)
|
|
assert.Equal(t, vikunjaTasks[1].Tasks[0].StartDate, tickTickTasks[0].StartDate.Time)
|
|
assert.Equal(t, vikunjaTasks[1].Tasks[0].EndDate, tickTickTasks[0].DueDate.Time)
|
|
assert.Equal(t, vikunjaTasks[1].Tasks[0].DueDate, tickTickTasks[0].DueDate.Time)
|
|
assert.Equal(t, []*models.Label{
|
|
{Title: "label1"},
|
|
{Title: "label2"},
|
|
}, vikunjaTasks[1].Tasks[0].Labels)
|
|
assert.Equal(t, vikunjaTasks[1].Tasks[0].Reminders[0].RelativeTo, models.ReminderRelation("due_date"))
|
|
assert.Equal(t, vikunjaTasks[1].Tasks[0].Reminders[0].RelativePeriod, int64(-24*3600))
|
|
assert.Equal(t, vikunjaTasks[1].Tasks[0].Position, tickTickTasks[0].Order)
|
|
assert.False(t, vikunjaTasks[1].Tasks[0].Done)
|
|
|
|
assert.Equal(t, vikunjaTasks[1].Tasks[1].Title, tickTickTasks[1].Title)
|
|
assert.Equal(t, vikunjaTasks[1].Tasks[1].Position, tickTickTasks[1].Order)
|
|
assert.True(t, vikunjaTasks[1].Tasks[1].Done)
|
|
assert.Equal(t, vikunjaTasks[1].Tasks[1].DoneAt, tickTickTasks[1].CompletedTime.Time)
|
|
assert.Equal(t, models.RelatedTaskMap{
|
|
models.RelationKindParenttask: []*models.Task{
|
|
{
|
|
ID: int64(tickTickTasks[1].ParentID),
|
|
},
|
|
},
|
|
}, vikunjaTasks[1].Tasks[1].RelatedTasks)
|
|
|
|
assert.Equal(t, vikunjaTasks[1].Tasks[2].Title, tickTickTasks[2].Title)
|
|
assert.Equal(t, vikunjaTasks[1].Tasks[2].Description, tickTickTasks[2].Content)
|
|
assert.Equal(t, vikunjaTasks[1].Tasks[2].StartDate, tickTickTasks[2].StartDate.Time)
|
|
assert.Equal(t, vikunjaTasks[1].Tasks[2].EndDate, tickTickTasks[2].DueDate.Time)
|
|
assert.Equal(t, vikunjaTasks[1].Tasks[2].DueDate, tickTickTasks[2].DueDate.Time)
|
|
assert.Equal(t, []*models.Label{
|
|
{Title: "label1"},
|
|
{Title: "label2"},
|
|
{Title: "other label"},
|
|
}, vikunjaTasks[1].Tasks[2].Labels)
|
|
assert.Equal(t, vikunjaTasks[1].Tasks[2].Reminders[0].RelativeTo, models.ReminderRelation("due_date"))
|
|
assert.Equal(t, vikunjaTasks[1].Tasks[2].Reminders[0].RelativePeriod, int64(-24*3600))
|
|
assert.Equal(t, vikunjaTasks[1].Tasks[2].Position, tickTickTasks[2].Order)
|
|
assert.False(t, vikunjaTasks[1].Tasks[2].Done)
|
|
|
|
assert.Equal(t, vikunjaTasks[1].Tasks[3].Title, tickTickTasks[3].Title)
|
|
assert.Equal(t, vikunjaTasks[1].Tasks[3].Position, tickTickTasks[3].Order)
|
|
assert.True(t, vikunjaTasks[1].Tasks[3].Done)
|
|
assert.Equal(t, vikunjaTasks[1].Tasks[3].DoneAt, tickTickTasks[3].CompletedTime.Time)
|
|
|
|
assert.Len(t, vikunjaTasks[2].Tasks, 1)
|
|
assert.Equal(t, vikunjaTasks[2].Title, tickTickTasks[4].ProjectName)
|
|
|
|
assert.Equal(t, vikunjaTasks[2].Tasks[0].Title, tickTickTasks[4].Title)
|
|
assert.Equal(t, vikunjaTasks[2].Tasks[0].Position, tickTickTasks[4].Order)
|
|
}
|
|
|
|
func TestConvertTicktickTasksChildBeforeParent(t *testing.T) {
|
|
// Child appears BEFORE parent in the input — this is the order that
|
|
// causes the import to fail in create_from_structure.go because the
|
|
// placeholder for the not-yet-created parent has no title.
|
|
tickTickTasks := []*tickTickTask{
|
|
{
|
|
TaskID: 2,
|
|
ParentID: 1,
|
|
ProjectName: "Project 1",
|
|
Title: "Child task",
|
|
Status: "0",
|
|
Order: -1099511626,
|
|
},
|
|
{
|
|
TaskID: 1,
|
|
ParentID: 0,
|
|
ProjectName: "Project 1",
|
|
Title: "Parent task",
|
|
Status: "0",
|
|
Order: -1099511627776,
|
|
},
|
|
}
|
|
|
|
vikunjaTasks := convertTickTickToVikunja(tickTickTasks)
|
|
|
|
// Find the project with tasks
|
|
var projectTasks []*models.TaskWithComments
|
|
for _, p := range vikunjaTasks {
|
|
if len(p.Tasks) > 0 {
|
|
projectTasks = p.Tasks
|
|
break
|
|
}
|
|
}
|
|
|
|
require.Len(t, projectTasks, 2)
|
|
|
|
// The parent (TaskID=1) must come before the child (TaskID=2) in the
|
|
// output so that create_from_structure.go processes it first.
|
|
assert.Equal(t, "Parent task", projectTasks[0].Title)
|
|
assert.Equal(t, "Child task", projectTasks[1].Title)
|
|
|
|
// The child still has the correct parent relation
|
|
assert.Equal(t, models.RelatedTaskMap{
|
|
models.RelationKindParenttask: []*models.Task{
|
|
{ID: 1},
|
|
},
|
|
}, projectTasks[1].RelatedTasks)
|
|
}
|
|
|
|
func TestConvertTicktickTasksDeeplyNested(t *testing.T) {
|
|
// Grandchild -> child -> parent, all in reverse order
|
|
tickTickTasks := []*tickTickTask{
|
|
{
|
|
TaskID: 3,
|
|
ParentID: 2,
|
|
ProjectName: "Project 1",
|
|
Title: "Grandchild",
|
|
Status: "0",
|
|
},
|
|
{
|
|
TaskID: 2,
|
|
ParentID: 1,
|
|
ProjectName: "Project 1",
|
|
Title: "Child",
|
|
Status: "0",
|
|
},
|
|
{
|
|
TaskID: 1,
|
|
ParentID: 0,
|
|
ProjectName: "Project 1",
|
|
Title: "Root",
|
|
Status: "0",
|
|
},
|
|
}
|
|
|
|
vikunjaTasks := convertTickTickToVikunja(tickTickTasks)
|
|
|
|
var projectTasks []*models.TaskWithComments
|
|
for _, p := range vikunjaTasks {
|
|
if len(p.Tasks) > 0 {
|
|
projectTasks = p.Tasks
|
|
break
|
|
}
|
|
}
|
|
|
|
require.Len(t, projectTasks, 3)
|
|
assert.Equal(t, "Root", projectTasks[0].Title)
|
|
assert.Equal(t, "Child", projectTasks[1].Title)
|
|
assert.Equal(t, "Grandchild", projectTasks[2].Title)
|
|
}
|
|
|
|
func TestLinesToSkipBeforeHeader(t *testing.T) {
|
|
csvContent := "Date: 2024-01-01+0000\nVersion: 7.1\n" +
|
|
"\"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\"\n" +
|
|
",\"list\",\"task1\",\"TEXT\",\"\",\"\",\"N\",\"\",\"\",\"\",\"\",\"0\",\"0\",\"2022-10-09T15:09:48+0000\",\"\",\"-1099511627776\",\"\",\"true\",\"false\",,,\"list\",\"1\",\"\"\n"
|
|
|
|
r := bytes.NewReader([]byte(csvContent))
|
|
lines, err := linesToSkipBeforeHeader(r, int64(len(csvContent)))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 2, lines)
|
|
|
|
r2 := bytes.NewReader([]byte(csvContent))
|
|
dec, err := newLineSkipDecoder(r2, lines)
|
|
require.NoError(t, err)
|
|
tasks := []*tickTickTask{}
|
|
err = gocsv.UnmarshalDecoder(dec, &tasks)
|
|
require.NoError(t, err)
|
|
require.Len(t, tasks, 1)
|
|
assert.Equal(t, "task1", tasks[0].Title)
|
|
}
|
|
|
|
func TestLinesToSkipBeforeHeaderWithRealCSV(t *testing.T) {
|
|
// This is the actual format from a real TickTick export with BOM and multi-line status
|
|
csvContent := "\uFEFF\"Date: 2025-11-25+0000\"\n" +
|
|
"\"Version: 7.1\"\n" +
|
|
"\"Status: \n" +
|
|
"0 Normal\n" +
|
|
"1 Completed\n" +
|
|
"2 Archived\"\n" +
|
|
"\"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\"\n" +
|
|
"\"dsx\",\"x\",\"this task repeats\",\"TEXT\",\"\",\"\",\"N\",\"\",\"\",\"\",\"\",\"0\",\"0\",\"2022-10-09T15:09:48+0000\",\"\",\"-1099511627776\",\"Europe/Berlin\",,\"false\",,,\"list\",\"2\",\"\"\n"
|
|
|
|
t.Logf("CSV content length: %d", len(csvContent))
|
|
t.Logf("CSV content first 100 chars: %q", csvContent[:100])
|
|
|
|
r := bytes.NewReader([]byte(csvContent))
|
|
lines, err := linesToSkipBeforeHeader(r, int64(len(csvContent)))
|
|
require.NoError(t, err)
|
|
t.Logf("Lines to skip: %d", lines)
|
|
assert.Equal(t, 6, lines) // Should skip 6 lines to get to the header
|
|
|
|
r2 := bytes.NewReader([]byte(csvContent))
|
|
dec, err := newLineSkipDecoder(r2, lines)
|
|
require.NoError(t, err)
|
|
tasks := []*tickTickTask{}
|
|
err = gocsv.UnmarshalDecoder(dec, &tasks)
|
|
require.NoError(t, err)
|
|
require.Len(t, tasks, 1)
|
|
assert.Equal(t, "this task repeats", tasks[0].Title)
|
|
assert.Equal(t, "dsx", tasks[0].FolderName)
|
|
assert.Equal(t, "x", tasks[0].ProjectName)
|
|
}
|
|
|
|
func TestLinesToSkipBeforeHeaderWithCleanTestFile(t *testing.T) {
|
|
// Test with the cleaned-up test CSV file
|
|
file, err := os.Open("testdata_ticktick_export.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)
|
|
t.Logf("Lines to skip in test file: %d", lines)
|
|
assert.Equal(t, 6, lines) // Should skip 6 lines to get to the header
|
|
|
|
// Reset file position
|
|
_, err = file.Seek(0, io.SeekStart)
|
|
require.NoError(t, err)
|
|
|
|
// Let's manually check what the header line looks like after skipping
|
|
r := stripBOM(file)
|
|
scanner := bufio.NewScanner(r)
|
|
for i := 0; i <= lines; i++ {
|
|
if !scanner.Scan() {
|
|
break
|
|
}
|
|
if i == lines {
|
|
t.Logf("Header line after skipping %d lines: %q", lines, scanner.Text())
|
|
}
|
|
}
|
|
|
|
// Reset file position again
|
|
_, 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.Greater(t, len(tasks), 0)
|
|
|
|
// Verify that the first task has actual data
|
|
assert.Equal(t, "Work", tasks[0].FolderName)
|
|
assert.Equal(t, "Project Alpha", tasks[0].ProjectName)
|
|
assert.Equal(t, "Task with repeating schedule", tasks[0].Title)
|
|
}
|
|
|
|
func TestBOMStripping(t *testing.T) {
|
|
// Test BOM stripping specifically
|
|
csvWithBOM := "\uFEFF\"Folder Name\",\"List Name\",\"Title\"\n\"test\",\"list\",\"task\"\n"
|
|
|
|
r := stripBOM(bytes.NewReader([]byte(csvWithBOM)))
|
|
scanner := bufio.NewScanner(r)
|
|
|
|
// Read first line (header)
|
|
require.True(t, scanner.Scan())
|
|
header := scanner.Text()
|
|
t.Logf("Header after BOM stripping: %q", header)
|
|
|
|
// Read second line (data)
|
|
require.True(t, scanner.Scan())
|
|
data := scanner.Text()
|
|
t.Logf("Data line: %q", data)
|
|
|
|
// Test CSV parsing
|
|
r2 := stripBOM(bytes.NewReader([]byte(csvWithBOM)))
|
|
reader := csv.NewReader(r2)
|
|
records, err := reader.ReadAll()
|
|
require.NoError(t, err)
|
|
require.Len(t, records, 2)
|
|
t.Logf("CSV records: %+v", records)
|
|
}
|
|
|
|
func TestEmptyLabelHandling(t *testing.T) {
|
|
t.Run("Normal tags", func(t *testing.T) {
|
|
task := &tickTickTask{
|
|
Title: "Test Task",
|
|
ProjectName: "Test Project",
|
|
TagsList: "work, personal, urgent",
|
|
}
|
|
task.Tags = strings.Split(task.TagsList, ", ")
|
|
|
|
vikunjaTasks := convertTickTickToVikunja([]*tickTickTask{task})
|
|
projectWithTasks := findProjectWithTasks(t, vikunjaTasks)
|
|
vikunjaTask := projectWithTasks.Tasks[0]
|
|
|
|
expectedTags := []string{"work", "personal", "urgent"}
|
|
assertLabelsMatch(t, vikunjaTask, expectedTags)
|
|
})
|
|
|
|
t.Run("Tags with extra spaces", func(t *testing.T) {
|
|
task := &tickTickTask{
|
|
Title: "Test Task",
|
|
ProjectName: "Test Project",
|
|
TagsList: "work, personal , urgent",
|
|
}
|
|
task.Tags = strings.Split(task.TagsList, ", ")
|
|
|
|
vikunjaTasks := convertTickTickToVikunja([]*tickTickTask{task})
|
|
projectWithTasks := findProjectWithTasks(t, vikunjaTasks)
|
|
vikunjaTask := projectWithTasks.Tasks[0]
|
|
|
|
expectedTags := []string{"work", "personal", "urgent"}
|
|
assertLabelsMatch(t, vikunjaTask, expectedTags)
|
|
})
|
|
|
|
t.Run("Empty tags mixed with valid ones", func(t *testing.T) {
|
|
task := &tickTickTask{
|
|
Title: "Test Task",
|
|
ProjectName: "Test Project",
|
|
TagsList: "work, , urgent, ",
|
|
}
|
|
task.Tags = strings.Split(task.TagsList, ", ")
|
|
|
|
vikunjaTasks := convertTickTickToVikunja([]*tickTickTask{task})
|
|
projectWithTasks := findProjectWithTasks(t, vikunjaTasks)
|
|
vikunjaTask := projectWithTasks.Tasks[0]
|
|
|
|
expectedTags := []string{"work", "urgent"}
|
|
assertLabelsMatch(t, vikunjaTask, expectedTags)
|
|
})
|
|
|
|
t.Run("Only whitespace tags", func(t *testing.T) {
|
|
task := &tickTickTask{
|
|
Title: "Test Task",
|
|
ProjectName: "Test Project",
|
|
TagsList: " , , ",
|
|
}
|
|
task.Tags = strings.Split(task.TagsList, ", ")
|
|
|
|
vikunjaTasks := convertTickTickToVikunja([]*tickTickTask{task})
|
|
projectWithTasks := findProjectWithTasks(t, vikunjaTasks)
|
|
vikunjaTask := projectWithTasks.Tasks[0]
|
|
|
|
expectedTags := []string{}
|
|
assertLabelsMatch(t, vikunjaTask, expectedTags)
|
|
})
|
|
|
|
t.Run("Empty string", func(t *testing.T) {
|
|
task := &tickTickTask{
|
|
Title: "Test Task",
|
|
ProjectName: "Test Project",
|
|
TagsList: "",
|
|
}
|
|
task.Tags = strings.Split(task.TagsList, ", ")
|
|
|
|
vikunjaTasks := convertTickTickToVikunja([]*tickTickTask{task})
|
|
projectWithTasks := findProjectWithTasks(t, vikunjaTasks)
|
|
vikunjaTask := projectWithTasks.Tasks[0]
|
|
|
|
expectedTags := []string{}
|
|
assertLabelsMatch(t, vikunjaTask, expectedTags)
|
|
})
|
|
|
|
t.Run("Single valid tag", func(t *testing.T) {
|
|
task := &tickTickTask{
|
|
Title: "Test Task",
|
|
ProjectName: "Test Project",
|
|
TagsList: "important",
|
|
}
|
|
task.Tags = strings.Split(task.TagsList, ", ")
|
|
|
|
vikunjaTasks := convertTickTickToVikunja([]*tickTickTask{task})
|
|
projectWithTasks := findProjectWithTasks(t, vikunjaTasks)
|
|
vikunjaTask := projectWithTasks.Tasks[0]
|
|
|
|
expectedTags := []string{"important"}
|
|
assertLabelsMatch(t, vikunjaTask, expectedTags)
|
|
})
|
|
|
|
t.Run("Single empty tag", func(t *testing.T) {
|
|
task := &tickTickTask{
|
|
Title: "Test Task",
|
|
ProjectName: "Test Project",
|
|
TagsList: " ",
|
|
}
|
|
task.Tags = strings.Split(task.TagsList, ", ")
|
|
|
|
vikunjaTasks := convertTickTickToVikunja([]*tickTickTask{task})
|
|
projectWithTasks := findProjectWithTasks(t, vikunjaTasks)
|
|
vikunjaTask := projectWithTasks.Tasks[0]
|
|
|
|
expectedTags := []string{}
|
|
assertLabelsMatch(t, vikunjaTask, expectedTags)
|
|
})
|
|
|
|
t.Run("Tags with leading/trailing spaces", func(t *testing.T) {
|
|
task := &tickTickTask{
|
|
Title: "Test Task",
|
|
ProjectName: "Test Project",
|
|
TagsList: " work , personal, urgent ",
|
|
}
|
|
task.Tags = strings.Split(task.TagsList, ", ")
|
|
|
|
vikunjaTasks := convertTickTickToVikunja([]*tickTickTask{task})
|
|
projectWithTasks := findProjectWithTasks(t, vikunjaTasks)
|
|
vikunjaTask := projectWithTasks.Tasks[0]
|
|
|
|
expectedTags := []string{"work", "personal", "urgent"}
|
|
assertLabelsMatch(t, vikunjaTask, expectedTags)
|
|
})
|
|
}
|
|
|
|
// Helper function to find the project that contains tasks
|
|
func findProjectWithTasks(t *testing.T, vikunjaTasks []*models.ProjectWithTasksAndBuckets) *models.ProjectWithTasksAndBuckets {
|
|
t.Helper()
|
|
|
|
// The function creates a parent project and child projects
|
|
// We expect 2 projects: parent "Migrated from TickTick" and child "Test Project"
|
|
require.Len(t, vikunjaTasks, 2)
|
|
|
|
// Find the project with tasks (should be the child project)
|
|
for _, project := range vikunjaTasks {
|
|
if len(project.Tasks) > 0 {
|
|
require.Len(t, project.Tasks, 1)
|
|
return project
|
|
}
|
|
}
|
|
|
|
t.Fatal("Should find a project with tasks")
|
|
return nil
|
|
}
|
|
|
|
// Helper function to assert that labels match expected tags
|
|
func assertLabelsMatch(t *testing.T, vikunjaTask *models.TaskWithComments, expectedTags []string) {
|
|
t.Helper()
|
|
|
|
// Check that only non-empty labels were created
|
|
assert.Len(t, vikunjaTask.Labels, len(expectedTags), "Number of labels should match expected")
|
|
|
|
// Check that the label titles match expected tags
|
|
actualTags := make([]string, len(vikunjaTask.Labels))
|
|
for i, label := range vikunjaTask.Labels {
|
|
actualTags[i] = label.Title
|
|
}
|
|
|
|
assert.ElementsMatch(t, expectedTags, actualTags, "Label titles should match expected tags")
|
|
|
|
// Ensure no empty labels were created
|
|
for _, label := range vikunjaTask.Labels {
|
|
assert.NotEmpty(t, strings.TrimSpace(label.Title), "No label should be empty or whitespace-only")
|
|
}
|
|
}
|
|
|
|
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")
|
|
require.NoError(t, err, "Failed to open test fixture")
|
|
defer file.Close()
|
|
|
|
stat, err := file.Stat()
|
|
require.NoError(t, err)
|
|
|
|
lines, err := linesToSkipBeforeHeader(file, stat.Size())
|
|
require.NoError(t, err)
|
|
t.Logf("Lines to skip: %d", lines)
|
|
assert.Equal(t, 6, lines, "Should skip 6 metadata lines")
|
|
|
|
// Reset file position
|
|
_, 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, "Failed to parse CSV with multiline descriptions")
|
|
|
|
// We expect 2 tasks in this fixture
|
|
require.Len(t, tasks, 2, "Should parse exactly 2 tasks")
|
|
|
|
// First task has multiline content in both Title and Content fields
|
|
task1 := tasks[0]
|
|
assert.Equal(t, "Work", task1.FolderName)
|
|
assert.Equal(t, "Project Alpha", task1.ProjectName)
|
|
|
|
// The title contains a newline
|
|
assert.Contains(t, task1.Title, "Task with multiline")
|
|
assert.Contains(t, task1.Title, "description")
|
|
assert.Contains(t, task1.Title, "\n", "Title should contain actual newline character")
|
|
|
|
// The content contains multiple newlines and paragraphs
|
|
assert.Contains(t, task1.Content, "This is a task description")
|
|
assert.Contains(t, task1.Content, "that spans multiple lines")
|
|
assert.Contains(t, task1.Content, "It has paragraphs and everything!")
|
|
assert.Contains(t, task1.Content, "Including special characters: #, *, @")
|
|
|
|
// Count newlines in content - should have at least 3 (between the 4 lines)
|
|
newlineCount := strings.Count(task1.Content, "\n")
|
|
assert.GreaterOrEqual(t, newlineCount, 3, "Content should have multiple newlines")
|
|
|
|
// Second task is a regular task without multiline content
|
|
task2 := tasks[1]
|
|
assert.Equal(t, "Regular task", task2.Title)
|
|
assert.Equal(t, "Simple description", task2.Content)
|
|
assert.NotContains(t, task2.Title, "\n", "Regular task title should not have newlines")
|
|
|
|
t.Logf("Successfully parsed tasks with multiline content:")
|
|
t.Logf(" Task 1 title: %q", task1.Title)
|
|
t.Logf(" Task 1 content: %q", task1.Content)
|
|
t.Logf(" Task 2 title: %q", task2.Title)
|
|
}
|
|
|
|
func TestEmptyLabelHandlingWithRealCSV(t *testing.T) {
|
|
t.Run("Parse CSV file", func(t *testing.T) {
|
|
file, err := os.Open("testdata_ticktick_export.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)
|
|
|
|
// Reset file position
|
|
_, 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.Greater(t, len(tasks), 0)
|
|
|
|
t.Logf("Successfully parsed %d tasks from CSV file", len(tasks))
|
|
})
|
|
|
|
t.Run("Process tags and check for empty labels", func(t *testing.T) {
|
|
file, err := os.Open("testdata_ticktick_export.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)
|
|
|
|
// Reset file position
|
|
_, 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)
|
|
|
|
// Process tags as the migration code does
|
|
for _, task := range tasks {
|
|
task.Tags = strings.Split(task.TagsList, ", ")
|
|
}
|
|
|
|
// Convert to Vikunja format
|
|
vikunjaTasks := convertTickTickToVikunja(tasks)
|
|
|
|
// Check all tasks for empty labels
|
|
totalLabels := 0
|
|
for _, project := range vikunjaTasks {
|
|
for _, task := range project.Tasks {
|
|
totalLabels += len(task.Labels)
|
|
for _, label := range task.Labels {
|
|
assert.NotEmpty(t, strings.TrimSpace(label.Title),
|
|
"No label should be empty or whitespace-only. Found empty label in task: %s", task.Title)
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
// TestMultipleTasksWithMalformedIDsAreNotDropped guards against a regression
|
|
// where collapsing several unparseable taskIds to 0 caused all but the first
|
|
// zero-ID task to be silently dropped by sortParentsBeforeChildren.
|
|
func TestMultipleTasksWithMalformedIDsAreNotDropped(t *testing.T) {
|
|
tasks := []*tickTickTask{
|
|
{TaskID: 0, ProjectName: "Project 1", Title: "First malformed"},
|
|
{TaskID: 0, ProjectName: "Project 1", Title: "Second malformed"},
|
|
{TaskID: 0, ProjectName: "Project 1", Title: "Third malformed"},
|
|
}
|
|
|
|
sorted := sortParentsBeforeChildren(tasks)
|
|
require.Len(t, sorted, 3, "no task with a zero ID should be dropped")
|
|
|
|
vikunjaTasks := convertTickTickToVikunja(tasks)
|
|
titles := []string{}
|
|
for _, project := range vikunjaTasks {
|
|
for _, task := range project.Tasks {
|
|
titles = append(titles, task.Title)
|
|
}
|
|
}
|
|
assert.ElementsMatch(t, []string{"First malformed", "Second malformed", "Third malformed"}, titles)
|
|
}
|