You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
Grichelde/GricheldeChat.lua

646 lines
26 KiB
Lua

-- import addon read namespace from global env
local _G = _G
local Grichelde = _G.Grichelde
local IsAddOnLoaded, assert, nilOrEmpty, pairs, ipairs, spairs, tContains, tFilter, tInsert, tConcat, tSize, tIsEmpty, find, sub, isUpper, isLower, toUpper, toLower, trim, length, lenUtf8
= Grichelde.functions.IsAddOnLoaded, Grichelde.functions.assert, Grichelde.functions.nilOrEmpty, Grichelde.functions.pairs, Grichelde.functions.ipairs, Grichelde.functions.spairs, Grichelde.functions.tContains, Grichelde.functions.tFilter, Grichelde.functions.tInsert, Grichelde.functions.tConcat, Grichelde.functions.tSize, Grichelde.functions.tIsEmpty,
Grichelde.functions.find, Grichelde.functions.sub, Grichelde.functions.isUpper, Grichelde.functions.isLower, Grichelde.functions.toUpper, Grichelde.functions.toLower, Grichelde.functions.trim, Grichelde.functions.length, Grichelde.functions.lenUtf8
local function cleanseMessage(this, message)
local text = message or ""
if (IsAddOnLoaded("Misspelled")) then
this:DebugPrint("Misspelled detected: cleansing message")
text = _G.Misspelled:RemoveHighlighting(message)
end
return trim(text)
end
--- Before a chat message is sent, check if replacement is required and replace the text accordingly.
-- @param message string
-- @param type string
-- @param language string
-- @param channel string
function Grichelde:SendChatMessage(message, type, ...)
local text = cleanseMessage(self, message)
if (self:CheckReplacementAllowed(text, type)) then
text = self:ReplaceText(text)
end
self:SendChunkifiedChatMessage(text, type, ...)
end
--- Always replaces the text accoording to the configuration, even if activation or channel was disabled.
--- This is used the the override slash command: "/gri /emote text to replace".
--- NOTE: type and channel (in case of whispers) are determined from the message text.
-- @param message string
-- @param type string
function Grichelde:SendChatMessageOverride(message, ...)
local text = cleanseMessage(self, message)
local chatType, lang, channel = text, DEFAULT_CHAT_FRAME.editBox.chatType or "SAY", DEFAULT_CHAT_FRAME.editBox.languageID
local msg, type, chan = self:CheckAndExtractMessageTypeTarget(text)
if msg ~= nil then
msg = self:ReplaceText(msg)
if type ~= nil then
self:SendChunkifiedChatMessage(msg, type, lang, chan, ...)
else
self:SendChunkifiedChatMessage(msg, chatType, lang, channel, ...)
end
else
-- suppress invalid messages/channels/targets
end
end
--- Send text in chunks if length exceeds 255 bytes after replacement.
function Grichelde:SendChunkifiedChatMessage(message, ...)
if length(message) > 255 then
local chunks = self:SplitText(message)
self:DebugPrint("SendChatMessage : #chunks:", #chunks)
for _, chunk in ipairs(chunks) do
self.hooks["SendChatMessage"](chunk, ...);
end
else
self.hooks["SendChatMessage"](message, ...);
end
end
local function IsOneBigEmote(this, text)
local firstWord, _ = this:SplitOnFirstMatch(text)
assert(firstWord ~= nil, "firstWord is never nil")
-- emote detection
local isOneBigEmote = false
-- scheme *emote*
if sub(firstWord, 1, 1) == "<" then
-- search for emote end
local _, emoteEnd = find(text, "%>", 2)
isOneBigEmote = (emoteEnd == length(text))
end
if not isOneBigEmote and sub(firstWord, 1, 1) == "*" then
-- search for emote end
local _, emoteEnd = find(text, "%*", 2)
isOneBigEmote = (emoteEnd == length(text))
end
-- scheme **emote**
if not isOneBigEmote and sub(firstWord, 1, 2) == "**" then
-- search for emote end
local _, emoteEnd = find(text, "%*%*", 3)
isOneBigEmote = (emoteEnd == length(text))
end
-- the whole text is one big emote
return isOneBigEmote
end
--- Checks if a message can be replaced according to configuration.
-- @return boolean
function Grichelde:CheckReplacementAllowed(text, channel)
self:DebugPrint("CheckReplacementAllowed : text:", text)
-- skip if disabled
if (not self.db.profile.enabled) then
self:DebugPrint("CheckReplacementAllowed : disabled")
return false
end
-- skip if no text
if nilOrEmpty(text) then
return false
end
-- skip if wrong channel
local chan = self:ConvertBlizzTypeToOption(channel)
local allowedChannels = tFilter(self.db.profile.channels,
function(_, k, v) return k == chan and v == true end,
function(_, k, _) return k end
)
self:DebugPrint("CheckReplacementAllowed : allowed channels:")
self:DebugPrint(allowedChannels)
if tIsEmpty(allowedChannels) then
self:DebugPrint("CheckReplacementAllowed : skip channel type:", chan)
return false
end
local firstWord, _ = self:SplitOnFirstMatch(text)
assert(firstWord ~= nil, "firstWord is never nil")
-- don't replace slash commands
if sub(firstWord, 1, 1) == "/" then
self:DebugPrint("CheckReplacementAllowed : skip other slash commands:", firstWord)
return false
end
-- emote detection
if IsOneBigEmote(self, text) then
self:DebugPrint("CheckReplacementAllowed : one big emote")
return self.db.profile.channels.emote
end
-- in any other case, treat as ordinary text or emote
return true
end
--- Checks if the text from the Grichelde slash command can be replaced and if so
--- returns the replacable text, the chat type and target (player or channel) from the message text.
--- This is used the the override slash command: "/gri /emote text to replace".
-- @param text (string) the whole message
-- @return message (string) stripped message
-- @return type (string) chat type
-- @return channel (string|number) channel number for whispers
function Grichelde:CheckAndExtractMessageTypeTarget(text)
self:DebugPrint("CheckAndExtractMessageTypeTarget : text:", text)
-- skip if no further text
if nilOrEmpty(text) then
return nil -- dont send text at all
end
-- first word should be a chat command
if sub(text, 1, 1) == "/" then
-- extract chat command
local chatCmd, targetAndText = self:SplitOnFirstMatch(text)
assert(chatCmd ~= nil, "chatCmd is never nil")
local type = tFilter(self.SUPPORTED_CHAT_COMMANDS,
function(_, k, _) return chatCmd == k end
)
self:DebugPrint("CheckAndExtractMessageTypeTarget : determined type:")
self:DebugPrint(type)
if not tIsEmpty(type) then
-- valid /chattype
if type[1] == "WHISPER" then
-- special reply handling
if "/r" == chatCmd or "/reply" == chatCmd then
-- reuse last type and target if possible
local lastTold, lastToldType = ChatEdit_GetLastToldTarget()
self:DebugPrint("CheckAndExtractMessageTypeTarget : lastTell, lastTellType =", lastTold, lastToldType)
return targetAndText, lastToldType or type[1], lastTold
elseif "/tt" == chatCmd then
-- determine target from game world selection
if (not UnitExists("target") or not UnitIsPlayer("target")) then
self:ErrorPrint(self.L.Error_InvalidWhisperTarget)
return nil -- dont send text at all
end
local target = UnitName("target");
if target == nil then
self:ErrorPrint(self.L.Error_InvalidWhisperTarget)
return nil -- dont send text at all
end
-- eventually we found our target
self:DebugPrint("CheckAndExtractMessageTypeTarget : target:", target)
return targetAndText, type[1], target
else
-- determine target from text
local target, msg = self:SplitOnFirstMatch(targetAndText)
if target == nil then
self:ErrorPrint(self.L.Error_InvalidWhisperTarget)
return nil -- dont send text at all
end
-- eventually we found our target
self:DebugPrint("CheckAndExtractMessageTypeTarget : determined target:", target)
return msg, type[1], target
end
else
-- all other chat types
return targetAndText, type[1], nil
end
else
-- if not a valid chat command, try as a numbered channel
local _, _, channelNumber = find(chatCmd, "^/(%d+)")
if channelNumber ~= nil then
local channelId = GetChannelName(channelNumber)
if channelId ~= nil then
return targetAndText, "CHANNEL", channelId
end
end
-- ignore any other slash commands
self:ErrorPrint(self.L.Error_InvalidChannel)
return nil -- dont send text at all
end
elseif IsOneBigEmote(self, text) then
self:DebugPrint("CheckAndExtractMessageTypeTarget : determined EMOTE type")
return text, "EMOTE", nil
else
-- in any other case, treat as ordinary text, assume default type and channel
return text
end
end
function Grichelde:ConvertBlizzTypeToOption(channel)
local option = tFilter(self.BLIZZ_TYPE_TO_OPTIONS,
function(_, k, _) return channel == k end
)
if not tIsEmpty(option) then
self:DebugPrint("ConvertBlizzTypeToOption : convert %s to %s", channel, option[1])
return option[1]
else
return nil
end
end
--- Replaces all character occurrences for which replacements have been defined in the options,
--- while preserving any itemLinks or textures. (http://www.wowwiki.com/ItemLink)
-- @param text string
-- @return string
function Grichelde:ReplaceText(text)
local lookAheads = {'|', '{', '%', '*', '<', '(', 'o'}
local newText = text
local finalText = ""
local currentChar, previousChar
local current = 1
local lastStart = 1
while current <= length(newText) do
previousChar = currentChar
currentChar = sub(newText, current, current)
self:TracePrint("current/char : %s,%s", current, currentChar)
if ( not tContains(lookAheads, currentChar)) then
current = current + 1
else
-- lookahead-check for all preservable patterns (itemLinks, textures, emotes, ooc, etc.)
local textAhead = sub(newText, current)
local posEnd = self:CheckForPreversableText(textAhead, previousChar)
if posEnd > 0 then
self:DebugPrint("ReplaceText : Found an ignore pattern")
local textBehind = sub(newText, lastStart, current - 1)
local replacement = self:ReplaceCharacters(textBehind)
local preservedText = sub(textAhead, 1, posEnd)
finalText = finalText .. replacement .. preservedText
current = current + posEnd
lastStart = current
self:DebugPrint("ReplaceText : restarting at", lastStart)
else
-- no corresponding end was found to start pattern, continue loop with next char
current = current + 1
end
end
end
-- cleanup remaining text to the end
local remainingText = sub(newText, lastStart)
local replacement = self:ReplaceCharacters(remainingText)
finalText = finalText .. replacement
self:DebugPrint("ReplaceText : replaced \"%s\"", text)
self:DebugPrint("ReplaceText : with \"%s\"", finalText)
return finalText
end
--- Checks if the text starts with a preversable ignore pattern, such as itemLinks, textures, raid target icons,
--- emotes, ooc or %-substitutons and returns the end location of the match, or 0 if no pattern was found
-- @param text string
-- @return number
function Grichelde:CheckForPreversableText(text, previousChar)
self:TracePrint("CheckForPreversableText : text:", text)
-- Calling find on ever pattern might be inefficient but its way less code than marching over every character
for _, pattern in ipairs(Grichelde.IGNORE_PATTERNS_CASE_SENSITIVE) do
local pos1, pos2 = find(text, pattern)
if pos1 == 1 and pos2 ~= nil then
self:DebugPrint("CheckForPreversableText : Found ignore pattern \"%s\" at (%d, %d)", pattern, pos1, pos2)
return pos2
end
end
-- emote detection
for _, pattern in ipairs(Grichelde.EMOTE_PATTERNS) do
local pos1, pos2 = find(text, pattern)
if pos1 == 1 and pos2 ~= nil then
local emote = sub(text, pos1, pos2)
if (not self.db.profile.channels.emote) then
self:DebugPrint("CheckForPreversableText : Found emote \"%s\" but preserved it", emote)
return pos2
else
self:DebugPrint("CheckForPreversableText : Found emote \"%s\" at (%d, %d)", emote, pos1, pos2)
end
end
end
-- %-substitutions
local lowerText = toLower(text)
for _, pattern in ipairs(Grichelde.IGNORE_PATTERNS_CASE_INSENSITIVE) do
local pos1, pos2 = find(lowerText, pattern)
if pos1 == 1 and pos2 ~= nil then
self:DebugPrint("CheckForPreversableText : Found ignore pattern \"%s\" at (%d, %d)", pattern, pos1, pos2)
return pos2
end
end
-- Localized raid target markers
for _, localizedRT in ipairs(Grichelde.LOCALIZED_RAID_TARGETS) do
local translation = toLower(self.L["IgnorePattern_" .. localizedRT])
local locPattern = "{" .. translation .. "}"
self:TracePrint("CheckForPreversableText : locPattern:", locPattern)
local pos1, pos2 = find(lowerText, locPattern)
if pos1 == 1 and pos2 ~= nil then
self:DebugPrint("CheckForPreversableText : Found localized raid target marker \"%s\" at (%d, %d)", locPattern, pos1, pos2)
return pos2
end
end
-- ooc detection remaing text is treated as ooc completely!
if (previousChar == nil or previousChar == ' ') and find(lowerText, "^ooc[%:%s]") then
self:DebugPrint("CheckForPreversableText : ooc for remaing text")
return length(text)
end
self:DebugPrint("CheckForPreversableText : no ignore pattern found")
return 0
end
--- Replaces all character occurrences for which replacements have been defined in the options
-- @param text string
-- @return string
function Grichelde:ReplaceCharacters(text)
local replacements = self.db.profile.replacements or {}
self:DebugPrint("ReplaceCharacters : replacements")
self:DebugPrint(replacements)
local result = text
local consolidate = {}
local stopOnMatch = nil
-- replacements are done first
for replName, replTable in spairs(replacements) do
local before = result
local search = replTable.searchText
if not nilOrEmpty(search) and replTable.active then
local replace = replTable.replaceText
consolidate[replName] = {}
if replTable.exactCase then
-- exact case
self:DebugPrint("ReplaceCharacters : \"%s => %s\" (exact case)", search, replace)
local pos, offset = 1, 0
local oldResult = result
local pos1, pos2 = find(oldResult, search, pos)
self:TracePrint("ReplaceCharacters : pos1: %d, pos2: %d", pos1, pos2)
while (pos1 and pos2 and pos1 <= pos2) do
if replTable.stopOnMatch and stopOnMatch == nil then
stopOnMatch = replName
end
local pre = sub(result, 1, pos1 - 1 + offset)
local post = sub(result, pos2 + 1 + offset)
self:TracePrint("ReplaceCharacters : pre: %s, post: %s", pre, post)
-- actual replacement
result = pre .. replace .. post
self:DebugPrint("result: %s", result)
-- remember positions for consolidate
if replTable.consolidate then
tInsert(consolidate[replName], pos1 + offset)
end
-- update previous consolidate markers
local diff = length(replace) - length(search)
for key, posList in pairs(consolidate) do
if key ~= replName then
for i, pc in ipairs(posList) do
if pos1 < pc then
consolidate[key][i] = consolidate[key][i] + diff
end
end
end
end
-- replacement text can lengthen or shorten the resulting text
-- after replacement result and lowerResult can have different sizes
offset = offset + diff
-- update values for next iteration
pos = pos2 + 1
pos1, pos2 = find(oldResult, search, pos)
end
else
self:DebugPrint("ReplaceCharacters : \"%s => %s\" (ignoreCase)", search, replace)
local pos, offset = 1, 0
local lowerResult = toLower(result)
local lowerSearch = toLower(search)
local pos1, pos2 = find(lowerResult, lowerSearch, pos)
self:TracePrint("ReplaceCharacters : pos1: %d, pos2: %d", pos1, pos2)
while (pos1 and pos2 and pos1 <= pos2) do
if replTable.stopOnMatch and stopOnMatch == nil then
stopOnMatch = replName
end
local pre = sub(result, 1, pos1 - 1 + offset)
local match = sub(result, pos1 + offset, pos2 + offset)
local post = sub(result, pos2 + 1 + offset)
self:TracePrint("ReplaceCharacters : pre: %s, match: %s, post: %s", pre, match, post)
-- keep cases
local utf8, uc, tc = nil, 0, 0
local repl = ""
local lastCase = nil
for p = pos1, pos2 do
self:TracePrint("ReplaceCharacters : p: %d", p)
local c = sub(match, p - pos1 + 1, p - pos1 + 1)
-- put together umlaut or accent
if utf8 ~= nil then
c = utf8 .. c
utf8 = nil
end
-- if not umlaut or accent
if c ~= "\195" then
local r = sub(replace, p - pos1 + 1 - uc + tc, p - pos1 + 1 - uc + tc) or ""
if r == "\195" then
r = sub(replace, p - pos1 + 1 - uc + tc, p - pos1 + 1 - uc + tc + 1) or ""
tc = tc + 1
end
self:TracePrint("ReplaceCharacters : character: %s, %s", c, r)
if (isUpper(c)) then -- UPPER-CASE letter
lastCase = true
repl = repl .. toUpper(r)
elseif (isLower(c)) then -- lower_case letter
lastCase = false
repl = repl .. toLower(r)
else -- no letter
lastCase = nil
repl = repl .. r
end
else
-- handle UTF8 characters
utf8 = c
uc = uc + 1
end
end
self:TracePrint("ReplaceCharacters : length %d > %d", length(replace), pos2 - pos1 + 1 - uc + tc)
if (length(replace) > pos2 - pos1 + 1 - uc + tc) then
local remainingReplace = sub(replace, pos2 - pos1 + 2 - uc + tc)
local nextLetter = sub(post, 1, 1)
self:TracePrint("ReplaceCharacters : rest: %s, n: %s, lastCase: %s", remainingReplace, nextLetter, lastCase)
if lastCase == nil then
if (isUpper(nextLetter)) then
repl = repl .. toUpper(remainingReplace)
else
repl = repl .. remainingReplace
end
elseif lastCase == false then
repl = repl .. remainingReplace
else
if (isLower(nextLetter)) then
repl = repl .. toLower(remainingReplace)
else
repl = repl .. toUpper(remainingReplace)
end
end
end
-- actual replacement
result = pre .. repl .. post
self:DebugPrint("ReplaceCharacters : result: %s", result)
-- remember positions for consolidate
if replTable.consolidate then
tInsert(consolidate[replName], pos1 + offset)
self:TracePrint("consolidate[" .. replName .. "] is:")
self:TracePrint(consolidate[replName])
end
-- update previous consolidate markers
local diff = length(repl) - length(lowerSearch)
for key, posList in pairs(consolidate) do
if key ~= replName then
for i, pc in ipairs(posList) do
if pos1 < pc then
consolidate[key][i] = consolidate[key][i] + diff
end
end
end
end
-- replacement text can be longer or shorter the resulting text
-- after replacement result and lowerResult can have different sizes
offset = offset + diff
-- update values for next iteration
pos = pos2 + 1
pos1, pos2 = find(lowerResult, lowerSearch, pos)
end
end
if before ~= result then
self:DebugPrint("ReplaceCharacters : replaced \"%s\" with \"%s\"", before, result)
end
if replTable.consolidate then
self:DebugPrint("consolidate[" .. replName .. "] is:")
self:DebugPrint(consolidate[replName])
end
else
self:DebugPrint("ReplaceCharacters : Skip replacement for %s", replName)
end
if stopOnMatch ~= nil then
break
end
end
-- consolidation is done last
for replName, replTable in spairs(replacements) do
local before = result
local search = replTable.searchText
if not nilOrEmpty(search) and replTable.active then
local replace = replTable.replaceText
local lowerResult = toLower(result)
local offset = 0
if replTable.consolidate then
self:DebugPrint("ReplaceCharacters : consolidating \"%s => %s\"", search, replace)
self:DebugPrint("consolidate[" .. replName .. "] is:")
self:DebugPrint(consolidate[replName])
for _, pos1 in spairs(consolidate[replName]) do
local pos2 = pos1 + length(replace) - 1
self:TracePrint("ReplaceCharacters : pos1: %d, pos2: %d", pos1, pos2)
local match = toLower(replace)
local next = sub(lowerResult, pos2 + 1, pos2 + 1 + pos2 - pos1)
self:TracePrint("ReplaceCharacters : match: %s, next: %s", match, next)
local _, p2 = find(next, "^" .. match)
self:TracePrint("ReplaceCharacters : p2: %d", p2)
if (p2) then
result = sub(result, 1, pos2 + offset) .. sub(result, pos2 + 1 + p2 + offset)
-- consolidation will shorten the resulting text
offset = offset + length(result) - length(lowerResult)
end
self:DebugPrint("ReplaceCharacters : result: %s", result)
end
end
if before ~= result then
self:DebugPrint("ReplaceCharacters : consolidate \"%s\" with \"%s\"", before, result)
end
else
self:DebugPrint("ReplaceCharacters : Skip consolidation for %s", replName)
end
if stopOnMatch == replName then
break
end
end
self:DebugPrint("ReplaceCharacters : final text:", result)
return result
end
--- Splits a long text in longest possible chunks of <= 255 length, split at last available space
-- @param text string
-- @return table
function Grichelde:SplitText(text)
local chunks = {}
local splitText = text
local textSize = length(splitText or "")
while textSize > 255 do
local chunk = sub(splitText, 1, 255)
local remaining = ""
-- special case: if space is the start of the next chunk, don't split this chunk
if sub(splitText, 256, 256) ~= ' ' then
-- split at last space, don't assign directly as nil might be returned
local left, right = self:SplitOnLastMatch(chunk)
if left ~= nil then
chunk = left
end
if right ~= nil then
remaining = right
end
end
self:DebugPrint("SplitText : chunk:", chunk)
tInsert(chunks, chunk)
splitText = remaining .. sub(splitText, 256)
textSize = length(splitText)
end
-- pickup remaining text < 255
self:DebugPrint("SplitText : last chunk:", splitText)
tInsert(chunks, splitText)
return chunks
end