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 -4Showing whitespace changes
public/script.js+3 -0
@@ -204,6 +204,7 @@ import {
204 applyTagsOnCharacterSelect,204 applyTagsOnCharacterSelect,
205 applyTagsOnGroupSelect,205 applyTagsOnGroupSelect,
206 tag_import_setting,206 tag_import_setting,
207 applyCharacterTagsToMessageDivs,
207} from './scripts/tags.js';208} from './scripts/tags.js';
208import {209import {
209 SECRET_KEYS,210 SECRET_KEYS,
@@ -2624,6 +2625,8 @@ export function addOneMessage(mes, { type = 'normal', insertAfter = null, scroll
2624 if (!insertAfter && !insertBefore && scroll) {2625 if (!insertAfter && !insertBefore && scroll) {
2625 scrollChatToBottom();2626 scrollChatToBottom();
2626 }2627 }
2628
2629 applyCharacterTagsToMessageDivs({ mesIds: newMessageId });
2627}2630}
26282631
2629/**2632/**
public/scripts/tags.js+180 -4
@@ -136,7 +136,7 @@ const TAG_FOLDER_DEFAULT_TYPE = 'NONE';
136 * @property {string} [color] - The background color of the tag136 * @property {string} [color] - The background color of the tag
137 * @property {string} [color2] - The foreground color of the tag137 * @property {string} [color2] - The foreground color of the tag
138 * @property {number} [create_date] - A number representing the date when this tag was created138 * @property {number} [create_date] - A number representing the date when this tag was created
139 * @property {boolean} is_hidden_on_character_card - Whether this tag is hidden on the character card139 * @property {boolean} [is_hidden_on_character_card] - Whether this tag is hidden on the character card
140 *140 *
141 * @property {function} [action] - An optional function that gets executed when this tag is an actionable tag and is clicked on.141 * @property {function} [action] - An optional function that gets executed when this tag is an actionable tag and is clicked on.
142 * @property {string} [class] - An optional css class added to the control representing this tag when printed. Used for custom tags in the filters.142 * @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
687 addTagsToEntity(tag, characterIds, { tagListSelector: listSelector, tagListOptions: tagListOptions });687 addTagsToEntity(tag, characterIds, { tagListSelector: listSelector, tagListOptions: tagListOptions });
688688
689 applyCharacterTagsToMessageDivs();
690
689 // need to return false to keep the input clear691 // need to return false to keep the input clear
690 return false;692 return false;
691}693}
@@ -1237,6 +1239,8 @@ function onTagRemoveClick(event) {
1237 const characterIds = characterData ? JSON.parse(characterData).characterIds : null;1239 const characterIds = characterData ? JSON.parse(characterData).characterIds : null;
12381240
1239 removeTagFromEntity(tag, characterIds, { tagElement: tagElement });1241 removeTagFromEntity(tag, characterIds, { tagElement: tagElement });
1242
1243 applyCharacterTagsToMessageDivs();
1240}1244}
12411245
1242// @ts-ignore1246// @ts-ignore
@@ -1718,6 +1722,8 @@ async function onTagDeleteClick() {
17181722
1719 printCharactersDebounced();1723 printCharactersDebounced();
1720 saveSettingsDebounced();1724 saveSettingsDebounced();
1725
1726 applyCharacterTagsToMessageDivs();
1721}1727}
17221728
1723function onTagRenameInput() {1729function onTagRenameInput() {
@@ -1728,6 +1734,8 @@ function onTagRenameInput() {
1728 $(this).attr('dirty', '');1734 $(this).attr('dirty', '');
1729 $(`.tag[id="${id}"] .tag_name`).text(newName);1735 $(`.tag[id="${id}"] .tag_name`).text(newName);
1730 saveSettingsDebounced();1736 saveSettingsDebounced();
1737
1738 applyCharacterTagsToMessageDivs();
1731}1739}
17321740
1733/**1741/**
@@ -1853,7 +1861,8 @@ function registerTagsSlashCommands() {
1853 }),1861 }),
1854 ],1862 ],
1855 unnamedArgumentList: [1863 unnamedArgumentList: [
1856 SlashCommandArgument.fromProps({ description: 'tag name',1864 SlashCommandArgument.fromProps({
1865 description: 'tag name',
1857 typeList: [ARGUMENT_TYPE.STRING],1866 typeList: [ARGUMENT_TYPE.STRING],
1858 isRequired: true,1867 isRequired: true,
1859 enumProvider: commonEnumProviders.tagsForChar('not-existing'),1868 enumProvider: commonEnumProviders.tagsForChar('not-existing'),
@@ -1890,7 +1899,8 @@ function registerTagsSlashCommands() {
1890 return String(result);1899 return String(result);
1891 },1900 },
1892 namedArgumentList: [1901 namedArgumentList: [
1893 SlashCommandNamedArgument.fromProps({ name: 'name',1902 SlashCommandNamedArgument.fromProps({
1903 name: 'name',
1894 description: 'Character name - or unique character identifier (avatar key)',1904 description: 'Character name - or unique character identifier (avatar key)',
1895 typeList: [ARGUMENT_TYPE.STRING],1905 typeList: [ARGUMENT_TYPE.STRING],
1896 defaultValue: '{{char}}',1906 defaultValue: '{{char}}',
@@ -1898,7 +1908,8 @@ function registerTagsSlashCommands() {
1898 }),1908 }),
1899 ],1909 ],
1900 unnamedArgumentList: [1910 unnamedArgumentList: [
1901 SlashCommandArgument.fromProps({ description: 'tag name',1911 SlashCommandArgument.fromProps({
1912 description: 'tag name',
1902 typeList: [ARGUMENT_TYPE.STRING],1913 typeList: [ARGUMENT_TYPE.STRING],
1903 isRequired: true,1914 isRequired: true,
1904 /**@param {SlashCommandExecutor} executor */1915 /**@param {SlashCommandExecutor} executor */
@@ -2002,6 +2013,171 @@ function registerTagsSlashCommands() {
2002 }));2013 }));
2003}2014}
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 */
2024export 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 */
2104function 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 */
2130function 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 */
2149function 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 */
2167function 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
2005export function initTags() {2181export function initTags() {
2006 createTagInput('#tagInput', '#tagList', { tagOptions: { removable: true } });2182 createTagInput('#tagInput', '#tagList', { tagOptions: { removable: true } });
2007 createTagInput('#groupTagInput', '#groupTagList', { tagOptions: { removable: true } });2183 createTagInput('#groupTagInput', '#groupTagList', { tagOptions: { removable: true } });