/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
This commit is contained in:
Wolfsblvt 2024-09-29 00:36:13 +02:00
parent 20a82fb242
commit 0be48c567a
3 changed files with 138 additions and 14 deletions

View File

@ -71,6 +71,7 @@ import { commonEnumProviders, enumIcons } from './slash-commands/SlashCommandCom
import { SlashCommandDebugController } from './slash-commands/SlashCommandDebugController.js';
import { SlashCommandBreakController } from './slash-commands/SlashCommandBreakController.js';
import { SlashCommandExecutionError } from './slash-commands/SlashCommandExecutionError.js';
import { getTagsList } from './tags.js';
export {
executeSlashCommands, executeSlashCommandsWithOptions, getSlashCommandsHelp, registerSlashCommand,
};
@ -173,6 +174,65 @@ export function initDefaultSlashCommands() {
</div>
`,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'char-find',
aliases: ['findchar'],
callback: (args, name) => {
if (typeof name !== 'string') throw new Error('name must be a string');
if (args.preferCurrent instanceof SlashCommandClosure || Array.isArray(args.preferCurrent)) throw new Error('preferCurrent cannot be a closure or array');
const char = findChar({ name: name, filteredByTags: validateArrayArgString(args.tag, 'tag'), preferCurrentChar: isTrueBoolean(args.preferCurrent) });
return char?.avatar ?? '';
},
returns: 'the avatar key (unique identifier) of the character',
namedArgumentList: [
SlashCommandNamedArgument.fromProps({
name: 'tag',
description: 'Supply one or more tags to filter down to the correct character for the provided name, if multiple characters have the same name.',
typeList: [ARGUMENT_TYPE.STRING],
enumProvider: commonEnumProviders.tags('assigned'),
acceptsMultiple: true,
}),
SlashCommandNamedArgument.fromProps({
name: 'preferCurrent',
description: 'Prefer current character or characters in a group, if multiple characters match',
typeList: [ARGUMENT_TYPE.BOOLEAN],
defaultValue: 'true',
}),
],
unnamedArgumentList: [
SlashCommandArgument.fromProps({
description: 'Character name',
typeList: [ARGUMENT_TYPE.STRING],
enumProvider: commonEnumProviders.characters('character'),
forceEnum: false,
}),
],
helpString: `
<div>
Searches for a character and returns its avatar key.
</div>
<div>
This can be used to choose the correct character for something like <code>/sendas</code> or other commands in need of a character name
if you have multiple characters with the same name.
</div>
<div>
<strong>Example:</strong>
<ul>
<li>
<pre><code>/char-find name="Chloe"</code></pre>
Returns the avatar key for "Chloe".
</li>
<li>
<pre><code>/search name="Chloe" tag="friend"</code></pre>
Returns the avatar key for the character "Chloe" that is tagged with "friend".
This is useful if you for example have multiple characters named "Chloe", and the others are "foe", "goddess", or anything else,
so you can actually select the character you are looking for.
</li>
</ul>
</div>
`,
}));
SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'sendas',
callback: sendMessageAs,
@ -3116,42 +3176,94 @@ async function setNarratorName(_, text) {
return '';
}
/**
* Checks if an argument is a string array (or undefined), and if not, throws an error
* @param {string|SlashCommandClosure|(string|SlashCommandClosure)[]|undefined} arg The named argument to check
* @param {string} name The name of the argument for the error message
* @param {object} [options={}] - The optional arguments
* @param {boolean} [options.allowUndefined=false] - Whether the argument can be undefined
* @throws {Error} If the argument is not an array
* @returns {string[]}
*/
export function validateArrayArgString(arg, name, { allowUndefined = true } = {}) {
if (arg === undefined) {
if (allowUndefined) return undefined;
throw new Error(`Argument "${name}" is undefined, but must be a string array`);
}
if (!Array.isArray(arg)) throw new Error(`Argument "${name}" must be an array`);
if (!arg.every(x => typeof x === 'string')) throw new Error(`Argument "${name}" must be an array of strings`);
return arg;
}
/**
* Checks if an argument is a string or closure array (or undefined), and if not, throws an error
* @param {string|SlashCommandClosure|(string|SlashCommandClosure)[]|undefined} arg The named argument to check
* @param {string} name The name of the argument for the error message
* @param {object} [options={}] - The optional arguments
* @param {boolean} [options.allowUndefined=false] - Whether the argument can be undefined
* @throws {Error} If the argument is not an array of strings or closures
* @returns {(string|SlashCommandClosure)[]}
*/
export function validateArrayArg(arg, name, { allowUndefined = true } = {}) {
if (arg === undefined) {
if (allowUndefined) return [];
throw new Error(`Argument "${name}" is undefined, but must be an array of strings or closures`);
}
if (!Array.isArray(arg)) throw new Error(`Argument "${name}" must be an array`);
if (!arg.every(x => typeof x === 'string' || x instanceof SlashCommandClosure)) throw new Error(`Argument "${name}" must be an array of strings or closures`);
return arg;
}
/**
* Finds a character by name, with optional filtering and precedence for avatars
* @param {string} name - The name to search for
* @param {object} [options={}] - The options for the search
* @param {string?} [options.name=null] - The name to search for
* @param {boolean} [options.allowAvatar=false] - Whether to allow searching by avatar
* @param {boolean} [options.insensitive=true] - Whether the search should be case insensitive
* @param {string[]?} [options.filteredByTags=null] - Tags to filter characters by
* @param {any?} [options.preferCurrentChar=null] - The current character to prefer
* @param {boolean} [options.preferCurrentChar=false] - Whether to prefer the current character(s)
* @param {boolean} [options.quiet=false] - Whether to suppress warnings
* @returns {any?} - The found character or null if not found
*/
export function findCharByName(name, { allowAvatar = false, insensitive = true, filteredByTags = null, preferCurrentChar = null } = {}) {
const matches = (char) => (allowAvatar && char.avatar === name) || insensitive ? equalsIgnoreCaseAndAccents(char.name, name) : char.name === name;
export function findChar({ name = null, allowAvatar = false, insensitive = true, filteredByTags = null, preferCurrentChar = false, quiet = false } = {}) {
const matches = (char) => (allowAvatar && char.avatar === name) || (insensitive ? equalsIgnoreCaseAndAccents(char.name, name) : char.name === name);
// Get the current character(s)
const currentChars = selected_group ? groups.find(group => group.id === selected_group)?.members.map(member => characters.find(char => char.avatar === member)) : [characters[this_chid]];
// If we have a current char and prefer it, return that if it matches - unless tags are provided, they have precedence
if (preferCurrentChar && !filteredByTags && matches(preferCurrentChar)) {
return preferCurrentChar;
if (preferCurrentChar && !filteredByTags) {
const preferredChar = currentChars.find(matches);
if (preferredChar) {
return preferredChar;
}
}
// Filter characters by tags if provided
let filteredCharacters = characters;
if (filteredByTags) {
filteredCharacters = characters.filter(char => filteredByTags.every(tag => char.tags.includes(tag)));
filteredCharacters = characters.filter(char => {
const charTags = getTagsList(char.avatar, false);
return filteredByTags.every(tagName => charTags.some(x => x.name == tagName));
});
}
// If allowAvatar is true, search by avatar first
if (allowAvatar) {
if (allowAvatar && name) {
const characterByAvatar = filteredCharacters.find(char => char.avatar === name);
if (characterByAvatar) {
return characterByAvatar;
}
}
// Search for a matching character by name
let character = filteredCharacters.find(matches);
// Search for matching characters by name
const matchingCharacters = name ? filteredCharacters.filter(matches) : filteredCharacters;
if (matchingCharacters.length > 1) {
if (!quiet) toastr.warning(`Multiple characters found for name "${name}" and given conditions.`);
else console.warn(`Multiple characters found for name "${name}". Returning the first match.`);
}
return character;
return matchingCharacters[0] || null;
}
export async function sendMessageAs(args, text) {

View File

@ -15,13 +15,13 @@ import { SlashCommandScope } from './SlashCommandScope.js';
* _abortController:SlashCommandAbortController,
* _debugController:SlashCommandDebugController,
* _hasUnnamedArgument:boolean,
* [id:string]:string|SlashCommandClosure|(string|SlashCommandClosure)[],
* [id:string]:string|SlashCommandClosure|(string|SlashCommandClosure)[]|undefined,
* }} NamedArguments
*/
/**
* Alternative object for local JSDocs, where you don't need existing pipe, scope, etc. arguments
* @typedef {{[id:string]:string|SlashCommandClosure|(string|SlashCommandClosure)[]}} NamedArgumentsCapture
* @typedef {{[id:string]:string|SlashCommandClosure|(string|SlashCommandClosure)[]|undefined}} NamedArgumentsCapture
*/
/**

View File

@ -2,7 +2,7 @@ import { chat_metadata, characters, substituteParams, chat, extension_prompt_rol
import { extension_settings } from '../extensions.js';
import { getGroupMembers, groups } from '../group-chats.js';
import { power_user } from '../power-user.js';
import { searchCharByName, getTagsList, tags } from '../tags.js';
import { searchCharByName, getTagsList, tags, tag_map } from '../tags.js';
import { world_names } from '../world-info.js';
import { SlashCommandClosure } from './SlashCommandClosure.js';
import { SlashCommandEnumValue, enumTypes } from './SlashCommandEnumValue.js';
@ -181,6 +181,18 @@ export const commonEnumProviders = {
*/
personas: () => Object.values(power_user.personas).map(persona => new SlashCommandEnumValue(persona, null, enumTypes.name, enumIcons.persona)),
/**
* All possible tags, or only those that have been assigned
*
* @param {('all' | 'assigned')} [mode='all'] - Which types of tags to show
* @returns {() => SlashCommandEnumValue[]}
*/
tags: (mode = 'all') => () => {
let assignedTags = mode === 'assigned' ? new Set(Object.values(tag_map).flat()) : new Set();
return tags.filter(tag => mode === 'all' || (mode === 'assigned' && assignedTags.has(tag.id)))
.map(tag => new SlashCommandEnumValue(tag.name, null, enumTypes.command, enumIcons.tag));
},
/**
* All possible tags for a given char/group entity
*