Files
Brues 73b409fb88 Route slash registration through pfUI.api.RegisterSlashCommand
The RegisterSlashCommand helper in api/api.lua was effectively unused
(only macrotweak called it); every other command hand-rolled the
SLASH_*/SlashCmdList pair. Convert the existing manual registrations to
the helper with force=true, preserving the current always-bind behavior
while centralizing the pattern behind one code path (and its _G. and
conflict-check handling).

Left as-is: pfUI.lua's /rl, /pfui, /gm (registered before api.lua
defines the helper) and the vendored libs' debug commands.
2026-07-13 00:06:20 -05:00

397 lines
14 KiB
Lua

pfUI:RegisterModule("share", function ()
-- Profile sharing rides ClassicAPI's C_EncodingUtil:
-- export: diff vs defaults -> SerializeCBOR -> CompressString (zlib)
-- -> EncodeBase64, prefixed with the format magic
-- import: the reverse, into a plain table — no loadstring, so a
-- pasted profile can't execute code
-- The Decode/Encode button converts between the transport blob and an
-- editable JSON view of the same table.
local MAGIC = "!pf1!"
-- Deep-diff `tbl` against `default`, keeping only changed or added
-- values. `ignored` keys are skipped at the top level only (matches the
-- old serializer: "position"/"disabled" when Ignore Layout is checked).
-- Returns nil when nothing differs.
local function DiffConfig(tbl, default, ignored)
local diff = nil
for k, v in pairs(tbl) do
if not ( ignored and ignored[k] ) then
local dv = default and default[k]
if type(v) == "table" then
local sub = DiffConfig(v, type(dv) == "table" and dv or nil)
if sub then
diff = diff or {}
diff[k] = sub
end
elseif v ~= dv and ( type(v) == "string" or type(v) == "number" or type(v) == "boolean" ) then
diff = diff or {}
diff[k] = v
end
end
end
return diff
end
-- EditBoxes don't soft-wrap one giant unbroken line; chunk the blob.
local function wrap(str, width)
local out = {}
for i = 1, strlen(str), width do
table.insert(out, strsub(str, i, i + width - 1))
end
return table.concat(out, "\n")
end
local function Encode(tbl)
return MAGIC .. wrap(C_EncodingUtil.EncodeBase64(
C_EncodingUtil.CompressString(
C_EncodingUtil.SerializeCBOR(tbl))), 92)
end
local function Decode(text)
if not text then return nil end
text = gsub(text, "%s", "")
if strsub(text, 1, strlen(MAGIC)) ~= MAGIC then return nil end
local ok, result = pcall(function()
return C_EncodingUtil.DeserializeCBOR(
C_EncodingUtil.DecompressString(
C_EncodingUtil.DecodeBase64(strsub(text, strlen(MAGIC) + 1))))
end)
if ok and type(result) == "table" then return result end
return nil
end
local function DecodeJSON(text)
if not text or gsub(text, "%s", "") == "" then return nil end
local ok, result = pcall(function()
return C_EncodingUtil.DeserializeJSON(text)
end)
if ok and type(result) == "table" then return result end
return nil
end
-- Legacy import (pre-!pf1! exports): base64 -> LZW -> Lua source
-- "pfUI_config = {...}". The base64 layer is standard and handled by
-- C_EncodingUtil.DecodeBase64; only the custom LZW format needs the old
-- Lua decoder. Import-only — new exports always use the CBOR pipeline.
local function decompress(input)
-- based on Rochet2's lzw compression
if type(input) ~= "string" or strlen(input) < 1 then
return nil
end
local control = strsub(input, 1, 1)
if control == "u" then
return strsub(input, 2)
elseif control ~= "c" then
return nil
end
input = strsub(input, 2)
local len = strlen(input)
if len < 2 then
return nil
end
local dict = {}
for i = 0, 255 do
local ic, iic = strchar(i), strchar(i, 0)
dict[iic] = ic
end
local a, b = 0, 1
local result = {}
local n = 1
local last = strsub(input, 1, 2)
result[n] = dict[last]
n = n+1
for i = 3, len, 2 do
local code = strsub(input, i, i+1)
local lastStr = dict[last]
if not lastStr then
return nil
end
local toAdd = dict[code]
if toAdd then
result[n] = toAdd
n = n+1
local str = lastStr..strsub(toAdd, 1, 1)
if a >= 256 then
a, b = 0, b+1
if b >= 256 then
dict = {}
b = 1
end
end
dict[strchar(a,b)] = str
a = a+1
else
local str = lastStr..strsub(lastStr, 1, 1)
result[n] = str
n = n+1
if a >= 256 then
a, b = 0, b+1
if b >= 256 then
dict = {}
b = 1
end
end
dict[strchar(a,b)] = str
a = a+1
end
last = code
end
return table.concat(result)
end
local function DecodeLegacy(text)
if not text or gsub(text, "%s", "") == "" then return nil end
-- encoded blob? peel base64 + LZW down to Lua source. Raw (already
-- decoded) source pastes are accepted as-is.
local source = text
local stripped = gsub(text, "%s", "")
local ok, decoded = pcall(function()
return C_EncodingUtil.DecodeBase64(stripped)
end)
if ok and decoded then
local decompressed = decompress(decoded)
if decompressed then source = decompressed end
end
local chunk = loadstring(source)
if not chunk then return nil end
-- Sandbox the chunk: an empty environment means it can assign its
-- config table but can't reach any global or API function — a legacy
-- profile string is data, never code.
local sandbox = {}
setfenv(chunk, sandbox)
if not pcall(chunk) then return nil end
if type(sandbox.pfUI_config) == "table" then return sandbox.pfUI_config end
return nil
end
do -- Window
local f = CreateFrame("Frame", "pfShare", UIParent)
f:Hide()
f:SetPoint("CENTER", 0, 0)
f:SetWidth(580)
f:SetHeight(420)
f:SetMovable(true)
f:EnableMouse(true)
f:RegisterForDrag("LeftButton")
f:SetScript("OnDragStart", function() f:StartMoving() end)
f:SetScript("OnDragStop", function() f:StopMovingOrSizing() end)
f:SetFrameStrata("DIALOG")
f:SetScript("OnShow", function()
if pfUI.gui and pfUI.gui:IsShown() then
f.hadGUI = true
pfUI.gui:Hide()
else
f.hadGUI = nil
end
end)
f:SetScript("OnHide", function()
if f.hadGUI then
pfUI.gui:Show()
end
end)
CreateBackdrop(f, nil, true, 0.8)
CreateBackdropShadow(f)
table.insert(UISpecialFrames, "pfShare")
do -- Edit Box
f.scroll = pfUI.api.CreateScrollFrame("pfShareScroll", f)
f.scroll:SetPoint("TOPLEFT", f, "TOPLEFT", 10, -30)
f.scroll:SetPoint("BOTTOMRIGHT", f, "BOTTOMRIGHT", -10, 50)
f.scroll:SetWidth(560)
f.scroll:SetHeight(400)
f.scroll.backdrop = CreateFrame("Frame", "pfShareScrollBackdrop", f.scroll)
f.scroll.backdrop:SetFrameLevel(1)
f.scroll.backdrop:SetPoint("TOPLEFT", f.scroll, "TOPLEFT", -5, 5)
f.scroll.backdrop:SetPoint("BOTTOMRIGHT", f.scroll, "BOTTOMRIGHT", 5, -5)
pfUI.api.CreateBackdrop(f.scroll.backdrop, nil, true)
f.scroll.text = CreateFrame("EditBox", "pfShareEditBox", f.scroll)
f.scroll.text.bg = f.scroll.text:CreateTexture(nil, "OVERLAY")
f.scroll.text.bg:SetAllPoints(f.scroll.text)
f.scroll.text.bg:SetTexture(1,1,1,.05)
f.scroll.text:SetMultiLine(true)
f.scroll.text:SetWidth(560)
f.scroll.text:SetHeight(400)
f.scroll.text:SetAllPoints(f.scroll)
f.scroll.text:SetTextInsets(15,15,15,15)
f.scroll.text:SetFont(pfUI.media["font:RobotoMono.ttf"], 9)
f.scroll.text:SetAutoFocus(false)
f.scroll.text:SetJustifyH("LEFT")
f.scroll.text:SetScript("OnEscapePressed", function() this:ClearFocus() end)
f.scroll.text:SetScript("OnTextChanged", function()
this:GetParent():UpdateScrollChildRect()
this:GetParent():UpdateScrollState()
local text = this:GetText()
local blob = Decode(text)
local json = not blob and DecodeJSON(text) or nil
local legacy = not blob and not json and DecodeLegacy(text) or nil
if blob or json or legacy then
f.loadButton:Enable()
f.loadButton.text:SetTextColor(.5,1,.5,1)
else
f.loadButton:Disable()
f.loadButton.text:SetTextColor(1,.5,.5,1)
end
if blob or legacy then
-- decoding a legacy string yields the JSON view; re-encoding it
-- from there produces a new-format blob (migration path)
f.readButton:Enable()
f.readButton.text:SetText(T["Decode"])
f.readButton.func = function()
local current = f.scroll.text:GetText()
local config = Decode(current) or DecodeLegacy(current)
if config then
f.scroll.text:SetText(C_EncodingUtil.SerializeJSON(config))
end
end
elseif json then
f.readButton:Enable()
f.readButton.text:SetText(T["Encode"])
f.readButton.func = function()
local config = DecodeJSON(f.scroll.text:GetText())
if config then
f.scroll.text:SetText(Encode(config))
end
end
else
f.readButton:Disable()
f.readButton.text:SetText(T["N/A"])
end
end)
f.scroll:SetScrollChild(f.scroll.text)
end
do -- button: close
f.closeButton = CreateFrame("Button", "pfShareClose", f)
f.closeButton:SetPoint("TOPRIGHT", -5, -5)
f.closeButton:SetHeight(12)
f.closeButton:SetWidth(12)
f.closeButton.texture = f.closeButton:CreateTexture("pfQuestionDialogCloseTex")
f.closeButton.texture:SetTexture(pfUI.media["img:close"])
f.closeButton.texture:ClearAllPoints()
f.closeButton.texture:SetAllPoints(f.closeButton)
f.closeButton.texture:SetVertexColor(1,.25,.25,1)
pfUI.api.SkinButton(f.closeButton, 1, .5, .5)
f.closeButton:SetScript("OnClick", function()
this:GetParent():Hide()
end)
end
do -- checkbox: ignore positions
f.ignorePosition = CreateFrame("CheckButton", "pfShareIgnorePosition", f, "UICheckButtonTemplate")
f.ignorePosition:SetNormalTexture("")
f.ignorePosition:SetPushedTexture("")
f.ignorePosition:SetHighlightTexture("")
CreateBackdrop(f.ignorePosition, nil, true)
f.ignorePosition:SetWidth(14)
f.ignorePosition:SetHeight(14)
f.ignorePosition:SetPoint("BOTTOMLEFT", 10, 10)
f.ignorePositionCaption = f.ignorePosition:CreateFontString("Status", "LOW", "GameFontNormal")
f.ignorePositionCaption:SetFont(pfUI.font_default, C.global.font_size + 2, "OUTLINE")
f.ignorePositionCaption:SetPoint("LEFT", f.ignorePosition, "RIGHT", 5, 0)
f.ignorePositionCaption:SetFontObject(GameFontWhite)
f.ignorePositionCaption:SetJustifyH("LEFT")
f.ignorePositionCaption:SetText(T["Ignore Layout"])
end
do -- button: load
f.loadButton = CreateFrame("Button", "pfShareLoad", f)
pfUI.api.SkinButton(f.loadButton)
f.loadButton:SetPoint("BOTTOMRIGHT", -5, 5)
f.loadButton:SetWidth(75)
f.loadButton:SetHeight(25)
f.loadButton.text = f.loadButton:CreateFontString("Caption", "LOW", "GameFontWhite")
f.loadButton.text:SetAllPoints(f.loadButton)
f.loadButton.text:SetFont(pfUI.font_default, pfUI_config.global.font_size, "OUTLINE")
f.loadButton.text:SetText(T["Import"])
f.loadButton:SetScript("OnClick", function()
local text = f.scroll.text:GetText()
local config = Decode(text) or DecodeJSON(text) or DecodeLegacy(text)
if not config then return end
_G.pfUI_config = config
C = _G.pfUI_config
pfUI:LoadConfig()
-- Skip firstrun wizard when importing a shared profile
-- The imported config is a complete setup, no wizard needed
if pfUI.firstrun and pfUI.firstrun.steps then
for _, step in pairs(pfUI.firstrun.steps) do
pfUI_init[step.name] = true
end
end
CreateQuestionDialog(T["Some settings need to reload the UI to take effect.\nDo you want to reloadUI now?"], ReloadUI)
end)
end
do -- button: read
f.readButton = CreateFrame("Button", "pfShareDecode", f)
pfUI.api.SkinButton(f.readButton)
f.readButton:SetPoint("RIGHT", f.loadButton, "LEFT", -10, 0)
f.readButton:SetWidth(75)
f.readButton:SetHeight(25)
f.readButton.text = f.readButton:CreateFontString("Caption", "LOW", "GameFontWhite")
f.readButton.text:SetAllPoints(f.readButton)
f.readButton.text:SetFont(pfUI.font_default, pfUI_config.global.font_size, "OUTLINE")
f.readButton.text:SetText(T["N/A"])
f.readButton:SetScript("OnClick", function()
this.func()
end)
end
do -- button: export
f.exportButton = CreateFrame("Button", "pfShareExport", f)
pfUI.api.SkinButton(f.exportButton)
f.exportButton:SetPoint("RIGHT", f.readButton, "LEFT", -10, 0)
f.exportButton:SetWidth(75)
f.exportButton:SetHeight(25)
f.exportButton.text = f.exportButton:CreateFontString("Caption", "LOW", "GameFontWhite")
f.exportButton.text:SetAllPoints(f.exportButton)
f.exportButton.text:SetFont(pfUI.font_default, pfUI_config.global.font_size, "OUTLINE")
f.exportButton.text:SetText(T["Export"])
f.exportButton:SetScript("OnClick", function()
-- generate a default config
local myconfig = CopyTable(pfUI_config)
_G.pfUI_config = {}
pfUI:LoadConfig()
local defconfig = CopyTable(pfUI_config)
-- restore config and references
_G.pfUI_config = CopyTable(myconfig)
C = _G.pfUI_config
local ignored = {}
ignored["position"] = f.ignorePosition:GetChecked()
ignored["disabled"] = f.ignorePosition:GetChecked()
local encoded = Encode(DiffConfig(myconfig, defconfig, ignored) or {})
f.scroll.text:SetText(encoded)
f.scroll.text.value = encoded
f.scroll:SetVerticalScroll(0)
end)
end
pfUI.api.RegisterSlashCommand("PFEXPORT", { "/export", "/import", "/share" }, function(msg, editbox)
f:Show()
end, true)
end
end)