Files
Guda-ClassicAPI/Core/CategoryManager.lua
T
2026-03-29 20:17:37 +04:00

1481 lines
50 KiB
Lua

-- 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_<time>_<random>"
function CategoryManager:AddCategory(categoryId, definition)
local cats = self:GetCategories()
-- Auto-generate ID if not provided
if not categoryId then
categoryId = "Custom_" .. time() .. "_" .. math.random(1000, 9999)
-- Ensure unique
while cats.definitions[categoryId] do
categoryId = "Custom_" .. time() .. "_" .. math.random(1000, 9999)
end
end
if cats.definitions[categoryId] then
addon:Debug("CategoryManager: Category already exists: " .. categoryId)
return false
end
definition.isBuiltIn = definition.isBuiltIn or false
if not definition.group then
definition.group = GROUP_MAIN
end
cats.definitions[categoryId] = definition
-- Insert at end of the category's group in the order list
local insertPos = nil
local group = definition.group
-- Find last category in the same group
for i = table.getn(cats.order), 1, -1 do
local existDef = cats.definitions[cats.order[i]]
if existDef and existDef.group == group then
insertPos = i + 1
break
end
end
if insertPos then
-- Lua 5.0 table.insert with position
table.insert(cats.order, insertPos, categoryId)
else
table.insert(cats.order, categoryId)
end
self:SaveCategories(cats)
return true, categoryId
end
-- Update an existing category
function CategoryManager:UpdateCategory(categoryId, definition)
local cats = self:GetCategories()
if not cats.definitions[categoryId] then
addon:Debug("CategoryManager: Category not found: " .. categoryId)
return false
end
-- Shallow merge: update existing definition with new fields
-- This preserves fields the caller didn't pass (group, priority, categoryMark, etc.)
local existing = cats.definitions[categoryId]
for k, v in pairs(definition) do
existing[k] = v
end
-- Always preserve isBuiltIn from original
existing.isBuiltIn = existing.isBuiltIn
self:SaveCategories(cats)
return true
end
-- Delete a category (only custom categories can be deleted)
function CategoryManager:DeleteCategory(categoryId)
local cats = self:GetCategories()
local def = cats.definitions[categoryId]
if not def then
return false
end
if def.isBuiltIn then
addon:Debug("CategoryManager: Cannot delete built-in category: " .. categoryId)
return false
end
cats.definitions[categoryId] = nil
-- Remove from order
for i, id in ipairs(cats.order) do
if id == categoryId then
table.remove(cats.order, i)
break
end
end
self:SaveCategories(cats)
return true
end
-- Check if a category can move up within its group
function CategoryManager:CanMoveUp(categoryId)
local cats = self:GetCategories()
local def = cats.definitions[categoryId]
if not def or def.hideControls then return false end
for i, id in ipairs(cats.order) do
if id == categoryId then
-- Find any previous non-hideControls category (can cross group boundaries)
for j = i - 1, 1, -1 do
local prevDef = cats.definitions[cats.order[j]]
if prevDef and not prevDef.hideControls then
return true
end
end
return false -- very first movable category
end
end
return false
end
-- Check if a category can move down (crosses group boundaries)
function CategoryManager:CanMoveDown(categoryId)
local cats = self:GetCategories()
local def = cats.definitions[categoryId]
if not def or def.hideControls then return false end
local count = table.getn(cats.order)
for i, id in ipairs(cats.order) do
if id == categoryId then
-- Find any next non-hideControls category (can cross group boundaries)
for j = i + 1, count do
local nextDef = cats.definitions[cats.order[j]]
if nextDef and not nextDef.hideControls then
return true
end
end
return false -- very last movable category
end
end
return false
end
-- Move category up in order (crosses group boundaries, changes group when crossing)
function CategoryManager:MoveCategoryUp(categoryId)
local cats = self:GetCategories()
local def = cats.definitions[categoryId]
if not def or def.hideControls then return false end
for i, id in ipairs(cats.order) do
if id == categoryId then
-- Find previous non-hideControls category
for j = i - 1, 1, -1 do
local prevDef = cats.definitions[cats.order[j]]
if prevDef and not prevDef.hideControls then
-- Swap positions
cats.order[i] = cats.order[j]
cats.order[j] = categoryId
-- If crossing into a different group, adopt that group
local prevGroup = prevDef.group or GROUP_MAIN
if (def.group or GROUP_MAIN) ~= prevGroup then
def.group = prevGroup
end
self:SaveCategories(cats)
return true
end
end
return false
end
end
return false
end
-- Move category down in order (crosses group boundaries, changes group when crossing)
function CategoryManager:MoveCategoryDown(categoryId)
local cats = self:GetCategories()
local def = cats.definitions[categoryId]
if not def or def.hideControls then return false end
local count = table.getn(cats.order)
for i, id in ipairs(cats.order) do
if id == categoryId then
-- Find next non-hideControls category
for j = i + 1, count do
local nextDef = cats.definitions[cats.order[j]]
if nextDef and not nextDef.hideControls then
-- Swap positions
cats.order[i] = cats.order[j]
cats.order[j] = categoryId
-- If crossing into a different group, adopt that group
local nextGroup = nextDef.group or GROUP_MAIN
if (def.group or GROUP_MAIN) ~= nextGroup then
def.group = nextGroup
end
self:SaveCategories(cats)
return true
end
end
return false
end
end
return false
end
-- Toggle category enabled state
function CategoryManager:ToggleCategory(categoryId)
local cats = self:GetCategories()
local def = cats.definitions[categoryId]
if def then
def.enabled = not def.enabled
self:SaveCategories(cats)
return true
end
return false
end
-- Reset all categories to defaults
function CategoryManager:ResetToDefaults()
Guda_CharDB.categories = self:GetDefaultCategories()
addon:Debug("CategoryManager: Reset to default categories")
end
-------------------------------------------
-- Rule Evaluation Engine
-------------------------------------------
-- Evaluate a single rule against item data
function CategoryManager:EvaluateRule(rule, itemData, bagID, slotID, isOtherChar)
local ruleType = rule.type
local ruleValue = rule.value
if ruleType == "itemType" then
return (itemData.class == ruleValue) or (itemData.type == ruleValue)
elseif ruleType == "itemSubtype" then
local subclass = itemData.subclass or ""
-- Check for partial match (e.g., "Food" matches "Food & Drink")
if string.find(subclass, ruleValue) then
return true
end
return subclass == ruleValue
elseif ruleType == "namePattern" then
local itemName = itemData.name or ""
return string.find(itemName, ruleValue) ~= nil
elseif ruleType == "quality" then
-- For quality 0 (gray/junk), also check tooltip as fallback
if ruleValue == 0 then
if itemData.quality == 0 then
return true
end
-- Tooltip fallback for gray detection (current character only)
if not isOtherChar and addon.Modules.Utils and addon.Modules.Utils.IsItemGrayTooltip then
return addon.Modules.Utils:IsItemGrayTooltip(bagID, slotID, itemData.link)
end
return false
end
return itemData.quality == ruleValue
elseif ruleType == "qualityMin" then
-- Minimum quality check (item quality >= ruleValue)
return (itemData.quality or 0) >= (ruleValue or 0)
elseif ruleType == "isBoE" then
if isOtherChar then return false end
if itemData.class ~= "Weapon" and itemData.class ~= "Armor" then
return false
end
local isBoE = addon.Modules.Utils:IsBindOnEquip(bagID, slotID, itemData.link)
return isBoE == ruleValue
elseif ruleType == "isQuestItem" then
-- Use centralized ItemDetection for quest item detection
if addon.Modules.ItemDetection then
local props = addon.Modules.ItemDetection:GetItemProperties(itemData, bagID, slotID)
return props.isQuestItem == ruleValue
end
-- Fallback to Utils if ItemDetection not available
local isQuestItem, _ = addon.Modules.Utils:IsQuestItem(bagID, slotID, itemData, isOtherChar, false)
return isQuestItem == ruleValue
elseif ruleType == "texturePattern" then
local texture = itemData.texture or ""
return string.find(string.lower(texture), string.lower(ruleValue)) ~= nil
elseif ruleType == "itemID" then
if not itemData.link then return false end
local itemID = addon.Modules.Utils:ExtractItemID(itemData.link)
if not itemID then return false end
-- ruleValue can be a single ID or a table of IDs
if type(ruleValue) == "table" then
for _, id in ipairs(ruleValue) do
if itemID == tonumber(id) then return true end
end
return false
else
-- Convert string to number if needed
return itemID == tonumber(ruleValue)
end
elseif ruleType == "isSoulShard" then
return addon.Modules.Utils:IsSoulShard(itemData.link) == ruleValue
elseif ruleType == "isProjectile" then
local isProj = (itemData.class == "Projectile" or
itemData.subclass == "Arrow" or
itemData.subclass == "Bullet")
return isProj == ruleValue
elseif ruleType == "restoreTag" then
-- restoreTag is set by tooltip scanning: "eat", "drink", or "restore"
local tag = itemData.restoreTag
if not tag then return false end
return tag == ruleValue
elseif ruleType == "isProfessionTool" then
-- Check if item is a profession tool by ID or subtype
local isProfessionTool = false
-- Check by item ID
if itemData.link then
local itemID = addon.Modules.Utils:ExtractItemID(itemData.link)
if itemID and addon.Constants.PROFESSION_TOOL_IDS and addon.Constants.PROFESSION_TOOL_IDS[itemID] then
isProfessionTool = true
end
end
-- Check by subtype (e.g., Fishing Pole)
if not isProfessionTool then
local itemSubclass = itemData.subclass or ""
if addon.Constants.PROFESSION_TOOL_SUBTYPES and addon.Constants.PROFESSION_TOOL_SUBTYPES[itemSubclass] then
isProfessionTool = true
end
end
return isProfessionTool == ruleValue
elseif ruleType == "isJunk" then
-- Use centralized ItemDetection for junk detection
if addon.Modules.ItemDetection then
local props = addon.Modules.ItemDetection:GetItemProperties(itemData, bagID, slotID)
return props.isJunk == ruleValue
end
-- Fallback: gray items are always junk
return (itemData.quality == 0) == ruleValue
end
return false
end
-- Evaluate all rules for a category
function CategoryManager:EvaluateCategoryRules(categoryDef, itemData, bagID, slotID, isOtherChar)
if not categoryDef.enabled then
return false
end
local rules = categoryDef.rules or {}
-- No rules = fallback category (matches everything)
if table.getn(rules) == 0 then
return categoryDef.isFallback == true
end
local matchMode = categoryDef.matchMode or "any"
if matchMode == "all" then
-- All rules must match
for _, rule in ipairs(rules) do
if not self:EvaluateRule(rule, itemData, bagID, slotID, isOtherChar) then
return false
end
end
return true
else
-- Any rule must match
for _, rule in ipairs(rules) do
if self:EvaluateRule(rule, itemData, bagID, slotID, isOtherChar) then
return true
end
end
return false
end
end
-- Get sorted categories by priority (highest first)
function CategoryManager:GetCategoriesByPriority()
local cats = self:GetCategories()
local sorted = {}
for id, def in pairs(cats.definitions) do
if def.enabled then
table.insert(sorted, { id = id, def = def })
end
end
table.sort(sorted, function(a, b)
return (a.def.priority or 0) > (b.def.priority or 0)
end)
return sorted
end
-- Categorize an item using the rule engine
-- Returns category ID or "Miscellaneous" as fallback
function CategoryManager:CategorizeItem(itemData, bagID, slotID, isOtherChar)
-- Check cache first
local cacheKey = GetCacheKey(itemData and itemData.link, isOtherChar)
if cacheKey and categoryCache[cacheKey] then
cacheHits = cacheHits + 1
return categoryCache[cacheKey]
end
cacheMisses = cacheMisses + 1
-- Reclassify equippable items that have wrong itemType (e.g. Trade Goods trinkets/devices)
-- If subclass indicates an equip slot, treat as Armor
if itemData and itemData.subclass then
local sub = itemData.subclass
if sub == "INVTYPE_TRINKET" or sub == "INVTYPE_FINGER" or sub == "INVTYPE_NECK"
or sub == "INVTYPE_HEAD" or sub == "INVTYPE_SHOULDER" or sub == "INVTYPE_CHEST"
or sub == "INVTYPE_WAIST" or sub == "INVTYPE_LEGS" or sub == "INVTYPE_FEET"
or sub == "INVTYPE_WRIST" or sub == "INVTYPE_HAND" or sub == "INVTYPE_CLOAK"
or sub == "INVTYPE_ROBE" or sub == "INVTYPE_BODY" then
if itemData.class ~= "Armor" and itemData.class ~= "Weapon" then
local override = {}
for k, v in pairs(itemData) do override[k] = v end
override.class = "Armor"
itemData = override
end
end
end
-- Check item overrides first (flat map: itemID -> categoryId)
local itemID
if itemData and itemData.link then
itemID = addon.Modules.Utils:ExtractItemID(itemData.link)
if itemID then
local cats = self:GetCategories()
if cats.itemOverrides then
local overrideCatId = cats.itemOverrides[itemID]
if overrideCatId then
local overrideDef = cats.definitions[overrideCatId]
if overrideDef and overrideDef.enabled then
if cacheKey then
categoryCache[cacheKey] = overrideCatId
end
return overrideCatId
end
end
end
end
end
-- Equipment set categories (higher priority than rule-based matching)
if itemID and addon.Modules.EquipmentSets then
local showEquipSets = addon.Modules.DB:GetSetting("showEquipSetCategories")
if showEquipSets ~= false then
if addon.Modules.EquipmentSets:IsInSet(itemID) then
local setNames = addon.Modules.EquipmentSets:GetSetNames(itemID)
if setNames and table.getn(setNames) > 0 then
table.sort(setNames)
local catId = "EquipSet:" .. setNames[1]
local catDef = self:GetCategory(catId)
if catDef and catDef.enabled then
if cacheKey then
categoryCache[cacheKey] = catId
end
return catId
end
end
end
end
end
local sortedCats = self:GetCategoriesByPriority()
-- Debug: show white item categorization
if itemData.quality == 1 and (itemData.class == "Weapon" or itemData.class == "Armor") then
addon:Debug("Categorizing white equip: %s (class=%s, quality=%s)", tostring(itemData.name), tostring(itemData.class), tostring(itemData.quality))
for i, entry in ipairs(sortedCats) do
addon:Debug(" Cat %d: %s (priority=%s, enabled=%s)", i, entry.id, tostring(entry.def.priority), tostring(entry.def.enabled))
if i > 10 then break end -- Limit debug output
end
end
local result = "Miscellaneous"
for _, entry in ipairs(sortedCats) do
if not entry.def.isFallback then
local matches = self:EvaluateCategoryRules(entry.def, itemData, bagID, slotID, isOtherChar)
-- Debug: show which category matched for white equip
if itemData.quality == 1 and (itemData.class == "Weapon" or itemData.class == "Armor") and matches then
addon:Debug(" -> MATCHED: %s", entry.id)
end
if matches then
result = entry.id
break
end
end
end
-- Cache the result
if cacheKey then
categoryCache[cacheKey] = result
end
return result
end
-- Build the Guda_CategoryList from current category order (for compatibility)
function CategoryManager:BuildCategoryList()
local order = self:GetCategoryOrder()
local list = {}
for _, id in ipairs(order) do
local def = self:GetCategory(id)
if def and def.enabled then
table.insert(list, id)
end
end
return list
end
-------------------------------------------
-- Group Management
-------------------------------------------
-- Get ordered list of unique groups from the category order
function CategoryManager:GetGroups()
local cats = self:GetCategories()
local seen = {}
local groups = {}
for _, id in ipairs(cats.order) do
local def = cats.definitions[id]
if def then
local g = def.group or GROUP_MAIN
if not seen[g] then
seen[g] = true
table.insert(groups, g)
end
end
end
return groups
end
-- Get categories organized by group: { groupName => {catIds} }
-- Also returns ungrouped list for any categories without a group
function CategoryManager:GetCategoriesByGroup()
local cats = self:GetCategories()
local result = {}
local ungrouped = {}
for _, id in ipairs(cats.order) do
local def = cats.definitions[id]
if def then
local g = def.group
if g then
if not result[g] then result[g] = {} end
table.insert(result[g], id)
else
table.insert(ungrouped, id)
end
end
end
return result, ungrouped
end
-- Change a category's group
function CategoryManager:SetCategoryGroup(categoryId, groupName)
local cats = self:GetCategories()
local def = cats.definitions[categoryId]
if not def then return false end
local oldGroup = def.group or GROUP_MAIN
if oldGroup == groupName then return true end
def.group = groupName
-- Remove from current position in order
local currentPos = nil
for i, id in ipairs(cats.order) do
if id == categoryId then
currentPos = i
break
end
end
if currentPos then
table.remove(cats.order, currentPos)
end
-- Insert at end of new group
local insertPos = nil
for i = table.getn(cats.order), 1, -1 do
local existDef = cats.definitions[cats.order[i]]
if existDef and (existDef.group or GROUP_MAIN) == groupName then
insertPos = i + 1
break
end
end
if insertPos then
table.insert(cats.order, insertPos, categoryId)
else
table.insert(cats.order, categoryId)
end
self:SaveCategories(cats)
return true
end
-- Get the group order (unique groups in display order)
function CategoryManager:GetGroupOrder()
return self:GetGroups()
end
-- Get group constants
function CategoryManager:GetGroupMain() return GROUP_MAIN end
function CategoryManager:GetGroupOther() return GROUP_OTHER end
function CategoryManager:GetGroupClass() return GROUP_CLASS end
-------------------------------------------
-- Item Override System
-------------------------------------------
-- Assign an item to a specific category by item ID
-- Uses flat map at categories level: cats.itemOverrides[itemID] = categoryId
function CategoryManager:AssignItemToCategory(itemID, categoryId)
if not itemID or not categoryId then return false end
local cats = self:GetCategories()
local def = cats.definitions[categoryId]
if not def then return false end
if not cats.itemOverrides then
cats.itemOverrides = {}
end
cats.itemOverrides[itemID] = categoryId
self:SaveCategories(cats)
self:ClearCache()
return true
end
-- Remove an item from a specific category's overrides
function CategoryManager:RemoveItemFromCategory(itemID, categoryId)
if not itemID or not categoryId then return false end
local cats = self:GetCategories()
if not cats.itemOverrides then return false end
if cats.itemOverrides[itemID] == categoryId then
cats.itemOverrides[itemID] = nil
self:SaveCategories(cats)
self:ClearCache()
return true
end
return false
end
-- Remove an item override regardless of category
function CategoryManager:RemoveItemOverride(itemID)
if not itemID then return end
local cats = self:GetCategories()
if cats.itemOverrides then
cats.itemOverrides[itemID] = nil
end
end
-------------------------------------------
-- Equipment Set Category Sync
-------------------------------------------
-- Note: savedEquipSetProps is stored in cats.savedEquipSetProps (persisted in SavedVariables)
-- Preserves user changes (enabled state, group, mark, order position) when sets are deleted/recreated
-- Sync equipment set categories with current set data from EquipmentSets module
function CategoryManager:SyncEquipmentSetCategories()
local equipSets = addon.Modules.EquipmentSets
if not equipSets then return end
local showEquipSets = addon.Modules.DB:GetSetting("showEquipSetCategories")
if showEquipSets == false then return end
local setNames = equipSets:GetAllSetNames()
if not setNames then return end
local cats = self:GetCategories()
local existingSetCats = {}
-- Ensure savedEquipSetProps table exists (persisted in SavedVariables)
if not cats.savedEquipSetProps then
cats.savedEquipSetProps = {}
end
-- Find existing EquipSet categories
for id, def in pairs(cats.definitions) do
if string.find(id, "^EquipSet:") then
existingSetCats[id] = true
end
end
-- Create/update categories for current sets
for _, setName in ipairs(setNames) do
local catId = "EquipSet:" .. setName
if not cats.definitions[catId] then
-- Check for saved properties from a previously deleted set
local props = cats.savedEquipSetProps[catId]
local defaultMark = "Interface\\AddOns\\Guda\\Assets\\equipment"
local newDef = {
name = setName,
icon = "Interface\\Icons\\INV_Chest_Chain_04",
rules = {},
matchMode = "all",
priority = props and props.priority or 65,
enabled = props and props.enabled or true,
isBuiltIn = false,
isEquipSetCategory = true,
group = props and props.group or GROUP_MAIN,
categoryMark = props and props.categoryMark or defaultMark,
}
cats.definitions[catId] = newDef
-- Restore saved order position or insert at end of Main group
local insertPos = nil
if props and props.orderPos then
-- Clamp to valid range
insertPos = props.orderPos
if insertPos > table.getn(cats.order) + 1 then
insertPos = table.getn(cats.order) + 1
end
end
if not insertPos then
-- Insert before Junk category if it exists, otherwise at end of Main group
for i, orderId in ipairs(cats.order) do
if orderId == "Junk" then
insertPos = i
break
end
end
end
if not insertPos then
for i = table.getn(cats.order), 1, -1 do
local existDef = cats.definitions[cats.order[i]]
if existDef and (existDef.group or GROUP_MAIN) == GROUP_MAIN then
insertPos = i + 1
break
end
end
end
if insertPos then
table.insert(cats.order, insertPos, catId)
else
table.insert(cats.order, catId)
end
addon:Debug("CategoryManager: Created equipment set category: " .. catId)
end
existingSetCats[catId] = nil -- Mark as still active
end
-- Remove categories for sets that no longer exist
for catId in pairs(existingSetCats) do
local def = cats.definitions[catId]
if def then
-- Find current order position before removal
local orderPos = nil
for i, id in ipairs(cats.order) do
if id == catId then
orderPos = i
break
end
end
-- Save user-edited properties before deletion (persisted across reloads)
cats.savedEquipSetProps[catId] = {
enabled = def.enabled,
categoryMark = def.categoryMark,
group = def.group,
priority = def.priority,
orderPos = orderPos,
}
end
cats.definitions[catId] = nil
for i, id in ipairs(cats.order) do
if id == catId then
table.remove(cats.order, i)
break
end
end
addon:Debug("CategoryManager: Removed equipment set category: " .. catId)
end
self:SaveCategories(cats)
end
-- Get available rule types for UI
function CategoryManager:GetRuleTypes()
return {
{ id = "itemType", name = "Item Type", description = "Match by item class (Armor, Weapon, etc.)" },
{ id = "itemSubtype", name = "Item Subtype", description = "Match by item subclass (Cloth, Potion, etc.)" },
{ id = "namePattern", name = "Name Pattern", description = "Match item name (supports Lua patterns)" },
{ id = "quality", name = "Quality", description = "Match by item quality (0=Gray to 5=Legendary)" },
{ id = "qualityMin", name = "Quality (min)", description = "Match items with at least this quality" },
{ id = "isBoE", name = "Bind on Equip", description = "Match items that bind when equipped" },
{ id = "isQuestItem", name = "Quest Item", description = "Match quest items" },
{ id = "isJunk", name = "Is Junk", description = "Match junk items (gray + white equippable)" },
{ id = "isProfessionTool", name = "Profession Tool", description = "Match profession tools (skinning knife, mining pick, etc.)" },
{ id = "texturePattern", name = "Icon Pattern", description = "Match icon texture path" },
{ id = "itemID", name = "Item ID", description = "Match specific item IDs" },
{ id = "isSoulShard", name = "Soul Shard", description = "Match soul shards" },
{ id = "isProjectile", name = "Projectile", description = "Match arrows and bullets" },
{ id = "restoreTag", name = "Restore Type", description = "Match by consumable type (eat, drink, restore)" },
}
end
-- Get common item types for UI dropdowns
function CategoryManager:GetItemTypes()
return {
"Armor", "Weapon", "Consumable", "Container", "Trade Goods",
"Projectile", "Quiver", "Reagent", "Recipe", "Key", "Miscellaneous", "Quest"
}
end
-- Get quality names for UI
function CategoryManager:GetQualityNames()
return {
[0] = "Poor (Gray)",
[1] = "Common (White)",
[2] = "Uncommon (Green)",
[3] = "Rare (Blue)",
[4] = "Epic (Purple)",
[5] = "Legendary (Orange)",
}
end