diff --git a/public/css/tags.css b/public/css/tags.css index 9075f659e..f9896d992 100644 --- a/public/css/tags.css +++ b/public/css/tags.css @@ -103,7 +103,8 @@ } #bulkTagsList, -#tagList .tag { +#tagList .tag, +#groupTagList .tag { opacity: 0.6; } diff --git a/public/scripts/tags.js b/public/scripts/tags.js index 82583c5c0..f39e1e926 100644 --- a/public/scripts/tags.js +++ b/public/scripts/tags.js @@ -14,10 +14,12 @@ import { // eslint-disable-next-line no-unused-vars import { FILTER_TYPES, FILTER_STATES, DEFAULT_FILTER_STATE, isFilterState, FilterHelper } from './filters.js'; -import { groupCandidatesFilter, groups, selected_group } from './group-chats.js'; +import { groupCandidatesFilter, groups, select_group_chats, selected_group } from './group-chats.js'; import { download, onlyUnique, parseJsonFile, uuidv4, getSortableDelay, flashHighlight } from './utils.js'; import { power_user } from './power-user.js'; -import { debounce_timeout } from './constants.js'; +import { SlashCommandParser } from './slash-commands/SlashCommandParser.js'; +import { SlashCommand } from './slash-commands/SlashCommand.js'; +import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js'; export { TAG_FOLDER_TYPES, @@ -357,7 +359,7 @@ function createTagMapFromList(listElement, key) { * If you have an entity, you can get it's key easily via `getTagKeyForEntity(entity)`. * * @param {string} key - The key for which to get tags via the tag map - * @param {boolean} [sort=true] - + * @param {boolean} [sort=true] - Whether the tag list should be sorted * @returns {Tag[]} A list of tags */ function getTagsList(key, sort = true) { @@ -463,35 +465,122 @@ export function getTagKeyForEntityElement(element) { return undefined; } +/** + * Adds a tag to a given entity + * @param {Tag} tag - The tag to add + * @param {string|string[]} entityId - The entity to add this tag to. Has to be the entity key (e.g. `addTagToEntity`). (Also allows multiple entities to be passed in) + * @param {object} [options={}] - Optional arguments + * @param {JQuery|string?} [options.tagListSelector=null] - An optional selector if a specific list should be updated with the new tag too (for example because the add was triggered for that function) + * @param {PrintTagListOptions} [options.tagListOptions] - Optional parameters for printing the tag list. Can be set to be consistent with the expected behavior of tags in the list that was defined before. + * @returns {boolean} Whether at least one tag was added + */ +export function addTagToEntity(tag, entityId, { tagListSelector = null, tagListOptions = {} } = {}) { + let result = false; + // Add tags to the map + if (Array.isArray(entityId)) { + entityId.forEach((id) => result = addTagToMap(tag.id, id) || result); + } else { + result = addTagToMap(tag.id, entityId); + } + + // Save and redraw + printCharactersDebounced(); + saveSettingsDebounced(); + + // We should manually add the selected tag to the print tag function, so we cover places where the tag list did not automatically include it + tagListOptions.addTag = tag; + + // add tag to the UI and internal map - we reprint so sorting and new markup is done correctly + if (tagListSelector) printTagList(tagListSelector, tagListOptions); + const inlineSelector = getInlineListSelector(); + if (inlineSelector) { + printTagList($(inlineSelector), tagListOptions); + } + + return result; +} + +/** + * Removes a tag from a given entity + * @param {Tag} tag - The tag to remove + * @param {string|string[]} entityId - The entity to remove this tag from. Has to be the entity key (e.g. `addTagToEntity`). (Also allows multiple entities to be passed in) + * @param {object} [options={}] - Optional arguments + * @param {JQuery|string?} [options.tagListSelector=null] - An optional selector if a specific list should be updated with the tag removed too (for example because the add was triggered for that function) + * @param {JQuery?} [options.tagElement=null] - Optionally a direct html element of the tag to be removed, so it can be removed from the UI + * @returns {boolean} Whether at least one tag was removed + */ +export function removeTagFromEntity(tag, entityId, { tagListSelector = null, tagElement = null } = {}) { + let result = false; + // Remove tag from the map + if (Array.isArray(entityId)) { + entityId.forEach((id) => result = removeTagFromMap(tag.id, id) || result); + } else { + result = removeTagFromMap(tag.id, entityId); + } + + // Save and redraw + printCharactersDebounced(); + saveSettingsDebounced(); + + // We don't reprint the lists, we can just remove the html elements from them. + if (tagListSelector) { + const $selector = (typeof tagListSelector === 'string') ? $(tagListSelector) : tagListSelector; + $selector.find(`.tag[id="${tag.id}"]`).remove(); + } + if (tagElement) tagElement.remove(); + $(`${getInlineListSelector()} .tag[id="${tag.id}"]`).remove(); + + return result; +} + +/** + * Adds a tag from a given character. If no character is provided, adds it from the currently active one. + * @param {string} tagId - The id of the tag + * @param {string} characterId - The id/key of the character or group + * @returns {boolean} Whether the tag was added or not + */ function addTagToMap(tagId, characterId = null) { const key = characterId !== null && characterId !== undefined ? getTagKeyForEntity(characterId) : getTagKey(); if (!key) { - return; + return false; } if (!Array.isArray(tag_map[key])) { tag_map[key] = [tagId]; + return true; } else { + if (tag_map[key].includes(tagId)) + return false; + tag_map[key].push(tagId); tag_map[key] = tag_map[key].filter(onlyUnique); + return true; } } +/** + * Removes a tag from a given character. If no character is provided, removes it from the currently active one. + * @param {string} tagId - The id of the tag + * @param {string} characterId - The id/key of the character or group + * @returns {boolean} Whether the tag was removed or not + */ function removeTagFromMap(tagId, characterId = null) { const key = characterId !== null && characterId !== undefined ? getTagKeyForEntity(characterId) : getTagKey(); if (!key) { - return; + return false; } if (!Array.isArray(tag_map[key])) { tag_map[key] = []; + return false; } else { const indexOf = tag_map[key].indexOf(tagId); tag_map[key].splice(indexOf, 1); + return indexOf !== -1; } } @@ -535,24 +624,7 @@ function selectTag(event, ui, listSelector, { tagListOptions = {} } = {}) { const characterData = event.target.closest('#bulk_tags_div')?.dataset.characters; const characterIds = characterData ? JSON.parse(characterData).characterIds : null; - if (characterIds) { - characterIds.forEach((characterId) => addTagToMap(tag.id, characterId)); - } else { - addTagToMap(tag.id); - } - - printCharactersDebounced(); - saveSettingsDebounced(); - - // We should manually add the selected tag to the print tag function, so we cover places where the tag list did not automatically include it - tagListOptions.addTag = tag; - - // add tag to the UI and internal map - we reprint so sorting and new markup is done correctly - printTagList(listSelector, tagListOptions); - const inlineSelector = getInlineListSelector(); - if (inlineSelector) { - printTagList($(inlineSelector), tagListOptions); - } + addTagToEntity(tag, characterIds, { tagListSelector: listSelector, tagListOptions: tagListOptions }); // need to return false to keep the input clear return false; @@ -635,6 +707,7 @@ function createNewTag(tagName) { create_date: Date.now(), }; tags.push(tag); + console.debug('Created new tag', tag.name, 'with id', tag.id); return tag; } @@ -932,8 +1005,8 @@ function updateTagFilterIndicator() { function onTagRemoveClick(event) { event.stopPropagation(); - const tag = $(this).closest('.tag'); - const tagId = tag.attr('id'); + const tagElement = $(this).closest('.tag'); + const tagId = tagElement.attr('id'); // Check if we are inside the drilldown. If so, we call remove on the bogus folder if ($(this).closest('.rm_tag_bogus_drilldown').length > 0) { @@ -942,24 +1015,13 @@ function onTagRemoveClick(event) { return; } + const tag = tags.find(t => t.id === tagId); + // Optional, check for multiple character ids being present. const characterData = event.target.closest('#bulk_tags_div')?.dataset.characters; const characterIds = characterData ? JSON.parse(characterData).characterIds : null; - tag.remove(); - - if (characterIds) { - characterIds.forEach((characterId) => removeTagFromMap(tagId, characterId)); - } else { - removeTagFromMap(tagId); - } - - $(`${getInlineListSelector()} .tag[id="${tagId}"]`).remove(); - - printCharactersDebounced(); - saveSettingsDebounced(); - - + removeTagFromEntity(tag, characterIds, { tagElement: tagElement }); } // @ts-ignore @@ -1461,6 +1523,194 @@ function printViewTagList(empty = true) { } } +function registerTagsSlashCommands() { + /** + * Gets the key for char/group for a slash command. If none can be found, a toastr will be shown and null returned. + * @param {string?} [charName] The optionally provided char name + * @returns {string?} - The char/group key, or null if none found + */ + function paraGetCharKey(charName) { + const entity = charName + ? (characters.find(x => x.name === charName) || groups.find(x => x.name == charName)) + : (selected_group ? groups.find(x => x.id == selected_group) : characters[this_chid]); + const key = getTagKeyForEntity(entity); + if (!key) { + toastr.warning(`Character ${charName} not found.`); + return null; + } + return key; + } + /** + * Gets a tag by its name. Optionally can create the tag if it does not exist. + * @param {string} tagName - The name of the tag + * @param {object} options - Optional arguments + * @param {boolean} [options.allowCreate=false] - Whether a new tag should be created if no tag with the name exists + * @returns {Tag?} The tag, or null if not found + */ + function paraGetTag(tagName, { allowCreate = false } = {}) { + if (!tagName) { + toastr.warning('Tag name must be provided.'); + return null; + } + let tag = tags.find(t => t.name === tagName); + if (allowCreate && !tag) { + tag = createNewTag(tagName); + } + if (!tag) { + toastr.warning(`Tag ${tagName} not found.`); + return null; + } + return tag; + } + + function updateTagsList() { + switch (menu_type) { + case 'characters': + printTagFilters(tag_filter_types.character); + printTagFilters(tag_filter_types.group_member); + break; + case 'character_edit': + applyTagsOnCharacterSelect(); + break; + case 'group_edit': + select_group_chats(selected_group, true); + break; + } + } + + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'tag-add', + returns: 'true/false - Whether the tag was added or was assigned already', + /** @param {{name: string}} namedArgs @param {string} tagName @returns {string} */ + callback: ({ name }, tagName) => { + const key = paraGetCharKey(name); + if (!key) return 'false'; + const tag = paraGetTag(tagName, { allowCreate: true }); + if (!tag) return 'false'; + const result = addTagToEntity(tag, key); + updateTagsList(); + return String(result); + }, + namedArgumentList: [ + new SlashCommandNamedArgument('name', 'Character name', [ARGUMENT_TYPE.STRING], false, false, '{{char}}'), + ], + unnamedArgumentList: [ + new SlashCommandArgument('tag name', [ARGUMENT_TYPE.STRING], true), + ], + helpString: ` +
+ Adds a tag to the character. If no character is provided, it adds it to the current character ({{char}}). + If the tag doesn't exist, it is created. +
+
+ Example: +
    +
  • +
    /tag-add name="Chloe" scenario
    + will add the tag "scenario" to the character named Chloe. +
  • +
+
+ `, + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'tag-remove', + returns: 'true/false - Whether the tag was removed or wasn\'t assigned already', + /** @param {{name: string}} namedArgs @param {string} tagName @returns {string} */ + callback: ({ name }, tagName) => { + const key = paraGetCharKey(name); + if (!key) return 'false'; + const tag = paraGetTag(tagName); + if (!tag) return 'false'; + const result = removeTagFromEntity(tag, key); + updateTagsList(); + return String(result); + }, + namedArgumentList: [ + new SlashCommandNamedArgument('name', 'Character name', [ARGUMENT_TYPE.STRING], false, false, '{{char}}'), + ], + unnamedArgumentList: [ + new SlashCommandArgument('tag name', [ARGUMENT_TYPE.STRING], true), + ], + helpString: ` +
+ Removes a tag from the character. If no character is provided, it removes it from the current character ({{char}}). +
+
+ Example: +
    +
  • +
    /tag-remove name="Chloe" scenario
    + will remove the tag "scenario" from the character named Chloe. +
  • +
+
+ `, + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'tag-exists', + returns: 'true/false - Whether the given tag name is assigned to the character', + /** @param {{name: string}} namedArgs @param {string} tagName @returns {string} */ + callback: ({ name }, tagName) => { + const key = paraGetCharKey(name); + if (!key) return 'false'; + const tag = paraGetTag(tagName); + if (!tag) return 'false'; + return String(tag_map[key].includes(tag.id)); + }, + namedArgumentList: [ + new SlashCommandNamedArgument('name', 'Character name', [ARGUMENT_TYPE.STRING], false, false, '{{char}}'), + ], + unnamedArgumentList: [ + new SlashCommandArgument('tag name', [ARGUMENT_TYPE.STRING], true), + ], + helpString: ` +
+ Checks whether the given tag is assigned to the character. If no character is provided, it checks the current character ({{char}}). +
+
+ Example: +
    +
  • +
    /tag-exists name="Chloe" scenario
    + will return true if the character named Chloe has the tag "scenario". +
  • +
+
+ `, + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ + name: 'tag-list', + returns: 'Comma-separated list of all assigned tags', + /** @param {{name: string}} namedArgs @returns {string} */ + callback: ({ name }) => { + const key = paraGetCharKey(name); + if (!key) return ''; + const tags = getTagsList(key); + return tags.map(x => x.name).join(', '); + }, + namedArgumentList: [ + new SlashCommandNamedArgument('name', 'Character name', [ARGUMENT_TYPE.STRING], false, false, '{{char}}'), + ], + helpString: ` +
+ Lists all assigned tags of the character. If no character is provided, it uses the current character ({{char}}). +
+ Note that there is no special handling for tags containing commas, they will be printed as-is. +
+
+ Example: +
    +
  • +
    /tag-list name="Chloe"
    + could return something like OC, scenario, edited, funny +
  • +
+
+ `, + })); +} + export function initTags() { createTagInput('#tagInput', '#tagList', { tagOptions: { removable: true } }); createTagInput('#groupTagInput', '#groupTagList', { tagOptions: { removable: true } }); @@ -1505,4 +1755,6 @@ export function initTags() { printCharactersDebounced(); } } + + registerTagsSlashCommands(); } diff --git a/public/style.css b/public/style.css index 9d9e99006..71f885fe1 100644 --- a/public/style.css +++ b/public/style.css @@ -2068,6 +2068,7 @@ input[type="file"] { gap: 5px; justify-content: center; align-items: center; + flex-wrap: wrap; } .bulk_select_checkbox { @@ -4892,4 +4893,4 @@ body:not(.movingUI) .drawer-content.maximized { .regex-operator { color: #FFB6C1; } /* Light Pink */ .regex-flags { color: #98FB98; } /* Pale Green */ .regex-delimiter { font-weight: bold; color: #FF6961; } /* Pastel Red */ -.regex-highlight { color: #FAF8F6; } /* Pastel White */ \ No newline at end of file +.regex-highlight { color: #FAF8F6; } /* Pastel White */