fix(migration): reuse existing labels on re-import

Seed the dedup map at the start of insertFromStructure with the importing
user's existing labels, keyed by title + normalized hex color. Previously
the map was empty on each run, so importing the same CSV (or any other
migration format) twice would create a second copy of every label.

Scoped to the user's own labels so imports don't silently link to other
users' labels visible via shared projects.

Fixes #2742
This commit is contained in:
Tink bot 2026-05-19 08:48:30 +00:00 committed by kolaente
parent 3c048223c3
commit fa6e1f8e49
2 changed files with 69 additions and 2 deletions

View File

@ -27,6 +27,7 @@ import (
"code.vikunja.io/api/pkg/models"
"code.vikunja.io/api/pkg/modules/background/handler"
"code.vikunja.io/api/pkg/user"
"code.vikunja.io/api/pkg/utils"
)
// InsertFromStructure takes a fully nested Vikunja data structure and a user and then creates everything for this user
@ -57,7 +58,17 @@ func insertFromStructure(s *xorm.Session, str []*models.ProjectWithTasksAndBucke
log.Debugf("[creating structure] Creating %d projects", len(str))
// Seed the dedup map with the user's existing labels so re-imports
// reuse them instead of creating duplicates (see issue #2742).
labels := make(map[string]*models.Label)
existingLabels := []*models.Label{}
if err = s.Where("created_by_id = ?", user.ID).Find(&existingLabels); err != nil {
return err
}
for _, l := range existingLabels {
labels[l.Title+utils.NormalizeHex(l.HexColor)] = l
}
archivedProjects := []int64{}
childRelations := make(map[int64][]int64) // old id is the key, slice of old children ids
@ -436,14 +447,15 @@ func createProjectWithEverything(s *xorm.Session, project *models.ProjectWithTas
if label == nil {
continue
}
lb, exists = labels[label.Title+label.HexColor]
key := label.Title + utils.NormalizeHex(label.HexColor)
lb, exists = labels[key]
if !exists {
err = label.Create(s, user)
if err != nil {
return err
}
log.Debugf("[creating structure] Created new label %d", label.ID)
labels[label.Title+label.HexColor] = label
labels[key] = label
lb = label
}

View File

@ -155,4 +155,59 @@ func TestInsertFromStructure(t *testing.T) {
assert.NotEqual(t, 0, testStructure[1].Tasks[0].BucketID) // Should get the default bucket
assert.NotEqual(t, 0, testStructure[1].Tasks[6].BucketID) // Should get the default bucket
})
t.Run("reuses existing labels across imports", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
makeStructure := func() []*models.ProjectWithTasksAndBuckets {
return []*models.ProjectWithTasksAndBuckets{
{
Project: models.Project{Title: "Import project"},
Tasks: []*models.TaskWithComments{
{
Task: models.Task{
Title: "Task with label",
Labels: []*models.Label{
{Title: "Mealie", HexColor: "abcdef"},
},
},
},
},
},
}
}
require.NoError(t, InsertFromStructure(makeStructure(), u))
require.NoError(t, InsertFromStructure(makeStructure(), u))
s := db.NewSession()
defer s.Close()
count, err := s.Where("created_by_id = ? AND title = ?", u.ID, "Mealie").Count(&models.Label{})
require.NoError(t, err)
assert.Equal(t, int64(1), count, "second import must reuse the existing 'Mealie' label")
})
t.Run("does not merge into another user's label", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
// Fixture label #3 'Label #3 - other user' is created_by_id: 2.
// Importing the same title for user 1 must create a new, user-owned label.
structure := []*models.ProjectWithTasksAndBuckets{
{
Project: models.Project{Title: "Import project"},
Tasks: []*models.TaskWithComments{
{
Task: models.Task{
Title: "Task",
Labels: []*models.Label{{Title: "Label #3 - other user"}},
},
},
},
},
}
require.NoError(t, InsertFromStructure(structure, u))
db.AssertExists(t, "labels", map[string]interface{}{
"title": "Label #3 - other user",
"created_by_id": u.ID,
}, false)
})
}