Files
Bagshui/Components/Categories.lua
T
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

797 lines
31 KiB
Lua

-- Bagshui Categories
-- Exposes: BsCategories (and Bagshui.components.Categories)
-- Raises: BAGSHUI_CATEGORY_UPDATE
--
-- The high-level flow of categorization and rule evaluation is as follows:
-- 1. During inventory layout updates, Categories:Categorize() is called for each item.
-- 2. Categories:Categorize() loops through all active categories [any category assigned to a group], in category sequence order.
-- 3. For each category, Categories:MatchCategory() is called.
-- 4. Categories:MatchCategory() calls Rules:Match() with the finalized rule.
-- 5. Rules:Match() executes the rule it within the protected rule environment and returns true if it matches.
-- 6. Once a match is found, Categories:Categorize() updates the bagshuiGroupId, bagshuiCategoryId, and uncategorized properties.
Bagshui:AddComponent(function()
Bagshui:AddConstants({
-- Allowed properties.
BS_CATEGORY_SKELETON = {
-- Category name.
name = "",
-- Alternate name to be used in sorting.
nameSort = "",
-- Global evaluation ordering.
sequence = 0,
-- Array of item IDs (the 0 in the array is so that TableCopy will know what type of value is allowed).
list = { 0 },
-- Rule expression.
rule = "",
classes = {
-- Class categories can have any valid player class (DRUID, HUNTER, etc.) as the key.
-- That is expressed here by putting the GameInfo.characterClasses table as the key within
-- the template, which Util.TableClear() knows to interpret as a list of valid keys.
-- IMPORTANT: There is code in Categories.Ui.lua that depends on this table.
[BsGameInfo.characterClasses] = {
list = { 0 }, -- List of item IDs.
rule = "", -- Rule expression.
},
},
},
-- Template for creating a new standard category.
BS_NEW_CATEGORY_TEMPLATE = {
name = "",
nameSort = "",
sequence = BS_DEFAULT_CATEGORY_SEQUENCE,
list = {},
rule = "",
},
-- Template for creating a new class category.
BS_NEW_CLASS_CATEGORY_TEMPLATE = {
name = "",
nameSort = "",
sequence = BS_DEFAULT_CATEGORY_SEQUENCE,
classes = {},
},
-- Which template properties can be used in sort orders?
BS_CATEGORY_PROPERTIES_ALLOWED_IN_SORT_ORDERS = {
"name",
},
})
-- Key in _G.BagshuiData where categories will be stored.
local CATEGORIES_DATA_STORAGE_KEY = "categories"
-- Categories class is built on the ObjectList prototype.
local Categories = Bagshui.prototypes.ObjectList:New({
dataStorageKey = CATEGORIES_DATA_STORAGE_KEY,
objectVersion = Bagshui.config.Categories.version,
objectMigrationFunction = Bagshui.config.Categories.migrate,
objectName = "Category",
objectNamePlural = "Categories",
objectSkeleton = BS_CATEGORY_SKELETON,
objectTemplate = BS_NEW_CATEGORY_TEMPLATE,
defaults = Bagshui.config.Categories.defaults,
defaultObjectValues = {
-- Put built-in rules without a specified sequence higher than the user default.
sequence = BS_DEFAULT_BUILTIN_CATEGORY_SEQUENCE,
},
wikiPage = BS_WIKI_PAGES.Categories,
-- Default category ID when Categories:Categorize() can't find a match.
defaultCategory = BS_DEFAULT_CATEGORY_ID,
-- "Compiled" rules i.e. rule expressions run through `loadstring()`.
finalizedCategoryRules = {},
-- Error tracking.
errors = {}, -- Category ID to error message mapping for quick retrieval.
errorTimestamps = {}, -- Track when errors occurred so we know whether re-testing is needed.
recursedCategories = {}, -- Any category that contains a MatchCategory() rule function call.
-- There's some initialization that needs to happen on `PLAYER_ENTERING_WORLD`
-- (see `Categories:OnEvent()`), but that event fires when zoning and we
-- only want to do the stuff once.
finalInitComplete = false,
-- Debug.
debugResetOnLoad = false and BS_DEBUG,
})
--- Initialize categories list.
function Categories:Init()
-- Add sorting by sequence (doing this before superclass init so that it will get sorted automatically).
self.sortedIdLists.sequence = {}
self.idListSortFunctions.sequence = function(idA, idB)
return (self._idListSortFunctions_objectList[idA].sequence or BS_DEFAULT_CATEGORY_SEQUENCE)
< (self._idListSortFunctions_objectList[idB].sequence or BS_DEFAULT_CATEGORY_SEQUENCE)
end
-- Calls ObjectList:Init().
self._super.Init(self, true)
-- Add auto-split Categories menu.
Bagshui.prototypes.Menus:AddAutoSplitMenu(BS_AUTO_SPLIT_MENU_TYPE.CATEGORIES, {
defaultIdList = self.sortedIdLists.name,
sortFunc = function(categoryIds)
self:SortIdList(categoryIds, nil, "name")
end,
nameFunc = function(id)
return self:GetName(id) or tostring(id)
end,
})
-- Register events.
Bagshui:RegisterEvent("PLAYER_ENTERING_WORLD", self)
Bagshui:RegisterEvent("BAGSHUI_CATEGORIES_CACHE_LOAD", self)
Bagshui:RegisterEvent("BAGSHUI_CATEGORY_UPDATE", self)
self.initialized = true
end
--- Event handling.
---@param event string Event name.
---@param arg1 any Event argument.
function Categories:OnEvent(event, arg1)
--Bagshui:PrintDebug("Categories OnEvent " .. event .. " (prototype: " .. tostring(self == Categories) .. ")")
-- Check to see if the ObjectList prototype is handling this event
if self._super.OnEvent(self, event, arg1) then
return
end
-- After PLAYER_ENTERING_WORLD, wait a bit and finish loading.
if event == "PLAYER_ENTERING_WORLD" and not self.finalInitComplete then
-- Need to wait to finalize rules so 3rd party integrations can register first.
Bagshui:QueueClassCallback(self, self.FinalizeAllRules, 1)
-- Don't make the client too busy with server queries immediately.
Bagshui:QueueEvent("BAGSHUI_CATEGORIES_CACHE_LOAD", 3)
-- Make sure this only happens once.
self.finalInitComplete = true
return
end
-- Queued event has fired, so perform the item cache load.
if event == "BAGSHUI_CATEGORIES_CACHE_LOAD" then
self:LoadListItemsIntoGameCache()
return
end
-- Re-validate categories in error state if the error was due to a MatchCategory() rule function dependency.
if event == "BAGSHUI_CATEGORY_UPDATE" then
local prevErrorMessages = self:GetErrors()
-- For all categories that have a MatchCategory() rule function other
-- than the one that was just saved and triggered this event:
-- 1. Clear their errors.
-- 2. Re-test.
-- This needs to be done in two loops because we don't track the
-- dependency relationships.
--
-- The `Categories.recursedCategories` table is updated in
-- `Categories:FinalizeRule()` based on whether a MatchCategory() rule
-- function was used (could be other things in the future if needed).
-- Loop 1: Clear errors.
for categoryId, categoryInfo in pairs(self.list) do
if self.recursedCategories[categoryId] and categoryId ~= self.lastUpdatedCategoryId then
self.errors[categoryId] = nil
end
end
-- Loop 2: Re-test everybody.
for categoryId, categoryInfo in pairs(self.list) do
if self.recursedCategories[categoryId] and categoryId ~= self.lastUpdatedCategoryId then
self:FinalizeRule(categoryId, categoryInfo, nil, true)
end
end
-- Refresh the category list if visible.
if
self:GetErrors() ~= prevErrorMessages
and self.objectManager
and self.objectManager.uiFrame
and self.objectManager.uiFrame:IsVisible()
then
self.objectManager:UpdateList()
end
return
end
end
--- Ensure all items referenced in category item lists are in the local cache so that GetItemInfo() works.
--- (There's too much of a delay to try and do this in real-time while editing a category).
function Categories:LoadListItemsIntoGameCache()
if self.finalInitComplete then
return
end
for _, category in pairs(self.list) do
if category.classes then
for _, classCategory in pairs(category.classes) do
self:ItemInfoCacheLoad(classCategory.list)
end
else
self:ItemInfoCacheLoad(category.list)
end
end
end
--- Helper for LoadListItemsIntoGameCache() to share code between standard and class categories.
function Categories:ItemInfoCacheLoad(itemList)
if type(itemList) == "table" and table.getn(itemList) > 0 then
for _, itemId in pairs(itemList) do
BsItemInfo:LoadItemIntoLocalGameCache(itemId)
end
end
end
--- See `ObjectList:GetUses()`.
---@param objectId string|number
---@param objectMetadata table Metadata about the object.
---@return boolean inUse Whether the object is being used.
function Categories:GetUses(objectId, objectMetadata)
return BsProfiles:GetProfilesUsingObject(objectId, objectMetadata, BsCategories)
end
--- Things that need to happen before ObjectList:Save() actually saves the category.
---@param categoryId string|number ID of the new category.
---@param category table New category information.
function Categories:DoPreSaveOperations(categoryId, category)
-- Reset revalidation tracking (used in `Categories:OnEvent()` for `BAGSHUI_CATEGORY_UPDATE`).
self.lastUpdatedCategoryId = nil
-- Don't allow empty rules or lists
if category.rule and string.len(tostring(category.rule)) == 0 then
category.rule = nil
end
if type(category.list) ~= "table" or (type(category.list) == "table" and table.getn(category.list) == 0) then
category.list = nil
end
-- Default sequence number / type check
if not category.sequence then
category.sequence = BS_DEFAULT_CATEGORY_SEQUENCE
end
if type(category.sequence) ~= "number" then
category.sequence = tonumber(category.sequence)
end
end
--- Things that need to happen after ObjectList:Save() actually saves the category.
---@param categoryId string|number ID of the new category.
---@param category table New category information.
function Categories:DoPostSaveOperations(categoryId, category)
-- Don't revalidate this category because it was just saved
-- (used in `Categories:OnEvent()` for `BAGSHUI_CATEGORY_UPDATE`).
self.lastUpdatedCategoryId = categoryId
-- Sort list entries so they look good in the editor.
if type(category.list) == "table" and table.getn(category.list) > 0 then
table.sort(category.list, function(itmA, itmB)
local itmAName = _G.GetItemInfo(itmA)
local itmBName = _G.GetItemInfo(itmB)
return string.lower(itmAName or "") < string.lower(itmBName or "")
end)
end
-- Compile the rule functions.
-- Blocking this at startup because FinalizeAllRules() will take care of the first compile.
if self.initialized then
self:FinalizeCategoryRules(categoryId, true)
end
end
--- Loop through all categories and pre-compile them.
function Categories:FinalizeAllRules()
if type(self.list) ~= "table" then
return
end
for categoryId, _ in pairs(self.list) do
self:FinalizeCategoryRules(categoryId, true)
end
end
--- "Compile" a category's rule expression(s) by passing to `Categories:FinalizeRule()`.
--- This wrapper is needed because class categories can contain one rule per class.
---@param categoryId string|number ID of the category.
---@param recompile boolean true to force recompilation.
function Categories:FinalizeCategoryRules(categoryId, recompile)
assert(categoryId, "categoryId must be specified")
local category = self:Get(categoryId)
if category.classes then
for class, classCategory in pairs(category.classes) do
self:FinalizeRule(categoryId, classCategory, class, recompile)
end
else
self:FinalizeRule(categoryId, category, nil, recompile)
end
end
--- "Compile" a category's rule expression(s) by passing to `Rules:Compile()`.
--- Ultimately this will run it through loadstring() to produce a function.
--- Store the result in `Categories.finalizedCategoryRules` so we don't have
--- to do this over and over while categorizing items.
---@param categoryId string|number ID of the category.
---@param category table Category object.
---@param ruleIdSuffix string? Suffix to uniquely identify the compiled rule.
---@param recompile boolean? true to force recompilation.
---@param recursed boolean? This call is coming from a `MatchCategory()` rule function,
---@return function|string compiledRule
function Categories:FinalizeRule(categoryId, category, ruleIdSuffix, recompile, recursed)
assert(categoryId, "categoryId must be specified")
assert(category, "category must be specified")
-- When a rule is in the process of being edited, short-circuit and return
-- the edited version. Without this, MatchCategory() loops wouldn't be detected
-- until the category is saved.
if self.objectManager and self.objectManager.editors then
for _, editor in ipairs(self.objectManager.editors) do
if editor.objectId == categoryId and editor.uiFrame:IsVisible() and editor:IsDirty() then
return editor.updatedObject.rule
end
end
end
--Bagshui:PrintDebug("FinalizeRule: "..categoryId)
local finalRuleId = self:BuildFinalizedRuleId(categoryId, ruleIdSuffix)
-- When a recompile isn't being forced and the finalized rule already exists, just return it.
-- This optimization is leveraged by MatchCategory() to avoid constantly calling loadstring().
if self.finalizedCategoryRules[finalRuleId] and not recompile then
return self.finalizedCategoryRules[finalRuleId]
end
-- Otherwise, proceed with compiling/recompiling the rule.
local finalRule = ""
self.errors[categoryId] = nil
-- Remove previously compiled rule if it exists.
-- This does mean that when a rule is changed and the recompile fails, the rule will become invalid.
self.finalizedCategoryRules[finalRuleId] = nil
-- The rule expression is the category.rule property, so long as it's not the magic
-- match-anything "*".
if type(category.rule) == "string" and string.len(category.rule) > 0 and category.rule ~= "*" then
finalRule = category.rule
end
-- During a MatchCategory() loop, we *must* stop here or we'll overflow the call stack.
-- To prevent that, just immediately return the rule without trying to compile it.
if recursed then
return finalRule
end
-- Handling item lists in MatchCategory() instead of here to improve efficiency;
-- it just loops through the list looking for an ID match instead of invoking
-- the overhead of the rules engine.
-- if type(category.list) == "table" and table.getn(category.list) > 0 then
-- if string.len(finalRule) > 0 then
-- finalRule = "(" .. finalRule .. ") or "
-- end
-- finalRule = finalRule .. "id(" .. table.concat(category.list, ",") .. ")"
-- Make sure there's a rule to compile.
if string.len(finalRule) > 0 then
-- Validate before compiling and store status.
local valid, errorMessage = BsRules:Validate(category.rule, categoryId)
if valid then
-- Compile the rule once we know it's good.
self.finalizedCategoryRules[finalRuleId] = BsRules:Compile(finalRule)
else
self:ReportError(categoryId, errorMessage)
end
-- Identify this as a category that needs to be re-tested every time there's
-- a category edit of any kind (see `Categories:OnEvent()` for `BAGSHUI_CATEGORY_UPDATE`).
-- [MatchCategory() sets the _matchCategory_originalCaller property on the Rules class for us.]
self.recursedCategories[categoryId] = categoryId == BsRules._matchCategory_originalCaller or nil
end
return self.finalizedCategoryRules[finalRuleId]
end
--- Create a unique ID for a rule based on the category ID and optional suffix.
---@param categoryId string|number ID of the category.
---@param ruleIdSuffix string? Suffix to uniquely identify the compiled rule.
---@return string ruleId `"<categoryId>:[<ruleIdSuffix>]"`
function Categories:BuildFinalizedRuleId(categoryId, ruleIdSuffix)
return categoryId .. (ruleIdSuffix and (":" .. tostring(ruleIdSuffix)) or "")
end
--- Given an item, a list of sequenced category IDs, a table mapping categories to group IDs,
--- and a default category ID, determine the category to which that item should be assigned.
---@param item table An item from the Bagshui inventory cache (should be based on `BS_ITEM_SKELETON`).
---@param character table? Character information table (uses `Bagshui.currentCharacterInfo` if not provided).
---@param sortedSequenceNumbers number[] An array of numbers that correspond to indexes of the `categoriesToGroups` table. This is what dictates priority of category evaluation.
---@param categoriesToGroups table Table of `{ <sequence number> = { categoryId1 = groupId1, categoryId2 = groupId2 } }`. (The `categoryIdsGroupedBySequence` table produced by `Inventory:UpdateLayoutLookupTables()`).
---@param defaultCategoryId string|number? If no matching category is found, assign the item to this category. (When not specified, use `Categories.defaultCategoryId`).
function Categories:Categorize(item, character, sortedSequenceNumbers, categoriesToGroups, defaultCategoryId)
if not item then
return
end
character = character or Bagshui.currentCharacterInfo
defaultCategoryId = defaultCategoryId or self.defaultCategory
-- Start a rule engine session so that we can call BsRules:Match() without needing to constantly check/update Rules.item and Rules.character.
local ruleSession = BsRules:SetItemAndCharacter(item, character)
-- Process categories in sequence order.
for _, sequenceNum in ipairs(sortedSequenceNumbers) do
--Bagshui:PrintDebug("processing categories with sequence #"..sequenceNum)
for categoryId, groupId in pairs(categoriesToGroups[sequenceNum]) do
--Bagshui:PrintDebug("testing category ID " .. categoryId)
-- Try to match the category.
if
groupId
and string.len(groupId or "") > 0
and self:MatchCategory(categoryId, item, character, ruleSession)
then
--Bagshui:PrintDebug("Category " .. categoryId .. " and group " .. groupId .. " assigned to " .. item.name)
item.bagshuiGroupId = groupId
item.bagshuiCategoryId = categoryId
item.uncategorized = (categoryId == defaultCategoryId) and 1 or 0
return
end
end
end
-- If we make it this far, assign the default category.
Bagshui:PrintDebug(tostring(item.Name) .. " is uncategorized")
item.bagshuiGroupId = categoriesToGroups[defaultCategoryId]
item.bagshuiCategoryId = defaultCategoryId
item.uncategorized = 1
end
-- Reusable variables for MatchCategory() to reduce garbage collector load.
local category, ruleIdSuffix, finalRule, status, retVal, errorMessage
--- Return true/false based on whether the given item matches the given category
---@param categoryId string|number ID of the category.
---@param item table An item from the Bagshui inventory cache.
---@param character table? Character information table (uses `Bagshui.currentCharacterInfo` if not provided).
---@param ruleSession number? Rule session ID from `Rules:SetItemAndCharacter()`.
---@param recursed boolean? This call is coming from a `MatchCategory()` rule function,
---@return boolean
function Categories:MatchCategory(categoryId, item, character, ruleSession, recursed)
category = self:Get(categoryId)
if not category then
return false
end
-- MatchCategory() rule function help: Add to call stack.
if BsRules._matchCategory_callStack then
table.insert(BsRules._matchCategory_callStack, categoryId)
end
-- Very fast processing for catchall category.
if category.rule == "*" then
return true
end
ruleIdSuffix = nil
character = character or Bagshui.currentCharacterInfo
-- Class-specific categories.
if category.classes then
-- Redefine the category reference to be the class-specific category.
category = category.classes[character.class]
-- There's nothing to try and match when there's not an entry for the current class.
if not category then
return false
end
-- When this class-specific rule is compiled, make it unique by adding the class name as the ID suffix.
ruleIdSuffix = character.class
end
-- Try to match item lists first.
if type(category.list) == "table" and table.getn(category.list) > 0 then
for _, itemId in ipairs(category.list) do
if itemId == item.id then
return true
end
end
end
-- If the rule is in error status and hasn't been changed since the error occurred,
-- just fail (checking this here makes sense because item lists can't have errors)
if self.errors[categoryId] and (self.errorTimestamps[categoryId] or 0) >= category.dateModified then
self:ReportError(categoryId, self.errors[categoryId])
return false
end
-- Quick fail if no rule.
if not category.rule or string.len(tostring(category.rule)) == 0 then
return false
end
-- Get the compiled rule to be evaluated.
finalRule = self:FinalizeRule(categoryId, category, ruleIdSuffix, false, recursed)
-- Final check to ensure there's a rule to execute.
if
not finalRule
or (
not recursed -- Don't stop here or MatchCategory() won't work.
and type(finalRule) ~= "function"
)
then
return false
end
-- Do the test.
retVal, errorMessage = BsRules:Match(finalRule, item, character, ruleSession, nil, recursed)
if errorMessage == nil then
-- Successful rule evaluation, return rule function value.
return retVal
else
self:ReportError(categoryId, errorMessage)
return false
end
end
-- Reusable variables for ReportError() to reduce garbage collector load.
local formattedMessage
--- Display an error when so long as it hasn't yet been shown.
---@param categoryId any
---@param errorMessage any
function Categories:ReportError(categoryId, errorMessage)
assert(categoryId, "categoryId must be specified")
if not self.errors[categoryId] then
formattedMessage = self:FormatErrorMessage(errorMessage, categoryId)
self.errors[categoryId] = formattedMessage
self.errorTimestamps[categoryId] = _G.time()
-- Don't spam chat with errors at startup.
if self.initialized then
Bagshui:PrintError(formattedMessage, L.Category)
end
end
end
--- Given information about an error, fill out the error message template.
---@param errorMessage string The error.
---@param categoryId string|number ID of the category that caused the error.
---@param categoryName string? Category name. If not provided, the category ID will be used to look it up. Or if nothing is found, this is assumed to be a new category.
---@return string errorMessage Display-ready error message.
function Categories:FormatErrorMessage(errorMessage, categoryId, categoryName)
return string.format(
L.Error_CategoryEvaluation,
tostring(
categoryName
or (self.list[categoryId] and self.list[categoryId].name)
or string.format(L.Prefix_New, L.Category)
),
tostring(errorMessage)
)
end
--- Get all category errors as a single newline-separated string.
---@param filter table<string,boolean>? Category IDs to check for errors.
---@return string errors
function Categories:GetErrors(filter)
if BsUtil.TrueTableSize(self.errors) == 0 then
return ""
end
local errors = ""
for _, categoryId in ipairs(self.sortedIdLists.name) do
if self.errors[categoryId] and (type(filter) ~= "table" or filter[categoryId]) then
errors = errors .. (string.len(errors) > 0 and BS_NEWLINE or "") .. self.errors[categoryId]
end
end
return errors
end
--- Obtain the list of categories to which a given item ID has been manually assigned.
---@param itemId number Item ID.
---@param categoryIdListOutput any[] Array to fill with category IDs (will be cleared).
---@param limitToCategoryIds (string|number)[]? When provided, only allow category IDs within this array to be matched.
function Categories:GetDirectCategoryAssignmentsForItem(itemId, categoryIdListOutput, limitToCategoryIds)
assert(
type(categoryIdListOutput) == "table",
"Categories:GetDirectCategoryAssignmentsForItem(): categoryIdListOutput is required and must be the table to fill."
)
BsUtil.TableClear(categoryIdListOutput)
for categoryId, categoryInfo in pairs(self.list) do
if categoryInfo.classes ~= nil then
for class, classInfo in pairs(categoryInfo.classes) do
self:ItemInDirectAssignmentList(classInfo.list, categoryId, itemId, categoryIdListOutput, limitToCategoryIds)
end
else
self:ItemInDirectAssignmentList(categoryInfo.list, categoryId, itemId, categoryIdListOutput, limitToCategoryIds)
end
end
end
--- Test whether the given Category contains the specified item ID in its Direct Assignment list.
--- Two modes:
--- 1. Helper for `Categories:GetDirectCategoryAssignmentsForItem()` to fill `categoryIdListOutput`.
--- 2. Return `true` if the Category has the item. Used for Direct Assignment Class menus in Edit Mode.
---@param list number[]? Direct Assignment list.
---@param itemId number Item ID.
---@param categoryId number|string Unique Category identifier.
---@param categoryIdListOutput? any[] See `Categories:GetDirectCategoryAssignmentsForItem()`
---@param limitToCategoryIds (string|number)[]? See `Categories:GetDirectCategoryAssignmentsForItem()`
---@param class string? Required if `list` is not provided and `categoryId` represents a Class Category.
---@return boolean?
function Categories:ItemInDirectAssignmentList(
list,
categoryId,
itemId,
categoryIdListOutput,
limitToCategoryIds,
class
)
if not list then
local categoryInfo = self.list[categoryId]
if not categoryInfo then
return
end
if categoryInfo.classes then
if not class or not categoryInfo.classes[class] then
return
end
list = categoryInfo.classes[class].list
else
list = categoryInfo.list
end
end
if type(list) == "table" then
for i, categoryItemId in ipairs(list) do
if
categoryItemId == itemId
and (
(limitToCategoryIds and BsUtil.TableContainsValue(limitToCategoryIds, itemId) ~= nil)
or not limitToCategoryIds
)
then
if categoryIdListOutput then
table.insert(categoryIdListOutput, categoryId)
end
return true
end
end
end
end
--- Directly assign an item to a category.
---@param categoryId string|number
---@param itemId string|number
---@param class string? Required for class categories.
function Categories:AssignItemToCategory(categoryId, itemId, class)
assert(itemId, "Categories:AssignItemToCategory(): itemID is required.")
local categoryInfo = self.list[categoryId]
if not categoryInfo then
return
end
local list
if categoryInfo.classes ~= nil then
assert(class, "Categories:AssignItemToCategory(): class is required for Class Categories.")
if not categoryInfo.classes[class] then
categoryInfo.classes[class] = {}
end
if not categoryInfo.classes[class].list then
categoryInfo.classes[class].list = {}
end
list = categoryInfo.classes[class].list
else
if not categoryInfo.list then
categoryInfo.list = {}
end
list = categoryInfo.list
end
if not list then
return
end
if BsUtil.TableContainsValue(list, itemId) == nil then
table.insert(list, itemId)
end
self:Save(categoryId, categoryInfo)
end
--- Remove the direct assignment of an item to a category.
---@param categoryId string|number
---@param itemId string|number
---@param class string? Required for class categories.
function Categories:RemoveItemFromCategory(categoryId, itemId, class)
assert(itemId, "Categories:RemoveItemFromCategory(): itemID is required.")
local categoryInfo = self.list[categoryId]
if not categoryInfo then
return
end
local list = categoryInfo.list
if categoryInfo.classes ~= nil then
assert(class, "Categories:RemoveItemFromCategory(): class is required for Class Categories.")
if not categoryInfo.classes[class] then
return
end
list = categoryInfo.classes[class].list
end
if not list then
return
end
BsUtil.TableRemoveArrayItem(list, itemId)
self:Save(categoryId, categoryInfo)
end
--- Assign/unassign and item to a category, depending on its current status.
---@param categoryId string|number
---@param itemId string|number
---@param class string? Required for class categories.
function Categories:ToggleItemCategoryAssignment(categoryId, itemId, class)
assert(itemId, "Categories:ToggleItemCategoryAssignment(): itemID is required.")
local categoryInfo = self.list[categoryId]
if not categoryInfo then
return
end
local list = categoryInfo.list
if categoryInfo.classes ~= nil then
assert(class, "Categories:ToggleItemCategoryAssignment(): class is required for Class Categories.")
if categoryInfo.classes[class] and categoryInfo.classes[class].list then
list = categoryInfo.classes[class].list
end
end
if list and BsUtil.TableContainsValue(list, itemId) ~= nil then
self:RemoveItemFromCategory(categoryId, itemId, class)
else
self:AssignItemToCategory(categoryId, itemId, class)
end
end
--- Obtain the list of all categories that match an item, whether via rules or direct assignment.
---@param item table Bagshui item cache entry.
---@param character table? Character to use for rule matching.
---@param categoryIdListOutput any[] Array to fill with category IDs (will be cleared).
function Categories:GetAllMatchingCategoriesForItem(item, character, categoryIdListOutput)
assert(
type(categoryIdListOutput) == "table",
"Categories:GetDirectCategoryAssignmentsForItem(): categoryIdListOutput is required and must be the table to fill."
)
if type(categoryIdListOutput) ~= "table" then
categoryIdListOutput = {}
else
BsUtil.TableClear(categoryIdListOutput)
end
local ruleSession = BsRules:SetItemAndCharacter(item)
for categoryId, _ in pairs(self.list) do
if self:MatchCategory(categoryId, item, character, ruleSession) then
table.insert(categoryIdListOutput, categoryId)
end
end
end
-- Exports and initialization
Bagshui.environment.BsCategories = Categories
Bagshui.components.Categories = Categories
Bagshui:RegisterEvent("BAGSHUI_ADDON_LOADED", Categories)
end)