/char-find command to get a specific unique char - findChar utility function that does the heavy lifting of finding a specific char based on conditions - Log/warn if multiple characters match - Validation function for named args that should be arrays
| @@ -71,6 +71,7 @@ import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCom | ||
| 71 | 71 | import { SlashCommandDebugController } from './slash-commands/SlashCommandDebugController.js'; |
| 72 | 72 | import { SlashCommandBreakController } from './slash-commands/SlashCommandBreakController.js'; |
| 73 | 73 | import { SlashCommandExecutionError } from './slash-commands/SlashCommandExecutionError.js'; |
| 74 | +import { getTagsList } from './tags.js'; | |
| 74 | 75 | export { |
| 75 | 76 | executeSlashCommands, executeSlashCommandsWithOptions, getSlashCommandsHelp, registerSlashCommand, |
| 76 | 77 | }; |
| @@ -174,6 +175,65 @@ export function initDefaultSlashCommands() { | ||
| 174 | 175 | `, |
| 175 | 176 | })); |
| 176 | 177 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 178 | + name: 'char-find', | |
| 179 | + aliases: ['findchar'], | |
| 180 | + callback: (args, name) => { | |
| 181 | + if (typeof name !== 'string') throw new Error('name must be a string'); | |
| 182 | + if (args.preferCurrent instanceof SlashCommandClosure || Array.isArray(args.preferCurrent)) throw new Error('preferCurrent cannot be a closure or array'); | |
| 183 | + | |
| 184 | + const char = findChar({ name: name, filteredByTags: validateArrayArgString(args.tag, 'tag'), preferCurrentChar: isTrueBoolean(args.preferCurrent) }); | |
| 185 | + return char?.avatar ?? ''; | |
| 186 | + }, | |
| 187 | + returns: 'the avatar key (unique identifier) of the character', | |
| 188 | + namedArgumentList: [ | |
| 189 | + SlashCommandNamedArgument.fromProps({ | |
| 190 | + name: 'tag', | |
| 191 | + description: 'Supply one or more tags to filter down to the correct character for the provided name, if multiple characters have the same name.', | |
| 192 | + typeList: [ARGUMENT_TYPE.STRING], | |
| 193 | + enumProvider: commonEnumProviders.tags('assigned'), | |
| 194 | + acceptsMultiple: true, | |
| 195 | + }), | |
| 196 | + SlashCommandNamedArgument.fromProps({ | |
| 197 | + name: 'preferCurrent', | |
| 198 | + description: 'Prefer current character or characters in a group, if multiple characters match', | |
| 199 | + typeList: [ARGUMENT_TYPE.BOOLEAN], | |
| 200 | + defaultValue: 'true', | |
| 201 | + }), | |
| 202 | + ], | |
| 203 | + unnamedArgumentList: [ | |
| 204 | + SlashCommandArgument.fromProps({ | |
| 205 | + description: 'Character name', | |
| 206 | + typeList: [ARGUMENT_TYPE.STRING], | |
| 207 | + enumProvider: commonEnumProviders.characters('character'), | |
| 208 | + forceEnum: false, | |
| 209 | + }), | |
| 210 | + ], | |
| 211 | + helpString: ` | |
| 212 | + <div> | |
| 213 | + Searches for a character and returns its avatar key. | |
| 214 | + </div> | |
| 215 | + <div> | |
| 216 | + This can be used to choose the correct character for something like <code>/sendas</code> or other commands in need of a character name | |
| 217 | + if you have multiple characters with the same name. | |
| 218 | + </div> | |
| 219 | + <div> | |
| 220 | + <strong>Example:</strong> | |
| 221 | + <ul> | |
| 222 | + <li> | |
| 223 | + <pre><code>/char-find name="Chloe"</code></pre> | |
| 224 | + Returns the avatar key for "Chloe". | |
| 225 | + </li> | |
| 226 | + <li> | |
| 227 | + <pre><code>/search name="Chloe" tag="friend"</code></pre> | |
| 228 | + Returns the avatar key for the character "Chloe" that is tagged with "friend". | |
| 229 | + This is useful if you for example have multiple characters named "Chloe", and the others are "foe", "goddess", or anything else, | |
| 230 | + so you can actually select the character you are looking for. | |
| 231 | + </li> | |
| 232 | + </ul> | |
| 233 | + </div> | |
| 234 | + `, | |
| 235 | + })); | |
| 236 | + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ | |
| 177 | 237 | name: 'sendas', |
| 178 | 238 | callback: sendMessageAs, |
| 179 | 239 | namedArgumentList: [ |
| @@ -3117,41 +3177,93 @@ async function setNarratorName(_, text) { | ||
| 3117 | 3177 | } |
| 3118 | 3178 | |
| 3119 | 3179 | /** |
| 3180 | + * Checks if an argument is a string array (or undefined), and if not, throws an error | |
| 3181 | + * @param {string|SlashCommandClosure|(string|SlashCommandClosure)[]|undefined} arg The named argument to check | |
| 3182 | + * @param {string} name The name of the argument for the error message | |
| 3183 | + * @param {object} [options={}] - The optional arguments | |
| 3184 | + * @param {boolean} [options.allowUndefined=false] - Whether the argument can be undefined | |
| 3185 | + * @throws {Error} If the argument is not an array | |
| 3186 | + * @returns {string[]} | |
| 3187 | + */ | |
| 3188 | +export function validateArrayArgString(arg, name, { allowUndefined = true } = {}) { | |
| 3189 | + if (arg === undefined) { | |
| 3190 | + if (allowUndefined) return undefined; | |
| 3191 | + throw new Error(`Argument "${name}" is undefined, but must be a string array`); | |
| 3192 | + } | |
| 3193 | + if (!Array.isArray(arg)) throw new Error(`Argument "${name}" must be an array`); | |
| 3194 | + if (!arg.every(x => typeof x === 'string')) throw new Error(`Argument "${name}" must be an array of strings`); | |
| 3195 | + return arg; | |
| 3196 | +} | |
| 3197 | + | |
| 3198 | +/** | |
| 3199 | + * Checks if an argument is a string or closure array (or undefined), and if not, throws an error | |
| 3200 | + * @param {string|SlashCommandClosure|(string|SlashCommandClosure)[]|undefined} arg The named argument to check | |
| 3201 | + * @param {string} name The name of the argument for the error message | |
| 3202 | + * @param {object} [options={}] - The optional arguments | |
| 3203 | + * @param {boolean} [options.allowUndefined=false] - Whether the argument can be undefined | |
| 3204 | + * @throws {Error} If the argument is not an array of strings or closures | |
| 3205 | + * @returns {(string|SlashCommandClosure)[]} | |
| 3206 | + */ | |
| 3207 | +export function validateArrayArg(arg, name, { allowUndefined = true } = {}) { | |
| 3208 | + if (arg === undefined) { | |
| 3209 | + if (allowUndefined) return []; | |
| 3210 | + throw new Error(`Argument "${name}" is undefined, but must be an array of strings or closures`); | |
| 3211 | + } | |
| 3212 | + if (!Array.isArray(arg)) throw new Error(`Argument "${name}" must be an array`); | |
| 3213 | + if (!arg.every(x => typeof x === 'string' || x instanceof SlashCommandClosure)) throw new Error(`Argument "${name}" must be an array of strings or closures`); | |
| 3214 | + return arg; | |
| 3215 | +} | |
| 3216 | + | |
| 3217 | +/** | |
| 3120 | 3218 | * Finds a character by name, with optional filtering and precedence for avatars |
| 3121 | - * @param {string} name - The name to search for | |
| 3122 | 3219 | * @param {object} [options={}] - The options for the search |
| 3220 | + * @param {string?} [options.name=null] - The name to search for | |
| 3123 | 3221 | * @param {boolean} [options.allowAvatar=false] - Whether to allow searching by avatar |
| 3124 | 3222 | * @param {boolean} [options.insensitive=true] - Whether the search should be case insensitive |
| 3125 | 3223 | * @param {string[]?} [options.filteredByTags=null] - Tags to filter characters by |
| 3126 | 3224 | * @param {any?boolean} [options.preferCurrentChar=nullfalse] - The current characterWhether to prefer the current character(s) |
| 3225 | + * @param {boolean} [options.quiet=false] - Whether to suppress warnings | |
| 3127 | 3226 | * @returns {any?} - The found character or null if not found |
| 3128 | 3227 | */ |
| 3129 | 3228 | export function findCharByNamefindChar({ name, {= null, allowAvatar = false, insensitive = true, filteredByTags = null, preferCurrentChar = nullfalse, quiet = false } = {}) { |
| 3130 | 3229 | const matches = (char) => (allowAvatar && char.avatar === name) || (insensitive ? equalsIgnoreCaseAndAccents(char.name, name) : char.name === name); |
| 3230 | + | |
| 3231 | + // Get the current character(s) | |
| 3232 | + const currentChars = selected_group ? groups.find(group => group.id === selected_group)?.members.map(member => characters.find(char => char.avatar === member)) : [characters[this_chid]]; | |
| 3131 | 3233 | |
| 3132 | 3234 | // If we have a current char and prefer it, return that if it matches - unless tags are provided, they have precedence |
| 3133 | 3235 | if (preferCurrentChar && !filteredByTags && matches(preferCurrentChar)) { |
| 3134 | - return preferCurrentChar; | |
| 3236 | + const preferredChar = currentChars.find(matches); | |
| 3237 | + if (preferredChar) { | |
| 3238 | + return preferredChar; | |
| 3239 | + } | |
| 3135 | 3240 | } |
| 3136 | 3241 | |
| 3137 | 3242 | // Filter characters by tags if provided |
| 3138 | 3243 | let filteredCharacters = characters; |
| 3139 | 3244 | if (filteredByTags) { |
| 3140 | 3245 | filteredCharacters = characters.filter(char => filteredByTags.every(tag => char.tags.includes(tag)));{ |
| 3246 | + const charTags = getTagsList(char.avatar, false); | |
| 3247 | + return filteredByTags.every(tagName => charTags.some(x => x.name == tagName)); | |
| 3248 | + }); | |
| 3141 | 3249 | } |
| 3142 | 3250 | |
| 3143 | 3251 | // If allowAvatar is true, search by avatar first |
| 3144 | 3252 | if (allowAvatar && name) { |
| 3145 | 3253 | const characterByAvatar = filteredCharacters.find(char => char.avatar === name); |
| 3146 | 3254 | if (characterByAvatar) { |
| 3147 | 3255 | return characterByAvatar; |
| 3148 | 3256 | } |
| 3149 | 3257 | } |
| 3150 | 3258 | |
| 3151 | 3259 | // Search for a matching charactercharacters by name |
| 3152 | 3260 | letconst charactermatchingCharacters = name ? filteredCharacters.findfilter(matches) : filteredCharacters; |
| 3261 | + if (matchingCharacters.length > 1) { | |
| 3262 | + if (!quiet) toastr.warning(`Multiple characters found for name "${name}" and given conditions.`); | |
| 3263 | + else console.warn(`Multiple characters found for name "${name}". Returning the first match.`); | |
| 3264 | + } | |
| 3153 | 3265 | |
| 3154 | - return character; | |
| 3266 | + return matchingCharacters[0] || null; | |
| 3155 | 3267 | } |
| 3156 | 3268 | |
| 3157 | 3269 | export async function sendMessageAs(args, text) { |
| @@ -15,13 +15,13 @@ import { SlashCommandScope } from './SlashCommandScope.js'; | ||
| 15 | 15 | * _abortController:SlashCommandAbortController, |
| 16 | 16 | * _debugController:SlashCommandDebugController, |
| 17 | 17 | * _hasUnnamedArgument:boolean, |
| 18 | 18 | * [id:string]:string|SlashCommandClosure|(string|SlashCommandClosure)[]|undefined, |
| 19 | 19 | * }} NamedArguments |
| 20 | 20 | */ |
| 21 | 21 | |
| 22 | 22 | /** |
| 23 | 23 | * Alternative object for local JSDocs, where you don't need existing pipe, scope, etc. arguments |
| 24 | 24 | * @typedef {{[id:string]:string|SlashCommandClosure|(string|SlashCommandClosure)[]|undefined}} NamedArgumentsCapture |
| 25 | 25 | */ |
| 26 | 26 | |
| 27 | 27 | /** |
| @@ -2,7 +2,7 @@ import { chat_metadata, characters, substituteParams, chat, extension_prompt_rol | ||
| 2 | 2 | import { extension_settings } from '../extensions.js'; |
| 3 | 3 | import { getGroupMembers, groups } from '../group-chats.js'; |
| 4 | 4 | import { power_user } from '../power-user.js'; |
| 5 | 5 | import { searchCharByName, getTagsList, tags, tag_map } from '../tags.js'; |
| 6 | 6 | import { world_names } from '../world-info.js'; |
| 7 | 7 | import { SlashCommandClosure } from './SlashCommandClosure.js'; |
| 8 | 8 | import { SlashCommandEnumValue, enumTypes } from './SlashCommandEnumValue.js'; |
| @@ -182,6 +182,18 @@ export const commonEnumProviders = { | ||
| 182 | 182 | personas: () => Object.values(power_user.personas).map(persona => new SlashCommandEnumValue(persona, null, enumTypes.name, enumIcons.persona)), |
| 183 | 183 | |
| 184 | 184 | /** |
| 185 | + * All possible tags, or only those that have been assigned | |
| 186 | + * | |
| 187 | + * @param {('all' | 'assigned')} [mode='all'] - Which types of tags to show | |
| 188 | + * @returns {() => SlashCommandEnumValue[]} | |
| 189 | + */ | |
| 190 | + tags: (mode = 'all') => () => { | |
| 191 | + let assignedTags = mode === 'assigned' ? new Set(Object.values(tag_map).flat()) : new Set(); | |
| 192 | + return tags.filter(tag => mode === 'all' || (mode === 'assigned' && assignedTags.has(tag.id))) | |
| 193 | + .map(tag => new SlashCommandEnumValue(tag.name, null, enumTypes.command, enumIcons.tag)); | |
| 194 | + }, | |
| 195 | + | |
| 196 | + /** | |
| 185 | 197 | * All possible tags for a given char/group entity |
| 186 | 198 | * |
| 187 | 199 | * @param {('all' | 'existing' | 'not-existing')?} [mode='all'] - Which types of tags to show |