diff --git a/Data/BankScanner.lua b/Data/BankScanner.lua index 2c90d11..f1ef4a3 100644 --- a/Data/BankScanner.lua +++ b/Data/BankScanner.lua @@ -8,33 +8,48 @@ addon.Modules.BankScanner = BankScanner local bankOpen = false --- Cache for bank data to avoid re-scanning all slots on every update local bankCache = nil local cacheValid = false - --- Event pending tracking (like Baganator's IsBagEventPending) local eventPending = false -local dirtySlots = {} -- Track specific slots that changed +local dirtySlots = {} +local dirtyBags = {} + +local function IsBankAccessible() + if bankOpen then return true end + local testSlots = GetContainerNumSlots(-1) + return testSlots and testSlots > 0 +end + +local function ReleaseBankBagData(bagData) + if addon.Modules.BagScanner and addon.Modules.BagScanner.ReleaseContainerData then + addon.Modules.BagScanner:ReleaseContainerData(bagData) + end +end + +local function ReleaseAllBankCacheData() + if not bankCache then return end + for _, bagData in pairs(bankCache) do + ReleaseBankBagData(bagData) + end +end --- Clear the bank cache (called when bank opens or significant changes occur) function BankScanner:ClearCache() + ReleaseAllBankCacheData() bankCache = nil cacheValid = false dirtySlots = {} + dirtyBags = {} eventPending = false end --- Check if a bank event is pending (use before transfers) function BankScanner:IsEventPending() return eventPending end --- Clear the pending flag (call after processing) function BankScanner:ClearEventPending() eventPending = false end --- Mark a specific slot as dirty (incremental tracking) function BankScanner:MarkSlotDirty(bagID, slotID) if not dirtySlots[bagID] then dirtySlots[bagID] = {} @@ -43,52 +58,132 @@ function BankScanner:MarkSlotDirty(bagID, slotID) eventPending = true end --- Get cached bank data, or scan if cache is invalid -function BankScanner:GetBankData() - addon:DebugCategory("GetBankData: ENTRY bankOpen=%s, cacheValid=%s, hasCache=%s", - tostring(bankOpen), tostring(cacheValid), tostring(bankCache ~= nil)) +local function GetLiveSlotInfo(bagID, slotID) + local api = addon.Modules.ClassicAPI + if api and api.GetContainerItemInfo then + return api:GetContainerItemInfo(bagID, slotID) + end - -- Check if bank is accessible (either officially open OR we can access bank slots) - local bankAccessible = bankOpen - local forceRescan = false - if not bankAccessible then - -- Try to access main bank - if it has slots, bank is actually accessible - local testSlots = GetContainerNumSlots(-1) - if testSlots and testSlots > 0 then - bankAccessible = true - -- Force rescan when bankOpen is false but bank is accessible - -- This handles edge cases where cache has stale data - forceRescan = true - addon:DebugCategory("GetBankData: bankOpen=false but bank accessible (%d slots), forcing rescan", testSlots) + local texture, count, locked, quality, readable, lootable = + GetContainerItemInfo(bagID, slotID) + local link = GetContainerItemLink(bagID, slotID) + if not texture and not link then return nil end + + local itemID = nil + if link then + local _, _, idStr = string.find(link, "item:(%d+)") + if idStr then itemID = tonumber(idStr) end + end + + return { + iconFileID = texture, + stackCount = count or 1, + isLocked = locked and true or false, + quality = quality, + isReadable = readable and true or false, + hasLoot = lootable and true or false, + hyperlink = link, + itemID = itemID, + } +end + +-- Refresh only one affected bank container. Unlike the old cache-hit path, +-- this does not walk every bank bag just to prove that an already-valid cache +-- is valid. ClassicAPI gives us cheap per-slot identity/count state for the one +-- bag the UI/event layer marked dirty. +function BankScanner:RefreshBankBagInPlace(bagID) + if not bankCache then return end + + local oldBag = bankCache[bagID] + if not oldBag then + bankCache[bagID] = self:ScanBankBag(bagID) + return + end + + local numSlots = addon.Modules.Utils:GetBagSlotCount(bagID) + if not addon.Modules.Utils:IsBagValid(bagID) or oldBag.numSlots ~= numSlots then + ReleaseBankBagData(oldBag) + bankCache[bagID] = self:ScanBankBag(bagID) + return + end + + local freeSlots = 0 + for slotID = 1, numSlots do + local liveInfo = GetLiveSlotInfo(bagID, slotID) + local oldData = oldBag.slots[slotID] + + if not liveInfo then + if oldData then + addon.Modules.BagScanner:ReleaseItemData(oldData) + oldBag.slots[slotID] = nil + end + freeSlots = freeSlots + 1 + else + local liveItemID = tonumber(liveInfo.itemID) + local liveLink = liveInfo.hyperlink + local liveCount = liveInfo.stackCount or 1 + local liveLocked = liveInfo.isLocked and true or false + + local changed = false + if not oldData then + changed = true + elseif oldData.itemID ~= liveItemID then + changed = true + elseif oldData.link ~= liveLink then + changed = true + elseif oldData.count ~= liveCount then + changed = true + elseif (oldData.locked and true or false) ~= liveLocked then + changed = true + elseif liveInfo.quality ~= nil and oldData.quality ~= liveInfo.quality then + changed = true + elseif liveInfo.iconFileID and oldData.texture ~= liveInfo.iconFileID then + changed = true + end + + if changed then + if oldData then addon.Modules.BagScanner:ReleaseItemData(oldData) end + oldBag.slots[slotID] = addon.Modules.BagScanner:ScanSlot(bagID, slotID) + end + + if not oldBag.slots[slotID] then + freeSlots = freeSlots + 1 + end end end - if not bankAccessible then - addon:DebugCategory("GetBankData: bank NOT accessible, returning empty") + oldBag.numSlots = numSlots + oldBag.freeSlots = freeSlots +end + +function BankScanner:GetBankData() + if not IsBankAccessible() then + addon:DebugCategory("GetBankData: bank not accessible") return {} end - -- Force rescan when bankOpen state is inconsistent - if forceRescan then - cacheValid = false - end - if cacheValid and bankCache then - addon:DebugCategory("GetBankData: using cached data (cacheValid=true)") - -- Process any dirty slots incrementally + local bagsToRefresh = dirtyBags + dirtyBags = {} + + for bagID in pairs(bagsToRefresh) do + self:RefreshBankBagInPlace(bagID) + end + for bagID, slots in pairs(dirtySlots) do - if bagID ~= nil and type(slots) == "table" then + if not bagsToRefresh[bagID] + and bagID ~= nil and type(slots) == "table" then if bankCache[bagID] then for slotID in pairs(slots) do - -- Validate slotID is a valid number if type(slotID) == "number" and slotID >= 1 then local oldData = bankCache[bagID].slots[slotID] local newData = addon.Modules.BagScanner:ScanSlot(bagID, slotID) + local wasEmpty = oldData == nil + local isEmpty = newData == nil + + if oldData then addon.Modules.BagScanner:ReleaseItemData(oldData) end bankCache[bagID].slots[slotID] = newData - -- Update free slot count - local wasEmpty = (oldData == nil) - local isEmpty = (newData == nil) if wasEmpty and not isEmpty then bankCache[bagID].freeSlots = bankCache[bagID].freeSlots - 1 elseif not wasEmpty and isEmpty then @@ -97,170 +192,69 @@ function BankScanner:GetBankData() end end else - -- Bag not in cache, scan it bankCache[bagID] = self:ScanBankBag(bagID) end end end + dirtySlots = {} - - -- Check for invalidated bags (nil entries) and rescan them - local rescannedBags = 0 - for _, bagID in ipairs(addon.Constants.BANK_BAGS) do - if bankCache[bagID] == nil then - addon:DebugCategory("GetBankData: rescanning invalidated bag %d", bagID) - bankCache[bagID] = self:ScanBankBag(bagID) - rescannedBags = rescannedBags + 1 - end - end - if rescannedBags > 0 then - addon:DebugCategory("GetBankData: rescanned %d invalidated bags", rescannedBags) - end - - -- Verify cache matches reality for ALL bank bags - -- Only force full rescan when items are ADDED (API > cache) - -- When items are REMOVED (cache > API), the incremental update + empty placeholders should handle it - local needsFullRescan = false - for _, bagID in ipairs(addon.Constants.BANK_BAGS) do - local cacheItems = 0 - local realItems = 0 - if bankCache[bagID] and bankCache[bagID].slots then - for slotID, item in pairs(bankCache[bagID].slots) do - if item then cacheItems = cacheItems + 1 end - end - end - -- Check actual API state - local numSlots = GetContainerNumSlots(bagID) or 0 - for slot = 1, numSlots do - local texture = GetContainerItemInfo(bagID, slot) - if texture then realItems = realItems + 1 end - end - if realItems > cacheItems then - -- Items were ADDED to bank - need full rescan to show them - addon:DebugCategory("GetBankData: ITEMS ADDED in bag %d! cache=%d, API=%d -> full rescan", - bagID, cacheItems, realItems) - needsFullRescan = true - elseif cacheItems > realItems then - -- Items were REMOVED from bank - incremental update handles this - -- Just update the cache for this bag to reflect removals - addon:DebugCategory("GetBankData: ITEMS REMOVED in bag %d, cache=%d, API=%d -> incremental update", - bagID, cacheItems, realItems) - -- Rescan just this bag to update the cache (not full UI redraw) - bankCache[bagID] = self:ScanBankBag(bagID) - end - end - if needsFullRescan then - -- Force a full rescan only when items were added - cacheValid = false - bankCache = self:ScanBank() - cacheValid = true - addon:DebugCategory("GetBankData: forced full rescan due to new items") - end - + eventPending = false return bankCache end - -- Cache miss - do full scan - addon:DebugCategory("GetBankData: cache miss, doing full scan (cacheValid=%s, bankCache=%s)", - tostring(cacheValid), tostring(bankCache ~= nil)) + -- Full rescan is now reserved for first open / explicit full invalidation. + ReleaseAllBankCacheData() bankCache = self:ScanBank() cacheValid = true dirtySlots = {} + dirtyBags = {} + eventPending = false return bankCache end --- Update a single slot in the cache (incremental update) function BankScanner:UpdateSlot(bagID, slotID) if not bankOpen then return end - - -- Mark as dirty for next GetBankData call self:MarkSlotDirty(bagID, slotID) end --- Invalidate cache (force full re-scan on next update) function BankScanner:InvalidateCache() cacheValid = false + eventPending = true end --- Get the cached item count for a bag WITHOUT triggering a rescan --- Used for comparing before/after counts during event handling function BankScanner:GetCachedItemCount(bagID) if not bankCache or not bankCache[bagID] or not bankCache[bagID].slots then return 0 end local count = 0 - for slotID, item in pairs(bankCache[bagID].slots) do + for _, item in pairs(bankCache[bagID].slots) do if item then count = count + 1 end end return count end --- Invalidate a specific bag in the cache (force re-scan of just that bag) +-- Keep the existing API name used by BankFrame, but change its semantics from +-- "destroy this cache entry" to "refresh this one bank container on demand". function BankScanner:InvalidateBag(bagID) - -- Allow invalidation if bank is accessible (not just officially open) - local bankAccessible = bankOpen - if not bankAccessible then - local testSlots = GetContainerNumSlots(-1) - if testSlots and testSlots > 0 then - bankAccessible = true - end - end - if not bankAccessible then - addon:DebugCategory("InvalidateBag(%d): bank not accessible, skipping", bagID) - return - end - if not bankCache then - addon:DebugCategory("InvalidateBag(%d): no bankCache exists, skipping", bagID) - return - end - local hadBag = (bankCache[bagID] ~= nil) - bankCache[bagID] = nil - addon:DebugCategory("InvalidateBag(%d): invalidated (hadBag=%s, cacheValid=%s)", - bagID, tostring(hadBag), tostring(cacheValid)) + if not IsBankAccessible() then return end + dirtyBags[bagID] = true + eventPending = true end --- Scan all bank bags and return data (full scan) function BankScanner:ScanBank() - -- Check if bank is accessible (officially open OR slots are readable) - local bankAccessible = bankOpen - if not bankAccessible then - local testSlots = GetContainerNumSlots(-1) - if testSlots and testSlots > 0 then - bankAccessible = true - end - end - - if not bankAccessible then + if not IsBankAccessible() then addon:Debug("Cannot scan bank - not accessible") return {} end local bankData = {} - local totalItems = 0 - for _, bagID in ipairs(addon.Constants.BANK_BAGS) do bankData[bagID] = self:ScanBankBag(bagID) - local bagItems = 0 - if bankData[bagID] and bankData[bagID].slots then - for _, item in pairs(bankData[bagID].slots) do - if item then - bagItems = bagItems + 1 - totalItems = totalItems + 1 - end - end - end - if bagItems > 0 then - addon:DebugCategory("ScanBank: bag %d has %d items", bagID, bagItems) - end end - addon:DebugCategory("ScanBank: total %d items across all bags", totalItems) - return bankData end --- Scan a single bank bag function BankScanner:ScanBankBag(bagID) - -- Determine bag type local bagType = "regular" if addon.Modules.Utils:IsSoulBag(bagID) then bagType = "soul" @@ -280,66 +274,92 @@ function BankScanner:ScanBankBag(bagID) } if not addon.Modules.Utils:IsBagValid(bagID) then - addon:DebugCategory("ScanBankBag(%d): bag not valid", bagID) return bag end - local itemCount = 0 for slot = 1, bag.numSlots do local itemData = addon.Modules.BagScanner:ScanSlot(bagID, slot) bag.slots[slot] = itemData - if not itemData then bag.freeSlots = bag.freeSlots + 1 - else - itemCount = itemCount + 1 end end - addon:DebugCategory("ScanBankBag(%d): numSlots=%d, items=%d, freeSlots=%d", - bagID, bag.numSlots, itemCount, bag.freeSlots) - return bag end --- Save current bank to database -function BankScanner:SaveToDatabase() - if not bankOpen then - return +local function CloneItemData(itemData) + if not itemData then return nil end + return { + link = itemData.link, + itemID = itemData.itemID, + texture = itemData.texture, + count = itemData.count, + quality = itemData.quality, + name = itemData.name, + iLevel = itemData.iLevel, + type = itemData.type, + class = itemData.class, + subclass = itemData.subclass, + equipSlot = itemData.equipSlot, + locked = itemData.locked, + readable = itemData.readable, + lootable = itemData.lootable, + isBound = itemData.isBound, + } +end + +local function CloneBankDataForDatabase(runtimeData) + local snapshot = {} + if not runtimeData then return snapshot end + + for bagID, bag in pairs(runtimeData) do + local bagCopy = { + slots = {}, + numSlots = bag.numSlots, + freeSlots = bag.freeSlots, + bagType = bag.bagType, + } + if bag.slots then + for slotID, itemData in pairs(bag.slots) do + bagCopy.slots[slotID] = CloneItemData(itemData) + end + end + snapshot[bagID] = bagCopy end - local bankData = self:GetBankData() -- Use cached data - addon.Modules.DB:SaveBank(bankData) + return snapshot +end + +function BankScanner:SaveToDatabase() + if not bankOpen then return end + + local bankData = self:GetBankData() + addon.Modules.DB:SaveBank(CloneBankDataForDatabase(bankData)) addon:Debug("Bank data saved") end --- Initialize bank scanner function BankScanner:Initialize() - -- Bank opened - do initial scan addon.Modules.Events:OnBankOpen(function() bankOpen = true - BankScanner:ClearCache() -- Clear cache on open + BankScanner:ClearCache() addon:Debug("Bank opened") - -- Delay scan to ensure bank is fully loaded (uses pooled timer) Guda_ScheduleTimer(0.5, function() BankScanner:SaveToDatabase() end) end, "BankScanner") - -- Bank closed addon.Modules.Events:OnBankClose(function() - -- Do a final save on close before marking bank as closed addon:Debug("Bank closing - performing final save") BankScanner:SaveToDatabase() bankOpen = false - BankScanner:ClearCache() -- Clear cache on close + BankScanner:ClearCache() addon:Debug("Bank closed") end, "BankScanner") end --- Check if bank is currently open function BankScanner:IsBankOpen() return bankOpen end