Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions store/migrator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
54 changes: 54 additions & 0 deletions store/test/migrator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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: "[email protected]",
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")
}