-- 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 --[[ --- Detect OOC in text, patterns are (( ooc )) or ooc: local function IsOoc(this, text) local firstWord, _ = this:SplitOnFirstMatch(text) assert(firstWord ~= nil, "firstWord is never nil") -- scheme: (( ooc )) if sub(firstWord, 1, 2) == "((" then -- search for emote end local _, oocEnd = find(text, "%)%)", 3) if (oocEnd == length(text)) then this:TracePrintPrint("IsOoc : skip ((ooc))", text) return true end end -- scheme: ooc: if sub(firstWord, 1, 4) == "ooc:" then this:TracePrint("IsOoc : skip ooc:", text) return true end return false 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 current = 1 local lastStart = 1 while current <= length(newText) do local 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) 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) 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 sub(lowerText, 1, 4) == "ooc:" 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 = {} -- 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 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 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) elseif (isLower(nextLetter)) then repl = repl .. toLower(remainingReplace) else repl = repl .. remainingReplace end elseif lastCase == false then repl = repl .. toLower(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 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 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