forked from EllesmereGaming/EllesmereUI
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathEllesmereUI_Migration.lua
More file actions
1272 lines (1171 loc) · 55.2 KB
/
EllesmereUI_Migration.lua
File metadata and controls
1272 lines (1171 loc) · 55.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
--------------------------------------------------------------------------------
-- EllesmereUI_Migration.lua
-- Loaded via TOC after EllesmereUI_Lite.lua, before EllesmereUI_Profiles.lua.
-- Runs at ADDON_LOADED time for "EllesmereUI" (before child addons init).
--
-- All legacy migrations have been removed. The beta-exit wipe (reset
-- version 5) guarantees every user starts from a clean slate.
--------------------------------------------------------------------------------
local floor = math.floor
--- Round all width/height values in a table to whole pixels.
--- Call from each child addon's OnInitialize after its DB is loaded.
--- keys: list of field names to round (e.g. {"width", "height"})
--- tables: list of profile sub-tables to scan
function EllesmereUI.RoundSizeFields(keys, tables)
for _, tbl in ipairs(tables) do
if type(tbl) == "table" then
for _, key in ipairs(keys) do
local v = tbl[key]
if type(v) == "number" then
tbl[key] = floor(v + 0.5)
end
end
end
end
end
--------------------------------------------------------------------------------
-- ONE-TIME MIGRATION RUNNER
--
-- Single, global system for any addon to register a one-time data migration
-- that needs to run reliably across upgrades, multiple characters, multiple
-- profiles, and multiple specs.
--
-- USAGE:
-- EllesmereUI.RegisterMigration({
-- id = "cdm_pandemic_glow_color_table",
-- scope = "profile", -- "global" | "profile" | "specProfile"
-- description = "Migrate flat pandemicR/G/B keys to pandemicGlowColor table",
-- body = function(ctx)
-- -- ctx fields depend on scope:
-- -- global -> ctx.db (= EllesmereUIDB)
-- -- profile -> ctx.profile, ctx.profileName
-- -- specProfile -> ctx.specProfile, ctx.specKey
-- local cdm = ctx.profile.addons and ctx.profile.addons.EllesmereUICooldownManager
-- local bars = cdm and cdm.cdmBars and cdm.cdmBars.bars
-- if not bars then return end
-- for _, b in ipairs(bars) do
-- if b.pandemicR and not b.pandemicGlowColor then
-- b.pandemicGlowColor = { r = b.pandemicR, g = b.pandemicG, b = b.pandemicB }
-- end
-- end
-- end,
-- })
--
-- GUARANTEES:
-- - Migration body wraps in pcall: a buggy body cannot break the runner.
-- - Flag is stamped only on success: a failed body retries next session.
-- - "global" scope flag lives at EllesmereUIDB._migrations[id]
-- - "profile" scope flag lives at profileData._migrations[id], runner walks
-- ALL profiles so multi-character is handled in one pass.
-- - "specProfile" scope flag lives at specProfData._migrations[id], runner
-- walks ALL spec profiles so multi-spec is handled in one pass.
-- - Phase: currently only "early" (parent ADDON_LOADED, before child
-- addons init). Add more phases lazily if a real need appears.
--
-- AUTHORING RULES:
-- 1. IDs are forever. Never change an existing migration's id; register a
-- new migration with a new id if logic needs to change.
-- 2. Bodies must be idempotent. Even with the flag, the body should be
-- safe to re-run on already-migrated data (predicate-gated).
-- 3. Don't iterate profiles inside a body if scope = "profile" -- the
-- runner does that for you. Same for "specProfile".
-- 4. Don't call live game APIs (UnitClass, GetSpecialization,
-- C_CooldownViewer, etc.) -- only "early" phase exists, none of these
-- are reliable yet.
-- 5. Walk raw stored data via ctx.profile / ctx.specProfile, not via
-- child.db.profile -- child addons haven't initialized yet.
--------------------------------------------------------------------------------
local _migrations = {} -- ordered registration list (1..N)
local _migrationsById = {} -- id -> spec, for dedup + lookup
local _migrationErrors = {} -- session-only error buffer for /eui migrations
EllesmereUI._migrationErrors = _migrationErrors
local VALID_SCOPES = { global = true, profile = true, specProfile = true }
function EllesmereUI.RegisterMigration(spec)
if type(spec) ~= "table" then
error("RegisterMigration: spec must be a table", 2)
end
if type(spec.id) ~= "string" or spec.id == "" then
error("RegisterMigration: spec.id must be a non-empty string", 2)
end
if type(spec.body) ~= "function" then
error("RegisterMigration: spec.body must be a function", 2)
end
if not VALID_SCOPES[spec.scope] then
error("RegisterMigration: spec.scope must be 'global', 'profile', or 'specProfile' (got '" .. tostring(spec.scope) .. "')", 2)
end
if _migrationsById[spec.id] then
error("RegisterMigration: duplicate migration id '" .. spec.id .. "'", 2)
end
_migrations[#_migrations + 1] = spec
_migrationsById[spec.id] = spec
end
-- Get (and lazily create) the per-scope flag table on the host table.
local function GetFlagTable(host)
if not host._migrations then host._migrations = {} end
return host._migrations
end
-- Run a single migration body, stamp the flag on success, log on error.
local function RunOne(spec, ctx, flagHost)
local flags = GetFlagTable(flagHost)
if flags[spec.id] then return end
local ok, err = pcall(spec.body, ctx)
if ok then
flags[spec.id] = true
else
_migrationErrors[#_migrationErrors + 1] = {
id = spec.id,
scope = spec.scope,
err = tostring(err),
time = GetTime(),
}
end
end
-- Iterate one migration across the appropriate set of targets for its scope.
local function RunMigration(spec)
if spec.scope == "global" then
RunOne(spec, { db = EllesmereUIDB }, EllesmereUIDB)
elseif spec.scope == "profile" then
if EllesmereUIDB.profiles then
for profName, profData in pairs(EllesmereUIDB.profiles) do
if type(profData) == "table" then
RunOne(spec, {
profile = profData,
profileName = profName,
}, profData)
end
end
end
elseif spec.scope == "specProfile" then
local sa = EllesmereUIDB.spellAssignments
local sp = sa and sa.specProfiles
if sp then
for specKey, specProfData in pairs(sp) do
if type(specProfData) == "table" then
RunOne(spec, {
specProfile = specProfData,
specKey = specKey,
}, specProfData)
end
end
end
end
end
-- Public: run all registered migrations. Currently called once from the
-- parent ADDON_LOADED handler after PerformResetWipe + StampResetVersion.
function EllesmereUI.RunRegisteredMigrations()
if not EllesmereUIDB then return end
for _, spec in ipairs(_migrations) do
RunMigration(spec)
end
end
-- Inspection helper for the slash command.
function EllesmereUI.GetMigrationStatus()
local out = {
registered = {},
errors = _migrationErrors,
}
for _, spec in ipairs(_migrations) do
local entry = {
id = spec.id,
scope = spec.scope,
description = spec.description or "",
ranScopes = {}, -- list of {target, ran}
}
if spec.scope == "global" then
local flags = EllesmereUIDB and EllesmereUIDB._migrations
entry.ranScopes[1] = { target = "global", ran = (flags and flags[spec.id]) and true or false }
elseif spec.scope == "profile" then
if EllesmereUIDB and EllesmereUIDB.profiles then
for profName, profData in pairs(EllesmereUIDB.profiles) do
if type(profData) == "table" then
local flags = profData._migrations
entry.ranScopes[#entry.ranScopes + 1] = {
target = profName,
ran = (flags and flags[spec.id]) and true or false,
}
end
end
end
elseif spec.scope == "specProfile" then
local sp = EllesmereUIDB and EllesmereUIDB.spellAssignments and EllesmereUIDB.spellAssignments.specProfiles
if sp then
for specKey, specProfData in pairs(sp) do
if type(specProfData) == "table" then
local flags = specProfData._migrations
entry.ranScopes[#entry.ranScopes + 1] = {
target = specKey,
ran = (flags and flags[spec.id]) and true or false,
}
end
end
end
end
out.registered[#out.registered + 1] = entry
end
return out
end
-- /eui migrations slash command. Lists registered migrations, run status
-- per scope target, and any session errors.
SLASH_EUIMIGRATIONS1 = "/euimig"
SLASH_EUIMIGRATIONS2 = "/euimigrations"
SlashCmdList["EUIMIGRATIONS"] = function()
local status = EllesmereUI.GetMigrationStatus()
print("|cff0cd29fEllesmereUI Migrations|r")
print(string.format(" Registered: %d", #status.registered))
for _, entry in ipairs(status.registered) do
local ranCount, totalCount = 0, #entry.ranScopes
for _, s in ipairs(entry.ranScopes) do if s.ran then ranCount = ranCount + 1 end end
local marker
if totalCount == 0 then
marker = "|cffaaaaaa(no targets)|r"
elseif ranCount == totalCount then
marker = "|cff00ff00OK|r"
elseif ranCount == 0 then
marker = "|cffff8800PENDING|r"
else
marker = string.format("|cffffff00%d/%d|r", ranCount, totalCount)
end
print(string.format(" [%s] %s (%s)", marker, entry.id, entry.scope))
if entry.description ~= "" then
print(" |cffaaaaaa" .. entry.description .. "|r")
end
end
if #status.errors > 0 then
print(string.format("|cffff4444Errors this session: %d|r", #status.errors))
for _, e in ipairs(status.errors) do
print(string.format(" |cffff4444[%s]|r %s", e.id, e.err))
end
else
print("|cff00ff00No errors this session.|r")
end
end
--------------------------------------------------------------------------------
-- Position snap helpers
-- File-scope helpers used by the position_snap_v3 migration below AND
-- exposed on EllesmereUI for profile import (import path calls
-- EllesmereUI.SnapProfilePositions(profData) to snap imported positions).
--
-- These are FUNCTION definitions only -- the bodies run on demand, not at
-- file load. Reads of EllesmereUIDB.ppUIScale and GetPhysicalScreenSize()
-- inside MakeSnappers() happen whenever the function is called, by which
-- time SavedVariables are loaded and the screen size API is available.
--------------------------------------------------------------------------------
local function MakeSnappers()
local physH = select(2, GetPhysicalScreenSize())
local perfect = physH and physH > 0 and (768 / physH) or 1
local uiScale = EllesmereUIDB and EllesmereUIDB.ppUIScale or perfect
if uiScale <= 0 then uiScale = perfect end
local onePixel = perfect / uiScale
local function snap(v)
if type(v) ~= "number" or v == 0 then return v end
return floor(v / onePixel + 0.5) * onePixel
end
local function snapPos(tbl)
if type(tbl) ~= "table" then return end
if tbl.x then tbl.x = snap(tbl.x) end
if tbl.y then tbl.y = snap(tbl.y) end
end
local function snapPosMap(map)
if type(map) ~= "table" then return end
for _, pos in pairs(map) do snapPos(pos) end
end
local function snapAnchors(anchors)
if type(anchors) ~= "table" then return end
for _, info in pairs(anchors) do
if type(info) == "table" then
if info.offsetX then info.offsetX = snap(info.offsetX) end
if info.offsetY then info.offsetY = snap(info.offsetY) end
end
end
end
return snapPos, snapPosMap, snapAnchors
end
-- Snap all positions in a single profile data table.
-- Called by the position_snap_v3 migration (for each profile) and by
-- profile import (for a single imported profile).
local function SnapProfilePositions(profData)
if type(profData) ~= "table" then return end
local snapPos, snapPosMap, snapAnchors = MakeSnappers()
local ul = profData.unlockLayout
if ul then snapAnchors(ul.anchors) end
local addons = profData.addons
if type(addons) ~= "table" then return end
local uf = addons.EllesmereUIUnitFrames
if uf then snapPosMap(uf.positions) end
local eab = addons.EllesmereUIActionBars
if eab then snapPosMap(eab.barPositions) end
local cdm = addons.EllesmereUICooldownManager
if cdm then snapPosMap(cdm.cdmBarPositions) end
local erb = addons.EllesmereUIResourceBars
if type(erb) == "table" then
for _, section in pairs(erb) do
if type(section) == "table" and section.unlockPos then
snapPos(section.unlockPos)
end
end
end
local abr = addons.EllesmereUIAuraBuffReminders
if type(abr) == "table" and abr.unlockPos then
snapPos(abr.unlockPos)
end
local basics = addons.EllesmereUIBasics
if type(basics) == "table" then
if basics.questTracker then snapPos(basics.questTracker.pos) end
if basics.minimap then snapPos(basics.minimap.position) end
if basics.friends then snapPos(basics.friends.position) end
end
local cursor = addons.EllesmereUICursor
if type(cursor) == "table" then
if cursor.gcd then snapPos(cursor.gcd.pos) end
if cursor.cast then snapPos(cursor.cast.pos) end
end
end
-- Expose for profile import
EllesmereUI.SnapProfilePositions = SnapProfilePositions
--------------------------------------------------------------------------------
-- Registered migrations
-- Each migration below is a one-time data transformation gated by the runner's
-- per-scope flag. Bodies must be idempotent. Legacy flag checks bridge the
-- transition from old inline migrations; they can be removed after a few
-- release cycles once all existing users have been through the new system.
--------------------------------------------------------------------------------
EllesmereUI.RegisterMigration({
id = "quest_tracker_sec_color_default",
scope = "profile",
description = "Clear questTracker.secColor if it matches the legacy hardcoded green default, so accent color fallback can take over.",
body = function(ctx)
-- Legacy bridge: skip if the old inline migration already ran.
-- Old flag location: EllesmereUIDB._questTrackerSecColorMigrated
if EllesmereUIDB and EllesmereUIDB._questTrackerSecColorMigrated then return end
local addons = ctx.profile.addons
local basics = addons and addons.EllesmereUIBasics
if not basics or not basics.questTracker then return end
local sc = basics.questTracker.secColor
if type(sc) == "table"
and sc.r == 0.047 and sc.g == 0.824 and sc.b == 0.624 then
basics.questTracker.secColor = nil
end
end,
})
EllesmereUI.RegisterMigration({
id = "friends_data_wipe_v1",
scope = "profile",
description = "Wipe legacy friends list data across all profiles (sessions 15-17 module rebuild).",
body = function(ctx)
-- Legacy bridge: skip if the old inline migration already ran.
-- Old flag location: EllesmereUIDB._friendsWipeDone
-- DESTRUCTIVE: this resets basics.friends to { enabled = wasEnabled }.
-- The bridge is critical -- without it, re-running would wipe any
-- configuration the user has made since the original migration.
if EllesmereUIDB and EllesmereUIDB._friendsWipeDone then return end
local addons = ctx.profile.addons
local basics = addons and addons.EllesmereUIBasics
if not basics or not basics.friends then return end
local wasEnabled = basics.friends.enabled
basics.friends = { enabled = wasEnabled }
end,
})
EllesmereUI.RegisterMigration({
id = "friend_notes_wipe_v1",
scope = "global",
description = "Wipe legacy bnetAccountID-keyed friendAssignments and friendNotes (sessions 15-17 rebuild).",
body = function(ctx)
-- Legacy bridge: skip if the old inline migration already ran.
-- Old flag location: EllesmereUIDB.global._friendNotesMigrated
-- DESTRUCTIVE: wipes EllesmereUIDB.global.friendAssignments and
-- .friendNotes. Without the bridge, re-running would destroy any
-- data the user has accumulated since the original migration.
if EllesmereUIDB and EllesmereUIDB.global
and EllesmereUIDB.global._friendNotesMigrated then return end
local g = ctx.db.global
if not g then return end
-- Set the one-time popup flag only if the user actually had
-- group assignments pre-wipe (so users who never used the feature
-- don't see a popup about it being "reset").
local hadAssignments = false
if g.friendAssignments then
for _ in pairs(g.friendAssignments) do
hadAssignments = true
break
end
end
if hadAssignments then
g._friendGroupReassignPopup = true
end
g.friendAssignments = {}
g.friendNotes = {}
end,
})
EllesmereUI.RegisterMigration({
id = "position_snap_v3",
scope = "global",
description = "Re-snap all stored positions (unlock anchors + every profile's position fields) to the physical pixel grid.",
body = function(ctx)
-- Legacy bridge: skip if the old inline migration already ran.
-- Old flag location: EllesmereUIDB._positionSnapV3Done
-- Idempotence: snapping already-snapped positions is a fixpoint
-- (floor(n/onePixel + 0.5) * onePixel returns n for grid values),
-- so the bridge is belt-and-suspenders here, not strictly required.
if EllesmereUIDB and EllesmereUIDB._positionSnapV3Done then return end
-- Mixed scope: one global thing (unlockAnchors) + one pass per
-- profile (via SnapProfilePositions). Registered as "global" so
-- the runner invokes the body exactly once; the body does its own
-- profile walk because the two operations are conceptually one
-- migration with a single flag.
local _, _, snapAnchors = MakeSnappers()
snapAnchors(ctx.db.unlockAnchors)
if ctx.db.profiles then
for _, profData in pairs(ctx.db.profiles) do
SnapProfilePositions(profData)
end
end
end,
})
EllesmereUI.RegisterMigration({
id = "eab_round_button_sizes",
scope = "profile",
description = "Round EllesmereUIActionBars buttonWidth/buttonHeight to whole pixels.",
body = function(ctx)
-- No legacy flag to bridge: the old inline code had no gate and
-- ran every login. Migration is naturally idempotent (floor(x+0.5)
-- is a fixpoint on integers) so running it once per profile via
-- the runner's per-profile flag produces the same result.
local eab = ctx.profile.addons and ctx.profile.addons.EllesmereUIActionBars
local bars = eab and eab.bars
if type(bars) ~= "table" or not EllesmereUI.RoundSizeFields then return end
local sizeKeys = { "buttonWidth", "buttonHeight" }
for _, barSettings in pairs(bars) do
if type(barSettings) == "table" then
EllesmereUI.RoundSizeFields(sizeKeys, { barSettings })
end
end
end,
})
EllesmereUI.RegisterMigration({
id = "erb_round_size_fields",
scope = "profile",
description = "Round EllesmereUIResourceBars width/height/pipWidth/pipHeight on primary, secondary, health, and castBar tables to whole pixels.",
body = function(ctx)
-- No legacy flag to bridge: the old inline code had no gate and
-- ran every login. Migration is naturally idempotent.
local erb = ctx.profile.addons and ctx.profile.addons.EllesmereUIResourceBars
if type(erb) ~= "table" or not EllesmereUI.RoundSizeFields then return end
local sizeKeys = { "width", "height", "pipWidth", "pipHeight" }
EllesmereUI.RoundSizeFields(sizeKeys, {
erb.primary, erb.secondary, erb.health, erb.castBar,
})
end,
})
EllesmereUI.RegisterMigration({
id = "basics_minimap_hide_buttons_split",
scope = "profile",
description = "Split legacy minimap.hideButtons boolean into per-button keys (hideZoomButtons, hideTrackingButton, hideGameTime).",
body = function(ctx)
-- Self-gating: only fires when the legacy hideButtons key still
-- exists. After running once, mp.hideButtons is nil and the body
-- is a no-op for that profile.
local basics = ctx.profile.addons and ctx.profile.addons.EllesmereUIBasics
local mp = basics and basics.minimap
if not mp or mp.hideButtons == nil then return end
if mp.hideButtons == true then
mp.hideZoomButtons = true
mp.hideTrackingButton = true
mp.hideGameTime = true
else
mp.hideZoomButtons = false
mp.hideTrackingButton = false
mp.hideGameTime = false
end
mp.hideButtons = nil
end,
})
EllesmereUI.RegisterMigration({
id = "basics_minimap_round_to_circle",
scope = "profile",
description = "Rename minimap.shape value 'round' to 'circle'.",
body = function(ctx)
local basics = ctx.profile.addons and ctx.profile.addons.EllesmereUIBasics
local mp = basics and basics.minimap
if not mp then return end
if mp.shape == "round" then
mp.shape = "circle"
end
end,
})
EllesmereUI.RegisterMigration({
id = "basics_minimap_strip_scale",
scope = "profile",
description = "Strip the deprecated minimap.scale field. Direct sizing via the snapshot replaces it.",
body = function(ctx)
local basics = ctx.profile.addons and ctx.profile.addons.EllesmereUIBasics
local mp = basics and basics.minimap
if not mp then return end
mp.scale = nil
end,
})
EllesmereUI.RegisterMigration({
id = "cdm_pandemic_glow_color_table",
scope = "profile",
description = "Migrate CDM bar flat pandemicR/G/B keys into a pandemicGlowColor table, plus default pandemicGlowStyle.",
body = function(ctx)
-- No legacy flag to bridge: original inline migration was self-gated
-- by the `pandemicR and not pandemicGlowColor` predicate. Body is
-- naturally idempotent (only fires when the legacy flat keys exist
-- AND the new table is missing) so the runner's per-profile flag
-- stops further runs after the first successful pass.
local cdm = ctx.profile.addons and ctx.profile.addons.EllesmereUICooldownManager
local cdmBars = cdm and cdm.cdmBars
local bars = cdmBars and cdmBars.bars
if type(bars) ~= "table" then return end
for _, barData in ipairs(bars) do
if type(barData) == "table"
and barData.pandemicR
and not barData.pandemicGlowColor then
barData.pandemicGlowColor = {
r = barData.pandemicR or 1,
g = barData.pandemicG or 1,
b = barData.pandemicB or 0,
}
barData.pandemicGlowStyle = barData.pandemicGlowStyle or 1
end
end
end,
})
EllesmereUI.RegisterMigration({
id = "cdm_repair_bar_keys_v1",
scope = "profile",
description = "Repair CDM bars that lost their `key` field via Lite DB delta-strip. Assigns missing core keys (cooldowns, utility, buffs) in order.",
body = function(ctx)
-- Self-gating via `if not bd.key` -- once every bar has a key,
-- the loop is a no-op. The runner's per-profile flag stops
-- further runs after the first successful pass.
--
-- Originally paired with a Tier 2 defensive guard in
-- BuildAllCDMBars; that inline guard was deleted after verifying
-- the round-trip (StripDefaults -> save -> load -> DeepMergeDefaults)
-- correctly restores identity fields in every code path
-- including profile switch and import. The runner pass is the
-- one-time recovery for any user with pre-existing broken data.
local cdm = ctx.profile.addons and ctx.profile.addons.EllesmereUICooldownManager
local cdmBars = cdm and cdm.cdmBars
local bars = cdmBars and cdmBars.bars
if type(bars) ~= "table" then return end
local CORE_KEYS = { "cooldowns", "utility", "buffs" }
local CORE_NAMES = { cooldowns = "Cooldowns", utility = "Utility", buffs = "Buffs" }
local present = {}
for _, bd in ipairs(bars) do
if bd.key then present[bd.key] = true end
end
local missing = {}
for _, ck in ipairs(CORE_KEYS) do
if not present[ck] then missing[#missing + 1] = ck end
end
if #missing == 0 then return end
local mi = 1
for _, bd in ipairs(bars) do
if not bd.key and mi <= #missing then
bd.key = missing[mi]
bd.name = bd.name or CORE_NAMES[missing[mi]]
if bd.enabled == nil then bd.enabled = true end
mi = mi + 1
end
end
end,
})
EllesmereUI.RegisterMigration({
id = "cdm_remove_misc_bars",
scope = "profile",
description = "Remove obsolete CDM bars with barType=='misc' and clear anchorTo references that pointed at them.",
body = function(ctx)
-- Self-gating via the barType check -- once misc bars are gone,
-- the predicate is false and the body is a no-op. Previously ran
-- every BuildAllCDMBars call against the active profile only;
-- the runner promotes this to once-per-profile-ever and walks
-- all profiles automatically.
local cdm = ctx.profile.addons and ctx.profile.addons.EllesmereUICooldownManager
local cdmBars = cdm and cdm.cdmBars
local bars = cdmBars and cdmBars.bars
if type(bars) ~= "table" then return end
-- Pass 1: find and remove misc bars (reverse iteration so removal
-- doesn't shift indices we still need to visit).
local miscKeys = {}
for i = #bars, 1, -1 do
if bars[i].barType == "misc" then
miscKeys[bars[i].key] = true
table.remove(bars, i)
end
end
-- Pass 2: clear anchorTo on any remaining bar that referenced
-- a removed misc bar.
if next(miscKeys) then
for _, bd in ipairs(bars) do
if bd.anchorTo and miscKeys[bd.anchorTo] then
bd.anchorTo = "none"
end
end
end
end,
})
EllesmereUI.RegisterMigration({
id = "cdm_active_state_anim_none_to_hideactive",
scope = "profile",
description = "Rename CDM bar activeStateAnim value 'none' (No Animation) to 'hideActive' (Hide Active State).",
body = function(ctx)
-- Self-gating via the value check -- once no bar holds the legacy
-- 'none' value, the body is a no-op. MUST register before
-- cdm_active_state_per_bar_to_per_icon, which depends on bars
-- carrying the post-rename 'hideActive' value.
--
-- Previously ran every BuildAllCDMBars call against the active
-- profile only; the runner promotes this to once-per-profile-ever
-- and walks all profiles automatically.
local cdm = ctx.profile.addons and ctx.profile.addons.EllesmereUICooldownManager
local cdmBars = cdm and cdm.cdmBars
local bars = cdmBars and cdmBars.bars
if type(bars) ~= "table" then return end
for _, bd in ipairs(bars) do
if bd.activeStateAnim == "none" then
bd.activeStateAnim = "hideActive"
end
end
end,
})
EllesmereUI.RegisterMigration({
id = "cdm_mouseover_visibility_to_always",
scope = "profile",
description = "Rewrite CDM bar barVisibility 'mouseover' to 'always' (mouseover mode was removed).",
body = function(ctx)
-- Self-gating: once no bar carries 'mouseover', the body is a no-op.
-- Previously ran every CDMFinishSetup against the active profile;
-- the runner promotes this to once-per-profile-ever and walks
-- all profiles automatically.
local cdm = ctx.profile.addons and ctx.profile.addons.EllesmereUICooldownManager
local cdmBars = cdm and cdm.cdmBars
local bars = cdmBars and cdmBars.bars
if type(bars) ~= "table" then return end
for _, bd in ipairs(bars) do
if bd.barVisibility == "mouseover" then
bd.barVisibility = "always"
end
end
end,
})
EllesmereUI.RegisterMigration({
id = "cdm_strip_tbb_linked_frames",
scope = "specProfile",
description = "Strip stale _linkedFrame/_linkedCdID/_linkedGen fields from Tracked Buff Bar configs (legacy bloat from removed frame-tree serialization).",
body = function(ctx)
-- Naturally idempotent: setting nil to nil is a no-op. The runner's
-- per-spec-profile flag stops further runs after the first pass.
--
-- Previously ran every CDM OnInitialize against every spec profile.
-- The runner promotes this to once-per-spec-profile-ever.
--
-- The bug-producing code (frame tree serialization into TBB
-- configs) was removed -- this migration is pure legacy cleanup,
-- nothing in the live codebase writes these fields anymore.
local tbb = ctx.specProfile.trackedBuffBars
local tbbBars = tbb and tbb.bars
if type(tbbBars) ~= "table" then return end
for _, barCfg in ipairs(tbbBars) do
barCfg._linkedFrame = nil
barCfg._linkedCdID = nil
barCfg._linkedGen = nil
end
end,
})
EllesmereUI.RegisterMigration({
id = "cdm_removed_spells_to_ghost_cd_bar",
scope = "specProfile",
description = "Move per-bar removedSpells (legacy filter mechanic) into the ghost CD bar's assignedSpells. The ghost bar replaced the per-bar removedSpells filter.",
body = function(ctx)
-- Naturally idempotent: removedSpells is wiped after migration,
-- so a second run finds nothing. The runner's per-spec-profile
-- flag also stops further runs after the first pass.
--
-- Previously ran in EnsureGhostBars which was called from
-- BuildAllCDMBars on every CDM rebuild (login, spec change,
-- profile swap, options change, layout reset, etc.). The runner
-- promotes this to a single per-spec-profile pass.
--
-- Skips work entirely if no bar has any removedSpells, so cold
-- specs without legacy data don't get an empty ghost bar entry
-- pre-created on their behalf -- the runtime getter creates the
-- ghost bar entry when something actually needs to write to it.
local barSpells = ctx.specProfile.barSpells
if type(barSpells) ~= "table" then return end
local GHOST_CD = "__ghost_cd"
local GHOST_BUFF = "__ghost_buffs"
-- First pass: is there anything to migrate?
local hasWork = false
for barKey, bs in pairs(barSpells) do
if barKey ~= GHOST_CD and barKey ~= GHOST_BUFF
and type(bs) == "table"
and bs.removedSpells and next(bs.removedSpells) then
hasWork = true
break
end
end
if not hasWork then return end
-- Ensure ghost CD bar entry exists in the spell store.
local ghostBS = barSpells[GHOST_CD]
if not ghostBS then
ghostBS = {}
barSpells[GHOST_CD] = ghostBS
end
if not ghostBS.assignedSpells then ghostBS.assignedSpells = {} end
-- Build dedupe set from any spells already on the ghost bar.
local existing = {}
for _, sid in ipairs(ghostBS.assignedSpells) do existing[sid] = true end
-- Second pass: migrate and wipe.
for barKey, bs in pairs(barSpells) do
if barKey ~= GHOST_CD and barKey ~= GHOST_BUFF
and type(bs) == "table"
and bs.removedSpells and next(bs.removedSpells) then
for sid in pairs(bs.removedSpells) do
if not existing[sid] then
existing[sid] = true
ghostBS.assignedSpells[#ghostBS.assignedSpells + 1] = sid
end
end
wipe(bs.removedSpells)
end
end
end,
})
-- Inline copy of every racial spell ID across every race. Mirrors
-- RACE_RACIALS in EllesmereUICooldownManager.lua. Used by the ghost CD
-- bar cleanup migration so we can identify racials without depending
-- on the CDM child addon (which loads after the migration runner fires)
-- or the per-character _myRacialsSet (which only contains the current
-- character's racials). If a new race ships, update both copies.
local CDM_ALL_RACIAL_SPELL_IDS = {
[7744] = true, [20549] = true,
[20572] = true, [33697] = true, [33702] = true,
[202719] = true, [50613] = true, [25046] = true, [69179] = true,
[80483] = true, [155145] = true, [129597] = true, [232633] = true, [28730] = true,
[20594] = true, [26297] = true,
[28880] = true, [59543] = true, [59545] = true, [121093] = true,
[59544] = true, [370626] = true, [59547] = true, [59548] = true, [59542] = true, [416250] = true,
[58984] = true, [59752] = true,
[265221] = true, [20589] = true, [69041] = true, [68992] = true, [69070] = true,
[107079] = true, [274738] = true, [255647] = true, [256948] = true, [287712] = true,
[291944] = true, [312411] = true, [312924] = true,
[357214] = true, [368970] = true,
[436344] = true, [1287685] = true,
}
EllesmereUI.RegisterMigration({
id = "cdm_ghost_cd_bar_cleanup_v3",
scope = "specProfile",
description = "Strip junk entries from the ghost CD bar: negative IDs (presets/trinkets), racials, customs (best-effort), and duplicates of spells already on a real bar.",
body = function(ctx)
-- Naturally idempotent: after the first run, the junk entries
-- are gone, and the runner's per-spec-profile flag stops
-- further runs anyway.
--
-- Previously ran in EnsureGhostBars on every CDM rebuild and
-- gated by a manual prof._ghostBarCleaned3 flag. The runner
-- promotes this to a single per-spec-profile pass with its
-- own flag, so the manual one is gone.
--
-- Customs detection is best-effort: the bs.customSpellIDs
-- stamping was added in v6.1, so customs added before that
-- won't be detected. Customs that were tracked via the legacy
-- bs.customSpells field are also undetectable now (C5 wiped
-- those). Stale customs that slip through stay on the ghost
-- bar (which is hidden, so the impact is purely cosmetic).
local barSpells = ctx.specProfile.barSpells
if type(barSpells) ~= "table" then return end
local GHOST_CD = "__ghost_cd"
local GHOST_BUFF = "__ghost_buffs"
local ghostBS = barSpells[GHOST_CD]
if not (ghostBS and ghostBS.assignedSpells) then return end
-- Build sets of spells currently on real bars + currently stamped as custom.
local realBarSpells = {}
local customSet = {}
for bk, bs in pairs(barSpells) do
if bk ~= GHOST_CD and bk ~= GHOST_BUFF and type(bs) == "table" then
if bs.assignedSpells then
for _, sid in ipairs(bs.assignedSpells) do
if sid and sid > 0 then realBarSpells[sid] = true end
end
end
if bs.customSpellIDs then
for sid in pairs(bs.customSpellIDs) do
customSet[sid] = true
end
end
end
end
for i = #ghostBS.assignedSpells, 1, -1 do
local sid = ghostBS.assignedSpells[i]
if sid and (
sid <= 0
or customSet[sid]
or CDM_ALL_RACIAL_SPELL_IDS[sid]
or realBarSpells[sid]
) then
table.remove(ghostBS.assignedSpells, i)
end
end
end,
})
EllesmereUI.RegisterMigration({
id = "cdm_strip_legacy_spell_keys",
scope = "specProfile",
description = "Strip legacy trackedSpells/customSpells keys from CDM bar data. The current shape is bs.assignedSpells; legacy keys are no longer written by any code path.",
body = function(ctx)
-- Naturally idempotent: setting nil to nil is a no-op. The runner's
-- per-spec-profile flag stops further runs after the first pass.
--
-- Previously ran on every GetBarSpellData/GetBarSpellDataForSpec
-- call as a lazy in-getter migration. That hot read path executed
-- the nil-check on every spell read, route lookup, picker query,
-- BuildAllCDMBars iteration, etc. The runner promotes this to a
-- single per-spec-profile pass.
--
-- Cold profiles with legacy data lose those entries rather than
-- being auto-ported into the new shape -- the schema has drifted
-- enough that auto-porting would likely produce broken entries.
-- The bar simply comes up with assignedSpells == nil, which the
-- new-spec auto-population path correctly interprets as "never
-- seen" and fills with defaults.
local barSpells = ctx.specProfile.barSpells
if type(barSpells) ~= "table" then return end
for _, bs in pairs(barSpells) do
if type(bs) == "table" then
bs.trackedSpells = nil
bs.customSpells = nil
end
end
end,
})
EllesmereUI.RegisterMigration({
id = "cdm_consolidate_buff_bars",
scope = "global",
description = "Remove all extra (custom) buff bars across every parent profile, nil stale assignedSpells on the main buffs bar across every spec profile, and prune orphaned spell data for the deleted bars. Replaces the old _buffBarMigrationV2Done and _buffsBarCleanupV2 inline migrations.",
body = function(ctx)
-- Naturally idempotent: after the first run, no extra buff bars
-- exist to remove and main-buffs assignedSpells is already nil.
-- The runner's global flag also stops further runs.
--
-- The main "buffs" bar is Blizzard-owned and auto-populated from
-- the CDM viewer -- it should never have manual assignedSpells.
-- Extra/custom buff bars (custom_5_1234 etc.) were removed from
-- the system entirely; their bar list entries and spell data
-- both need to go.
--
-- This consolidates two previous inline migrations:
-- _buffBarMigrationV2Done (v5.6.5) -- the full cleanup
-- _buffsBarCleanupV2 (v6.0.4) -- defensive re-run of the
-- main-buffs nil step
-- Both have been live since late March / early April 2026 so
-- most users have already had them run; for those users the
-- new migration is a pure no-op.
local removedBuffBarKeys = {}
-- 1. Walk every parent profile, drop extra buff bars from the
-- bar list, and remember their keys for the spell-data prune.
if ctx.db.profiles then
for _, profData in pairs(ctx.db.profiles) do
local cdm = profData.addons and profData.addons.EllesmereUICooldownManager
local cdmBars = cdm and cdm.cdmBars
local bars = cdmBars and cdmBars.bars
if type(bars) == "table" then
local kept = {}
for _, bd in ipairs(bars) do
if bd.barType == "buffs" and bd.key ~= "buffs" then
removedBuffBarKeys[bd.key] = true
else
kept[#kept + 1] = bd
end
end
cdmBars.bars = kept
end
end
end
-- 2. Walk every spec profile, nil stale main-buffs assignedSpells
-- and prune orphaned spell data for deleted extra bars.
local sa = ctx.db.spellAssignments
local specProfiles = sa and sa.specProfiles
if type(specProfiles) == "table" then
for _, specProf in pairs(specProfiles) do
local barSpells = specProf.barSpells
if type(barSpells) == "table" then
if barSpells["buffs"] then
barSpells["buffs"].assignedSpells = nil
end
for removedKey in pairs(removedBuffBarKeys) do
barSpells[removedKey] = nil
end
end
end
end
-- 3. Wipe the old manual flag bytes -- the runner's own flag
-- replaces them and the old ones are dead bloat in SV.
ctx.db._buffBarMigrationV2Done = nil
ctx.db._buffsBarCleanupV2 = nil
end,
})
EllesmereUI.RegisterMigration({
id = "cdm_wipe_legacy_glows_tbb_locations",
scope = "global",
description = "Wipe legacy bar glows / TBB / tbbPositions storage locations. The data was moved to per-spec storage in v5.5.7. No live code writes to these locations anymore -- they are dead bytes that the previous inline migration was still trying to copy out.",
body = function(ctx)
-- Naturally idempotent: nil = nil. The runner's global flag
-- also stops further runs.
--
-- Previously the inline CDMFinishSetup migration COPIED these