/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

0be48c567aaf94c922b77a0afaf2b70782d5ddc2

Wolfsblvt <wolfsblvt@gmail.com>

3 files changed, +138 -14Showing whitespace changes
public/scripts/slash-commands.js+123 -11
@@ -71,6 +71,7 @@ import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCom
71import { SlashCommandDebugController } from './slash-commands/SlashCommandDebugController.js';71import { SlashCommandDebugController } from './slash-commands/SlashCommandDebugController.js';
72import { SlashCommandBreakController } from './slash-commands/SlashCommandBreakController.js';72import { SlashCommandBreakController } from './slash-commands/SlashCommandBreakController.js';
73import { SlashCommandExecutionError } from './slash-commands/SlashCommandExecutionError.js';73import { SlashCommandExecutionError } from './slash-commands/SlashCommandExecutionError.js';
74import { getTagsList } from './tags.js';
74export {75export {
75 executeSlashCommands, executeSlashCommandsWithOptions, getSlashCommandsHelp, registerSlashCommand,76 executeSlashCommands, executeSlashCommandsWithOptions, getSlashCommandsHelp, registerSlashCommand,
76};77};
@@ -174,6 +175,65 @@ export function initDefaultSlashCommands() {
174 `,175 `,
175 }));176 }));
176 SlashCommandParser.addCommandObject(SlashCommand.fromProps({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 name: 'sendas',237 name: 'sendas',
178 callback: sendMessageAs,238 callback: sendMessageAs,
179 namedArgumentList: [239 namedArgumentList: [
@@ -3117,41 +3177,93 @@ async function setNarratorName(_, text) {
3117}3177}
31183178
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 */
3188export 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 */
3207export 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 * Finds a character by name, with optional filtering and precedence for avatars3218 * Finds a character by name, with optional filtering and precedence for avatars
3121 * @param {string} name - The name to search for
3122 * @param {object} [options={}] - The options for the search3219 * @param {object} [options={}] - The options for the search
3220 * @param {string?} [options.name=null] - The name to search for
3123 * @param {boolean} [options.allowAvatar=false] - Whether to allow searching by avatar3221 * @param {boolean} [options.allowAvatar=false] - Whether to allow searching by avatar
3124 * @param {boolean} [options.insensitive=true] - Whether the search should be case insensitive3222 * @param {boolean} [options.insensitive=true] - Whether the search should be case insensitive
3125 * @param {string[]?} [options.filteredByTags=null] - Tags to filter characters by3223 * @param {string[]?} [options.filteredByTags=null] - Tags to filter characters by
3126 * @param {any?} [options.preferCurrentChar=null] - The current character to prefer3224 * @param {boolean} [options.preferCurrentChar=false] - Whether to prefer the current character(s)
3225 * @param {boolean} [options.quiet=false] - Whether to suppress warnings
3127 * @returns {any?} - The found character or null if not found3226 * @returns {any?} - The found character or null if not found
3128 */3227 */
3129export function findCharByName(name, { allowAvatar = false, insensitive = true, filteredByTags = null, preferCurrentChar = null } = {}) {3228export function findChar({ name = null, allowAvatar = false, insensitive = true, filteredByTags = null, preferCurrentChar = false, quiet = false } = {}) {
3130 const matches = (char) => (allowAvatar && char.avatar === name) || insensitive ? equalsIgnoreCaseAndAccents(char.name, name) : char.name === name;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]];
31313233
3132 // If we have a current char and prefer it, return that if it matches - unless tags are provided, they have precedence3234 // If we have a current char and prefer it, return that if it matches - unless tags are provided, they have precedence
3133 if (preferCurrentChar && !filteredByTags && matches(preferCurrentChar)) {3235 if (preferCurrentChar && !filteredByTags) {
3134 return preferCurrentChar;3236 const preferredChar = currentChars.find(matches);
3237 if (preferredChar) {
3238 return preferredChar;
3239 }
3135 }3240 }
31363241
3137 // Filter characters by tags if provided3242 // Filter characters by tags if provided
3138 let filteredCharacters = characters;3243 let filteredCharacters = characters;
3139 if (filteredByTags) {3244 if (filteredByTags) {
3140 filteredCharacters = characters.filter(char => filteredByTags.every(tag => char.tags.includes(tag)));3245 filteredCharacters = characters.filter(char => {
3246 const charTags = getTagsList(char.avatar, false);
3247 return filteredByTags.every(tagName => charTags.some(x => x.name == tagName));
3248 });
3141 }3249 }
31423250
3143 // If allowAvatar is true, search by avatar first3251 // If allowAvatar is true, search by avatar first
3144 if (allowAvatar) {3252 if (allowAvatar && name) {
3145 const characterByAvatar = filteredCharacters.find(char => char.avatar === name);3253 const characterByAvatar = filteredCharacters.find(char => char.avatar === name);
3146 if (characterByAvatar) {3254 if (characterByAvatar) {
3147 return characterByAvatar;3255 return characterByAvatar;
3148 }3256 }
3149 }3257 }
31503258
3151 // Search for a matching character by name3259 // Search for matching characters by name
3152 let character = filteredCharacters.find(matches);3260 const matchingCharacters = name ? filteredCharacters.filter(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 }
31533265
3154 return character;3266 return matchingCharacters[0] || null;
3155}3267}
31563268
3157export async function sendMessageAs(args, text) {3269export async function sendMessageAs(args, text) {
public/scripts/slash-commands/SlashCommand.js+2 -2
@@ -15,13 +15,13 @@ import { SlashCommandScope } from './SlashCommandScope.js';
15 * _abortController:SlashCommandAbortController,15 * _abortController:SlashCommandAbortController,
16 * _debugController:SlashCommandDebugController,16 * _debugController:SlashCommandDebugController,
17 * _hasUnnamedArgument:boolean,17 * _hasUnnamedArgument:boolean,
18 * [id:string]:string|SlashCommandClosure|(string|SlashCommandClosure)[],18 * [id:string]:string|SlashCommandClosure|(string|SlashCommandClosure)[]|undefined,
19 * }} NamedArguments19 * }} NamedArguments
20 */20 */
2121
22/**22/**
23 * Alternative object for local JSDocs, where you don't need existing pipe, scope, etc. arguments23 * Alternative object for local JSDocs, where you don't need existing pipe, scope, etc. arguments
24 * @typedef {{[id:string]:string|SlashCommandClosure|(string|SlashCommandClosure)[]}} NamedArgumentsCapture24 * @typedef {{[id:string]:string|SlashCommandClosure|(string|SlashCommandClosure)[]|undefined}} NamedArgumentsCapture
25 */25 */
2626
27/**27/**
public/scripts/slash-commands/SlashCommandCommonEnumsProvider.js+13 -1
@@ -2,7 +2,7 @@ import { chat_metadata, characters, substituteParams, chat, extension_prompt_rol
2import { extension_settings } from '../extensions.js';2import { extension_settings } from '../extensions.js';
3import { getGroupMembers, groups } from '../group-chats.js';3import { getGroupMembers, groups } from '../group-chats.js';
4import { power_user } from '../power-user.js';4import { power_user } from '../power-user.js';
5import { searchCharByName, getTagsList, tags } from '../tags.js';5import { searchCharByName, getTagsList, tags, tag_map } from '../tags.js';
6import { world_names } from '../world-info.js';6import { world_names } from '../world-info.js';
7import { SlashCommandClosure } from './SlashCommandClosure.js';7import { SlashCommandClosure } from './SlashCommandClosure.js';
8import { SlashCommandEnumValue, enumTypes } from './SlashCommandEnumValue.js';8import { SlashCommandEnumValue, enumTypes } from './SlashCommandEnumValue.js';
@@ -182,6 +182,18 @@ export const commonEnumProviders = {
182 personas: () => Object.values(power_user.personas).map(persona => new SlashCommandEnumValue(persona, null, enumTypes.name, enumIcons.persona)),182 personas: () => Object.values(power_user.personas).map(persona => new SlashCommandEnumValue(persona, null, enumTypes.name, enumIcons.persona)),
183183
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 * All possible tags for a given char/group entity197 * All possible tags for a given char/group entity
186 *198 *
187 * @param {('all' | 'existing' | 'not-existing')?} [mode='all'] - Which types of tags to show199 * @param {('all' | 'existing' | 'not-existing')?} [mode='all'] - Which types of tags to show