-- Guda Category Manager -- Handles custom category definitions and rule-based item categorization local addon = Guda local CategoryManager = {} addon.Modules.CategoryManager = CategoryManager --===================================================== -- Category Result Caching -- Caches CategorizeItem results to avoid repeated evaluations --===================================================== local categoryCache = {} local cacheHits = 0 local cacheMisses = 0 -- Clear the category cache (call when categories change or on full refresh) function CategoryManager:ClearCache() categoryCache = {} cacheHits = 0 cacheMisses = 0 addon:Debug("CategoryManager cache cleared") end -- Get cache statistics (for debugging/performance monitoring) function CategoryManager:GetCacheStats() local total = cacheHits + cacheMisses local hitRate = total > 0 and (cacheHits / total * 100) or 0 return { hits = cacheHits, misses = cacheMisses, total = total, hitRate = hitRate, size = 0, -- Will count below } end -- Generate cache key for an item local function GetCacheKey(itemLink, isOtherChar) if not itemLink then return nil end -- Include isOtherChar flag since categorization may differ return itemLink .. (isOtherChar and ":other" or ":current") end -- Rule Types: -- itemType: Match by GetItemInfo type (Armor, Weapon, Consumable, etc.) -- itemSubtype: Match by subtype (Cloth, Potion, Herb, etc.) -- namePattern: Lua pattern match on item name -- quality: Match by quality level (0=Gray, 1=White, 2=Green, 3=Blue, 4=Purple, 5=Orange) -- isBoE: Boolean for Bind on Equip items -- isQuestItem: Boolean for quest items -- texturePattern: Match icon texture path -- itemID: Specific item IDs (table of IDs) -- Group constants local GROUP_MAIN = "Main" local GROUP_OTHER = "Other" local GROUP_CLASS = "Class" -- Default category definitions that replicate the existing hardcoded behavior local DEFAULT_CATEGORIES = { order = { "BoE", "Weapon", "Armor", "Trinket", "Consumable", "Food", "Drink", "Trade Goods", "Reagent", "Recipe", "Quiver", "Container", "Soul Bag", "Miscellaneous", "Quest", "Junk", "Class Items", "Keyring", "Home", "Tools", "Empty" }, itemOverrides = {}, -- flat map: [itemID] = categoryId definitions = { ["BoE"] = { name = "BoE", icon = "Interface\\Icons\\INV_Misc_Orb_01", rules = { { type = "isBoE", value = true } }, matchMode = "all", priority = 75, enabled = true, isBuiltIn = true, group = GROUP_MAIN, }, ["Weapon"] = { name = "Weapon", icon = "Interface\\Icons\\INV_Sword_04", rules = { { type = "itemType", value = "Weapon" } }, matchMode = "all", priority = 70, enabled = true, isBuiltIn = true, group = GROUP_MAIN, }, ["Armor"] = { name = "Armor", icon = "Interface\\Icons\\INV_Chest_Chain", rules = { { type = "itemType", value = "Armor" } }, matchMode = "all", priority = 70, enabled = true, isBuiltIn = true, group = GROUP_MAIN, }, ["Trinket"] = { name = "Trinket", icon = "Interface\\Icons\\INV_Trinket_Naaru_01", rules = { { type = "itemSubtype", value = "INVTYPE_TRINKET" } }, matchMode = "all", priority = 72, enabled = true, isBuiltIn = true, group = GROUP_MAIN, }, ["Consumable"] = { name = "Consumable", icon = "Interface\\Icons\\INV_Potion_54", rules = { { type = "itemType", value = "Consumable" } }, matchMode = "all", priority = 50, enabled = true, isBuiltIn = true, group = GROUP_MAIN, }, ["Food"] = { name = "Food", icon = "Interface\\Icons\\INV_Misc_Food_14", rules = { { type = "itemType", value = "Consumable" }, { type = "restoreTag", value = "eat" } }, matchMode = "all", priority = 55, enabled = true, isBuiltIn = true, group = GROUP_MAIN, }, ["Drink"] = { name = "Drink", icon = "Interface\\Icons\\INV_Drink_07", rules = { { type = "itemType", value = "Consumable" }, { type = "restoreTag", value = "drink" } }, matchMode = "all", priority = 55, enabled = true, isBuiltIn = true, group = GROUP_MAIN, }, ["Trade Goods"] = { name = "Trade Goods", icon = "Interface\\Icons\\INV_Fabric_Silk_02", rules = { { type = "itemType", value = "Trade Goods" } }, matchMode = "all", priority = 40, enabled = true, isBuiltIn = true, group = GROUP_MAIN, }, ["Reagent"] = { name = "Reagent", icon = "Interface\\Icons\\INV_Misc_Dust_02", rules = { { type = "itemType", value = "Reagent" } }, matchMode = "all", priority = 40, enabled = true, isBuiltIn = true, group = GROUP_MAIN, }, ["Recipe"] = { name = "Recipe", icon = "Interface\\Icons\\INV_Scroll_03", rules = { { type = "itemType", value = "Recipe" } }, matchMode = "all", priority = 40, enabled = true, isBuiltIn = true, group = GROUP_MAIN, }, ["Quiver"] = { name = "Quiver", icon = "Interface\\Icons\\INV_Misc_Quiver_03", rules = { { type = "itemType", value = "Quiver" } }, matchMode = "all", priority = 40, enabled = true, isBuiltIn = true, group = GROUP_MAIN, }, ["Container"] = { name = "Container", icon = "Interface\\Icons\\INV_Misc_Bag_07", rules = { { type = "itemType", value = "Container" } }, matchMode = "all", priority = 40, enabled = true, isBuiltIn = true, group = GROUP_MAIN, }, ["Soul Bag"] = { name = "Soul Bag", icon = "Interface\\Icons\\INV_Misc_Bag_EnchantedMageweave", rules = { { type = "itemSubtype", value = "Soul Bag" } }, matchMode = "all", priority = 45, enabled = true, isBuiltIn = true, group = GROUP_MAIN, }, ["Miscellaneous"] = { name = "Miscellaneous", icon = "Interface\\Icons\\INV_Misc_Rune_01", rules = {}, matchMode = "any", priority = 0, enabled = true, isBuiltIn = true, isFallback = true, group = GROUP_MAIN, }, ["Quest"] = { name = "Quest", icon = "Interface\\Icons\\INV_Misc_Book_08", rules = { { type = "isQuestItem", value = true } }, matchMode = "all", priority = 80, enabled = true, isBuiltIn = true, group = GROUP_MAIN, }, ["Junk"] = { name = "Junk", icon = "Interface\\Icons\\INV_Misc_Gear_06", rules = { { type = "isJunk", value = true } }, matchMode = "any", priority = 85, enabled = true, isBuiltIn = true, group = GROUP_MAIN, }, ["Class Items"] = { name = "Class Items", icon = "Interface\\Icons\\INV_Misc_Ammo_Arrow_01", rules = { { type = "itemType", value = "Projectile" }, { type = "isSoulShard", value = true } }, matchMode = "any", priority = 90, enabled = true, isBuiltIn = true, group = GROUP_CLASS, }, ["Keyring"] = { name = "Keyring", icon = "Interface\\Icons\\INV_Misc_Key_04", rules = { { type = "itemType", value = "Key" } }, matchMode = "all", priority = 40, enabled = true, isBuiltIn = true, group = GROUP_CLASS, }, ["Home"] = { name = "Home", icon = "Interface\\Icons\\INV_Misc_Rune_01", rules = { { type = "itemID", value = {6948} } }, matchMode = "all", priority = 100, enabled = true, isBuiltIn = true, group = GROUP_OTHER, }, ["Tools"] = { name = "Tools", icon = "Interface\\Icons\\Trade_BlackSmithing", rules = { { type = "isProfessionTool", value = true } }, matchMode = "all", priority = 72, enabled = true, isBuiltIn = true, group = GROUP_OTHER, }, ["Empty"] = { name = "Empty", icon = "Interface\\PaperDoll\\UI-PaperDoll-Slot-Bag", rules = {}, matchMode = "all", priority = -10, enabled = true, isBuiltIn = true, hideControls = true, isEmptyCategory = true, group = GROUP_OTHER, }, } } -- Group definitions for display order and built-in group mapping local GROUP_ORDER = { GROUP_MAIN, GROUP_CLASS, GROUP_OTHER } -- Map of built-in category IDs to their default groups (for migration) local BUILTIN_GROUP_MAP = {} for id, def in pairs(DEFAULT_CATEGORIES.definitions) do BUILTIN_GROUP_MAP[id] = def.group end -- Deep copy a table local function deepCopy(orig) local copy if type(orig) == "table" then copy = {} for k, v in pairs(orig) do copy[k] = deepCopy(v) end else copy = orig end return copy end -- Get default categories (returns a deep copy) function CategoryManager:GetDefaultCategories() return deepCopy(DEFAULT_CATEGORIES) end -- Initialize categories from database or defaults function CategoryManager:Initialize() if not Guda_CharDB then return end if not Guda_CharDB.categories then Guda_CharDB.categories = self:GetDefaultCategories() addon:Debug("CategoryManager: Initialized with default categories") else -- Ensure all built-in categories exist (migration support) self:MigrateCategories() end end -- Migrate/update categories to ensure all built-ins exist function CategoryManager:MigrateCategories() local cats = Guda_CharDB.categories if not cats then return end -- Ensure definitions table exists if not cats.definitions then cats.definitions = {} end -- Ensure order table exists if not cats.order then cats.order = {} end -- Add any missing built-in categories for id, def in pairs(DEFAULT_CATEGORIES.definitions) do if not cats.definitions[id] then cats.definitions[id] = deepCopy(def) -- Add to end of order if not present local found = false for _, orderId in ipairs(cats.order) do if orderId == id then found = true break end end if not found then table.insert(cats.order, id) end addon:Debug("CategoryManager: Added missing built-in category: " .. id) end end -- Migrate Food/Drink categories to use restoreTag instead of itemSubtype -- This fixes the issue where subtype "Food & Drink" matched both categories local foodCat = cats.definitions["Food"] if foodCat and foodCat.isBuiltIn then local needsUpdate = false if foodCat.rules then for _, rule in ipairs(foodCat.rules) do if rule.type == "itemSubtype" then needsUpdate = true break end end end if needsUpdate then foodCat.rules = { { type = "itemType", value = "Consumable" }, { type = "restoreTag", value = "eat" } } addon:Debug("CategoryManager: Migrated Food category to use restoreTag") end end local drinkCat = cats.definitions["Drink"] if drinkCat and drinkCat.isBuiltIn then local needsUpdate = false if drinkCat.rules then for _, rule in ipairs(drinkCat.rules) do if rule.type == "itemSubtype" then needsUpdate = true break end end end if needsUpdate then drinkCat.rules = { { type = "itemType", value = "Consumable" }, { type = "restoreTag", value = "drink" } } addon:Debug("CategoryManager: Migrated Drink category to use restoreTag") end end -- Migrate BoE priority to be higher than Weapon/Armor (75 > 70) local boeCat = cats.definitions["BoE"] if boeCat and boeCat.isBuiltIn and boeCat.priority and boeCat.priority < 75 then boeCat.priority = 75 addon:Debug("CategoryManager: Migrated BoE priority to 75") end -- Migrate Junk category to use isJunk rule type local junkCat = cats.definitions["Junk"] if junkCat and junkCat.isBuiltIn then local needsUpdate = false -- Check if priority is too low if not junkCat.priority or junkCat.priority < 85 then junkCat.priority = 85 needsUpdate = true end -- Migrate from quality=0 rule to isJunk rule if junkCat.rules then for i, rule in ipairs(junkCat.rules) do if rule.type == "quality" and rule.value == 0 then junkCat.rules = { { type = "isJunk", value = true } } needsUpdate = true break end end end -- Check if rules are missing or wrong if not junkCat.rules or table.getn(junkCat.rules) == 0 then junkCat.rules = { { type = "isJunk", value = true } } needsUpdate = true end if needsUpdate then addon:Debug("CategoryManager: Migrated Junk category to use isJunk rule") end end -- Migrate: Add group to all categories that lack it for id, def in pairs(cats.definitions) do if not def.group then -- Use built-in mapping if available, otherwise default to Main def.group = BUILTIN_GROUP_MAP[id] or GROUP_MAIN addon:Debug("CategoryManager: Added group '%s' to category: %s", def.group, id) end end -- Ensure savedEquipSetProps table exists if not cats.savedEquipSetProps then cats.savedEquipSetProps = {} end -- Migrate: Convert per-category itemOverrides arrays to flat map at categories level if not cats.itemOverrides then cats.itemOverrides = {} end local migratedOverrides = false for catId, def in pairs(cats.definitions) do if def.itemOverrides and type(def.itemOverrides) == "table" then -- Check if it's an array (old format) by looking for numeric keys local isArray = false for k, v in pairs(def.itemOverrides) do if type(k) == "number" then isArray = true break end end if isArray then for _, itemID in ipairs(def.itemOverrides) do cats.itemOverrides[itemID] = catId migratedOverrides = true end def.itemOverrides = nil end end end if migratedOverrides then addon:Debug("CategoryManager: Migrated per-category itemOverrides to flat map") end -- Migrate Home category: add rules and remove hideControls local homeCat = cats.definitions["Home"] if homeCat and homeCat.isBuiltIn then -- Remove hideControls if homeCat.hideControls then homeCat.hideControls = nil addon:Debug("CategoryManager: Removed hideControls from Home") end -- Add rules if empty if not homeCat.rules or table.getn(homeCat.rules) == 0 then homeCat.rules = { { type = "itemID", value = {6948} } } homeCat.priority = 100 addon:Debug("CategoryManager: Added itemID rule to Home category") end -- Ensure group is Other if homeCat.group ~= GROUP_OTHER then homeCat.group = GROUP_OTHER end end -- Migrate Class Items: add isSoulShard rule if missing local classItemsCat = cats.definitions["Class Items"] if classItemsCat and classItemsCat.isBuiltIn then local hasSoulShard = false if classItemsCat.rules then for _, rule in ipairs(classItemsCat.rules) do if rule.type == "isSoulShard" then hasSoulShard = true break end end end if not hasSoulShard then if not classItemsCat.rules then classItemsCat.rules = {} end table.insert(classItemsCat.rules, { type = "isSoulShard", value = true }) classItemsCat.matchMode = "any" addon:Debug("CategoryManager: Added isSoulShard rule to Class Items") end end -- Migrate Tools priority: must be above Weapon (70) to catch fishing poles, skinning knives, etc. local toolsCat = cats.definitions["Tools"] if toolsCat and toolsCat.isBuiltIn and toolsCat.priority and toolsCat.priority < 72 then toolsCat.priority = 72 addon:Debug("CategoryManager: Updated Tools priority to 72 (above Weapon/Armor)") end -- Ensure new categories in the order list are in correct positions -- Check if order needs rebuilding to include new group-based ordering local hasTools, hasEmpty = false, false for _, id in ipairs(cats.order) do if id == "Tools" then hasTools = true end if id == "Empty" then hasEmpty = true end end -- If Tools or Empty were just added by the built-in migration above, -- they're already at end of order. Move them to the Other group area. if hasTools or hasEmpty then -- Rebuild order to respect groups: Other, Main, Class local grouped = {} for _, g in ipairs(GROUP_ORDER) do grouped[g] = {} end grouped["_ungrouped"] = {} for _, id in ipairs(cats.order) do local def = cats.definitions[id] if def then local g = def.group or GROUP_MAIN if grouped[g] then table.insert(grouped[g], id) else table.insert(grouped["_ungrouped"], id) end end end -- Rebuild order local newOrder = {} for _, g in ipairs(GROUP_ORDER) do if grouped[g] then for _, id in ipairs(grouped[g]) do table.insert(newOrder, id) end end end for _, id in ipairs(grouped["_ungrouped"]) do table.insert(newOrder, id) end cats.order = newOrder addon:Debug("CategoryManager: Rebuilt category order for group ordering") end end -- Get all categories function CategoryManager:GetCategories() if not Guda_CharDB or not Guda_CharDB.categories then return self:GetDefaultCategories() end return Guda_CharDB.categories end -- Get category order function CategoryManager:GetCategoryOrder() local cats = self:GetCategories() return cats.order or {} end -- Get category definition by ID function CategoryManager:GetCategory(categoryId) local cats = self:GetCategories() if cats.definitions then return cats.definitions[categoryId] end return nil end -- Save categories to database function CategoryManager:SaveCategories(categories) if not Guda_CharDB then return end Guda_CharDB.categories = categories -- Clear cache when categories change self:ClearCache() end -- Add a new custom category -- If categoryId is nil, auto-generates a unique ID like "Custom_