Quantcast
--[[
	ItemSearch
		An item text search engine of some sort

	Grammar:
		<search> 			:=	<intersect search>
		<intersect search> 	:=	<union search> & <union search> ; <union search>
		<union search>		:=	<negatable search>  | <negatable search> ; <negatable search>
		<negatable search> 	:=	!<primitive search> ; <primitive search>
		<primitive search>	:=	<tooltip search> ; <quality search> ; <type search> ; <text search>
		<tooltip search>	:=  bop ; boa ; bou ; boe ; quest
		<quality search>	:=	q<op><text> ; q<op><digit>
		<ilvl search>		:=	ilvl<op><number>
		<type search>		:=	t:<text>
		<text search>		:=	<text>
		<item set search>	:=	s:<setname> (setname can be * for all sets)
		<op>				:=  : | = | == | != | ~= | < | > | <= | >=
--]]

local Lib = LibStub:NewLibrary('LibItemSearch-1.0', 9)
if not Lib then
  return
else
  Lib.searchTypes = Lib.searchTypes or {}
end


--[[ Locals ]]--

local tonumber, select, split = tonumber, select, strsplit
local function useful(a) -- check if the search has a decent size
  return a and #a >= 1
end

local function compare(op, a, b)
  if op == '<=' then
    return a <= b
  end

  if op == '<' then
    return a < b
  end

  if op == '>' then
    return a > b
  end

  if op == '>=' then
    return a >= b
  end

  return a == b
end

local function match(search, ...)
  for i = 1, select('#', ...) do
    local text = select(i, ...)
    if text and text:lower():find(search) then
      return true
    end
  end
  return false
end


--[[ User API ]]--

function Lib:Find(itemLink, search)
	if not useful(search) then
		return true
	end

	if not itemLink then
		return false
	end

  return self:FindUnionSearch(itemLink, split('\124', search:lower()))
end


--[[ Top-Layer Processing ]]--

-- union search: <search>&<search>
function Lib:FindUnionSearch(item, ...)
	for i = 1, select('#', ...) do
		local search = select(i, ...)
		if useful(search) and self:FindIntersectSearch(item, split('\038', search)) then
      		return true
		end
	end
end


-- intersect search: <search>|<search>
function Lib:FindIntersectSearch(item, ...)
	for i = 1, select('#', ...) do
		local search = select(i, ...)
		if useful(search) and not self:FindNegatableSearch(item, search) then
        	return false
		end
	end
	return true
end


-- negated search: !<search>
function Lib:FindNegatableSearch(item, search)
  local negatedSearch = search:match('^[!~][%s]*(.+)$')
  if negatedSearch then
    return not self:FindTypedSearch(item, negatedSearch)
  end
  return self:FindTypedSearch(item, search, true)
end


--[[
     Search Types:
      easly defined search types

      A typed search object should look like the following:
        {
          string id
            unique identifier for the search type,

          string searchCapture = function canSearch(self, search)
            returns a capture if the given search matches this typed search

          bool isMatch = function findItem(self, itemLink, searchCapture)
            returns true if <itemLink> is in the search defined by <searchCapture>
          }
--]]

function Lib:RegisterTypedSearch(object)
	self.searchTypes[object.id] = object
end

function Lib:GetTypedSearches()
	return pairs(self.searchTypes)
end

function Lib:GetTypedSearch(id)
	return self.searchTypes[id]
end

function Lib:FindTypedSearch(item, search, default)
  if not useful(search) then
    return default
  end

  local tag, rest = search:match('^[%s]*(%w+):(.*)$')
  if tag then
    if useful(rest) then
      search = rest
    else
      return default
    end
  end

  local operator, search = search:match('^[%s]*([%>%<%=]*)[%s]*(.*)$')
  if useful(search) then
    operator = useful(operator) and operator
  else
    return default
  end

  if tag then
    tag = '^' .. tag
    for id, searchType in self:GetTypedSearches() do
      if searchType.tags then
        for _, value in pairs(searchType.tags) do
          if value:find(tag) then
            return self:UseTypedSearch(searchType, item, operator, search)
          end
        end
      end
    end
  else
    for id, searchType in self:GetTypedSearches() do
      if not searchType.onlyTags and self:UseTypedSearch(searchType, item, operator, search) then
        return true
      end
    end
    return false
  end

  return default
end

function Lib:UseTypedSearch(searchType, item, operator, search)
  local capture1, capture2, capture3 = searchType:canSearch(operator, search)
  if capture1 then
    if searchType:findItem(item, operator, capture1, capture2, capture3) then
      return true
    end
  end
end


--[[ Item name ]]--

Lib:RegisterTypedSearch{
  id = 'itemName',
  tags = {'n', 'name'},

	canSearch = function(self, operator, search)
		return not operator and search
	end,

	findItem = function(self, item, _, search)
		local name = item:match('%[(.-)%]')
		return match(search, name)
	end
}


--[[ Item type, subtype and equiploc ]]--

Lib:RegisterTypedSearch{
	id = 'itemType',
	tags = {'t', 'type', 'slot'},

	canSearch = function(self, operator, search)
		return not operator and search
	end,

	findItem = function(self, item, _, search)
		local type, subType, _, equipSlot = select(6, GetItemInfo(item))
		return match(search, type, subType, _G[equipSlot])
	end
}


--[[ Item quality ]]--

local qualities = {}
for i = 0, #ITEM_QUALITY_COLORS do
  qualities[i] = _G['ITEM_QUALITY' .. i .. '_DESC']:lower()
end

Lib:RegisterTypedSearch{
	id = 'itemQuality',
	tags = {'q', 'quality'},

	canSearch = function(self, _, search)
		for i, name in pairs(qualities) do
		  if name:find(search) then
			return i
		  end
		end
	end,

	findItem = function(self, link, operator, num)
		local quality = select(3, GetItemInfo(link))
		return compare(operator, quality, num)
	end,
}


--[[ Item level ]]--

Lib:RegisterTypedSearch{
	id = 'itemLevel',
	tags = {'l', 'level', 'lvl'},

	canSearch = function(self, _, search)
		return tonumber(search)
	end,

	findItem = function(self, link, operator, num)
		local lvl = select(4, GetItemInfo(link))
		if lvl then
			return compare(operator, lvl, num)
		end
	end,
}


--[[ Tooltip searches ]]--

local tooltipCache = setmetatable({}, {__index = function(t, k) local v = {} t[k] = v return v end})
local tooltipScanner = _G['LibItemSearchTooltipScanner'] or CreateFrame('GameTooltip', 'LibItemSearchTooltipScanner', UIParent, 'GameTooltipTemplate')

local function link_FindSearchInTooltip(itemLink, search)
	local itemID = itemLink:match('item:(%d+)')
	if not itemID then
		return
	end

	local cachedResult = tooltipCache[search][itemID]
	if cachedResult ~= nil then
		return cachedResult
	end

	tooltipScanner:SetOwner(UIParent, 'ANCHOR_NONE')
	tooltipScanner:SetHyperlink(itemLink)

	local result = false
	if tooltipScanner:NumLines() > 1 and _G[tooltipScanner:GetName() .. 'TextLeft2']:GetText() == search then
		result = true
	elseif tooltipScanner:NumLines() > 2 and _G[tooltipScanner:GetName() .. 'TextLeft3']:GetText() == search then
		result = true
	end

	tooltipCache[search][itemID] = result
	return result
end


Lib:RegisterTypedSearch{
	id = 'bindType',

	canSearch = function(self, _, search)
		return self.keywords[search]
	end,

	findItem = function(self, itemLink, _, search)
		return search and link_FindSearchInTooltip(itemLink, search)
	end,

	keywords = {
    		['soulbound'] = ITEM_BIND_ON_PICKUP,
    		['bound'] = ITEM_BIND_ON_PICKUP,
		['boe'] = ITEM_BIND_ON_EQUIP,
		['bop'] = ITEM_BIND_ON_PICKUP,
		['bou'] = ITEM_BIND_ON_USE,
		['quest'] = ITEM_BIND_QUEST,
		['boa'] = ITEM_BIND_TO_BNETACCOUNT
	}
}

Lib:RegisterTypedSearch{
	id = 'tooltip',
	tags = {'tt', 'tip', 'tooltip'},
	onlyTags = true,

	canSearch = function(self, _, search)
		return search
	end,

	findItem = function(self, link, _, search)
		tooltipScanner:SetOwner(UIParent, 'ANCHOR_NONE')
		tooltipScanner:SetHyperlink(link)

		for i = 1, tooltipScanner:NumLines() do
			local text =  _G[tooltipScanner:GetName() .. 'TextLeft' .. i]:GetText():lower()

			if text:find(search) then
				return true
			end
		end

		return false
	end,
}


--[[ Equipment sets ]]--

--Placeholder variables; will be replaced with references to the addon-appropriate handlers at runtime
local ES_FindSets, ES_CheckItem

--Helper: Global Pattern Matching Function (matches ANY set name if search is *, or the EXACT set name if exactMatch is true, or any set name STARTING with the provided search terms if exactMatch is false (this means it will not match in middle of strings). all equipment set searches below use this function to FIRST try to find a set with the EXACT name entered, and if that fails they'll look for all sets that START with the search term, using recursive calls.
local function ES_TrySetName(setName, search, exactMatch)
	return (search == '*') or (exactMatch and setName:lower() == search) or (not exactMatch and setName:lower():sub(1,strlen(search)) == search)
end

--Addon Support: ItemRack
if IsAddOnLoaded('ItemRack') then
	function ES_FindSets(setList, search, exactMatch)
		for setName, _ in pairs(ItemRackUser.Sets) do
			if ES_TrySetName(setName, search, exactMatch) then
				if (search ~= '*') or (search == '*' and setName:sub(1,1) ~= '~') then --note: this additional tilde check skips internal ItemRack sets when doing a global set search (internal sets are prefixed with tilde, such as ~Unequip, and they contain temporary data that should not be part of a global search)
					table.insert(setList, setName)
				end
			end
		end
		if (search ~= '*') and exactMatch and #setList == 0 then --if we just finished an exact, non-global (not "*"), name match search and still have no results, try one more time with partial ("starts with") set name matching instead
			ES_FindSets(setList, search, false)
		end
	end

	local irSameID = (ItemRack and ItemRack.SameID or nil) --set up local reference for speed if they're an ItemRack user
	function ES_CheckItem(itemLink, setList)
		local itemID = string.match(itemLink or '','item:(%-?%d+)') or 0 --grab the baseID of the item we are searching for (we don't need the full itemString, since we'll only be doing a loose baseID comparison below)

		for _, setName in pairs(setList) do
			for _, irItemData in pairs(ItemRackUser.Sets[setName].equip) do --note: do not change this to ipairs() or it will abort scanning at empty slots in a set
				--[[ commented out due to libItemSearch lacking a "best match before generic match" priority matching system, so we'll have to go for "generic match" only (below), which matches items that have the same base ItemID as items from the set, as ItemRack cannot guarantee that the stored ItemString will be valid anymore (if the user has modified the item since last saving the set)
				if itemString == irItemData then -- strict match: perform a strict match to check if this is the *exact* same item (same gems, enchants, etc)
					return true
				end]]--

				if irSameID(itemID, irItemData) then --loose match: use ItemRack's built-in "Base ItemID" comparison function to allow us to match any items that have the same base itemID (disregarding strict matching of gems, enchants, etc); due to libItemSearch limitations it's the best compromise and guarantees to always highlight the correct items even if we may catch some extras/duplicates that weren't part of the set
					return true
				end
			end
		end

		return false
	end

--Addon Support: Wardrobe
elseif IsAddOnLoaded('Wardrobe') then
	function ES_FindSets(setList, search, exactMatch)
		for _, waOutfit in ipairs(Wardrobe.CurrentConfig.Outfit) do
			if ES_TrySetName(waOutfit.OutfitName, search, exactMatch) then
				table.insert(setList, waOutfit) --insert an actual reference to the matching set's data table, instead of just storing the /name/ of the set. we do this due to how Wardrobe works (all sets are in a numerically indexed table and storing the table offset would therefore be unreliable)
			end
		end
		if (search ~= '*') and exactMatch and #setList == 0 then --if we just finished an exact, non-global (not "*"), name match search and still have no results, try one more time with partial ("starts with") set name matching instead
			ES_FindSets(setList, search, false)
		end
	end

	function ES_CheckItem(itemLink, setList)
		local itemID = tonumber(string.match(itemLink or '','item:(%-?%d+)') or 0) --grab the baseID of the item we are searching for (we don't need the full itemString, since we'll only be doing a loose baseID comparison below)

		for _, waOutfit in pairs(setList) do
			for _, waItemData in pairs(waOutfit.Item) do
				if (waItemData.IsSlotUsed == 1) and (waItemData.ItemID == itemID) then --loose match: compare the current item's baseID to the baseID of the set item
					return true
				end
			end
		end

		return false
	end

--Last Resort: Blizzard Equipment Manager
else
	function ES_FindSets(setList, search, exactMatch)
		for i = 1, GetNumEquipmentSets() do
			local setName = GetEquipmentSetInfo(i)
			if ES_TrySetName(setName, search, exactMatch) then
				table.insert(setList, setName)
			end
		end
		if (search ~= '*') and exactMatch and #setList == 0 then --if we just finished an exact, non-global (not "*"), name match search and still have no results, try one more time with partial ("starts with") set name matching instead
			ES_FindSets(setList, search, false)
		end
	end

	function ES_CheckItem(itemLink, setList)
		local itemID = tonumber(string.match(itemLink or '','item:(%-?%d+)') or 0) --grab the baseID of the item we are searching for (we don't need the full itemString, since we'll only be doing a loose baseID comparison below)

		for _, setName in pairs(setList) do
			local bzSetItemIDs = GetEquipmentSetItemIDs(setName)
			for _, bzItemID in pairs(bzSetItemIDs) do --note: do not change this to ipairs() or it will abort scanning at empty slots in a set
				if itemID == bzItemID then --loose match: compare the current item's baseID to the baseID of the set item
					return true
				end
			end
		end

		return false
	end
end

Lib:RegisterTypedSearch{
	id = 'equipmentSet',
	tags = {'s', 'set'},

	canSearch = function(self, operator, search)
		return not operator and search
	end,

	findItem = function(self, itemLink, _, search)
		--this is an item-set search and we know that the only items that can possibly match will be *equippable* items, so we'll short-circuit the response for non-equippable items to speed up searches.
		if not IsEquippableItem(itemLink) then return false end

		--default to matching *all* equipment sets if no set name has been provided yet
		if search == '' then search = '*' end

		--generate a list of all equipment sets whose names begin with the search term (or a single set if an exact set name match is found), then look for our item in those equipment sets
		local setList = {}
		ES_FindSets(setList, search, true)
		if #setList == 0 then return false end
		return ES_CheckItem(itemLink, setList)
	end,
}