Files
OEVG 896f46f0d1
enUS Localization Update Announcement / localization-update-announcement (push) Canceled after 0s
Bagshui 1.6.4
2026-10-30 15:55:43 +00:00

1234 lines
41 KiB
Lua

-- Bagshui Utility Functions
-- Exposes: BsUtil (and Bagshui.components.Util)
Bagshui:LoadComponent(function()
local Util = {}
Bagshui.environment.BsUtil = Util
Bagshui.components.Util = Util
--#region Strings
-- In Lua, "." won't match multi-byte UTF8 characters. This pattern will.
local ANY_CHAR_INCL_UTF8 = "[%z\1-\127\194-\244][\128-\191]*"
--- Remove whitespace (or desired characters) from beginning and end of a string.
---@param str string
---@param trim string? Characters to trim instead of whitespace.
---@return string
function Util.Trim(str, trim)
trim = trim or "[%s\n]"
return (string.gsub(str, "^" .. trim .. "*(.-)" .. trim .. "*$", "%1"))
end
-- Reusable table for Util.Split().
local split_ReturnTable = {}
--- Split a string by the provided delimiter (whitespace by default).
--- Credit: https://stackoverflow.com/a/76989560
---@param str string
---@param delimiter string Character(s) or pattern by which to split the string.
---@param delimiterIsPattern boolean? Don't escape pattern characters in `delimiter`.
---@param includeEmpty boolean? Include empty strings in the results.
---@return string[] # `str` split into multiple strings.
function Util.Split(str, delimiter, delimiterIsPattern, includeEmpty)
assert(
type(str) == "string",
"Util.Split() - str must be a string (was " .. type(str) .. ": " .. tostring(str) .. ")"
)
Util.TableClear(split_ReturnTable)
if not delimiter then
delimiter = "%s+"
elseif not delimiterIsPattern then
delimiter = Util.EscapeMagicCharacters(delimiter)
end
str = string.gsub(str, delimiter, "\1")
for match in string.gfind(str, "([^\1]" .. (includeEmpty and "*" or "+") .. ")") do
table.insert(split_ReturnTable, match)
end
return split_ReturnTable
end
--- Escape the magic characters used in Lua patterns so they are interpreted literally.
---@param str any
---@return string
function Util.EscapeMagicCharacters(str)
return (string.gsub(str, "([%(%)%.%%%+%-%*%?%[%]%^%$])", "%%%1"))
end
-- Clean all WoW UI escape sequences from the given string.
-- Useful for matching text against tooltips which may contain formatting.
---@param str string
---@param extractHyperlinkContentsInsteadOfText boolean Get item links instead of the text displayed in the UI. ("|Hitem:1234:0:0:0|h[Item Name]|h" becomes "item:1234:0:0:0" instead of "[Item Name]")
---@return string
function Util.RemoveUiEscapes(str, extractHyperlinkContentsInsteadOfText)
-- Font colors.
str = string.gsub(str, "|c%w%w%w%w%w%w%w%w(.+)|r", "%1")
-- Links.
if extractHyperlinkContentsInsteadOfText then
str = string.gsub(str, "|H(.+)|h.+|h", "%1")
else
str = string.gsub(str, "|H.+|h(.+)|h", "%1")
end
return str
end
--- Turn "This is a string" into "string a is This"
---@param str string
---@return string
function Util.ReverseStringByWords(str)
local reversed = ""
local nextWord = nil
local remainingString = str
repeat
nextWord, remainingString = Util.SplitOnFirstSpace(remainingString)
if reversed == "" then
reversed = nextWord
else
reversed = nextWord .. " " .. reversed
end
until remainingString == ""
return reversed
end
-- Split a string at the first instance of a space.
-- Used by ReverseStringByWords
---@param str string
---@return string nextWord
---@return string remainingString
function Util.SplitOnFirstSpace(str)
if str then
local firstWord = str
local leftoverString = ""
local nextSpacePosition = string.find(str, " ")
if nextSpacePosition then
firstWord = string.sub(str, 1, nextSpacePosition - 1)
leftoverString = string.sub(str, nextSpacePosition + 1)
end
return firstWord, leftoverString
else
return "", ""
end
end
-- Truncate a string to the specified length.
---@param str string
---@param len number
---@param noEllipsis boolean? Don't add an ellipsis to the end of the string when it's truncated.
---@return string
function Util.TruncateString(str, len, noEllipsis)
local ret = Util.Utf8Sub(str, 1, len)
-- Add ellipsis if needed.
if not noEllipsis and Util.Utf8Len(ret) < Util.Utf8Len(str) then
ret = ret .. "…"
end
return ret
end
--- Lowercase the first letter of a string.
---@param str string
---@return string
function Util.LowercaseFirstLetter(str)
if type(str) ~= "string" then
return ""
end
return (string.gsub(str, "^%w", string.lower))
end
--- Capitalize a string (uppercase first letter, lowercase the rest).
---@param str string
---@return string
function Util.Capitalize(str)
if type(str) ~= "string" then
return ""
end
return (string.gsub(string.lower(str), "^%w", string.upper))
end
-- Return a substring, taking UTF8 characters into account (UTF8 version of `string.sub()`).
---@param str string
---@param first number
---@param last number
---@return string
function Util.Utf8Sub(str, first, last)
-- We'll need he original string length repeatedly.
local len = Util.Utf8Len(str)
if len == 0 then
return ""
end
-- Go through the end if we didn't get a final position.
if not last then
last = len
end
-- Handle negatives.
if first < 0 then
first = len + first + 1
end
if last < 0 then
last = len + last + 1
end
-- Prevent out of bounds.
first = math.max(first, 1)
last = math.min(last, len)
-- Nothing to do if positions are inverted.
if last < first then
return ""
end
local desiredReturnLen = last - first + 1
local ret = ""
local i, firstChar, lastChar, charsFound, newLen
-- Update i to be the REAL start (accounting for UTF8ness) point by iterating character-by-character.
if first > 1 then
charsFound = 0
lastChar = 0
repeat
i = lastChar + 1
_, lastChar = string.find(str, ANY_CHAR_INCL_UTF8, i) -- Step forward by the next single UTF character, which can be one or more ASCII character points.
charsFound = charsFound + 1
until charsFound == first
end
-- Build up the return string
repeat
-- Find the ASCII character positions of the next single UTF character.
firstChar, lastChar = string.find(str, ANY_CHAR_INCL_UTF8, i)
-- Add to return string.
ret = ret .. string.sub(str, firstChar, lastChar)
-- Increment start point for next iteration.
i = lastChar + 1
-- Calculate true length of return string.
newLen = Util.Utf8Len(ret)
until newLen >= desiredReturnLen or newLen >= len -- (This is written with >= just to be safe).
return ret
end
-- String length, taking into account UTF8 characters (UTF8 version of `string.len()`).
---@param str any
---@return integer
function Util.Utf8Len(str)
if not str or type(str) == "table" then
return 0
end
local _, len = string.gsub(tostring(str), ANY_CHAR_INCL_UTF8, "")
return len
end
--- Return true when `str` matches either `strToMatch` or its localized equivalent.
--- Case-insensitive.
---@param str string String to test.
---@param strToMatch string String to match.
---@return boolean
function Util.MatchLocalizedOrNon(str, strToMatch)
if not str then
return false
end
return string.lower(Util.Trim(str)) == string.lower(strToMatch)
or string.lower(Util.Trim(str)) == string.lower(L[strToMatch])
end
function Util.ExtractErrorMessage(str)
return string.gsub(str, "^.-:.-:%s*", "")
end
--#endregion Strings
--#region Numbers
--- Get the first number from a string.
---@param str any
---@return number?
function Util.ExtractNumber(str)
local _, _, num = string.find(tostring(str), "(%d+)")
return tonumber(num)
end
--- Scale a number from one range to another.
---@param num number Number to scale.
---@param originalMin number Source range minimum value.
---@param originalMax number Source range maximum value.
---@param scaledMin number Target range minimum value.
---@param scaledMax number Target range maximum value.
---@return number
function Util.ScaleNumber(num, originalMin, originalMax, scaledMin, scaledMax)
return scaledMin + ((num - originalMin) * (scaledMax - scaledMin)) / (originalMax - originalMin)
end
--- Lua doesn't have native rounding.
---@param num number Number to round.
---@param decimals number? Precision (default: 2).
---@return number?
function Util.Round(num, decimals)
if not num then
return
end
return tonumber(string.format("%." .. (decimals or 2) .. "f", num))
end
--#endregion Numbers
--#region Tables
--- Deep table copy, optionally using a template table to filter keys and values.
--- Copy will be performed in-place (i.e. `dest` will be overwritten and will *not*
--- be returned). The only time a value is returned is when `dest` is not provided,
--- since a new table is created in this scenario.
---
--- #### `template` notes:
--- The general idea is that template keys define what keys will be copied from `source`,
--- and template values define the allowed data types for `source`'s values.
--- There are a few special cases:
--- * An associative key-value table as a template key will define a set of multiple
--- allowed keys. Each value associated with these keys in `source` will be expected
--- to follow the data types allowed by this template key's value.
--- See `BS_CATEGORY_SKELETON` for an example.
--- * A key with a single-value array-type table as its value will allow an array-type
--- table in the corresponding `source` location, with the data type of the single
--- value in the template's array. BS_CATEGORY_SKELETON also has an example of this.
--- * Providing a `tableWildcard` will whitelist any table found in `source` at a
--- template location that has the pointer to `tableWildcard` as its value.
--- See the profileSkeleton setup in Components\Profiles.lua for an example.
---@param source table Table to copy from.
---@param dest table? Table to copy into, or `nil` to copy into a new table and return that.
---@param forceKeyValueCopy boolean? Use key-value copy even when `table.getn(source) > 0`.
---@param template table? Table that defines the allowable structure of `source`. Anything not found in the template won't be copied.
---@param tableWildcard table? When the pointer to this table appears in the template, anything in the corresponding `source` location will be copied over.
---@param parentHadTableWildcard boolean? Will be `true` when a recursive call is made and the `tableWildcard` was found as the value.
---@return table? newDest Newly created copy if `dest` was not provided.
function Util.TableCopy(source, dest, forceKeyValueCopy, template, tableWildcard, parentHadTableWildcard)
assert(type(source) == "table", "TableCopy(): type(source) = " .. type(source) .. " instead of table")
assert(
dest == nil or type(dest) == "table",
"TableCopy:() type(dest) = " .. type(dest) .. " instead of table or nil"
)
-- Use an empty table if we don't have a destination (or if destination is the same as source).
local target = (dest and dest ~= source) and dest or {}
-- Ensure the destination table is empty.
Util.TableClear(target)
-- Assume that table.getn() > 0 is an array -- pass forceKeyValueCopy to override this.
if table.getn(source) > 0 and not forceKeyValueCopy then
for i = 1, table.getn(source) do
if type(source[i]) == "table" then
table.insert(target, Util.TableCopy(source[i]))
else
table.insert(target, source[i])
end
end
else
-- Only process the template when the parent element in the template wasn't a wildcard.
if template and not parentHadTableWildcard then
-- Templates are handled by looping through the template instead of
-- the source table, since anything that isn't in the template should
-- be omitted.
for key, templateVal in pairs(template) do
if type(key) == "table" then
-- If a table is found as a template key, use that to define
-- a set of multiple allowed keys.
for refKey, refVal in pairs(key) do
if source[refKey] then
if type(templateVal) == "table" then
target[refKey] = Util.TableCopy(source[refKey], nil, nil, templateVal)
else
target[refKey] = source[refKey]
end
end
end
elseif
type(templateVal) == "table"
and table.getn(templateVal) == 1
and source[key]
and templateVal ~= tableWildcard
then
-- When the template has an array-type table with a single value,
-- use that to determine what type is allowed in the target array.
target[key] = {}
for _, sourceVal in ipairs(source[key]) do
if type(sourceVal) == type(templateVal[1]) then
if type(templateVal[1]) == "table" then
table.insert(target[key], Util.TableCopy(sourceVal, nil, nil, templateVal[1]))
else
table.insert(target[key], sourceVal)
end
end
end
elseif source[key] ~= nil and type(source[key]) == type(templateVal) then
-- Normal behavior - only copy if type matches.
if type(templateVal) == "table" then
-- Recurse for tables.
target[key] = Util.TableCopy(
source[key],
nil, -- No dest since we need to create a new table.
nil,
templateVal,
tableWildcard,
templateVal == tableWildcard -- parentHadTableWildcard.
)
else
target[key] = source[key]
end
end
end
else
-- No template -- just copy everything.
for key, val in pairs(source) do
if type(val) == "table" then
target[key] = Util.TableCopy(val)
else
target[key] = val
end
end
end
end
-- Only return if dest was nil.
if not dest then
return target
end
end
--- Non-recursive table copy; just loop through the first level.
--- Useful when it's important to preserve an array of table pointers.
--- `TableCopy()` could be refactored to handle this too, but it's not worth it.
---@param source table Table to copy from.
---@param dest table? Table to copy into, or `nil` to copy into a new table and return that.
---@param forceKeyValueCopy boolean? Use key-value copy even when `table.getn(source) > 0`.
---@return table? newDest Newly created copy if `dest` was not provided.
function Util.TableCopyFlat(source, dest, forceKeyValueCopy)
assert(type(source) == "table", "TableCopy(): type(source) = " .. type(source) .. " instead of table")
assert(
dest == nil or type(dest) == "table",
"TableCopy:() type(dest) = " .. type(dest) .. " instead of table or nil"
)
local target = (dest and dest ~= source) and dest or {}
Util.TableClear(target)
if table.getn(source) > 0 and not forceKeyValueCopy then
for _, value in ipairs(source) do
table.insert(target, value)
end
else
for key, value in pairs(source) do
target[key] = value
end
end
if not dest then
return target
end
end
--- Wipe a table. Using this is easier on the garbage collector than creating a new table.
---@param tbl table
function Util.TableClear(tbl)
if type(tbl) ~= "table" then
return
end
-- Clear array-type tables first so table.insert will start over at 1.
for i = table.getn(tbl), 1, -1 do
table.remove(tbl, i)
end
-- Remove any remaining associative table elements.
-- Credit: https://stackoverflow.com/a/27287723
for k in next, tbl do
rawset(tbl, k, nil)
end
end
--- Get the actual table size regardless of whether it's a numeric or associative array.
---@param tbl table
---@return integer # Count of all elements.
function Util.TrueTableSize(tbl)
if type(tbl) ~= "table" then
return 0
end
local size = 0
for k, v in pairs(tbl) do
size = size + 1
end
return size
end
--- Find the key (index) of the given element in a table.
---@param table table
---@param element any
---@return any? # Key/index of element, or nil if not found.
function Util.TableContainsValue(table, element)
if type(table) ~= "table" then
return nil
end
for key, value in pairs(table) do
if value == element then
return key
end
end
return nil
end
--- `table.insert()`, but only if `val` isn't `nil`.
---@param tbl table Table into which `val` will be inserted.
---@param val any Value to insert.
---@param emptyStringAsNil boolean? Treat empty string as nil.
---@return boolean # Whether insert was performed.
function Util.TableInsertNonNil(tbl, val, emptyStringAsNil)
if type(tbl) ~= "table" or val == nil or (emptyStringAsNil and type(val) == "string" and string.len(val) == 0) then
return false
end
table.insert(tbl, val)
return true
end
--- Remove an item from an array-type table.
---@param tbl table
---@param item any Value of array entry to remove.
function Util.TableRemoveArrayItem(tbl, item)
if type(tbl) ~= "table" then
return
end
for i = table.getn(tbl), 1, -1 do
if tbl[i] == item then
table.remove(tbl, i)
end
end
end
--- Add an item to an array-type table only if it isn't already present.
---@param tbl table
---@param item any Value to add.
function Util.TableInsertArrayItemUnique(tbl, item)
if type(tbl) ~= "table" then
return
end
if not Util.TableContainsValue(tbl, item) then
table.insert(tbl, item)
end
end
--#endregion Tables
--#region Classes
--- Instantiate a new class with multiple inheritance.
--- https://www.lua.org/pil/16.3.html
---@param ... table Parent classes, in order of priority (i.e. array index #1 is the highest priority superclass).
---@return table newClass
function Util.NewClass(...)
local newClass = {}
local metatable = {}
-- newClass will search for each property it doesn't have in
-- its superclasses (arg is the list of superclasses).
setmetatable(metatable, {
__index = function(_, prop)
return Util.FindSuperclassProperty(prop, arg)
end,
})
metatable.__index = metatable
-- Create and return the new class.
setmetatable(newClass, metatable)
return newClass
end
--- Helper function to implement multiple inheritance.
--- Iterates the list of superclasses and return the value of `prop` from the first one that has it.
---@param prop any
---@param parents table[]
---@return any?
function Util.FindSuperclassProperty(prop, parents)
for i = 1, table.getn(parents) do
local value = parents[i][prop]
if value then
return value
end
end
end
--#endregion Classes
--#region WoW UI
-- Convert "LEFT" to "RIGHT" and "TOP" to "BOTTOM" (and vice versa).
---@param point string
---@return string
function Util.FlipAnchorPoint(point)
if point == "LEFT" or point == "RIGHT" then
return (point == "LEFT") and "RIGHT" or "LEFT"
elseif point == "TOP" or point == "BOTTOM" then
return (point == "TOP") and "BOTTOM" or "TOP"
end
return point
end
--- Flip only one part of a multi-component anchor.
---```
--- Util.FlipAnchorPointComponent("TOPLEFT", 1) -> "BOTTOMLEFT"
--- Util.FlipAnchorPointComponent("TOPLEFT", 2) -> "TOPRIGHT"
---```
---@param point string
---@param componentNum number? 1 for the first half, 2 for the second half (defaults to 1).
---@return string
function Util.FlipAnchorPointComponent(point, componentNum)
-- "^%s" for componentNum 1 or "%s$" for componentNum 2.
-- This allows matching "^TOP"/"^BOTTOM" at the start of the string for
-- `componentNum` 1, but "^LEFT"/"^RIGHT" will never match (and the same
-- for `componentNum` 2 with "LEFT$"/"RIGHT$").
local searchAnchor = ((componentNum == 1 or not componentNum) and "^" or "")
.. "%s"
.. (componentNum == 2 and "$" or "")
if string.find(point, string.format(searchAnchor, "LEFT")) then
return (string.gsub(point, string.format(searchAnchor, "LEFT"), "RIGHT"))
end
if string.find(point, string.format(searchAnchor, "RIGHT")) then
return (string.gsub(point, string.format(searchAnchor, "RIGHT"), "LEFT"))
end
if string.find(point, string.format(searchAnchor, "BOTTOM")) then
return (string.gsub(point, string.format(searchAnchor, "BOTTOM"), "TOP"))
end
if string.find(point, string.format(searchAnchor, "TOP")) then
return (string.gsub(point, string.format(searchAnchor, "TOP"), "BOTTOM"))
end
return point
end
-- Return "LEFT", "RIGHT", or "" (i.e. center) depending on the provided anchor.
---@param point string
---@return string
function Util.GetAnchorLeftRight(point)
return (
(point ~= "BOTTOM" and point ~= "TOP") and (point and (string.find(point, "RIGHT$") and "RIGHT" or "LEFT"))
or ""
)
end
--- Take a RGB percent set (0.0-1.0) and convert it to a hex string.
--- Credit: https://warcraft.wiki.gg/wiki/RGBPercToHex
---@param r number Red
---@param g number Blue
---@param b number Green
---@return string hexColor
function Util.RGBPercentToHex(r, g, b)
r = r <= 1 and r >= 0 and r or 0
g = g <= 1 and g >= 0 and g or 0
b = b <= 1 and b >= 0 and b or 0
return string.format("%02x%02x%02x", r * 255, g * 255, b * 255)
end
--- Get a texture with its full path.
--- If the path has more than 2 backslashes, the path will be returned as-is.
--- Otherwise, the Bagshui addon path will be prefixed.
---@param path string
---@return string?
function Util.GetFullTexturePath(path)
if not path then
return
end
local _, slashCount = string.gsub(path, "\\", "")
return slashCount > 1 and path or ("Interface\\AddOns\\Bagshui\\Images\\" .. path)
end
--- Helper function to remove some redundant code for determining when a menu frame
--- is offscreen and how much adjustment needs to be done to bring it back on.
--- This uses `UIParent:GetLeft/Right/Top/Bottom()` in lieu of `GetScreenWidth/Height()`,
--- which seems to work much better.
--- Written primarily for `Bagshui:ToggleDropDownMenu()` but also used elsewhere.
---@param frame table Menu frame.
---@param xy "x"|"y" Horizontal or vertical adjustment.
---@return number adjustment How much the frame needs to be moved to bring it back onscreen.
function Util.GetFrameOffscreenAmount(frame, xy)
assert(
frame and frame.GetScale,
"Util.GetFrameOffscreenAmount(): frame does not appear to be a valid WoW UI frame."
)
xy = xy or "x"
local lesserDirection = "Left"
local greaterDirection = "Right"
if string.lower(xy) == "y" then
lesserDirection = "Bottom"
greaterDirection = "Top"
end
local frameScale = frame:GetScale()
-- Only apply UI scale to non-menu frames. For some reason I'm not going to bother figuring out,
-- menu frame positions are only accurately calculated if they are NOT scaled by UIParent's scale,
-- but other frames' positions need to be scaled. *shrug*
if not string.find(frame:GetName(), "^DropDownList%d$") then
frameScale = frameScale * _G.UIParent:GetScale()
end
local lesserDimension = frame["Get" .. lesserDirection] and frame["Get" .. lesserDirection](frame) or nil
lesserDimension = lesserDimension and lesserDimension * frameScale
local greaterDimension = frame["Get" .. greaterDirection] and frame["Get" .. greaterDirection](frame) or nil
greaterDimension = greaterDimension and greaterDimension * frameScale
local uiParentLesserDimension = _G.UIParent["Get" .. lesserDirection](_G.UIParent) * _G.UIParent:GetScale()
local uiParentGreaterDimension = _G.UIParent["Get" .. greaterDirection](_G.UIParent) * _G.UIParent:GetScale()
-- Bagshui:PrintDebug(xy .. " UIParent Lesser: " .. uiParentLesserDimension)
-- Bagshui:PrintDebug(xy .. " Frame Lesser: " .. tostring(lesserDimension))
-- Bagshui:PrintDebug(xy .. " UIParent Greater: " .. uiParentGreaterDimension)
-- Bagshui:PrintDebug(xy .. " Frame Greater: " .. tostring(greaterDimension))
if lesserDimension and lesserDimension < uiParentLesserDimension then
-- Left/Bottom will be less than the corresponding UIParent coordinate if offscreen.
return (uiParentLesserDimension - lesserDimension) / frameScale
elseif greaterDimension and greaterDimension > uiParentGreaterDimension then
-- Right/Top will be greater than the corresponding UIParent coordinate if offscreen.
return (uiParentGreaterDimension - greaterDimension) / frameScale
end
return 0
end
--- StaticPopupDialogs substitute for the `enterClicksFirstButton` property since
--- that doesn't exist in Vanilla. Assign this to the `EditBoxOnEnterPressed` property.
function Util.StaticPopupDialogs_EnterClicksFirstButton()
_G.StaticPopup_OnClick(_G.this:GetParent(), 1)
end
--- Dialogs with a text box should clear their text when hidden because some built-in
--- ones (like `DELETE_GOOD_ITEM`) don't do it OnShow.
function Util.StaticPopupDialogs_ClearTextOnHide()
_G[_G.this:GetName() .. "EditBox"]:SetText("")
end
--- Pause code execution until a dialog is dismissed.
---@param dialogName string Static popup name to wait for.
---@param timeoutSec number? Maximum wait time in seconds.
---@param callback function? Called once waiting is completed. Will receive a boolean indicating whether the dialog closed within timeout. *Technically* optional but without it this function is kind of meaningless.
---@param checkCount number? Number of checks performed. Don't pass this in during the initial call.
---@return boolean? dialogWasOpen `true` if popup was on screen during the *initial* call.
function Util.WaitForStaticPopupClose(dialogName, timeoutSec, callback, checkCount)
timeoutSec = timeoutSec or 30
if _G.StaticPopup_FindVisible(dialogName) then
if ((checkCount or 0) * 0.1) >= timeoutSec then
-- Dialog did NOT close within timeout (failure).
if type(callback) == "function" then
callback(true)
end
end
Bagshui:QueueEvent(
Util.WaitForStaticPopupClose,
0.1,
nil,
dialogName,
timeoutSec,
callback,
(checkCount or 0) + 1
)
return true
end
-- Dialog closed (success).
if type(callback) == "function" then
callback(true)
end
end
--#endregion WoW UI
--#region Import/Export
--- Prepare a table for export by serializing, compressing, and encoding.
---@param tbl table Object to export.
---@param humanReadable boolean? `true` to only pretty-print and NOT compress/encode.
---@return string?
function Util.Export(tbl, humanReadable)
local export = Util.Serialize(tbl)
if not humanReadable then
export = Util.Encode(Util.Compress(export))
end
return export
end
--- Import a table previously prepared by `Util.Export()`.
---@param str string
---@return table?
function Util.Import(str)
local import = Util.Trim(str)
if not string.find(import, "^{") then
import = Util.Trim(Util.Decompress(Util.Decode(import)) or "")
end
if not string.find(import, "^{") then
return nil
end
return Util.Deserialize(import)
end
--- Serialize a table to a string.
--- Credit: pfUi serialize() by Shagu - https://github.com/shagu/pfUI/blob/master/modules/share.lua
---@param tbl table Object to serialize.
---@param comp table? Anything in this table that is the same as `tbl` will be excluded.
---@param key string? Key name.
---@param spacing string? Indentation.
---@return string?
function Util.Serialize(tbl, comp, key, spacing)
if type(tbl) ~= "table" then
return tostring(tbl)
end
spacing = spacing or ""
local match = nil
local str = (spacing ~= "" and key) and (spacing .. "[" .. Util.ToPrintableString(key) .. "] = {\n") or "{\n"
for k, v in pairs(tbl) do
if not comp or not comp[k] or comp[k] ~= tbl[k] then
if type(v) == "table" then
local result = Util.Serialize(tbl[k], comp and comp[k], k, spacing .. " ")
if result then
match = true
str = str .. result
end
elseif type(v) == "string" or type(v) == "number" or type(v) == "boolean" then
match = true
str = str .. spacing .. " [" .. Util.ToPrintableString(k) .. "] = " .. Util.ToPrintableString(v) .. ",\n"
end
end
end
str = str .. spacing .. "}" .. (spacing == "" and "" or ",") .. BS_NEWLINE
return match and str or nil
end
--- Transform the given value into a string that can be printed or used in serialization.
--- Strings will be surrounded by double quotes (and double quotes within the string escaped)
---@param v any
---@param noStringEscapes boolean? Don't escape quotes and backslashes in strings.
---@return string
function Util.ToPrintableString(v, noStringEscapes)
if type(v) == "string" or type(v) == "userdata" then
if noStringEscapes then
return '"' .. v .. '"'
else
return '"' .. string.gsub(string.gsub(v, "\\", "\\\\"), '"', '\\"') .. '"'
end
end
return tostring(v)
end
--- Turn a previously-serialized table in string form back into a table.
---@param str string? Serialized table.
---@return table?
function Util.Deserialize(str)
if type(str) ~= "string" or string.len(str) < 1 then
return nil
end
local deserialize = assert(loadstring("return " .. str))
if type(deserialize) == "function" then
-- Add some sort of protection against code injection.
setfenv(deserialize, {})
return deserialize()
end
end
--- LZW-compress a string.
--- Credit: pfUi compress() by Shagu - https://github.com/shagu/pfUI/blob/master/modules/share.lua
---@param input any
---@return nil
function Util.Compress(input)
-- based on Rochet2's lzw compression
if type(input) ~= "string" then
return nil
end
local len = string.len(input)
if len <= 1 then
return "u" .. input
end
local dict = {}
for i = 0, 255 do
local ic, iic = string.char(i), string.char(i, 0)
dict[ic] = iic
end
local a, b = 0, 1
local result = { "c" }
local resultLen = 1
local n = 2
local word = ""
for i = 1, len do
local c = string.sub(input, i, i)
local wc = word .. c
if not dict[wc] then
local write = dict[word]
if not write then
return nil
end
result[n] = write
resultLen = resultLen + string.len(write)
n = n + 1
if len <= resultLen then
return "u" .. input
end
local str = wc
if a >= 256 then
a, b = 0, b + 1
if b >= 256 then
dict = {}
b = 1
end
end
dict[str] = string.char(a, b)
a = a + 1
word = c
else
word = wc
end
end
result[n] = dict[word]
resultLen = resultLen + string.len(result[n])
n = n + 1
if len <= resultLen then
return "u" .. input
end
return table.concat(result)
end
--- Decompress a previously LZW-compressed string.
--- Credit: pfUi decompress() by Shagu - https://github.com/shagu/pfUI/blob/master/modules/share.lua
---@param input any
---@return nil
function Util.Decompress(input)
-- based on Rochet2's lzw compression
if type(input) ~= "string" or string.len(input) < 1 then
return nil
end
local control = string.sub(input, 1, 1)
if control == "u" then
return string.sub(input, 2)
elseif control ~= "c" then
return nil
end
input = string.sub(input, 2)
local len = string.len(input)
if len < 2 then
return nil
end
local dict = {}
for i = 0, 255 do
local ic, iic = string.char(i), string.char(i, 0)
dict[iic] = ic
end
local a, b = 0, 1
local result = {}
local n = 1
local last = string.sub(input, 1, 2)
result[n] = dict[last]
n = n + 1
for i = 3, len, 2 do
local code = string.sub(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 .. string.sub(toAdd, 1, 1)
if a >= 256 then
a, b = 0, b + 1
if b >= 256 then
dict = {}
b = 1
end
end
dict[string.char(a, b)] = str
a = a + 1
else
local str = lastStr .. string.sub(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[string.char(a, b)] = str
a = a + 1
end
last = code
end
return table.concat(result)
end
--- Base64-encode a string.
--- Credit: pfUi enc() by Shagu - https://github.com/shagu/pfUI/blob/master/modules/share.lua
---@param to_encode string?
---@return string
function Util.Encode(to_encode)
if type(to_encode) ~= "string" or string.len(to_encode) < 1 then
return ""
end
local index_table = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
local bit_pattern = ""
local encoded = ""
local trailing = ""
for i = 1, string.len(to_encode) do
local remaining = tonumber(string.byte(string.sub(to_encode, i, i)))
local bin_bits = ""
for j = 7, 0, -1 do
local current_power = math.pow(2, j)
if remaining >= current_power then
bin_bits = bin_bits .. "1"
remaining = remaining - current_power
else
bin_bits = bin_bits .. "0"
end
end
bit_pattern = bit_pattern .. bin_bits
end
if mod(string.len(bit_pattern), 3) == 2 then
trailing = "=="
bit_pattern = bit_pattern .. "0000000000000000"
elseif mod(string.len(bit_pattern), 3) == 1 then
trailing = "="
bit_pattern = bit_pattern .. "00000000"
end
local count = 0
for i = 1, string.len(bit_pattern), 6 do
local byte = string.sub(bit_pattern, i, i + 5)
local offset = tonumber(tonumber(byte, 2))
encoded = encoded .. string.sub(index_table, offset + 1, offset + 1)
count = count + 1
if count >= 92 then
encoded = encoded .. BS_NEWLINE
count = 0
end
end
return string.sub(encoded, 1, -1 - string.len(trailing)) .. trailing
end
--- Decode a Base64-encoded string.
--- Credit: pfUi dec() by Shagu - https://github.com/shagu/pfUI/blob/master/modules/share.lua
---@param to_decode any
---@return string?
function Util.Decode(to_decode)
if type(to_decode) ~= "string" or string.len(to_decode) < 1 then
return ""
end
local index_table = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
local padded = string.gsub(to_decode, "%s", "")
local unpadded = string.gsub(padded, "=", "")
local bit_pattern = ""
local decoded = ""
to_decode = string.gsub(to_decode, BS_NEWLINE, "")
to_decode = string.gsub(to_decode, " ", "")
for i = 1, string.len(unpadded) do
local char = string.sub(to_decode, i, i)
local offset, _ = string.find(index_table, char)
if offset == nil then
return nil
end
local remaining = tonumber(offset - 1)
local bin_bits = ""
for i = 7, 0, -1 do
local current_power = math.pow(2, i)
if remaining >= current_power then
bin_bits = bin_bits .. "1"
remaining = remaining - current_power
else
bin_bits = bin_bits .. "0"
end
end
bit_pattern = bit_pattern .. string.sub(bin_bits, 3)
end
for i = 1, string.len(bit_pattern), 8 do
local byte = string.sub(bit_pattern, i, i + 7)
decoded = decoded .. string.char(tonumber(byte, 2))
end
local padding_length = string.len(padded) - string.len(unpadded)
if padding_length == 1 or padding_length == 2 then
decoded = string.sub(decoded, 1, -2)
end
return decoded
end
--#endregion Import/Export
--#region Misc
--- Compare two objects.
--- Based on: https://gist.github.com/sapphyrus/fd9aeb871e3ce966cc4b0b969f62f539?permalink_comment_id=4563041#gistcomment-4563041
---@param obj1 any
---@param obj2 any
---@param ignoreKeys table<string, true>? Table keys listed here (expressed in level1.level2.level3 notation) will not affect the outcome of the comparison.
---@param keyPath string? Recursion parameter for keeping track of the key path.
---@param maxDepth number? Don't recurse tables any further than this (anything lower than 1 will just return `obj1 == obj2` immediately and is basically pointless).
---@return boolean equal
function Util.ObjectsEqual(obj1, obj2, ignoreKeys, keyPath, maxDepth)
keyPath = keyPath or ""
-- Stop once maximum depth reached.
if type(maxDepth) == "number" and maxDepth <= 0 then
return obj1 == obj2
end
-- Same object.
if obj1 == obj2 then
return true
end
-- Different type.
if type(obj1) ~= type(obj2) then
return false
end
-- Same type but not table: we already know they're different
-- because of the very first test.
if type(obj1) ~= "table" then
return false
end
-- Build table key path separated by dots.
local keyPrefix = string.len(keyPath) > 0 and (keyPath .. ".") or ""
-- Iterate over obj1 and make sure every value is found in obj2.
for key1, value1 in pairs(obj1) do
local value2 = obj2[key1]
if
not (ignoreKeys and ignoreKeys[keyPrefix .. key1])
and (
value2 == nil
or Util.ObjectsEqual(
value1,
value2,
ignoreKeys,
keyPrefix .. key1,
(type(maxDepth) == "number" and maxDepth - 1 or nil)
)
== false
)
then
return false
end
end
--- Check for keys that exist in obj2 but not obj1.
for key2, _ in pairs(obj2) do
if not (ignoreKeys and ignoreKeys[keyPrefix .. key2]) and obj1[key2] == nil then
return false
end
end
-- All table values are equal.
return true
end
--- Use built-in formatting strings to nicely display remaining time expressed
--- in the largest unit `timeRemaining` contains.
---@param timeRemaining number Time in seconds.
---@return string
function Util.FormatTimeRemainingString(timeRemaining)
-- 60 secs in a minute.
-- 3600 secs in an hour.
-- 86400 secs in a day.
local days, hours, minutes, seconds
days = math.floor(timeRemaining / 86400)
timeRemaining = timeRemaining - 86400 * days
hours = math.floor(timeRemaining / 3600)
timeRemaining = timeRemaining - 3600 * hours
minutes = math.floor(timeRemaining / 60)
seconds = math.floor(timeRemaining - 60 * minutes)
if days > 0 then
return string.format(_G.INT_SPELL_DURATION_DAYS, days + 1)
elseif hours > 0 then
return string.format(_G.INT_SPELL_DURATION_HOURS_P1, hours + 1)
elseif minutes > 0 then
return string.format(_G.INT_SPELL_DURATION_MIN, minutes + 1)
else
return string.format(_G.INT_SPELL_DURATION_SEC, seconds)
end
end
--- Transform a money amount into a nicely formatted string.
--- Credit: `pfUI.api.CreateGoldString()` from [pfUI](https://github.com/shagu/pfUI/) (api.lua) by Shagu.
---@param money number Amount of money in copper (i.e. `GetMoney()` result).
---@return string # Colorized gold, silver, copper string (XgYsZc).
function Util.FormatMoneyString(money)
if type(money) ~= "number" then
return "-"
end
local gold = math.floor(money / 100 / 100)
local silver = math.floor(mod((money / 100), 100))
local copper = math.floor(mod(money, 100))
local string = ""
if gold > 0 then
string = string .. "|cffffffff" .. gold .. "|cffffd700g"
end
if silver > 0 or gold > 0 then
string = string .. "|cffffffff " .. silver .. "|cffc7c7cfs"
end
string = string .. "|cffffffff " .. copper .. "|cffeda55fc" .. FONT_COLOR_CODE_CLOSE
return string
end
--- Always returns `true`.
--- Used to temporarily override IsAltKeyDown/IsControlKeyDown/IsShiftKeyDown.
---@return boolean # false
function Util.ReturnFalse()
return false
end
--- Always returns `false`.
--- Used to temporarily override IsAltKeyDown/IsControlKeyDown/IsShiftKeyDown.
---@return boolean # true
function Util.ReturnTrue()
return true
end
--#endregion Misc
end)