diff --git a/pkg/modules/migration/create_from_structure.go b/pkg/modules/migration/create_from_structure.go index 7803d0af5..0e9c9b942 100644 --- a/pkg/modules/migration/create_from_structure.go +++ b/pkg/modules/migration/create_from_structure.go @@ -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 } diff --git a/pkg/modules/migration/create_from_structure_test.go b/pkg/modules/migration/create_from_structure_test.go index a8c7b8a89..c449db145 100644 --- a/pkg/modules/migration/create_from_structure_test.go +++ b/pkg/modules/migration/create_from_structure_test.go @@ -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) + }) }