-- Bagshui Catalog -- Exposes: BsCatalog (and Bagshui.components.Catalog) -- -- Account-wide index of all characters' inventories. -- Everything is calculated at runtime from what's in SavedVariables. -- -- Init() takes care of calculations for character other than the current one, -- while Update() handles the current character and grand totals. Bagshui:AddComponent(function() Bagshui:AddConstants({ BS_CATALOG_LOCATIONS = { EQUIPPED = "Equipped", -- This needs to match the Abbrev_[value] localization. MONEY = "$$$", }, }) -- Table key for totals and prefix for subtotals. local TOTAL_KEY = "==Total" -- Per-list lookup of itemStrings already present, used by `Catalog:AddToItemTable()`. -- Weak keys so lists that go away don't leak their lookups. local uniqueItemLookups = setmetatable({}, { __mode = "k" }) -- Stand-in table key for items that have no itemString (tables can't use nil as a key). local NIL_ITEM_STRING_KEY = "==NoItemString" -- Money storage. local MONEY_ITEM = { itemString = BS_CATALOG_LOCATIONS.MONEY, count = 0, } -- Cross-account sharing. -- Addons can't read another account's SavedVariables, but Nampower provides -- `WriteCustomFile()`/`ReadCustomFile()` for a `CustomData` folder that every client launched -- from this installation shares. Each character writes one file with the parts of its data the -- Catalog needs, and every character reads the others' files at startup. local SHARED_FILE_PREFIX = "Bagshui_" local SHARED_ROSTER_FILE = "Bagshui_roster.txt" -- Minimum seconds between writes of the current character's file (a final write always happens at logout). local SHARED_WRITE_MIN_INTERVAL = 60 -- Separates the compact Catalog data from the full inventory data in a character's file. -- The full inventory is only parsed when someone actually views that character's bags. -- (No newlines in the marker in case the file gets line-ending conversion.) local SHARED_INVENTORY_MARKER = "--[[BAGSHUI_INVENTORY_SECTION]]" -- `info` properties that are shared. local SHARED_INFO_KEYS = { "name", "realm", "class", "faction", "level", "race", "localizedClass", "localizedFaction", "localizedRace", "money", } --- Are Nampower's file functions available? ---@return boolean local function sharedFilesAvailable() return type(_G.WriteCustomFile) == "function" and type(_G.ReadCustomFile) == "function" end --- File name for a character's shared data. ---@param characterId string ---@return string local function sharedFileName(characterId) return SHARED_FILE_PREFIX .. (string.gsub(characterId, "[^%w]", "_")) .. ".txt" end --- Reduce an item to the few properties the Catalog uses. ---@param item table? ---@return table? local function compactItem(item) if type(item) ~= "table" or item.emptySlot == 1 or not item.itemString or (item.count or 0) < 1 then return nil end return { id = item.id, itemString = item.itemString, itemLink = item.itemLink, count = item.count, name = item.name, texture = item.texture, quality = item.quality, } end --- Escape a string for use between double quotes in Lua source. --- Unlike `BsUtil.Serialize()` this also handles newlines (item tooltips contain them) --- and other control characters, which would otherwise make the shared file unreadable. ---@param str string ---@return string local function escapeSharedString(str) str = string.gsub(str, "\\", "\\\\") str = string.gsub(str, '"', '\\"') str = string.gsub(str, "\n", "\\n") str = string.gsub(str, "\r", "\\r") str = string.gsub(str, "%c", function(c) return string.format("\\%03d", string.byte(c)) end) return str end --- Append the Lua source for a table to `parts`. Only strings, numbers, booleans and --- tables are written; anything else (and nan/infinite numbers) is skipped. ---@param tbl table ---@param parts string[] ---@param depth number local function serializeSharedTable(tbl, parts, depth) table.insert(parts, "{") if depth < 20 then for key, value in pairs(tbl) do local keyType = type(key) local valueType = type(value) if (keyType == "string" or keyType == "number") and (valueType == "string" or valueType == "number" or valueType == "boolean" or valueType == "table") and not (valueType == "number" and (value ~= value or value == 1 / 0 or value == -1 / 0)) and not (keyType == "number" and (key ~= key or key == 1 / 0 or key == -1 / 0)) then if keyType == "string" then table.insert(parts, '["' .. escapeSharedString(key) .. '"]=') else table.insert(parts, "[" .. tostring(key) .. "]=") end if valueType == "table" then serializeSharedTable(value, parts, depth + 1) elseif valueType == "string" then table.insert(parts, '"' .. escapeSharedString(value) .. '"') else table.insert(parts, tostring(value)) end table.insert(parts, ",") end end end table.insert(parts, "}") end --- Serialize a table to Lua source that `BsUtil.Deserialize()` can read back. ---@param tbl table ---@return string local function serializeShared(tbl) local parts = {} serializeSharedTable(tbl, parts, 0) return table.concat(parts) end --- Lua source for a table key (`["name"]=` or `[1]=`), or `nil` if the key can't be shared. ---@param key string|number ---@return string? local function sharedKeyText(key) if type(key) == "string" then return '["' .. escapeSharedString(key) .. '"]=' elseif type(key) == "number" and key == key and key ~= 1 / 0 and key ~= -1 / 0 then return "[" .. tostring(key) .. "]=" end return nil end --- Lua source for a single value, or `nil` if the value can't be shared. ---@param value any ---@return string? local function sharedValueText(value) local valueType = type(value) if valueType == "table" then return serializeShared(value) elseif valueType == "string" then return '"' .. escapeSharedString(value) .. '"' elseif valueType == "boolean" then return tostring(value) elseif valueType == "number" and value == value and value ~= 1 / 0 and value ~= -1 / 0 then return tostring(value) end return nil end -- Key suffixes for subtotals. local SUBTOTAL_TYPE = { Current = "Current", Other = "Other", } -- How much to indent each tooltip line. local TOOLTIP_INDENT = BS_INDENT local Catalog = { -- Array of the item info tables of all items on the account, sorted by name. items = {}, -- Calculation results as produced by `BuildIndex()` and `Update()`. -- Structure: -- ``` -- { -- -- Overall account-wide totals. -- ["==Total"] = { => }, -- -- Current character totals (will be the same as the realm-level total, obviously). -- ["==TotalCurrent"] = { => }, -- -- Other character totals. -- ["==TotalOther"] = { => }, -- -- Each realm will have an entry. -- ["realm1"] = { -- -- Overall realm-wide totals. -- ["==Total"] = { => }, -- -- Current character realm-level totals. -- ["==TotalCurrent"] = { => }, -- -- Other character realm-level totals. -- ["==TotalOther"] = { => }, -- -- Class-colored character name with faction appended. -- ["_formattedCharacterNames"] = { => }, -- -- Characters on this realm, sorted alphabetically. -- ["_sortedCharacterList"] = { "characterName1", "characterName2", ... }, -- -- Each character will have an entry. -- ["characterName1"] = { => }, -- }, -- } -- ``` totals = {}, -- Arrays of item tables that can later be iterated during calculations of totals. -- There isn't an `all` entry here because that's `self.items`. itemListTables = { currentCharacter = {}, otherCharacter = {}, }, -- Array of locations where items are stored (BS_INVENTORY_TYPE_UI_ORDER followed by EQUIPPED). -- Iterated to build per-character tooltips. itemLocations = {}, -- List of realms, sorted alphabetically. sortedRealmList = {}, -- Reusable table for calculating per-location totals for each character. -- ``` -- { -- = { => } -- } -- ``` tempItemCounts = {}, -- Tooltips for items from other characters need to be reloaded during `Update()`. tooltipUpdateNeeded = true, -- Has `Init()` finished? initialized = false, } Bagshui.environment.BsCatalog = Catalog Bagshui.components.Catalog = Catalog --- Event processing. ---@param event string WoW API event ---@param arg1 any First event argument. function Catalog:OnEvent(event, arg1) -- Bagshui:PrintDebug("Catalog event " .. event) -- Initial processing at startup. Delayed since it's not needed immediately. -- Other update events are allowed to delay initial processing even further. if event == "PLAYER_ENTERING_WORLD" or not self.initialized then -- This check is duplicated in Catalog:Init() but let's be extra safe. if not self.initialized then Bagshui:QueueClassCallback(self, self.Init, 3) end return end if event == "BAGSHUI_CHARACTER_UPDATE" then self.sharedDirty = true -- Refresh the current character's name as we may now have more data. self:StoreFormattedCharacterName(Bagshui.currentCharacterId, Bagshui.currentCharacterData.info) return end -- Anything that can change what other accounts see means the shared file needs rewriting. if event == "BAGSHUI_EQUIPPED_UPDATE" or event == "BAGSHUI_INVENTORY_CACHE_UPDATE" or event == "BAGSHUI_MONEY_UPDATE" then self.sharedDirty = true end if event == "BAGSHUI_CHARACTER_LEARNED_RECIPE" then -- Learning a new recipe requires updating tooltips for items from other characters. self.tooltipUpdateNeeded = true end if event == "BAGSHUI_CATALOG_SHOULD_UPDATE" then -- We've received the delayed event to trigger the update. self:Update() return end -- Any other event requires an update after a delay. Bagshui:QueueEvent("BAGSHUI_CATALOG_SHOULD_UPDATE", 0.75) end --- Initialize the Catalog class. --- Also calculates item totals for all characters other than the current one. function Catalog:Init() -- Bagshui:PrintDebug("Catalog Init()") -- Safeguard just in case. Without this, if Init() is called multiple times -- then the list of characters will be duplicated and tooltips will look weird. if self.initialized then return end -- Initialize storage tables. for _, inventoryType in ipairs(BS_INVENTORY_TYPE_UI_ORDER) do table.insert(self.itemLocations, inventoryType) end for _, itemLocation in pairs(BS_CATALOG_LOCATIONS) do table.insert(self.itemLocations, itemLocation) end for _, inventoryType in ipairs(self.itemLocations) do self.tempItemCounts[inventoryType] = {} end self:CreateTotalKeys(self.totals) -- Process one character: register it in the realm lists and, if it isn't the -- current character, calculate its totals. local function processCharacter(characterId, data) -- Make sure it's a fully formed character. if data.info and data.info.name and data.info.realm then local realm = data.info.realm -- Initialize realm storage table. if not self.totals[realm] then table.insert(self.sortedRealmList, realm) self.totals[realm] = { _sortedCharacterList = {}, _formattedCharacterNames = {}, } self:CreateTotalKeys(self.totals[realm]) end -- Store data about this character that will be needed later. table.insert(self.totals[realm]._sortedCharacterList, data.info.name) self:StoreFormattedCharacterName(characterId, data.info) -- Update() will take calculations for the current character, so Init() only needs to handle others. if characterId ~= Bagshui.currentCharacterId then self:CalculatePerCharacterTotal(characterId, data, self.itemListTables.otherCharacter) end end end -- Read characters from other accounts (if Nampower's file functions are available). self:LoadSharedCharacters() -- Process all characters in SavedVariables. for characterId, data in pairs(Bagshui.characters) do processCharacter(characterId, data) end -- Process characters from other accounts on this computer (see `Bagshui.sharedCharacters`). -- A pcall keeps one malformed shared character from breaking the whole Catalog. for characterId, data in pairs(Bagshui.sharedCharacters) do if not getmetatable(data) and BsCharacterData and BsCharacterData.objectMetatable then setmetatable(data, BsCharacterData.objectMetatable) end local ok, err = pcall(processCharacter, characterId, data) if not ok then Bagshui:PrintDebug("Skipping shared character " .. tostring(characterId) .. ": " .. tostring(err)) end end -- Ensure all items are known so tooltips will load immediately. -- This used to run inside the character loop above, which re-loaded every previously -- processed character's items again for each additional character (a hidden tooltip -- load per item), so the cost grew with characters x items. for _, item in ipairs(self.itemListTables.otherCharacter) do BsItemInfo:LoadItemIntoLocalGameCache(item.itemString) end -- Produce subtotals for all characters other than the current one. self:CalculateSubtotals(self.itemListTables.otherCharacter, SUBTOTAL_TYPE.Other) -- Character and realm lists only need to be sorted once because they can't change without logging out. table.sort(self.sortedRealmList) for _, realm in ipairs(self.sortedRealmList) do table.sort(self.totals[realm]._sortedCharacterList) end -- Get current character data and calculate totals. self:Update() -- Register slash command. BsSlash:AddOpenCloseHandler("Catalog", self) -- Allow events to be processed normally. self.initialized = true end --- Add this character to the shared roster, once per session. --- Lua can't list a directory, so the roster is how other characters find our file. function Catalog:JoinSharedRoster() if self.joinedSharedRoster then return end self.joinedSharedRoster = true local rosterLine = "R~" .. Bagshui.currentCharacterId local ok, roster = pcall(_G.ReadCustomFile, SHARED_ROSTER_FILE) if ok and type(roster) == "string" then for line in string.gfind(roster, "[^\n]+") do if BsUtil.Trim(line) == rosterLine then return end end end -- Appended (not rewritten) because several clients may start at the same moment. pcall(_G.WriteCustomFile, SHARED_ROSTER_FILE, rosterLine .. "\n", "a") end --- Write the current character's Catalog data where characters on other accounts can read it. --- Rate limited; `force` skips the limit (used at logout). ---@param force boolean? function Catalog:WriteSharedCharacter(force) if not sharedFilesAvailable() then return end -- Wait until the character's basic info has been filled in. local currentInfo = Bagshui.currentCharacterData.info if not currentInfo or not currentInfo.name or currentInfo.name == "" or not currentInfo.realm or currentInfo.realm == "" then return end -- Nothing has changed since the last successful write: nothing to do. -- (`sharedDirty` is nil until the first write, which counts as dirty.) if self.sharedDirty == false and self.lastSharedBody then return end local now = _G.time() if not force and self.lastSharedWrite and (now - self.lastSharedWrite) < SHARED_WRITE_MIN_INTERVAL then self.sharedWritePending = true return end -- The work is split into small steps that run one per frame, so building and serializing a big -- bank doesn't freeze the game for a moment. (At logout everything runs at once.) if self.sharedWriteJob then if not force then return end self.sharedWriteJob = nil end self.lastSharedWrite = now self.sharedWritePending = false -- Cleared now so a change that happens while the steps run triggers another write. self.sharedDirty = false local data = Bagshui.currentCharacterData local record = { version = BS_DATA_VERSION, id = Bagshui.currentCharacterId, info = {}, } local catalogBody -- pieces[storageKey][field] = array of serialized "[key]=value," strings (or a single string for non-tables). local pieces = {} local steps = {} -- Basic info and equipped items. table.insert(steps, function() local info = data.info or {} for _, key in ipairs(SHARED_INFO_KEYS) do record.info[key] = info[key] end record.info.equipped = {} if type(info.equipped) == "table" then for slot, item in pairs(info.equipped) do local compacted = compactItem(item) if compacted then record.info.equipped[slot] = compacted end end end end) -- Bags, bank, etc. for the Catalog. Empty slots are dropped, so each bag becomes a dense array. for _, inventoryType in pairs(BS_INVENTORY_TYPE) do local storageKey = BsUtil.LowercaseFirstLetter(inventoryType) table.insert(steps, function() if data[storageKey] and data[storageKey].inventory then local bagsOut = {} for bagNum, contents in pairs(data[storageKey].inventory) do local list = {} for _, item in ipairs(contents) do local compacted = compactItem(item) if compacted then table.insert(list, compacted) end end bagsOut[bagNum] = list end record[storageKey] = { inventory = bagsOut } end end) end table.insert(steps, function() catalogBody = serializeShared(record) end) -- Full inventory (every slot plus bag info) so other accounts can view this character's bags. -- Serialized one bag at a time; it reads the live saved tables, nothing is copied. for _, inventoryType in pairs(BS_INVENTORY_TYPE) do local storageKey = BsUtil.LowercaseFirstLetter(inventoryType) local stored = data[storageKey] if type(stored) == "table" then pieces[storageKey] = {} for _, fieldName in ipairs({ "inventory", "containers", "neverOnline" }) do -- Lua 5.0 doesn't give closures their own copy of `for` loop variables, so copy -- everything the delayed steps use into locals declared inside the loop body. local field = fieldName local value = stored[field] if type(value) == "table" then pieces[storageKey][field] = {} for loopKey in pairs(value) do local subKey = loopKey table.insert(steps, function() local current = data[storageKey] and data[storageKey][field] local subValue = type(current) == "table" and current[subKey] or nil local keyText = sharedKeyText(subKey) local valueText = subValue ~= nil and sharedValueText(subValue) or nil if keyText and valueText then table.insert(pieces[storageKey][field], keyText .. valueText .. ",") end end) end elseif value ~= nil then pieces[storageKey][field] = sharedValueText(value) end end end end -- Put it all together and write it. table.insert(steps, function() local inventoryParts = { "{" } for storageKey, fields in pairs(pieces) do table.insert(inventoryParts, sharedKeyText(storageKey) .. "{") for field, content in pairs(fields) do local valueText if type(content) == "table" then valueText = "{" .. table.concat(content) .. "}" else valueText = content end if valueText then table.insert(inventoryParts, sharedKeyText(field) .. valueText .. ",") end end table.insert(inventoryParts, "},") end table.insert(inventoryParts, "}") local body = catalogBody .. "\n" .. SHARED_INVENTORY_MARKER .. "\n" .. table.concat(inventoryParts) -- Skip the write if nothing changed. if body == self.lastSharedBody then return end self:JoinSharedRoster() local written = pcall(_G.WriteCustomFile, sharedFileName(Bagshui.currentCharacterId), "-- " .. now .. "\n" .. body, "w") if written then self.lastSharedBody = body else self.sharedDirty = true end end) local job = { steps = steps, index = 1 } local function runStep() local step = job.steps[job.index] job.index = job.index + 1 local ok, err = pcall(step) if not ok then Bagshui:PrintDebug("Could not write shared character data: " .. tostring(err)) self.sharedDirty = true job.index = getn(job.steps) + 1 end end if force then while job.index <= getn(job.steps) do runStep() end return end self.sharedWriteJob = job if not self.sharedWriteFrame then self.sharedWriteFrame = _G.CreateFrame("Frame") end self.sharedWriteFrame:SetScript("OnUpdate", function() if self.sharedWriteJob ~= job then -- Replaced by a forced write at logout. this:SetScript("OnUpdate", nil) return end if job.index <= getn(job.steps) then runStep() end if job.index > getn(job.steps) then self.sharedWriteJob = nil this:SetScript("OnUpdate", nil) end end) end --- Read the characters other accounts have shared and put them in `Bagshui.sharedCharacters`. --- Characters in this account's own SavedVariables always take priority. function Catalog:LoadSharedCharacters() BsUtil.TableClear(Bagshui.sharedCharacters) if not sharedFilesAvailable() then return end local ok, roster = pcall(_G.ReadCustomFile, SHARED_ROSTER_FILE) if not ok or type(roster) ~= "string" then return end for line in string.gfind(roster, "[^\n]+") do local _, _, characterId = string.find(BsUtil.Trim(line), "^R~(.+)$") if characterId and characterId ~= Bagshui.currentCharacterId and not Bagshui.characters[characterId] and not Bagshui.sharedCharacters[characterId] then local readOk, text = pcall(_G.ReadCustomFile, sharedFileName(characterId)) if readOk and type(text) == "string" and text ~= "" then -- Only the compact Catalog section is parsed here; see `Catalog:LoadSharedInventory()`. local catalogText = text local markerStart = string.find(text, SHARED_INVENTORY_MARKER, 1, true) if markerStart then catalogText = string.sub(text, 1, markerStart - 1) end local parseOk, record = pcall(BsUtil.Deserialize, catalogText) if parseOk and type(record) == "table" and record.version == BS_DATA_VERSION and type(record.info) == "table" and type(record.info.name) == "string" and type(record.info.realm) == "string" then -- Fill in any properties the file doesn't carry, the same way Bagshui does for its own saved items. for _, inventoryType in pairs(BS_INVENTORY_TYPE) do local storageKey = BsUtil.LowercaseFirstLetter(inventoryType) if type(record[storageKey]) == "table" and type(record[storageKey].inventory) == "table" then for _, contents in pairs(record[storageKey].inventory) do for _, item in ipairs(contents) do BsItemInfo:InitializeItem(item, false, true) end end end end if type(record.info.equipped) == "table" then for _, item in pairs(record.info.equipped) do BsItemInfo:InitializeItem(item, false, true) end end -- Last time the character's file was written (shown in the Character Data manager). local _, _, writtenAt = string.find(text, "^%-%- (%d+)") record.lastLogout = tonumber(writtenAt) Bagshui.sharedCharacters[characterId] = record else Bagshui:PrintDebug("Ignoring shared character file for " .. characterId) end end end end -- Let the Character Data manager list them too. if BsCharacterData and BsCharacterData.RefreshList then BsCharacterData:RefreshList() BsCharacterData:Update() end end --- Load a shared character's full inventory (all slots and bag info) from its file so it --- can be viewed in a Bagshui window. Replaces the compact Catalog data in the character's record. ---@param characterId string ---@return boolean loaded ---@return string? reason Why loading failed. function Catalog:LoadSharedInventory(characterId) local record = Bagshui.sharedCharacters[characterId] if not record or not sharedFilesAvailable() then return false, "not a shared character, or Nampower's file functions are missing" end -- Docked windows ask for the same character back to back, so don't read the file twice. if record.inventoryLoadedAt and (_G.GetTime() - record.inventoryLoadedAt) < 5 then return true end local readOk, text = pcall(_G.ReadCustomFile, sharedFileName(characterId)) if not readOk or type(text) ~= "string" or text == "" then return false, "its shared file could not be read" end local _, markerEnd = string.find(text, SHARED_INVENTORY_MARKER, 1, true) if not markerEnd then return false, "its inventory hasn't been shared yet. Log in on that character (on its own account) with this version of Bagshui once" end local parseOk, inventoryRecord = pcall(BsUtil.Deserialize, string.sub(text, markerEnd + 1)) if not parseOk or type(inventoryRecord) ~= "table" then return false, "its shared inventory couldn't be read (it was probably saved by an older version). Log in on that character once with this version of Bagshui" end for _, inventoryType in pairs(BS_INVENTORY_TYPE) do local storageKey = BsUtil.LowercaseFirstLetter(inventoryType) local stored = inventoryRecord[storageKey] if type(stored) == "table" then stored.inventory = type(stored.inventory) == "table" and stored.inventory or {} stored.containers = type(stored.containers) == "table" and stored.containers or {} -- Empty tables aren't serialized, so make sure every bag has an inventory table. for bagNum, _ in pairs(stored.containers) do if type(stored.inventory[bagNum]) ~= "table" then stored.inventory[bagNum] = {} end end for _, contents in pairs(stored.inventory) do for _, item in ipairs(contents) do BsItemInfo:InitializeItem(item, false, true) end end record[storageKey] = stored end end record.inventoryLoadedAt = _G.GetTime() return true end --- Stop sharing a character: blank its file and remove it from the roster. --- Called when a character is deleted from the Character Data manager. ---@param characterId string function Catalog:ForgetSharedCharacter(characterId) if not sharedFilesAvailable() then return end pcall(_G.WriteCustomFile, sharedFileName(characterId), "", "w") local ok, roster = pcall(_G.ReadCustomFile, SHARED_ROSTER_FILE) if ok and type(roster) == "string" then local rosterLine = "R~" .. characterId local remaining = "" for line in string.gfind(roster, "[^\n]+") do line = BsUtil.Trim(line) if line ~= rosterLine then remaining = remaining .. line .. "\n" end end pcall(_G.WriteCustomFile, SHARED_ROSTER_FILE, remaining, "w") end end --- Sort an array of item tables alphabetically by name. ---@param a table ---@param b table ---@return boolean local function sortItemList(a, b) return (a.name or "") < (b.name or "") end --- Refresh item counts and totals for the current character, then refresh overall totals. function Catalog:Update() -- Bagshui:PrintDebug("Catalog:Update() - tooltips? " .. tostring(self.tooltipUpdateNeeded)) -- Reset existing grand totals and current character subtotals. BsUtil.TableClear(self.totals[TOTAL_KEY]) for _, realm in ipairs(self.sortedRealmList) do BsUtil.TableClear(self.totals[realm][TOTAL_KEY]) end BsUtil.TableClear(self.totals[self:TotalKey(SUBTOTAL_TYPE.Current)]) for _, realm in ipairs(self.sortedRealmList) do BsUtil.TableClear(self.totals[realm][self:TotalKey(SUBTOTAL_TYPE.Current)]) end -- Wipe the item tracking table so `AddToItemTable()` will work. BsUtil.TableClear(self.itemListTables.currentCharacter) -- Get the item counts for the current character. self:CalculatePerCharacterTotal( Bagshui.currentCharacterId, Bagshui.currentCharacterData, self.itemListTables.currentCharacter ) -- Calculate subtotals for current character. self:CalculateSubtotals(self.itemListTables.currentCharacter, SUBTOTAL_TYPE.Current) -- Build unique, sorted list of all items on the account. BsUtil.TableClear(self.items) for _, itemTable in pairs(self.itemListTables) do for _, item in ipairs(itemTable) do if type(item.id) == "number" and item.id > 0 then -- Refresh the tooltip for items belonging to other characters so -- the Catalog will accurately indicate whether the current -- character can use the item. if self.tooltipUpdateNeeded and itemTable ~= self.itemListTables.currentCharacter then -- 3nd parameter: Force tooltip loading to use itemString -- instead of bagNum/slotNum, since those are for other -- characters in this context and will lead to pulling -- the wrong tooltip. BsItemInfo:GetTooltip(item, nil, true) end self:AddToItemTable(item, self.items) end end end table.sort(self.items, sortItemList) -- Combine totals from current character and other characters. self:CalculateGrandTotals() -- Refresh the account-wide search if it's visible. if self.objectManager and self.objectManager.uiFrame:IsVisible() then self.objectManager:UpdateList() end -- Reset flag. self.tooltipUpdateNeeded = false -- Share the current character with other accounts. self:WriteSharedCharacter() end --- Iterate a character's item storage locations and collect sums of each item. --- Totals are calculated at the item, character, and realm level. ---@param characterId string Unique ID of the character being analyzed. This is generated by Bagshui on startup and housed in the `Bagshui.currentCharacterId` property. It is also the key for each character in the SavedVariables `characters` table. ---@param characterData table Character's table from the SavedVariables `characters` table. ---@param itemTable table Array that will be filled with the unique list of all items belonging to this character. function Catalog:CalculatePerCharacterTotal(characterId, characterData, itemTable) assert(type(characterData) == "table", "Catalog:CalculatePerCharacterTotal() - characterData must be a table") assert( type(characterData.realm) == "string", "Catalog:CalculatePerCharacterTotal() - characterData.realm is required" ) local storage = self.totals[characterData.info.realm] local storageSuffix = (characterId == Bagshui.currentCharacterId and SUBTOTAL_TYPE.Current or SUBTOTAL_TYPE.Other) BsUtil.TableClear(storage[self:TotalKey(characterData.info.name)]) -- Reset the storage for this character. for _, inventoryType in ipairs(self.itemLocations) do BsUtil.TableClear(self.tempItemCounts[inventoryType]) -- Used by AddToItemTotal(). end -- Normal inventory locations (Bags, Bank, etc.). for _, inventoryType in pairs(BS_INVENTORY_TYPE) do local inventoryTypeStorageKey = BsUtil.LowercaseFirstLetter(inventoryType) if characterData[inventoryTypeStorageKey] and characterData[inventoryTypeStorageKey].inventory then for bagNum, contents in pairs(characterData[inventoryTypeStorageKey].inventory) do for slotNum, item in ipairs(contents) do if item.emptySlot ~= 1 then self:AddItemToSubtotals( item, storage, storageSuffix, characterId, characterData, itemTable, inventoryType ) end end end end end -- Equipped items. if type(characterData.info.equipped) == "table" then for slot, item in pairs(characterData.info.equipped) do self:AddItemToSubtotals( item, storage, storageSuffix, characterId, characterData, itemTable, BS_CATALOG_LOCATIONS.EQUIPPED ) end end -- Money. if type(characterData.info.money) == "number" then MONEY_ITEM.count = characterData.info.money self:AddItemToSubtotals( MONEY_ITEM, storage, storageSuffix, characterId, characterData, itemTable, BS_CATALOG_LOCATIONS.MONEY ) end -- Instead of building out tooltip strings when the tooltip is requested, -- we're just going to pre-build them. -- The goal is to come out with something like "Bags: 3" or "Bags: 2 + Bank 5 = 7". local tooltipString, multipleLocations, lastCount for itemString, totalCount in pairs(storage[self:TotalKey(characterData.info.name)]) do tooltipString = "" multipleLocations = false -- self.itemLocations provides the correct order for the inventory locations. for _, inventoryType in ipairs(self.itemLocations) do -- tempItemCounts was populated by `AddItemToSubtotals()` during the loops above. if self.tempItemCounts[inventoryType][itemString] then -- Add a separator if this item is found in more than one location. if string.len(tooltipString) > 0 then tooltipString = tooltipString .. GRAY_FONT_COLOR_CODE .. " + " .. FONT_COLOR_CODE_CLOSE multipleLocations = true end -- When there are not multiple locations for an item, this information will be used to recolor the number -- so that "Bags: 3" has a white 3 just like "Bags: 2 + Bank 5 = 7" will have a white 7. lastCount = self.tempItemCounts[inventoryType][itemString] if inventoryType == BS_CATALOG_LOCATIONS.MONEY and itemString == BS_CATALOG_LOCATIONS.MONEY then -- Money is a little weird in that it's shoehorned into the catalog in a special location that -- only contains the money itemString, so when we see it, we know that this is not a tooltip -- that needs to be built up from multiple locations; it's just a money string. tooltipString = BsUtil.FormatMoneyString(lastCount) break else tooltipString = tooltipString .. string.format(L.Symbol_Colon, (L_nil["Abbrev_" .. inventoryType] or L[inventoryType])) .. " " .. lastCount end end end -- Final touches -- either add the sum at the end or recolor the number. if multipleLocations then tooltipString = tooltipString .. GRAY_FONT_COLOR_CODE .. " = " .. FONT_COLOR_CODE_CLOSE .. HIGHLIGHT_FONT_COLOR_CODE .. totalCount .. FONT_COLOR_CODE_CLOSE else tooltipString = string.gsub(tooltipString, lastCount .. "$", HIGHLIGHT_FONT_COLOR_CODE .. lastCount .. FONT_COLOR_CODE_CLOSE) end -- Overwrite the existing total count with the tooltip string since we have no other use for the per-character total. storage[self:TotalKey(characterData.info.name)][itemString] = tooltipString end end --- Add up per-realm subtotals of a given type into an account-wide subtotal. ---@param uniqueItemList table Deduplicated list of items. ---@param subtotalType string `SUBTOTAL_TYPE` value. function Catalog:CalculateSubtotals(uniqueItemList, subtotalType) assert(subtotalType, "Catalog:CalculateSubtotals() - subtotalType is required") for _, item in ipairs(uniqueItemList) do for _, realm in ipairs(self.sortedRealmList) do self:AddToTotal( self.totals, subtotalType, item.itemString, self:GetTotal(self.totals[realm], subtotalType, item.itemString) ) end end end --- Combine "current" and "other" character totals into final tallies. function Catalog:CalculateGrandTotals() for _, subtotalType in pairs(SUBTOTAL_TYPE) do for _, realm in ipairs(self.sortedRealmList) do for itemString, count in pairs(self.totals[realm][self:TotalKey(subtotalType)]) do -- Per-realm grand total. self:AddToTotal(self.totals[realm], nil, itemString, count) -- Account-wide grand total. self:AddToTotal(self.totals, nil, itemString, count) end end end end --- Primary function called for each item in a character's inventory that is responsible --- for adding it to all the various subtotal tables. This is a helper for `CalculatePerCharacterTotal()` --- and isn't meant to be called independently because it relies on that function doing --- the necessary prep work of clearing tables. ---@param item table Bagshui ItemInfo table. ---@param storage table Place to store the calculation results. ---@param storageSuffix string One of the SUBTOTAL_TYPE values. Controls whether the subtotals are stored in the "Current" or "Other" character bucket. ---@param characterId string Unique ID of the character. ---@param characterData table Character's table from the SavedVariables `characters` table. ---@param uniqueItemList table Array where the deduplicated list of items will be stored (see `AddToItemTable()`). ---@param inventoryType string Which location in the `self.tempItemCounts` table this item belongs to. function Catalog:AddItemToSubtotals( item, storage, storageSuffix, characterId, characterData, uniqueItemList, inventoryType ) if type(item) == "table" or ((item.id or 0) > 0 or item.itemString == MONEY_ITEM.itemString) and (item.count or 0) > 0 then -- Add to list of known items if not already present. -- 3rd parameter: Make a copy of the item table if this item comes from another character's -- cached inventory so we can manipulate it (mainly updating tooltips to get usable status) -- without messing up their data. self:AddToItemTable(item, uniqueItemList, characterId ~= Bagshui.currentCharacterId) -- Character total. self:AddToTotal(storage, characterData.info.name, item.itemString, item.count) -- Inventory-type total. self:AddToItemTotal(self.tempItemCounts[inventoryType], item.itemString, item.count) -- Overall subtotal. self:AddToTotal(storage, storageSuffix, item.itemString, item.count) end end --- Add the given item's itemString to the provided item list table, but only --- if it hasn't already been inserted. ---@param item table Bagshui ItemInfo table. ---@param uniqueItemList any function Catalog:AddToItemTable(item, uniqueItemList, copy) -- Don't duplicate. -- A lookup table of itemStrings per list replaces scanning the whole list for every -- insert (which made rebuilding the account-wide list quadratic). The lookup is -- rebuilt from the list whenever its recorded size doesn't match, which also covers -- lists that were cleared with `BsUtil.TableClear()` or modified elsewhere. local lookup = uniqueItemLookups[uniqueItemList] local listSize = table.getn(uniqueItemList) if not lookup or lookup.size ~= listSize then lookup = { size = listSize, itemStrings = {} } for _, existingItem in ipairs(uniqueItemList) do lookup.itemStrings[existingItem.itemString or NIL_ITEM_STRING_KEY] = true end uniqueItemLookups[uniqueItemList] = lookup end local itemStringKey = item.itemString or NIL_ITEM_STRING_KEY if lookup.itemStrings[itemStringKey] then return end local itemToInsert = copy and BsUtil.TableCopy(item) or item table.insert(uniqueItemList, itemToInsert) lookup.itemStrings[itemStringKey] = true lookup.size = listSize + 1 end --- Prepare the storage location, then add up the item total. ---@param storage table Place to store the calculation results. ---@param subtotalType string? `SUBTOTAL_TYPE` value, or `nil` if this is a grand total. ---@param itemString string Unique item identifier. ---@param count number Amount by which to increase this item's total. function Catalog:AddToTotal(storage, subtotalType, itemString, count) if type(itemString) ~= "string" or type(count) ~= "number" then return end -- Ensure the storage location exists. local keyString = self:TotalKey(subtotalType) if not storage[keyString] then storage[keyString] = {} end self:AddToItemTotal(storage[keyString], itemString, count) end --- Add the given count to the given item's total. ---@param storage table Place to store the calculation results. Must be an `{ => }` table. ---@param itemString string Unique item identifier. ---@param count number Amount by which to increase this item's total. function Catalog:AddToItemTotal(storage, itemString, count) if type(itemString) ~= "string" or type(count) ~= "number" then return end storage[itemString] = (storage[itemString] and storage[itemString] + count or count) end -- Reusable variable for `GetTotal()`'s key checking. local getTotal_key --- Pull the calculated total for the given `itemString` from the specified storage location. ---@param storage table Calculation results to query. ---@param subtotalType string? `SUBTOTAL_TYPE` value, or `nil` to get the grand total. ---@param itemString string Unique item identifier. ---@return integer count function Catalog:GetTotal(storage, subtotalType, itemString) getTotal_key = self:TotalKey(subtotalType) return storage[getTotal_key] and storage[getTotal_key][itemString] or 0 end --- Prepare the given storage table with keys that are known to be required. ---@param storage table Place to create keys. function Catalog:CreateTotalKeys(storage) if not storage[TOTAL_KEY] then storage[TOTAL_KEY] = {} end for _, suffix in pairs(SUBTOTAL_TYPE) do local keyString = self:TotalKey(suffix) if not storage[keyString] then storage[keyString] = {} end end end --- Get the key for the calculation results table of the specified type. ---@param subtotalType string? `SUBTOTAL_TYPE` value, or `nil` if this is a grand total. ---@return string key function Catalog:TotalKey(subtotalType) return TOTAL_KEY .. (subtotalType or "") end --- Build a ` [ 0 then realmsWithItems = realmsWithItems + 1 end -- Now add all the other realms. for _, realm in ipairs(self.sortedRealmList) do if realm ~= Bagshui.currentRealm then if self:GetPerRealmTooltipInfo(itemString, realm) > 0 then realmsWithItems = realmsWithItems + 1 end end end -- Populate the tooltip. if table.getn(addTooltipInfo_LeftStrings) > 0 then if tooltip:NumLines() > 0 then tooltip:AddLine(" ") end -- Grand total for multi-realm. if realmsWithItems > 1 then tooltip:AddDoubleLine( HIGHLIGHT_FONT_COLOR_CODE .. L.Total .. FONT_COLOR_CODE_CLOSE, HIGHLIGHT_FONT_COLOR_CODE .. self:GetTotal(self.totals, nil, itemString) .. FONT_COLOR_CODE_CLOSE ) end -- Add tooltip lines, indenting the left side in multi-realm mode. for lineNum, left in ipairs(addTooltipInfo_LeftStrings) do tooltip:AddDoubleLine( (realmsWithItems > 1 and TOOLTIP_INDENT or HIGHLIGHT_FONT_COLOR_CODE) .. left .. FONT_COLOR_CODE_CLOSE, addTooltipInfo_RightStrings[lineNum] ) end return true else tooltip:AddDoubleLine( GRAY_FONT_COLOR_CODE .. L.Total .. FONT_COLOR_CODE_CLOSE, GRAY_FONT_COLOR_CODE .. 0 .. FONT_COLOR_CODE_CLOSE ) end return false end --- When at least 1 of the given item is held by one or more characters on the specified realm, --- add all the tooltip data for that realm to the reusable strings tables. ---@param itemString string Item to gather counts for. ---@param realm string Realm to get counts for. ---@return integer realmCount Total count of the given item found on the specified realm. function Catalog:GetPerRealmTooltipInfo(itemString, realm) -- This realm has no data. if not self.totals[realm] then return 0 end local overallTotal = self:GetTotal(self.totals[realm], nil, itemString) if (overallTotal or 0) > 0 or itemString == BS_CATALOG_LOCATIONS.MONEY then -- Realm-wide total goes first. self:AddTooltipInfoLine( realm, HIGHLIGHT_FONT_COLOR_CODE .. (itemString == BS_CATALOG_LOCATIONS.MONEY and BsUtil.FormatMoneyString(overallTotal) or overallTotal) .. FONT_COLOR_CODE_CLOSE ) -- Add the total held by each character (if more than 0) local characterTotals for _, characterName in ipairs(self.totals[realm]._sortedCharacterList) do characterTotals = self.totals[realm][self:TotalKey(characterName)][itemString] if characterTotals then self:AddTooltipInfoLine( TOOLTIP_INDENT .. self.totals[realm]._formattedCharacterNames[characterName], characterTotals ) end end end return overallTotal end --- Add the given strings to the reusable tooltip storage tables, to be consumed --- by `AddTooltipInfo()`. ---@param left string? ---@param right string? function Catalog:AddTooltipInfoLine(left, right) table.insert(addTooltipInfo_LeftStrings, left or "") table.insert(addTooltipInfo_RightStrings, right or "") end --- Get an item from the catalog by matching name and (optionally) texture. --- Useful when item ID isn't known (like for some hooked tooltip functions). --- This doesn't guarantee a 100% match since there are a few items that have --- the same name and texture but different IDs (Recruit's Boots/Pants/Shirt). ---@param name string Item name. ---@param texture string? Item texture (recommended for a guaranteed accurate match). ---@param property string? Extract a single item property instead of returning the entire item table. ---@return table|string? # Item table or item property. function Catalog:FindItemByNameAndTexture(name, texture, property) for _, item in ipairs(self.items) do if item.name == name and (not texture or item.texture == texture) then if property then return item[property] else return item end break end end end --- Build the account-wide search interface. function Catalog:InitUi() -- Don't initialize multiple times. if self.objectManager then return end -- Upvalue for use inside ObjectManager class functions. local catalog = self self.objectManager = Bagshui.prototypes.ObjectManager:New({ objectType = "Catalog", listType = BS_UI_SCROLLABLE_LIST_TYPE.ITEM, managerMultiSelect = false, disableObjectCreation = true, disableObjectDeletion = true, disableObjectEditing = true, disableObjectSharing = true, managerHeight = 700, managerWidth = 400, searchPlaceholderText = L.CatalogManager_SearchBoxPlaceholder, wikiPage = BS_WIKI_PAGES.Search, }) function self.objectManager:InitUi() self._super.InitUi(self) self.ui.searchBox:SetWidth(self.dimensions.width * 0.6) -- Add CharacterData manager button to toolbar. local characterButton = self.ui:CreateIconButton({ name = "Character", texture = "CharacterRobed", parentFrame = self.toolbar, anchorPoint = "RIGHT", anchorToPoint = "RIGHT", xOffset = -2, noTooltipDelay = true, noTooltipTextDelay = true, tooltipAnchor = "ANCHOR_PRESERVE", tooltipAnchorPoint = "TOPRIGHT", tooltipAnchorToPoint = "BOTTOMRIGHT", tooltipXOffset = 4, tooltipYOffset = -4, tooltipFunction = function(button, tooltip) tooltip:AddLine( HIGHLIGHT_FONT_COLOR_CODE .. string.format(L.Prefix_Manage, L.CharacterData) .. FONT_COLOR_CODE_CLOSE ) tooltip:AddLine(BS_NEWLINE .. L.CatalogManager_KnownCharacters) tooltip:AddLine("- " .. table.concat(BsCharacterData.characterNameList, BS_NEWLINE .. "- ")) end, onClick = function() BsCharacterData:Open() end, }) end function self.objectManager:GetObjectList() return catalog.items end end --- Open the account-wide search interface, optionally with a pre-populated search string. ---@param searchString string? Search string to pre-populate. function Catalog:Open(searchString) self:Init() self:InitUi() self.objectManager.Open(self.objectManager) if type(searchString) == "string" then self.objectManager.ui.listFrame.bagshuiData.searchBox:SetText(searchString) end end --- Hide the catalog window. function Catalog:Close() self.objectManager:Close() end --- Open the Catalog if it's closed and vice-versa. function Catalog:Toggle() if self:Visible() then self.objectManager:Close() else self:Open() end end --- Determine whether the Catalog window is open. function Catalog:Visible() return self.objectManager and self.objectManager.uiFrame and self.objectManager.uiFrame:IsVisible() end --- Open and search the Catalog, setting focus on the search box. ---@param searchString string? Search string to pre-populate. function Catalog:Search(searchString) self:Open(searchString) self.objectManager.ui.listFrame.bagshuiData.searchBox:SetFocus() end -- Class event registration. -- This is done at the end because RegisterEvent expects the class to have an OnEvent function. Bagshui:RegisterEvent("PLAYER_ENTERING_WORLD", Catalog) Bagshui:RegisterEvent("BAGSHUI_CHARACTER_UPDATE", Catalog) Bagshui:RegisterEvent("BAGSHUI_CHARACTER_LEARNED_RECIPE", Catalog) Bagshui:RegisterEvent("BAGSHUI_EQUIPPED_UPDATE", Catalog) Bagshui:RegisterEvent("BAGSHUI_INVENTORY_CACHE_UPDATE", Catalog) Bagshui:RegisterEvent("BAGSHUI_MONEY_UPDATE", Catalog) Bagshui:RegisterEvent("BAGSHUI_CATALOG_SHOULD_UPDATE", Catalog) end)