Feature/add character tags to message div option (#4225) * Adding toggle. * Adding events for the new toggle. Reloading chat when toggled. * Adding chat reload when tags are added or removed to char. * Adding main applyCharacterTagsToMessageDivs function to add tags to divs. * Adding information that the class name is normalized and lowercased to the tooltip. * Correcting console.traces to console.debug for minimal console printing. * Lint indentation fix. * Adding simple cache. * Adding reload event when a new message is added to chat. * Changing tags applied from classes to HTML data API. * Adding guide showing how to select data attributes with CSS. * Fixing trailing comma pointed out by lint. * Simplifying re-rendering by deleting previous attributes. * Adding the feature as default behavior, removing toggles. Will add docs to ST docs. * Removing code that is no longer needed. * Re-rendering divs after other tag operations. * Filtering message ids to only re-render specific divs. Adding suggested changes. * After testing, it seems to add all messages by one, so it appears it's not needed call this in the printMessages function. * Lint fixes. * Cache jquery selector * Fix local variables assignment * Move new functions to tags module * Normalizing tag names before adding them as data-* * Replace commas with spaces to avoid issues with tag names containing commas. * Correcting where the comma replacing should go. * Add error handling for applyCharacterTagsToMessageDivs --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>

38a7668535dabfcd71aabb9eaf1b2222c1fbb778

Marcela Petra Ferraz de Novaes <novaes.marcela@outlook.com>

Signed
2 files changed, +183 -4Ignore whitespace
public/script.js+3 -0
@@ -204,6 +204,7 @@ import {
204204 applyTagsOnCharacterSelect,
205205 applyTagsOnGroupSelect,
206206 tag_import_setting,
207+ applyCharacterTagsToMessageDivs,
207208} from './scripts/tags.js';
208209import {
209210 SECRET_KEYS,
@@ -2624,6 +2625,8 @@ export function addOneMessage(mes, { type = 'normal', insertAfter = null, scroll
26242625 if (!insertAfter && !insertBefore && scroll) {
26252626 scrollChatToBottom();
26262627 }
2628+
2629+ applyCharacterTagsToMessageDivs({ mesIds: newMessageId });
26272630}
26282631
26292632/**
public/scripts/tags.js+180 -4
@@ -136,7 +136,7 @@ const TAG_FOLDER_DEFAULT_TYPE = 'NONE';
136136 * @property {string} [color] - The background color of the tag
137137 * @property {string} [color2] - The foreground color of the tag
138138 * @property {number} [create_date] - A number representing the date when this tag was created
139139 * @property {boolean} [is_hidden_on_character_card] - Whether this tag is hidden on the character card
140140 *
141141 * @property {function} [action] - An optional function that gets executed when this tag is an actionable tag and is clicked on.
142142 * @property {string} [class] - An optional css class added to the control representing this tag when printed. Used for custom tags in the filters.
@@ -686,6 +686,8 @@ function selectTag(event, ui, listSelector, { tagListOptions = {} } = {}) {
686686
687687 addTagsToEntity(tag, characterIds, { tagListSelector: listSelector, tagListOptions: tagListOptions });
688688
689+ applyCharacterTagsToMessageDivs();
690+
689691 // need to return false to keep the input clear
690692 return false;
691693}
@@ -1237,6 +1239,8 @@ function onTagRemoveClick(event) {
12371239 const characterIds = characterData ? JSON.parse(characterData).characterIds : null;
12381240
12391241 removeTagFromEntity(tag, characterIds, { tagElement: tagElement });
1242+
1243+ applyCharacterTagsToMessageDivs();
12401244}
12411245
12421246// @ts-ignore
@@ -1718,6 +1722,8 @@ async function onTagDeleteClick() {
17181722
17191723 printCharactersDebounced();
17201724 saveSettingsDebounced();
1725+
1726+ applyCharacterTagsToMessageDivs();
17211727}
17221728
17231729function onTagRenameInput() {
@@ -1728,6 +1734,8 @@ function onTagRenameInput() {
17281734 $(this).attr('dirty', '');
17291735 $(`.tag[id="${id}"] .tag_name`).text(newName);
17301736 saveSettingsDebounced();
1737+
1738+ applyCharacterTagsToMessageDivs();
17311739}
17321740
17331741/**
@@ -1853,7 +1861,8 @@ function registerTagsSlashCommands() {
18531861 }),
18541862 ],
18551863 unnamedArgumentList: [
18561864 SlashCommandArgument.fromProps({ description: 'tag name',
1865+ description: 'tag name',
18571866 typeList: [ARGUMENT_TYPE.STRING],
18581867 isRequired: true,
18591868 enumProvider: commonEnumProviders.tagsForChar('not-existing'),
@@ -1890,7 +1899,8 @@ function registerTagsSlashCommands() {
18901899 return String(result);
18911900 },
18921901 namedArgumentList: [
18931902 SlashCommandNamedArgument.fromProps({ name: 'name',
1903+ name: 'name',
18941904 description: 'Character name - or unique character identifier (avatar key)',
18951905 typeList: [ARGUMENT_TYPE.STRING],
18961906 defaultValue: '{{char}}',
@@ -1898,7 +1908,8 @@ function registerTagsSlashCommands() {
18981908 }),
18991909 ],
19001910 unnamedArgumentList: [
19011911 SlashCommandArgument.fromProps({ description: 'tag name',
1912+ description: 'tag name',
19021913 typeList: [ARGUMENT_TYPE.STRING],
19031914 isRequired: true,
19041915 /**@param {SlashCommandExecutor} executor */
@@ -2002,6 +2013,171 @@ function registerTagsSlashCommands() {
20022013 }));
20032014}
20042015
2016+/**
2017+ * Function to apply character tags to message divs when rendering the chat
2018+ * @param {object} options Options for applying character tags
2019+ * @param {number|number[]} [options.mesIds=[]] An id or array of message IDs to filter by.
2020+ * If empty, all messages will be processed.
2021+ * @returns {void}
2022+ * @description This function iterates through the chat messages and applies character tags
2023+ */
2024+export function applyCharacterTagsToMessageDivs({ mesIds = [] } = {}) {
2025+ try {
2026+ const messagesFilter = buildMessagesFilter(mesIds);
2027+ const messages = $('#chat').children(messagesFilter);
2028+
2029+ // Clear existing tags
2030+ messages.each(function () {
2031+ const element = this; // Get the raw DOM element
2032+
2033+ for (const attr of [...element.attributes]) {
2034+ if (attr.name.startsWith('data-char-tag-') || attr.name === 'data-char-tags') {
2035+ element.removeAttribute(attr.name);
2036+ }
2037+ }
2038+ });
2039+
2040+ const tagsList = tags, characterTagData = tag_map;
2041+
2042+ if (!tagsList?.length || !characterTagData) {
2043+ return;
2044+ }
2045+
2046+ const tagNamesById = tagsList.reduce((acc, tag) => {
2047+ acc[tag.id] = tag.name;
2048+ return acc;
2049+ }, {});
2050+
2051+ const characterTagsCache = new Map();
2052+
2053+ // Iterate each message div
2054+ messages.each(function () {
2055+ const $this = $(this); // Store the jQuery object
2056+ const avatarFileName = extractCharacterAvatar($this.find('.avatar img').attr('src'));
2057+
2058+ if (!avatarFileName) {
2059+ return;
2060+ }
2061+
2062+ let tagsForCharacter = characterTagsCache.get(avatarFileName);
2063+
2064+ // If tags are NOT in the cache, compute and store them
2065+ if (!tagsForCharacter) {
2066+ const tagIds = characterTagData[avatarFileName];
2067+ if (tagIds?.length) {
2068+ const tagNames = tagIds
2069+ .map(id => tagNamesById[id])
2070+ .filter(Boolean);
2071+
2072+ if (tagNames.length) {
2073+ tagsForCharacter = {
2074+ tagNames,
2075+ joinedTagNames: tagNames
2076+ .map(name => name?.replace(/,/g, ' ')) // replace commas with spaces to avoid issues with tag names containing commas
2077+ .join(','),
2078+ };
2079+ // Add the newly computed tags to the cache
2080+ characterTagsCache.set(avatarFileName, tagsForCharacter);
2081+ }
2082+ }
2083+ }
2084+
2085+ // If we have tags (either from cache or newly computed), apply them
2086+ if (tagsForCharacter) {
2087+ applyTags($this, tagsForCharacter);
2088+ }
2089+ });
2090+ } catch (error) {
2091+ console.error('Error applying character tags to message divs:', error);
2092+ }
2093+}
2094+
2095+/**
2096+ * Builds a jQuery selector string to filter messages by their IDs.
2097+ * @param {number|number[]} mesIds - An id or array of message IDs to filter by.
2098+ * @returns {string} A jQuery selector string that matches messages with the specified IDs.
2099+ * If mesIds is empty, it returns '.mes' to select all messages.
2100+ * @example
2101+ * buildMessagesFilter([1, 5]); // Returns '.mes[mesid="1"],.mes[mesid="5"]'
2102+ * buildMessagesFilter([]); // Returns '.mes'
2103+ */
2104+function buildMessagesFilter(mesIds) {
2105+ const allMessages = '.mes';
2106+
2107+ if (!mesIds) {
2108+ return allMessages; // If no mesIds provided, select all messages
2109+ }
2110+
2111+ const mesIdsArray = Array.isArray(mesIds) ? mesIds : [mesIds];
2112+
2113+ if (mesIdsArray?.length) {
2114+ // Create a valid jQuery selector for multiple attribute values.
2115+ // Example output: '.mes[mesid="1"],.mes[mesid="5"]'
2116+ return mesIdsArray.map(id => `.mes[mesid="${id}"]`).join(',');
2117+ }
2118+
2119+ // If mesIds is empty, select all messages.
2120+ return allMessages;
2121+}
2122+
2123+/**
2124+ * Helper function to apply all necessary data attributes to a DOM element.
2125+ * @param {JQuery<HTMLElement>} $element - The jQuery object for the message div.
2126+ * @param {object} tagData - An object containing tag information.
2127+ * @param {string[]} tagData.tagNames - An array of tag names.
2128+ * @param {string} tagData.joinedTagNames - A comma-separated string of tag names.
2129+ */
2130+function applyTags($element, tagData) {
2131+ $element.attr('data-char-tags', tagData.joinedTagNames);
2132+ tagData.tagNames.forEach(tagName => {
2133+ const normalizedTagName = normalizeTagName(tagName);
2134+
2135+ if (!normalizedTagName) {
2136+ return; // Skip empty tag names
2137+ }
2138+
2139+ $element.attr(`data-char-tag-${normalizedTagName}`, '');
2140+ });
2141+}
2142+
2143+/**
2144+ * Normalizes a tag name by trimming, converting spaces to hyphens, replacing accented characters,
2145+ * removing special characters, and converting to lowercase.
2146+ * @param {string} name The tag name to normalize.
2147+ * @returns {string} The normalized tag name.
2148+ */
2149+function normalizeTagName(name) {
2150+ if (!name?.trim()) {
2151+ return '';
2152+ }
2153+
2154+ // Normalize the tag name by trimming, converting spaces to hyphens, replacing accented characters, removing special characters, and converting to lowercase
2155+ return name.trim()
2156+ .normalize('NFD') // Normalize accented characters
2157+ .replace(/[\u0300-\u036f]/g, '') // Remove diacritical marks
2158+ .replace(/[^a-zA-Z0-9\s_-]/g, '') // Remove special characters except spaces, underscores, and hyphens
2159+ .replace(/[\s_]+/g, '-') // Replace spaces and underscores with hyphens
2160+ .toLowerCase();
2161+}
2162+
2163+/** Extracts the character avatar file name from the avatar source URL.
2164+ * @param {string} avatarSrc The source URL of the character avatar.
2165+ * @returns {string|null} The normalized avatar file name, or null if the input is falsy or doesn't contain a valid file name.
2166+ */
2167+function extractCharacterAvatar(avatarSrc) {
2168+ if (!avatarSrc) {
2169+ return null;
2170+ }
2171+
2172+ try {
2173+ const url = new URL(avatarSrc, window.location.origin);
2174+ return url?.searchParams.get('file');
2175+ } catch (error) {
2176+ console.error('Unable to parse character avatar using avatarSrc', avatarSrc, error);
2177+ return null;
2178+ }
2179+}
2180+
20052181export function initTags() {
20062182 createTagInput('#tagInput', '#tagList', { tagOptions: { removable: true } });
20072183 createTagInput('#groupTagInput', '#groupTagList', { tagOptions: { removable: true } });