diff --git a/store/migrator.go b/store/migrator.go index d5446fcabafbb..4cdb64a38ee15 100644 --- a/store/migrator.go +++ b/store/migrator.go @@ -18,6 +18,11 @@ import ( storepb "github.com/usememos/memos/proto/gen/store" ) +const ( + // tagNormalizationSettingName is the setting name to track tag normalization status. + tagNormalizationSettingName = "tag_normalization_done" +) + // Migration System Overview: // // The migration system handles database schema versioning and upgrades. @@ -134,6 +139,10 @@ func (s *Store) Migrate(ctx context.Context) error { return errors.Wrap(err, "failed to apply migrations") } } + // Run data migrations after schema migrations. + if err := s.normalizeTagsToLowercase(ctx); err != nil { + return errors.Wrap(err, "failed to normalize tags") + } case modeDemo: // In demo mode, we should seed the database. if err := s.seed(ctx); err != nil { @@ -412,3 +421,93 @@ func (s *Store) checkMinimumUpgradeVersion(ctx context.Context) error { // So this should not be an issue in normal operation return nil } + +// normalizeTagsToLowercase normalizes all memo tags to lowercase. +// This is a one-time data migration to fix the issue where old memos have mixed-case tags +// while new memos have lowercase tags, causing duplicate tags in the UI. +func (s *Store) normalizeTagsToLowercase(ctx context.Context) error { + // Check if normalization has already been done. + settings, err := s.driver.ListInstanceSettings(ctx, &FindInstanceSetting{ + Name: tagNormalizationSettingName, + }) + if err != nil { + return errors.Wrap(err, "failed to check tag normalization status") + } + for _, setting := range settings { + if setting.Name == tagNormalizationSettingName && setting.Value == "true" { + slog.Info("tag normalization already completed, skipping") + return nil + } + } + + slog.Info("starting tag normalization migration") + + // Process memos in batches. + const batchSize = 100 + offset := 0 + normalized := 0 + + for { + limit := batchSize + memos, err := s.ListMemos(ctx, &FindMemo{ + Limit: &limit, + Offset: &offset, + }) + if err != nil { + return errors.Wrap(err, "failed to list memos") + } + + if len(memos) == 0 { + break + } + + for _, memo := range memos { + if memo.Payload == nil || len(memo.Payload.Tags) == 0 { + continue + } + + // Check if any tags need normalization. + needsUpdate := false + normalizedTags := make([]string, 0, len(memo.Payload.Tags)) + seen := make(map[string]bool) + + for _, tag := range memo.Payload.Tags { + lower := strings.ToLower(tag) + if lower != tag { + needsUpdate = true + } + if !seen[lower] { + seen[lower] = true + normalizedTags = append(normalizedTags, lower) + } + } + + if needsUpdate || len(normalizedTags) != len(memo.Payload.Tags) { + memo.Payload.Tags = normalizedTags + if err := s.UpdateMemo(ctx, &UpdateMemo{ + ID: memo.ID, + Payload: memo.Payload, + }); err != nil { + slog.Error("failed to update memo tags", "memoID", memo.ID, "err", err) + continue + } + normalized++ + } + } + + offset += len(memos) + } + + slog.Info("tag normalization completed", "normalizedMemos", normalized) + + // Mark normalization as done. + if _, err := s.driver.UpsertInstanceSetting(ctx, &InstanceSetting{ + Name: tagNormalizationSettingName, + Value: "true", + Description: "Tag normalization migration completed", + }); err != nil { + return errors.Wrap(err, "failed to mark tag normalization as done") + } + + return nil +} diff --git a/store/test/migrator_test.go b/store/test/migrator_test.go index a76f27ee15eb6..28154cbd96db5 100644 --- a/store/test/migrator_test.go +++ b/store/test/migrator_test.go @@ -5,6 +5,9 @@ import ( "testing" "github.com/stretchr/testify/require" + + storepb "github.com/usememos/memos/proto/gen/store" + "github.com/usememos/memos/store" ) func TestGetCurrentSchemaVersion(t *testing.T) { @@ -15,3 +18,54 @@ func TestGetCurrentSchemaVersion(t *testing.T) { require.NoError(t, err) require.Equal(t, "0.25.1", currentSchemaVersion) } + +func TestTagNormalization(t *testing.T) { + ctx := context.Background() + ts := NewTestingStore(ctx, t) + + // Create a user first. + user, err := ts.CreateUser(ctx, &store.User{ + Username: "test_tag_user", + Role: store.RoleUser, + Email: "tagtest@example.com", + Nickname: "Tag Test User", + }) + require.NoError(t, err) + + // Create memos with mixed-case tags. + memo1, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "test-memo-1", + CreatorID: user.ID, + Content: "Test memo with #House tag", + Visibility: store.Private, + Payload: &storepb.MemoPayload{ + Tags: []string{"House", "IMPORTANT", "work"}, + }, + }) + require.NoError(t, err) + + memo2, err := ts.CreateMemo(ctx, &store.Memo{ + UID: "test-memo-2", + CreatorID: user.ID, + Content: "Test memo with #house tag (already lowercase)", + Visibility: store.Private, + Payload: &storepb.MemoPayload{ + Tags: []string{"house", "important"}, + }, + }) + require.NoError(t, err) + + // Verify original tags. + memos, err := ts.ListMemos(ctx, &store.FindMemo{ID: &memo1.ID}) + require.NoError(t, err) + require.Len(t, memos, 1) + require.Contains(t, memos[0].Payload.Tags, "House") + require.Contains(t, memos[0].Payload.Tags, "IMPORTANT") + + // memo2 was already lowercase, should remain unchanged. + memos2, err := ts.ListMemos(ctx, &store.FindMemo{ID: &memo2.ID}) + require.NoError(t, err) + require.Len(t, memos2, 1) + require.Contains(t, memos2[0].Payload.Tags, "house") + require.Contains(t, memos2[0].Payload.Tags, "important") +}