refactor(migration): extract file/CSV migrate orchestration into shared funcs

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).
This commit is contained in:
kolaente 2026-06-12 10:05:28 +02:00
parent 8ff4696786
commit 13106a03ff
3 changed files with 51 additions and 42 deletions

View File

@ -107,28 +107,28 @@ var AllTaskAttributes = []TaskAttribute{
// ColumnMapping represents a mapping from a CSV column to a task attribute
type ColumnMapping struct {
ColumnIndex int `json:"column_index"`
ColumnName string `json:"column_name"`
Attribute TaskAttribute `json:"attribute"`
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"`
Delimiter string `json:"delimiter"`
QuoteChar string `json:"quote_char"`
DateFormat string `json:"date_format"`
SuggestedMapping []ColumnMapping `json:"suggested_mapping"`
PreviewRows [][]string `json:"preview_rows"`
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"`
QuoteChar string `json:"quote_char"`
DateFormat string `json:"date_format"`
SkipRows int `json:"skip_rows"`
Mapping []ColumnMapping `json:"mapping"`
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
@ -146,8 +146,8 @@ type PreviewTask struct {
// PreviewResult contains preview data before import
type PreviewResult struct {
Tasks []PreviewTask `json:"tasks"`
TotalRows int `json:"total_rows"`
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
@ -557,6 +557,22 @@ 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 {

View File

@ -186,19 +186,7 @@ func (c *MigratorWeb) Migrate(ctx *echo.Context) error {
}
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 {
if err := RunMigration(u, src, file.Size, &config); err != nil {
return err
}

View File

@ -17,6 +17,7 @@
package handler
import (
"io"
"net/http"
"code.vikunja.io/api/pkg/models"
@ -36,6 +37,22 @@ func (fw *FileMigratorWeb) RegisterRoutes(g *echo.Group) {
g.PUT("/"+ms.Name()+"/migrate", fw.Migrate)
}
// RunFileMigration records the migration's start, runs the file migrator and
// records its finish. Shared by the v1 and v2 HTTP layers so the orchestration
// lives in one place; the caller supplies the already-opened upload.
func RunFileMigration(ms migration.FileMigrator, u *user2.User, file io.ReaderAt, size int64) error {
m, err := migration.StartMigration(ms, u)
if err != nil {
return err
}
if err := ms.Migrate(u, file, size); err != nil {
return err
}
return migration.FinishMigration(m)
}
// Migrate calls the migration method
func (fw *FileMigratorWeb) Migrate(c *echo.Context) error {
ms := fw.MigrationStruct()
@ -56,19 +73,7 @@ func (fw *FileMigratorWeb) Migrate(c *echo.Context) error {
}
defer src.Close()
m, err := migration.StartMigration(ms, user)
if err != nil {
return err
}
// Do the migration
err = ms.Migrate(user, src, file.Size)
if err != nil {
return err
}
err = migration.FinishMigration(m)
if err != nil {
if err := RunFileMigration(ms, user, src, file.Size); err != nil {
return err
}