mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-05-04 10:56:45 +00:00
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.
582 lines
14 KiB
Go
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
|
|
}
|