mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-07-16 14:52:35 -05:00
Pull the StartMigration -> Migrate -> FinishMigration orchestration out of the v1 echo handlers into handler.RunFileMigration and csv.RunMigration so the v2 API can reuse the exact same business logic. v1 is refactored onto them and stays byte-identical on the wire. Also tag the CSV detect/preview/config DTOs with doc:/enum: so they carry descriptions in the v2 OpenAPI schema (ignored by v1 swaggo/xorm).
769 lines
22 KiB
Go
769 lines
22 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"
|
|
"encoding/csv"
|
|
"errors"
|
|
"io"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"code.vikunja.io/api/pkg/config"
|
|
"code.vikunja.io/api/pkg/models"
|
|
"code.vikunja.io/api/pkg/modules/migration"
|
|
"code.vikunja.io/api/pkg/user"
|
|
)
|
|
|
|
// Migrator is the CSV migrator
|
|
type Migrator struct{}
|
|
|
|
// Name returns the name of this migrator
|
|
func (m *Migrator) Name() string {
|
|
return "csv"
|
|
}
|
|
|
|
// SupportedDelimiters contains all supported CSV delimiters
|
|
var SupportedDelimiters = []string{",", ";", "\t", "|"}
|
|
|
|
// SupportedQuoteChars contains all supported quote characters
|
|
var SupportedQuoteChars = []string{"\"", "'"}
|
|
|
|
// SupportedDateFormats contains common date formats for parsing
|
|
var SupportedDateFormats = []string{
|
|
"2006-01-02", // ISO date
|
|
"2006-01-02T15:04:05", // ISO datetime
|
|
"2006-01-02T15:04:05Z07:00", // RFC3339
|
|
"2006-01-02T15:04:05-0700", // ISO with timezone
|
|
"02/01/2006", // DD/MM/YYYY
|
|
"01/02/2006", // MM/DD/YYYY
|
|
"02-01-2006", // DD-MM-YYYY
|
|
"01-02-2006", // MM-DD-YYYY
|
|
"Jan 2, 2006", // Month D, YYYY
|
|
"2 Jan 2006", // D Month YYYY
|
|
"02/01/2006 15:04", // DD/MM/YYYY HH:MM
|
|
"01/02/2006 15:04", // MM/DD/YYYY HH:MM
|
|
"2006-01-02 15:04:05", // MySQL datetime
|
|
"2006/01/02", // YYYY/MM/DD
|
|
"02.01.2006", // DD.MM.YYYY (European)
|
|
"02.01.2006 15:04", // DD.MM.YYYY HH:MM (European)
|
|
time.RFC1123, // RFC1123
|
|
time.RFC1123Z, // RFC1123 with numeric zone
|
|
time.RFC822, // RFC822
|
|
time.RFC822Z, // RFC822 with numeric zone
|
|
time.RFC850, // RFC850
|
|
time.ANSIC, // ANSIC
|
|
time.UnixDate, // Unix date
|
|
}
|
|
|
|
// TaskAttribute represents a task attribute that can be mapped from CSV
|
|
type TaskAttribute string
|
|
|
|
const (
|
|
AttrTitle TaskAttribute = "title"
|
|
AttrDescription TaskAttribute = "description"
|
|
AttrDueDate TaskAttribute = "due_date"
|
|
AttrStartDate TaskAttribute = "start_date"
|
|
AttrEndDate TaskAttribute = "end_date"
|
|
AttrDone TaskAttribute = "done"
|
|
AttrPriority TaskAttribute = "priority"
|
|
AttrLabels TaskAttribute = "labels"
|
|
AttrProject TaskAttribute = "project"
|
|
AttrReminder TaskAttribute = "reminder"
|
|
AttrIgnore TaskAttribute = "ignore"
|
|
)
|
|
|
|
// AllTaskAttributes returns all available task attributes for mapping
|
|
var AllTaskAttributes = []TaskAttribute{
|
|
AttrTitle,
|
|
AttrDescription,
|
|
AttrDueDate,
|
|
AttrStartDate,
|
|
AttrEndDate,
|
|
AttrDone,
|
|
AttrPriority,
|
|
AttrLabels,
|
|
AttrProject,
|
|
AttrReminder,
|
|
AttrIgnore,
|
|
}
|
|
|
|
// ColumnMapping represents a mapping from a CSV column to a task attribute
|
|
type ColumnMapping struct {
|
|
ColumnIndex int `json:"column_index" doc:"The zero-based index of the CSV column this mapping applies to."`
|
|
ColumnName string `json:"column_name" doc:"The header name of the CSV column, for display."`
|
|
Attribute TaskAttribute `json:"attribute" enum:"title,description,due_date,start_date,end_date,done,priority,labels,project,reminder,ignore" doc:"The task attribute the column maps to. Use \"ignore\" to drop the column."`
|
|
}
|
|
|
|
// DetectionResult contains the auto-detected CSV structure
|
|
type DetectionResult struct {
|
|
Columns []string `json:"columns" doc:"The detected column header names, in order."`
|
|
Delimiter string `json:"delimiter" doc:"The detected field delimiter (one of \",\", \";\", tab, \"|\")."`
|
|
QuoteChar string `json:"quote_char" doc:"The detected quote character."`
|
|
DateFormat string `json:"date_format" doc:"The detected Go reference date layout used to parse date columns."`
|
|
SuggestedMapping []ColumnMapping `json:"suggested_mapping" doc:"A best-guess column-to-attribute mapping; the client may edit it before previewing or migrating."`
|
|
PreviewRows [][]string `json:"preview_rows" doc:"The first few raw rows of the file, for the client to render a preview."`
|
|
}
|
|
|
|
// ImportConfig contains the configuration for CSV import
|
|
type ImportConfig struct {
|
|
Delimiter string `json:"delimiter" doc:"The field delimiter to parse with. Defaults to comma when empty."`
|
|
QuoteChar string `json:"quote_char" doc:"The quote character to parse with."`
|
|
DateFormat string `json:"date_format" doc:"The Go reference date layout used to parse date columns."`
|
|
SkipRows int `json:"skip_rows" doc:"Number of leading rows to skip (e.g. a header row) before importing."`
|
|
Mapping []ColumnMapping `json:"mapping" doc:"The column-to-attribute mappings that drive the import."`
|
|
}
|
|
|
|
// PreviewTask represents a task preview before import
|
|
type PreviewTask struct {
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
DueDate string `json:"due_date,omitempty"`
|
|
StartDate string `json:"start_date,omitempty"`
|
|
EndDate string `json:"end_date,omitempty"`
|
|
Done bool `json:"done"`
|
|
Priority int `json:"priority"`
|
|
Labels []string `json:"labels,omitempty"`
|
|
Project string `json:"project,omitempty"`
|
|
}
|
|
|
|
// PreviewResult contains preview data before import
|
|
type PreviewResult struct {
|
|
Tasks []PreviewTask `json:"tasks" doc:"The first few tasks that would be imported with the given config."`
|
|
TotalRows int `json:"total_rows" doc:"The total number of data rows in the file."`
|
|
}
|
|
|
|
// stripBOM removes the UTF-8 BOM from the beginning of a reader
|
|
func stripBOM(data []byte) []byte {
|
|
if len(data) >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF {
|
|
return data[3:]
|
|
}
|
|
return data
|
|
}
|
|
|
|
// detectDelimiter attempts to auto-detect the CSV delimiter
|
|
func detectDelimiter(data []byte) string {
|
|
content := string(data)
|
|
|
|
// Count occurrences of each delimiter in the first few lines
|
|
lines := strings.SplitN(content, "\n", 5)
|
|
if len(lines) < 2 {
|
|
return "," // Default to comma
|
|
}
|
|
|
|
delimiterCounts := make(map[string]int)
|
|
for _, delim := range SupportedDelimiters {
|
|
count := 0
|
|
for _, line := range lines[:minInt(3, len(lines))] {
|
|
count += strings.Count(line, delim)
|
|
}
|
|
delimiterCounts[delim] = count
|
|
}
|
|
|
|
// Find the delimiter with the most consistent count across lines
|
|
bestDelimiter := ","
|
|
maxCount := 0
|
|
for delim, count := range delimiterCounts {
|
|
if count > maxCount {
|
|
maxCount = count
|
|
bestDelimiter = delim
|
|
}
|
|
}
|
|
|
|
return bestDelimiter
|
|
}
|
|
|
|
// detectQuoteChar attempts to auto-detect the quote character
|
|
func detectQuoteChar(data []byte) string {
|
|
content := string(data)
|
|
|
|
doubleQuotes := strings.Count(content, "\"")
|
|
singleQuotes := strings.Count(content, "'")
|
|
|
|
if singleQuotes > doubleQuotes {
|
|
return "'"
|
|
}
|
|
return "\""
|
|
}
|
|
|
|
// detectDateFormat attempts to detect the date format from sample data
|
|
func detectDateFormat(sampleDates []string) string {
|
|
if len(sampleDates) == 0 {
|
|
return SupportedDateFormats[0] // Default to ISO
|
|
}
|
|
|
|
for _, format := range SupportedDateFormats {
|
|
matches := 0
|
|
for _, dateStr := range sampleDates {
|
|
dateStr = strings.TrimSpace(dateStr)
|
|
if dateStr == "" {
|
|
continue
|
|
}
|
|
_, err := time.Parse(format, dateStr)
|
|
if err == nil {
|
|
matches++
|
|
}
|
|
}
|
|
// If most dates match this format, use it
|
|
if matches > 0 && matches >= len(sampleDates)/2 {
|
|
return format
|
|
}
|
|
}
|
|
|
|
return SupportedDateFormats[0]
|
|
}
|
|
|
|
// suggestMapping suggests column mappings based on column names
|
|
func suggestMapping(columns []string) []ColumnMapping {
|
|
mappings := make([]ColumnMapping, len(columns))
|
|
|
|
// Common column name patterns for each attribute
|
|
patterns := map[TaskAttribute][]string{
|
|
AttrTitle: {"title", "name", "task", "subject", "summary"},
|
|
AttrDescription: {"description", "content", "notes", "details", "body", "text"},
|
|
AttrDueDate: {"due", "due_date", "duedate", "deadline", "due date"},
|
|
AttrStartDate: {"start", "start_date", "startdate", "begin", "start date"},
|
|
AttrEndDate: {"end", "end_date", "enddate", "finish", "end date"},
|
|
AttrDone: {"done", "completed", "complete", "finished", "status", "is_done"},
|
|
AttrPriority: {"priority", "importance", "urgent", "prio"},
|
|
AttrLabels: {"labels", "tags", "categories", "category", "label", "tag"},
|
|
AttrProject: {"project", "list", "folder", "group", "project_name", "list_name"},
|
|
AttrReminder: {"reminder", "remind", "alert", "notification"},
|
|
}
|
|
|
|
usedAttributes := make(map[TaskAttribute]bool)
|
|
|
|
for i, col := range columns {
|
|
colLower := strings.ToLower(strings.TrimSpace(col))
|
|
mappings[i] = ColumnMapping{
|
|
ColumnIndex: i,
|
|
ColumnName: col,
|
|
Attribute: AttrIgnore,
|
|
}
|
|
|
|
for attr, keywords := range patterns {
|
|
if usedAttributes[attr] && attr != AttrLabels {
|
|
continue // Don't map the same attribute twice (except labels)
|
|
}
|
|
for _, keyword := range keywords {
|
|
if strings.Contains(colLower, keyword) || colLower == keyword {
|
|
mappings[i].Attribute = attr
|
|
usedAttributes[attr] = true
|
|
break
|
|
}
|
|
}
|
|
if mappings[i].Attribute != AttrIgnore {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return mappings
|
|
}
|
|
|
|
// parseCSV parses CSV data with the given configuration
|
|
func parseCSV(data []byte, delimiter string) ([]string, [][]string, error) {
|
|
data = stripBOM(data)
|
|
|
|
// Go's csv.Reader only supports double-quote as the quote character.
|
|
// LazyQuotes mode handles most edge cases including unescaped quotes
|
|
// in fields. We intentionally do not replace non-standard quote chars
|
|
// (e.g. single quotes) as that would corrupt apostrophes in text content.
|
|
reader := csv.NewReader(bytes.NewReader(data))
|
|
|
|
if len(delimiter) > 0 {
|
|
reader.Comma = rune(delimiter[0])
|
|
}
|
|
reader.FieldsPerRecord = -1 // Allow variable field counts
|
|
reader.LazyQuotes = true
|
|
reader.TrimLeadingSpace = true
|
|
|
|
records, err := reader.ReadAll()
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
if len(records) == 0 {
|
|
return nil, nil, &migration.ErrFileIsEmpty{}
|
|
}
|
|
|
|
headers := records[0]
|
|
var dataRows [][]string
|
|
if len(records) > 1 {
|
|
dataRows = records[1:]
|
|
}
|
|
|
|
return headers, dataRows, nil
|
|
}
|
|
|
|
// DetectCSVStructure analyzes a CSV file and returns detection results
|
|
func DetectCSVStructure(file io.ReaderAt, size int64) (*DetectionResult, error) {
|
|
if size == 0 {
|
|
return nil, &migration.ErrFileIsEmpty{}
|
|
}
|
|
|
|
// Read the entire file
|
|
data := make([]byte, size)
|
|
_, err := file.ReadAt(data, 0)
|
|
if err != nil && !errors.Is(err, io.EOF) {
|
|
return nil, err
|
|
}
|
|
|
|
// Detect delimiter and quote character
|
|
delimiter := detectDelimiter(data)
|
|
quoteChar := detectQuoteChar(data)
|
|
|
|
// Parse CSV
|
|
headers, rows, err := parseCSV(data, delimiter)
|
|
if err != nil {
|
|
var emptyErr *migration.ErrFileIsEmpty
|
|
if errors.As(err, &emptyErr) {
|
|
return nil, err
|
|
}
|
|
return nil, &migration.ErrNotACSVFile{}
|
|
}
|
|
|
|
// Suggest column mappings
|
|
suggestedMapping := suggestMapping(headers)
|
|
|
|
// Collect sample dates for format detection
|
|
var sampleDates []string
|
|
for _, mapping := range suggestedMapping {
|
|
if mapping.Attribute == AttrDueDate || mapping.Attribute == AttrStartDate || mapping.Attribute == AttrEndDate {
|
|
for _, row := range rows {
|
|
if mapping.ColumnIndex < len(row) && row[mapping.ColumnIndex] != "" {
|
|
sampleDates = append(sampleDates, row[mapping.ColumnIndex])
|
|
if len(sampleDates) >= 10 {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
dateFormat := detectDateFormat(sampleDates)
|
|
|
|
// Get preview rows (first 5)
|
|
previewRows := rows
|
|
if len(previewRows) > 5 {
|
|
previewRows = previewRows[:5]
|
|
}
|
|
|
|
return &DetectionResult{
|
|
Columns: headers,
|
|
Delimiter: delimiter,
|
|
QuoteChar: quoteChar,
|
|
DateFormat: dateFormat,
|
|
SuggestedMapping: suggestedMapping,
|
|
PreviewRows: previewRows,
|
|
}, nil
|
|
}
|
|
|
|
// PreviewImport generates a preview of the import based on current mapping
|
|
func PreviewImport(file io.ReaderAt, size int64, config *ImportConfig) (*PreviewResult, error) {
|
|
if size == 0 {
|
|
return nil, &migration.ErrFileIsEmpty{}
|
|
}
|
|
|
|
data := make([]byte, size)
|
|
_, err := file.ReadAt(data, 0)
|
|
if err != nil && !errors.Is(err, io.EOF) {
|
|
return nil, err
|
|
}
|
|
|
|
_, rows, err := parseCSV(data, config.Delimiter)
|
|
if err != nil {
|
|
var emptyErr *migration.ErrFileIsEmpty
|
|
if errors.As(err, &emptyErr) {
|
|
return nil, err
|
|
}
|
|
return nil, &migration.ErrNotACSVFile{}
|
|
}
|
|
|
|
// Skip rows if configured
|
|
if config.SkipRows > 0 {
|
|
if config.SkipRows >= len(rows) {
|
|
return &PreviewResult{
|
|
Tasks: []PreviewTask{},
|
|
TotalRows: 0,
|
|
}, nil
|
|
}
|
|
rows = rows[config.SkipRows:]
|
|
}
|
|
|
|
result := &PreviewResult{
|
|
Tasks: make([]PreviewTask, 0, minInt(5, len(rows))),
|
|
TotalRows: len(rows),
|
|
}
|
|
|
|
previewCount := minInt(5, len(rows))
|
|
for i := 0; i < previewCount; i++ {
|
|
task := rowToPreviewTask(rows[i], config)
|
|
result.Tasks = append(result.Tasks, task)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// rowToPreviewTask converts a CSV row to a preview task
|
|
func rowToPreviewTask(row []string, config *ImportConfig) PreviewTask {
|
|
task := PreviewTask{}
|
|
|
|
for _, mapping := range config.Mapping {
|
|
if mapping.ColumnIndex < 0 || mapping.ColumnIndex >= len(row) {
|
|
continue
|
|
}
|
|
|
|
value := strings.TrimSpace(row[mapping.ColumnIndex])
|
|
if value == "" {
|
|
continue
|
|
}
|
|
|
|
switch mapping.Attribute {
|
|
case AttrTitle:
|
|
task.Title = value
|
|
case AttrDescription:
|
|
task.Description = value
|
|
case AttrDueDate:
|
|
task.DueDate = value
|
|
case AttrStartDate:
|
|
task.StartDate = value
|
|
case AttrEndDate:
|
|
task.EndDate = value
|
|
case AttrDone:
|
|
task.Done = parseBool(value)
|
|
case AttrPriority:
|
|
task.Priority = parsePriority(value)
|
|
case AttrLabels:
|
|
task.Labels = parseLabels(value)
|
|
case AttrProject:
|
|
task.Project = value
|
|
case AttrReminder:
|
|
// Reminders are not supported in preview tasks
|
|
case AttrIgnore:
|
|
// Ignored attributes are not processed
|
|
}
|
|
}
|
|
|
|
return task
|
|
}
|
|
|
|
// parseBool parses various boolean representations
|
|
func parseBool(value string) bool {
|
|
lower := strings.ToLower(strings.TrimSpace(value))
|
|
return lower == "true" || lower == "yes" || lower == "1" || lower == "done" || lower == "completed"
|
|
}
|
|
|
|
// parsePriority parses priority value
|
|
func parsePriority(value string) int {
|
|
// Try to parse as number
|
|
if p, err := strconv.Atoi(strings.TrimSpace(value)); err == nil {
|
|
// Vikunja uses 0-5 priority (0=unset, 1=low, 5=urgent)
|
|
if p < 0 {
|
|
return 0
|
|
}
|
|
if p > 5 {
|
|
return 5
|
|
}
|
|
return p
|
|
}
|
|
|
|
// Try to parse text priority
|
|
lower := strings.ToLower(strings.TrimSpace(value))
|
|
switch {
|
|
case strings.Contains(lower, "urgent") || strings.Contains(lower, "highest"):
|
|
return 5
|
|
case strings.Contains(lower, "high"):
|
|
return 4
|
|
case strings.Contains(lower, "medium") || strings.Contains(lower, "normal"):
|
|
return 3
|
|
case strings.Contains(lower, "lowest"):
|
|
return 1
|
|
case strings.Contains(lower, "low"):
|
|
return 2
|
|
}
|
|
|
|
return 0
|
|
}
|
|
|
|
// parseLabels parses comma-separated labels
|
|
func parseLabels(value string) []string {
|
|
parts := strings.Split(value, ",")
|
|
labels := make([]string, 0, len(parts))
|
|
for _, part := range parts {
|
|
label := strings.TrimSpace(part)
|
|
if label != "" {
|
|
labels = append(labels, label)
|
|
}
|
|
}
|
|
return labels
|
|
}
|
|
|
|
// parseDate parses a date string with the given format using the configured timezone
|
|
func parseDate(value, format string) time.Time {
|
|
if value == "" {
|
|
return time.Time{}
|
|
}
|
|
|
|
loc := config.GetTimeZone()
|
|
trimmed := strings.TrimSpace(value)
|
|
|
|
// Try the specified format first
|
|
if t, err := time.ParseInLocation(format, trimmed, loc); err == nil {
|
|
return t
|
|
}
|
|
|
|
// Try all supported formats as fallback
|
|
for _, f := range SupportedDateFormats {
|
|
if t, err := time.ParseInLocation(f, trimmed, loc); err == nil {
|
|
return t
|
|
}
|
|
}
|
|
|
|
return time.Time{}
|
|
}
|
|
|
|
// Migrate imports CSV data into Vikunja
|
|
// @Summary Import all tasks from a CSV file
|
|
// @Description Imports tasks from a CSV file into Vikunja. Requires a mapping configuration.
|
|
// @tags migration
|
|
// @Accept multipart/form-data
|
|
// @Produce json
|
|
// @Security JWTKeyAuth
|
|
// @Param import formData file true "The CSV file to import"
|
|
// @Param config formData string true "The import configuration JSON"
|
|
// @Success 200 {object} models.Message "A message telling you everything was migrated successfully."
|
|
// @Failure 400 {object} models.Message "Invalid CSV file or configuration"
|
|
// @Failure 500 {object} models.Message "Internal server error"
|
|
// @Router /migration/csv/migrate [put]
|
|
func (m *Migrator) Migrate(_ *user.User, _ io.ReaderAt, _ int64) error {
|
|
return &migration.ErrCSVConfigRequired{}
|
|
}
|
|
|
|
// RunMigration records the migration's start, imports the CSV with the given
|
|
// config and records its finish. Shared by the v1 and v2 HTTP layers so the
|
|
// status bookkeeping around MigrateWithConfig lives in one place.
|
|
func RunMigration(u *user.User, file io.ReaderAt, size int64, config *ImportConfig) error {
|
|
status, err := migration.StartMigration(&Migrator{}, u)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := MigrateWithConfig(u, file, size, config); err != nil {
|
|
return err
|
|
}
|
|
|
|
return migration.FinishMigration(status)
|
|
}
|
|
|
|
// MigrateWithConfig imports CSV data into Vikunja with the provided configuration
|
|
func MigrateWithConfig(u *user.User, file io.ReaderAt, size int64, config *ImportConfig) error {
|
|
if size == 0 {
|
|
return &migration.ErrFileIsEmpty{}
|
|
}
|
|
|
|
data := make([]byte, size)
|
|
_, err := file.ReadAt(data, 0)
|
|
if err != nil && !errors.Is(err, io.EOF) {
|
|
return err
|
|
}
|
|
|
|
_, rows, err := parseCSV(data, config.Delimiter)
|
|
if err != nil {
|
|
var emptyErr *migration.ErrFileIsEmpty
|
|
if errors.As(err, &emptyErr) {
|
|
return err
|
|
}
|
|
return &migration.ErrNotACSVFile{}
|
|
}
|
|
|
|
// Skip rows if configured
|
|
if config.SkipRows > 0 {
|
|
if config.SkipRows >= len(rows) {
|
|
rows = nil
|
|
} else {
|
|
rows = rows[config.SkipRows:]
|
|
}
|
|
}
|
|
|
|
if len(rows) == 0 {
|
|
return &migration.ErrFileIsEmpty{}
|
|
}
|
|
|
|
// Convert rows to Vikunja structure
|
|
vikunjaTasks := convertToVikunja(rows, config)
|
|
|
|
return migration.InsertFromStructure(vikunjaTasks, u)
|
|
}
|
|
|
|
// hasProjectMapping returns true if any column is mapped to the project attribute
|
|
func hasProjectMapping(config *ImportConfig) bool {
|
|
for _, mapping := range config.Mapping {
|
|
if mapping.Attribute == AttrProject {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// convertToVikunja converts CSV rows to Vikunja project/task structure
|
|
func convertToVikunja(rows [][]string, config *ImportConfig) []*models.ProjectWithTasksAndBuckets {
|
|
var pseudoParentID int64 = 1
|
|
parentProject := &models.ProjectWithTasksAndBuckets{
|
|
Project: models.Project{
|
|
ID: pseudoParentID,
|
|
Title: "Imported from CSV",
|
|
},
|
|
}
|
|
|
|
// If no project column is mapped, put all tasks directly in the parent project
|
|
if !hasProjectMapping(config) {
|
|
for i, row := range rows {
|
|
task := rowToTask(row, config, int64(i+1))
|
|
parentProject.Tasks = append(parentProject.Tasks, &models.TaskWithComments{Task: task})
|
|
}
|
|
return []*models.ProjectWithTasksAndBuckets{parentProject}
|
|
}
|
|
|
|
// Collect tasks by project name
|
|
projects := make(map[string]*models.ProjectWithTasksAndBuckets)
|
|
defaultProjectName := "Tasks"
|
|
|
|
for i, row := range rows {
|
|
task := rowToTask(row, config, int64(i+1))
|
|
|
|
// Determine project name
|
|
projectName := defaultProjectName
|
|
for _, mapping := range config.Mapping {
|
|
if mapping.Attribute == AttrProject && mapping.ColumnIndex >= 0 && mapping.ColumnIndex < len(row) {
|
|
if pn := strings.TrimSpace(row[mapping.ColumnIndex]); pn != "" {
|
|
projectName = pn
|
|
}
|
|
}
|
|
}
|
|
|
|
// Get or create project
|
|
if _, exists := projects[projectName]; !exists {
|
|
projects[projectName] = &models.ProjectWithTasksAndBuckets{
|
|
Project: models.Project{
|
|
ID: int64(len(projects)+2) + pseudoParentID,
|
|
ParentProjectID: pseudoParentID,
|
|
Title: projectName,
|
|
},
|
|
}
|
|
}
|
|
|
|
// Add task to project
|
|
projects[projectName].Tasks = append(projects[projectName].Tasks, &models.TaskWithComments{Task: task})
|
|
}
|
|
|
|
// If only one project exists, put all tasks directly in the parent project
|
|
if len(projects) == 1 {
|
|
for _, p := range projects {
|
|
parentProject.Tasks = p.Tasks
|
|
}
|
|
return []*models.ProjectWithTasksAndBuckets{parentProject}
|
|
}
|
|
|
|
// Multiple projects: create sub-projects under the parent
|
|
result := []*models.ProjectWithTasksAndBuckets{parentProject}
|
|
for _, p := range projects {
|
|
result = append(result, p)
|
|
}
|
|
|
|
// Sort projects by title for consistent ordering
|
|
sort.Slice(result[1:], func(i, j int) bool {
|
|
return result[i+1].Title < result[j+1].Title
|
|
})
|
|
|
|
return result
|
|
}
|
|
|
|
// rowToTask converts a CSV row to a Vikunja task
|
|
func rowToTask(row []string, config *ImportConfig, taskID int64) models.Task {
|
|
task := models.Task{
|
|
ID: taskID,
|
|
}
|
|
|
|
for _, mapping := range config.Mapping {
|
|
if mapping.ColumnIndex < 0 || mapping.ColumnIndex >= len(row) {
|
|
continue
|
|
}
|
|
|
|
value := strings.TrimSpace(row[mapping.ColumnIndex])
|
|
if value == "" {
|
|
continue
|
|
}
|
|
|
|
switch mapping.Attribute {
|
|
case AttrTitle:
|
|
task.Title = value
|
|
case AttrDescription:
|
|
task.Description = value
|
|
case AttrDueDate:
|
|
task.DueDate = parseDate(value, config.DateFormat)
|
|
case AttrStartDate:
|
|
task.StartDate = parseDate(value, config.DateFormat)
|
|
case AttrEndDate:
|
|
task.EndDate = parseDate(value, config.DateFormat)
|
|
case AttrDone:
|
|
task.Done = parseBool(value)
|
|
if task.Done {
|
|
task.DoneAt = time.Now()
|
|
}
|
|
case AttrPriority:
|
|
task.Priority = int64(parsePriority(value))
|
|
case AttrLabels:
|
|
labels := parseLabels(value)
|
|
for _, labelTitle := range labels {
|
|
task.Labels = append(task.Labels, &models.Label{Title: labelTitle})
|
|
}
|
|
case AttrReminder:
|
|
// Parse reminder as duration or date
|
|
reminderDate := parseDate(value, config.DateFormat)
|
|
if !reminderDate.IsZero() {
|
|
task.Reminders = []*models.TaskReminder{
|
|
{
|
|
Reminder: reminderDate,
|
|
},
|
|
}
|
|
}
|
|
case AttrProject:
|
|
// Project attribute is handled separately for task creation
|
|
case AttrIgnore:
|
|
// Ignored attributes are not processed
|
|
}
|
|
}
|
|
|
|
// Ensure task has a title
|
|
if task.Title == "" {
|
|
task.Title = "Untitled Task"
|
|
}
|
|
|
|
return task
|
|
}
|
|
|
|
func minInt(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|