Files
vikunja/pkg/modules/migration/csv/handler.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

207 lines
6.1 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 (
"encoding/json"
"net/http"
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/modules/migration"
user2 "code.vikunja.io/api/pkg/user"
"github.com/labstack/echo/v5"
)
// MigratorWeb handles CSV migration HTTP routes
type MigratorWeb struct{}
// RegisterRoutes registers all CSV migration routes
func (c *MigratorWeb) RegisterRoutes(g *echo.Group) {
g.GET("/csv/status", c.Status)
g.PUT("/csv/detect", c.Detect)
g.PUT("/csv/preview", c.Preview)
g.PUT("/csv/migrate", c.Migrate)
}
// Status returns the migration status
// @Summary Get CSV migration status
// @Description Returns if the current user already did the CSV migration or not.
// @tags migration
// @Produce json
// @Security JWTKeyAuth
// @Success 200 {object} migration.Status "The migration status"
// @Failure 500 {object} models.Message "Internal server error"
// @Router /migration/csv/status [get]
func (c *MigratorWeb) Status(ctx *echo.Context) error {
u, err := user2.GetCurrentUser(ctx)
if err != nil {
return err
}
m := &Migrator{}
s, err := migration.GetMigrationStatus(m, u)
if err != nil {
return err
}
return ctx.JSON(http.StatusOK, s)
}
// Detect analyzes a CSV file and returns detection results
// @Summary Detect CSV structure
// @Description Analyzes a CSV file and returns auto-detected columns, delimiter, quote character, and date format with suggested column mappings.
// @tags migration
// @Accept multipart/form-data
// @Produce json
// @Security JWTKeyAuth
// @Param import formData file true "The CSV file to analyze"
// @Success 200 {object} DetectionResult "Detection results with suggested mappings"
// @Failure 400 {object} models.Message "Invalid CSV file"
// @Failure 500 {object} models.Message "Internal server error"
// @Router /migration/csv/detect [put]
func (c *MigratorWeb) Detect(ctx *echo.Context) error {
_, err := user2.GetCurrentUser(ctx)
if err != nil {
return err
}
file, err := ctx.FormFile("import")
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "No file provided")
}
src, err := file.Open()
if err != nil {
return err
}
defer src.Close()
result, err := DetectCSVStructure(src, file.Size)
if err != nil {
return err
}
return ctx.JSON(http.StatusOK, result)
}
// Preview generates a preview of the import
// @Summary Preview CSV import
// @Description Generates a preview of the first 5 tasks that would be imported with the given configuration.
// @tags migration
// @Accept multipart/form-data
// @Produce json
// @Security JWTKeyAuth
// @Param import formData file true "The CSV file to preview"
// @Param config formData string true "The import configuration JSON"
// @Success 200 {object} PreviewResult "Preview of tasks to import"
// @Failure 400 {object} models.Message "Invalid CSV file or configuration"
// @Failure 500 {object} models.Message "Internal server error"
// @Router /migration/csv/preview [put]
func (c *MigratorWeb) Preview(ctx *echo.Context) error {
_, err := user2.GetCurrentUser(ctx)
if err != nil {
return err
}
file, err := ctx.FormFile("import")
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "No file provided")
}
configStr := ctx.FormValue("config")
if configStr == "" {
return echo.NewHTTPError(http.StatusBadRequest, "No configuration provided")
}
var config ImportConfig
if err := json.Unmarshal([]byte(configStr), &config); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid configuration: "+err.Error())
}
src, err := file.Open()
if err != nil {
return err
}
defer src.Close()
result, err := PreviewImport(src, file.Size, &config)
if err != nil {
return err
}
return ctx.JSON(http.StatusOK, result)
}
// Migrate imports the CSV file
// @Summary Import CSV file
// @Description Imports tasks from a CSV file into Vikunja with the provided 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 (c *MigratorWeb) Migrate(ctx *echo.Context) error {
u, err := user2.GetCurrentUser(ctx)
if err != nil {
return err
}
file, err := ctx.FormFile("import")
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "No file provided")
}
configStr := ctx.FormValue("config")
if configStr == "" {
return echo.NewHTTPError(http.StatusBadRequest, "No configuration provided")
}
var config ImportConfig
if err := json.Unmarshal([]byte(configStr), &config); err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid configuration: "+err.Error())
}
src, err := file.Open()
if err != nil {
return err
}
defer src.Close()
m := &Migrator{}
status, err := migration.StartMigration(m, u)
if err != nil {
return err
}
err = MigrateWithConfig(u, src, file.Size, &config)
if err != nil {
return err
}
err = migration.FinishMigration(status)
if err != nil {
return err
}
return ctx.JSON(http.StatusOK, models.Message{Message: "Everything was migrated successfully."})
}