Files
vikunja/pkg/modules/migration/csv/csv_test.go
Claude f555762def feat(migration): add generic CSV import with column mapping
Add a new CSV migration module that allows users to import tasks from
any CSV file with custom column mapping and parsing options.

Backend changes:
- New CSV migrator module with detection, preview, and import endpoints
- Auto-detection of delimiter, quote character, and date format
- Suggested column mappings based on column name patterns
- Transactional import using InsertFromStructure

Frontend changes:
- New CSV migration UI with two-step flow (upload -> mapping -> import)
- Column mapping selectors for all task attributes
- Live preview showing first 5 tasks with current mapping
- Parsing option controls for delimiter and date format

The CSV migrator creates a parent "Imported from CSV" project with
child projects based on the project column if provided, or a default
"Tasks" project for tasks without a specified project.
2026-04-07 15:20:06 +00:00

582 lines
14 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 csv
import (
"bytes"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestStripBOM(t *testing.T) {
tests := []struct {
name string
input []byte
expected []byte
}{
{
name: "with BOM",
input: []byte{0xEF, 0xBB, 0xBF, 'H', 'e', 'l', 'l', 'o'},
expected: []byte("Hello"),
},
{
name: "without BOM",
input: []byte("Hello"),
expected: []byte("Hello"),
},
{
name: "empty",
input: []byte{},
expected: []byte{},
},
{
name: "only BOM",
input: []byte{0xEF, 0xBB, 0xBF},
expected: []byte{},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := stripBOM(tc.input)
assert.Equal(t, tc.expected, result)
})
}
}
func TestDetectDelimiter(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "comma separated",
input: "name,email,phone\nJohn,john@test.com,123\nJane,jane@test.com,456",
expected: ",",
},
{
name: "semicolon separated",
input: "name;email;phone\nJohn;john@test.com;123\nJane;jane@test.com;456",
expected: ";",
},
{
name: "tab separated",
input: "name\temail\tphone\nJohn\tjohn@test.com\t123\nJane\tjane@test.com\t456",
expected: "\t",
},
{
name: "pipe separated",
input: "name|email|phone\nJohn|john@test.com|123\nJane|jane@test.com|456",
expected: "|",
},
{
name: "single line defaults to comma",
input: "just a single line",
expected: ",",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := detectDelimiter([]byte(tc.input))
assert.Equal(t, tc.expected, result)
})
}
}
func TestDetectQuoteChar(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{
name: "double quotes",
input: `"name","email"\n"John","john@test.com"`,
expected: "\"",
},
{
name: "single quotes",
input: `'name','email'\n'John','john@test.com'`,
expected: "'",
},
{
name: "no quotes defaults to double",
input: "name,email\nJohn,john@test.com",
expected: "\"",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := detectQuoteChar([]byte(tc.input))
assert.Equal(t, tc.expected, result)
})
}
}
func TestDetectDateFormat(t *testing.T) {
tests := []struct {
name string
sampleDates []string
expected string
}{
{
name: "ISO date",
sampleDates: []string{"2024-01-15", "2024-02-20", "2024-03-25"},
expected: "2006-01-02",
},
{
name: "ISO datetime",
sampleDates: []string{"2024-01-15T10:30:00", "2024-02-20T14:45:00"},
expected: "2006-01-02T15:04:05",
},
{
name: "European format",
sampleDates: []string{"15.01.2024", "20.02.2024", "25.03.2024"},
expected: "02.01.2006",
},
{
name: "empty defaults to ISO",
sampleDates: []string{},
expected: "2006-01-02",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
result := detectDateFormat(tc.sampleDates)
assert.Equal(t, tc.expected, result)
})
}
}
func TestSuggestMapping(t *testing.T) {
tests := []struct {
name string
columns []string
expected map[int]TaskAttribute
}{
{
name: "standard column names",
columns: []string{"Title", "Description", "Due Date", "Priority", "Labels"},
expected: map[int]TaskAttribute{
0: AttrTitle,
1: AttrDescription,
2: AttrDueDate,
3: AttrPriority,
4: AttrLabels,
},
},
{
name: "alternative column names",
columns: []string{"Task Name", "Notes", "Deadline", "Tags", "Project"},
expected: map[int]TaskAttribute{
0: AttrTitle,
1: AttrDescription,
2: AttrDueDate,
3: AttrLabels,
4: AttrProject,
},
},
{
name: "unknown columns",
columns: []string{"ID", "Random Column", "Unknown"},
expected: map[int]TaskAttribute{
0: AttrIgnore,
1: AttrIgnore,
2: AttrIgnore,
},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
mappings := suggestMapping(tc.columns)
require.Len(t, mappings, len(tc.columns))
for idx, expectedAttr := range tc.expected {
assert.Equal(t, expectedAttr, mappings[idx].Attribute, "Column %d (%s)", idx, tc.columns[idx])
}
})
}
}
func TestParseCSV(t *testing.T) {
tests := []struct {
name string
input string
delimiter string
quoteChar string
expectedCols []string
expectedRows int
expectedError bool
}{
{
name: "simple comma CSV",
input: "name,email,phone\nJohn,john@test.com,123\nJane,jane@test.com,456",
delimiter: ",",
quoteChar: "\"",
expectedCols: []string{"name", "email", "phone"},
expectedRows: 2,
},
{
name: "semicolon CSV",
input: "name;email;phone\nJohn;john@test.com;123",
delimiter: ";",
quoteChar: "\"",
expectedCols: []string{"name", "email", "phone"},
expectedRows: 1,
},
{
name: "quoted fields",
input: "name,description\n\"John Doe\",\"A long, complicated description\"\nJane,Simple",
delimiter: ",",
quoteChar: "\"",
expectedCols: []string{"name", "description"},
expectedRows: 2,
},
{
name: "with BOM",
input: "\xEF\xBB\xBFname,email\nJohn,john@test.com",
delimiter: ",",
quoteChar: "\"",
expectedCols: []string{"name", "email"},
expectedRows: 1,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
headers, rows, err := parseCSV([]byte(tc.input), tc.delimiter)
if tc.expectedError {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tc.expectedCols, headers)
assert.Len(t, rows, tc.expectedRows)
})
}
}
func TestParseBool(t *testing.T) {
tests := []struct {
input string
expected bool
}{
{"true", true},
{"True", true},
{"TRUE", true},
{"yes", true},
{"Yes", true},
{"1", true},
{"done", true},
{"completed", true},
{"false", false},
{"no", false},
{"0", false},
{"", false},
{"random", false},
}
for _, tc := range tests {
t.Run(tc.input, func(t *testing.T) {
result := parseBool(tc.input)
assert.Equal(t, tc.expected, result)
})
}
}
func TestParsePriority(t *testing.T) {
tests := []struct {
input string
expected int
}{
{"0", 0},
{"1", 1},
{"3", 3},
{"5", 5},
{"10", 5}, // capped at 5
{"-1", 0}, // minimum 0
{"low", 2},
{"medium", 3},
{"high", 4},
{"urgent", 5},
{"highest", 5},
{"lowest", 1},
{"normal", 3},
{"random", 0},
}
for _, tc := range tests {
t.Run(tc.input, func(t *testing.T) {
result := parsePriority(tc.input)
assert.Equal(t, tc.expected, result)
})
}
}
func TestParseLabels(t *testing.T) {
tests := []struct {
input string
expected []string
}{
{"work, personal, urgent", []string{"work", "personal", "urgent"}},
{"single", []string{"single"}},
{" spaced , labels ", []string{"spaced", "labels"}},
{"", []string{}},
{",,,", []string{}},
}
for _, tc := range tests {
t.Run(tc.input, func(t *testing.T) {
result := parseLabels(tc.input)
assert.Equal(t, tc.expected, result)
})
}
}
func TestDetectCSVStructure(t *testing.T) {
csvContent := `Title,Description,Due Date,Priority,Labels
Task 1,Description 1,2024-01-15,high,work
Task 2,Description 2,2024-01-20,low,"personal, urgent"
Task 3,Description 3,2024-01-25,medium,home`
reader := bytes.NewReader([]byte(csvContent))
result, err := DetectCSVStructure(reader, int64(len(csvContent)))
require.NoError(t, err)
assert.Equal(t, []string{"Title", "Description", "Due Date", "Priority", "Labels"}, result.Columns)
assert.Equal(t, ",", result.Delimiter)
assert.Len(t, result.SuggestedMapping, 5)
assert.Len(t, result.PreviewRows, 3)
// Check suggested mappings
titleMapping := result.SuggestedMapping[0]
assert.Equal(t, AttrTitle, titleMapping.Attribute)
assert.Equal(t, "Title", titleMapping.ColumnName)
descMapping := result.SuggestedMapping[1]
assert.Equal(t, AttrDescription, descMapping.Attribute)
dueDateMapping := result.SuggestedMapping[2]
assert.Equal(t, AttrDueDate, dueDateMapping.Attribute)
}
func TestPreviewImport(t *testing.T) {
csvContent := `Title,Description,Done,Priority
Task 1,Description 1,true,high
Task 2,Description 2,false,low
Task 3,Description 3,yes,medium
Task 4,Description 4,no,urgent
Task 5,Description 5,1,normal
Task 6,Description 6,0,low`
config := ImportConfig{
Delimiter: ",",
QuoteChar: "\"",
DateFormat: "2006-01-02",
Mapping: []ColumnMapping{
{ColumnIndex: 0, ColumnName: "Title", Attribute: AttrTitle},
{ColumnIndex: 1, ColumnName: "Description", Attribute: AttrDescription},
{ColumnIndex: 2, ColumnName: "Done", Attribute: AttrDone},
{ColumnIndex: 3, ColumnName: "Priority", Attribute: AttrPriority},
},
}
reader := bytes.NewReader([]byte(csvContent))
result, err := PreviewImport(reader, int64(len(csvContent)), &config)
require.NoError(t, err)
assert.Equal(t, 6, result.TotalRows)
assert.Len(t, result.Tasks, 5) // Preview limited to 5
// Check first task
assert.Equal(t, "Task 1", result.Tasks[0].Title)
assert.Equal(t, "Description 1", result.Tasks[0].Description)
assert.True(t, result.Tasks[0].Done)
assert.Equal(t, 4, result.Tasks[0].Priority) // "high" -> 4
// Check second task
assert.Equal(t, "Task 2", result.Tasks[1].Title)
assert.False(t, result.Tasks[1].Done)
assert.Equal(t, 2, result.Tasks[1].Priority) // "low" -> 2
}
func TestConvertToVikunja(t *testing.T) {
rows := [][]string{
{"Task 1", "Description 1", "Project A"},
{"Task 2", "Description 2", "Project A"},
{"Task 3", "Description 3", "Project B"},
{"Task 4", "Description 4", ""}, // No project -> default
}
config := ImportConfig{
Delimiter: ",",
QuoteChar: "\"",
DateFormat: "2006-01-02",
Mapping: []ColumnMapping{
{ColumnIndex: 0, Attribute: AttrTitle},
{ColumnIndex: 1, Attribute: AttrDescription},
{ColumnIndex: 2, Attribute: AttrProject},
},
}
result := convertToVikunja(rows, &config)
// Should have parent project + child projects
require.GreaterOrEqual(t, len(result), 2)
// First project should be the parent "Imported from CSV"
assert.Equal(t, "Imported from CSV", result[0].Title)
// Find Project A
var projectA, projectB, tasksProject *struct {
title string
numTasks int
}
for _, p := range result[1:] {
switch p.Title {
case "Project A":
projectA = &struct {
title string
numTasks int
}{p.Title, len(p.Tasks)}
case "Project B":
projectB = &struct {
title string
numTasks int
}{p.Title, len(p.Tasks)}
case "Tasks":
tasksProject = &struct {
title string
numTasks int
}{p.Title, len(p.Tasks)}
}
}
assert.NotNil(t, projectA, "Project A should exist")
assert.Equal(t, 2, projectA.numTasks, "Project A should have 2 tasks")
assert.NotNil(t, projectB, "Project B should exist")
assert.Equal(t, 1, projectB.numTasks, "Project B should have 1 task")
assert.NotNil(t, tasksProject, "Tasks project should exist for tasks without project")
assert.Equal(t, 1, tasksProject.numTasks, "Tasks project should have 1 task")
}
func TestRowToTask(t *testing.T) {
row := []string{"My Task", "Task description", "2024-01-15", "high", "work, urgent"}
config := ImportConfig{
DateFormat: "2006-01-02",
Mapping: []ColumnMapping{
{ColumnIndex: 0, Attribute: AttrTitle},
{ColumnIndex: 1, Attribute: AttrDescription},
{ColumnIndex: 2, Attribute: AttrDueDate},
{ColumnIndex: 3, Attribute: AttrPriority},
{ColumnIndex: 4, Attribute: AttrLabels},
},
}
task := rowToTask(row, &config, 1)
assert.Equal(t, "My Task", task.Title)
assert.Equal(t, "Task description", task.Description)
assert.Equal(t, 2024, task.DueDate.Year())
assert.Equal(t, 1, int(task.DueDate.Month()))
assert.Equal(t, 15, task.DueDate.Day())
assert.Equal(t, int64(4), task.Priority) // "high" -> 4
require.Len(t, task.Labels, 2)
assert.Equal(t, "work", task.Labels[0].Title)
assert.Equal(t, "urgent", task.Labels[1].Title)
}
func TestMigratorName(t *testing.T) {
m := &Migrator{}
assert.Equal(t, "csv", m.Name())
}
func TestEmptyFile(t *testing.T) {
reader := bytes.NewReader([]byte{})
_, err := DetectCSVStructure(reader, 0)
require.Error(t, err)
}
func TestRowToTaskWithMissingColumns(t *testing.T) {
// Row with fewer columns than expected
row := []string{"My Task"}
config := ImportConfig{
Mapping: []ColumnMapping{
{ColumnIndex: 0, Attribute: AttrTitle},
{ColumnIndex: 1, Attribute: AttrDescription}, // Index 1 doesn't exist
{ColumnIndex: 2, Attribute: AttrDueDate}, // Index 2 doesn't exist
},
}
task := rowToTask(row, &config, 1)
// Should still work with available columns
assert.Equal(t, "My Task", task.Title)
assert.Empty(t, task.Description)
assert.True(t, task.DueDate.IsZero())
}
func TestRowToTaskWithEmptyTitle(t *testing.T) {
row := []string{"", "Some description"}
config := ImportConfig{
Mapping: []ColumnMapping{
{ColumnIndex: 0, Attribute: AttrTitle},
{ColumnIndex: 1, Attribute: AttrDescription},
},
}
task := rowToTask(row, &config, 1)
// Should have default title
assert.Equal(t, "Untitled Task", task.Title)
assert.Equal(t, "Some description", task.Description)
}
func TestDoneTask(t *testing.T) {
row := []string{"Done Task", "completed"}
config := ImportConfig{
Mapping: []ColumnMapping{
{ColumnIndex: 0, Attribute: AttrTitle},
{ColumnIndex: 1, Attribute: AttrDone},
},
}
task := rowToTask(row, &config, 1)
assert.Equal(t, "Done Task", task.Title)
assert.True(t, task.Done)
assert.False(t, task.DoneAt.IsZero()) // DoneAt should be set
}