Rework expression slash commands - Common naming schema for slash commands, all starting with the name of the expression - moved the original names to aliases - Make char name optional for /expression-last if not in group chat - Removed legacy 'format' argument handling from /expression-classify - Fixed /expression-upload to the new backend call, added optional 'spriteName' argument

d316d51c0be9b1e66ec2b85c74cfc6b102198f0c

Wolfsblvt <wolfsblvt@gmail.com>

1 files changed, +82 -63Showing whitespace changes
public/scripts/extensions/expressions/index.js+82 -63
@@ -1,11 +1,11 @@
1import { Fuse } from '../../../lib.js';1import { Fuse } from '../../../lib.js';
22
3import { eventSource, event_types, generateRaw, getRequestHeaders, main_api, online_status, saveSettingsDebounced, substituteParams, substituteParamsExtended, system_message_types } from '../../../script.js';3import { characters, eventSource, event_types, generateRaw, getRequestHeaders, main_api, online_status, saveSettingsDebounced, substituteParams, substituteParamsExtended, system_message_types, this_chid } from '../../../script.js';
4import { dragElement, isMobile } from '../../RossAscends-mods.js';4import { dragElement, isMobile } from '../../RossAscends-mods.js';
5import { getContext, getApiUrl, modules, extension_settings, ModuleWorkerWrapper, doExtrasFetch, renderExtensionTemplateAsync } from '../../extensions.js';5import { getContext, getApiUrl, modules, extension_settings, ModuleWorkerWrapper, doExtrasFetch, renderExtensionTemplateAsync } from '../../extensions.js';
6import { loadMovingUIState, power_user } from '../../power-user.js';6import { loadMovingUIState, power_user } from '../../power-user.js';
7import { onlyUnique, debounce, getCharaFilename, trimToEndSentence, trimToStartSentence, waitUntilCondition, findChar } from '../../utils.js';7import { onlyUnique, debounce, getCharaFilename, trimToEndSentence, trimToStartSentence, waitUntilCondition, findChar } from '../../utils.js';
8import { hideMutedSprites } from '../../group-chats.js';8import { hideMutedSprites, selected_group } from '../../group-chats.js';
9import { isJsonSchemaSupported } from '../../textgen-settings.js';9import { isJsonSchemaSupported } from '../../textgen-settings.js';
10import { debounce_timeout } from '../../constants.js';10import { debounce_timeout } from '../../constants.js';
11import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';11import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
@@ -92,6 +92,8 @@ let lastTalkingStateMessage = null; // last message as seen by `updateTalkingSt
92let spriteCache = {};92let spriteCache = {};
93let inApiCall = false;93let inApiCall = false;
94let lastServerResponseTime = 0;94let lastServerResponseTime = 0;
95
96/** @type {{[characterName: string]: string}} */
95export let lastExpression = {};97export let lastExpression = {};
9698
97function isTalkingHeadEnabled() {99function isTalkingHeadEnabled() {
@@ -1033,17 +1035,21 @@ function spriteFolderNameFromCharacter(char) {
1033 * @param {object} args1035 * @param {object} args
1034 * @param {string} args.name Character name or avatar key, passed through findChar1036 * @param {string} args.name Character name or avatar key, passed through findChar
1035 * @param {string} args.label Expression label1037 * @param {string} args.label Expression label
1036 * @param {string} args.folder Sprite folder path, processed using backslash rules1038 * @param {string} [args.folder=null] Optional sprite folder path, processed using backslash rules
1039 * @param {string?} [args.spriteName=null] Optional sprite name
1037 * @param {string} imageUrl Image URI to fetch and upload1040 * @param {string} imageUrl Image URI to fetch and upload
1038 * @returns {Promise<void>}1041 * @returns {Promise<string>} the sprite name
1039 */1042 */
1040async function uploadSpriteCommand({ name, label, folder }, imageUrl) {1043async function uploadSpriteCommand({ name, label, folder = null, spriteName = null }, imageUrl) {
1041 if (!imageUrl) throw new Error('Image URL is required');1044 if (!imageUrl) throw new Error('Image URL is required');
1042 if (!label || typeof label !== 'string') throw new Error('Expression label is required');1045 if (!label || typeof label !== 'string') throw new Error('Expression label is required');
10431046
1044 label = label.replace(/[^a-z]/gi, '').toLowerCase().trim();1047 label = label.replace(/[^a-z]/gi, '').toLowerCase().trim();
1045 if (!label) throw new Error('Expression label must contain at least one letter');1048 if (!label) throw new Error('Expression label must contain at least one letter');
10461049
1050 spriteName = spriteName || label;
1051 if (!validateExpressionSpriteName(label, spriteName)) throw new Error('Invalid sprite name. Must follow the naming pattern for expression sprites.');
1052
1047 name = name || getLastCharacterMessage().original_avatar || getLastCharacterMessage().name;1053 name = name || getLastCharacterMessage().original_avatar || getLastCharacterMessage().name;
1048 const char = findChar({ name });1054 const char = findChar({ name });
10491055
@@ -1063,6 +1069,7 @@ async function uploadSpriteCommand({ name, label, folder }, imageUrl) {
1063 formData.append('name', folder); // this is the folder or character name1069 formData.append('name', folder); // this is the folder or character name
1064 formData.append('label', label); // this is the expression label1070 formData.append('label', label); // this is the expression label
1065 formData.append('avatar', file); // this is the image file1071 formData.append('avatar', file); // this is the image file
1072 formData.append('spriteName', spriteName); // this is a redundant comment
10661073
1067 await handleFileUpload('/api/sprites/upload', formData);1074 await handleFileUpload('/api/sprites/upload', formData);
1068 console.debug(`[${MODULE_NAME}] Upload of ${imageUrl} completed for ${name} with label ${label}`);1075 console.debug(`[${MODULE_NAME}] Upload of ${imageUrl} completed for ${name} with label ${label}`);
@@ -1070,6 +1077,8 @@ async function uploadSpriteCommand({ name, label, folder }, imageUrl) {
1070 console.error(`[${MODULE_NAME}] Error uploading file:`, error);1077 console.error(`[${MODULE_NAME}] Error uploading file:`, error);
1071 throw error;1078 throw error;
1072 }1079 }
1080
1081 return spriteName;
1073}1082}
10741083
1075/**1084/**
@@ -1876,6 +1885,12 @@ function withoutExtension(fileName) {
1876 return fileName.replace(/\.[^/.]+$/, '');1885 return fileName.replace(/\.[^/.]+$/, '');
1877}1886}
18781887
1888function validateExpressionSpriteName(expression, spriteName) {
1889 const filenameValidationRegex = new RegExp(`^${expression}(?:[-\\.].*?)?$`);
1890 const validFileName = filenameValidationRegex.test(spriteName);
1891 return validFileName;
1892}
1893
1879async function onClickExpressionUpload(event) {1894async function onClickExpressionUpload(event) {
1880 // Prevents the expression from being set1895 // Prevents the expression from being set
1881 event.stopPropagation();1896 event.stopPropagation();
@@ -1900,8 +1915,7 @@ async function onClickExpressionUpload(event) {
1900 if (extension_settings.expressions.allowMultiple) {1915 if (extension_settings.expressions.allowMultiple) {
1901 const matchesExisting = existingFiles.some(x => x.fileName === file.name);1916 const matchesExisting = existingFiles.some(x => x.fileName === file.name);
1902 const fileNameWithoutExtension = withoutExtension(file.name);1917 const fileNameWithoutExtension = withoutExtension(file.name);
1903 const filenameValidationRegex = new RegExp(`^${expression}(?:[-\\.].*?)?$`);1918 const validFileName = validateExpressionSpriteName(expression, fileNameWithoutExtension);
1904 const validFileName = filenameValidationRegex.test(fileNameWithoutExtension);
19051919
1906 // If there is no expression yet and it's a valid expression, we just take it1920 // If there is no expression yet and it's a valid expression, we just take it
1907 if (!clickedFileName && validFileName) {1921 if (!clickedFileName && validFileName) {
@@ -1932,15 +1946,15 @@ async function onClickExpressionUpload(event) {
1932 const message = await renderExtensionTemplateAsync(MODULE_NAME, 'templates/upload-expression', { expression, clickedFileName });1946 const message = await renderExtensionTemplateAsync(MODULE_NAME, 'templates/upload-expression', { expression, clickedFileName });
19331947
1934 spriteName = null;1948 spriteName = null;
1935 const result = await Popup.show.input(t`Upload Expression Sprite`, message,1949 const input = await Popup.show.input(t`Upload Expression Sprite`, message,
1936 `${expression}-${existingFiles.length}`, { customButtons: customButtons });1950 `${expression}-${existingFiles.length}`, { customButtons: customButtons });
19371951
1938 if (result) {1952 if (input) {
1939 if (!filenameValidationRegex.test(result)) {1953 if (!validateExpressionSpriteName(expression, input)) {
1940 toastr.warning(t`The name you entered does not follow the naming schema for the selected expression '${expression}'.`, t`Invalid Expression Sprite Name`);1954 toastr.warning(t`The name you entered does not follow the naming schema for the selected expression '${expression}'.`, t`Invalid Expression Sprite Name`);
1941 return;1955 return;
1942 }1956 }
1943 spriteName = result;1957 spriteName = input;
1944 }1958 }
1945 }1959 }
1946 } else {1960 } else {
@@ -2350,23 +2364,23 @@ function migrateSettings() {
2350 };2364 };
23512365
2352 SlashCommandParser.addCommandObject(SlashCommand.fromProps({2366 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2353 name: 'sprite',2367 name: 'expression-set',
2354 aliases: ['emote'],2368 aliases: ['sprite', 'emote'],
2355 callback: setSpriteSlashCommand,2369 callback: setSpriteSlashCommand,
2356 unnamedArgumentList: [2370 unnamedArgumentList: [
2357 SlashCommandArgument.fromProps({2371 SlashCommandArgument.fromProps({
2358 description: 'spriteId',2372 description: 'expression label to set',
2359 typeList: [ARGUMENT_TYPE.STRING],2373 typeList: [ARGUMENT_TYPE.STRING],
2360 isRequired: true,2374 isRequired: true,
2361 enumProvider: localEnumProviders.expressions,2375 enumProvider: localEnumProviders.expressions,
2362 }),2376 }),
2363 ],2377 ],
2364 helpString: 'Force sets the sprite for the current character.',2378 helpString: 'Force sets the expression for the current character.',
2365 returns: 'the currently set sprite label after setting it.',2379 returns: 'The currently set expression label after setting it.',
2366 }));2380 }));
2367 SlashCommandParser.addCommandObject(SlashCommand.fromProps({2381 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2368 name: 'spriteoverride',2382 name: 'expression-folder-override',
2369 aliases: ['costume'],2383 aliases: ['spriteoverride', 'costume'],
2370 callback: setSpriteSetCommand,2384 callback: setSpriteSetCommand,
2371 unnamedArgumentList: [2385 unnamedArgumentList: [
2372 new SlashCommandArgument(2386 new SlashCommandArgument(
@@ -2376,55 +2390,52 @@ function migrateSettings() {
2376 helpString: 'Sets an override sprite folder for the current character. If the name starts with a slash or a backslash, selects a sub-folder in the character-named folder. Empty value to reset to default.',2390 helpString: 'Sets an override sprite folder for the current character. If the name starts with a slash or a backslash, selects a sub-folder in the character-named folder. Empty value to reset to default.',
2377 }));2391 }));
2378 SlashCommandParser.addCommandObject(SlashCommand.fromProps({2392 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2379 name: 'lastsprite',2393 name: 'expression-last',
2380 callback: (_, name) => {2394 aliases: ['lastsprite'],
2395 /** @type {(args: object, name: string) => Promise<string>} */
2396 callback: async (_, name) => {
2381 if (typeof name !== 'string') throw new Error('name must be a string');2397 if (typeof name !== 'string') throw new Error('name must be a string');
2398 if (!name) {
2399 if (selected_group) {
2400 toastr.error(t`In group chats, you must specify a character name.`, t`No character name specified`);
2401 return '';
2402 }
2403 name = characters[this_chid]?.avatar;
2404 }
2405
2382 const char = findChar({ name: name });2406 const char = findChar({ name: name });
2407 if (!char) toastr.warning(t`Couldn't find character ${name}.`, t`Character not found`);
2408
2383 const sprite = lastExpression[char?.name ?? name] ?? '';2409 const sprite = lastExpression[char?.name ?? name] ?? '';
2384 return sprite;2410 return sprite;
2385 },2411 },
2386 returns: 'the last set sprite / expression for the named character.',2412 returns: 'the last set expression for the named character.',
2387 unnamedArgumentList: [2413 unnamedArgumentList: [
2388 SlashCommandArgument.fromProps({2414 SlashCommandArgument.fromProps({
2389 description: 'Character name - or unique character identifier (avatar key)',2415 description: 'Character name - or unique character identifier (avatar key). If not provided, the current character for this chat will be used (does not work in group chats)',
2390 typeList: [ARGUMENT_TYPE.STRING],2416 typeList: [ARGUMENT_TYPE.STRING],
2391 isRequired: true,
2392 enumProvider: commonEnumProviders.characters('character'),2417 enumProvider: commonEnumProviders.characters('character'),
2393 forceEnum: true,2418 forceEnum: true,
2394 }),2419 }),
2395 ],2420 ],
2396 helpString: 'Returns the last set sprite / expression for the named character.',2421 helpString: 'Returns the last set expression for the named character.',
2397 }));2422 }));
2398 SlashCommandParser.addCommandObject(SlashCommand.fromProps({2423 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2399 name: 'th',2424 name: 'expression-talkinghead',
2400 callback: toggleTalkingHeadCommand,2425 callback: toggleTalkingHeadCommand,
2401 aliases: ['talkinghead'],2426 aliases: ['th', 'talkinghead'],
2402 helpString: 'Character Expressions: toggles <i>Image Type - talkinghead (extras)</i> on/off.',2427 helpString: 'Character Expressions: toggles <i>Image Type - talkinghead (extras)</i> on/off.',
2403 returns: 'the current state of the <i>Image Type - talkinghead (extras)</i> on/off.',2428 returns: 'the current state of the <i>Image Type - talkinghead (extras)</i> on/off.',
2404 }));2429 }));
2405 SlashCommandParser.addCommandObject(SlashCommand.fromProps({2430 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2406 name: 'classify-expressions',2431 name: 'expression-classify',
2407 aliases: ['expressions'],2432 aliases: ['classify-expressions', 'expressions'],
2433 /** @type {(args: {return: string}) => Promise<string>} */
2408 callback: async (args) => {2434 callback: async (args) => {
2435 let returnType =
2409 /** @type {import('../../slash-commands/SlashCommandReturnHelper.js').SlashCommandReturnType} */2436 /** @type {import('../../slash-commands/SlashCommandReturnHelper.js').SlashCommandReturnType} */
2410 // @ts-ignore2437 (args.return);
2411 let returnType = args.return;
24122438
2413 // Old legacy return type handling
2414 if (args.format) {
2415 toastr.warning(`Legacy argument 'format' with value '${args.format}' is deprecated. Please use 'return' instead. Routing to the correct return type...`, 'Deprecation warning');
2416 const type = String(args?.format).toLowerCase().trim();
2417 switch (type) {
2418 case 'json':
2419 returnType = 'object';
2420 break;
2421 default:
2422 returnType = 'pipe';
2423 break;
2424 }
2425 }
2426
2427 // Now the actual new return type handling
2428 const list = await getExpressionsList();2439 const list = await getExpressionsList();
24292440
2430 return await slashCommandReturnHelper.doReturn(returnType ?? 'pipe', list, { objectToStringFunc: list => list.join(', ') });2441 return await slashCommandReturnHelper.doReturn(returnType ?? 'pipe', list, { objectToStringFunc: list => list.join(', ') });
@@ -2438,22 +2449,13 @@ function migrateSettings() {
2438 enumList: slashCommandReturnHelper.enumList({ allowObject: true }),2449 enumList: slashCommandReturnHelper.enumList({ allowObject: true }),
2439 forceEnum: true,2450 forceEnum: true,
2440 }),2451 }),
2441 // TODO remove some day
2442 SlashCommandNamedArgument.fromProps({
2443 name: 'format',
2444 description: '!!! DEPRECATED - use "return" instead !!! The format to return the list in: comma-separated plain text or JSON array. Default is plain text.',
2445 typeList: [ARGUMENT_TYPE.STRING],
2446 enumList: [
2447 new SlashCommandEnumValue('plain', null, enumTypes.enum, ', '),
2448 new SlashCommandEnumValue('json', null, enumTypes.enum, '[]'),
2449 ],
2450 }),
2451 ],2452 ],
2452 returns: 'The comma-separated list of available expressions, including custom expressions.',2453 returns: 'The comma-separated list of available expressions, including custom expressions.',
2453 helpString: 'Returns a list of available expressions, including custom expressions.',2454 helpString: 'Returns a list of available expressions, including custom expressions.',
2454 }));2455 }));
2455 SlashCommandParser.addCommandObject(SlashCommand.fromProps({2456 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2456 name: 'classify',2457 name: 'expression-classify',
2458 aliases: ['classify'],
2457 callback: classifyCallback,2459 callback: classifyCallback,
2458 namedArgumentList: [2460 namedArgumentList: [
2459 SlashCommandNamedArgument.fromProps({2461 SlashCommandNamedArgument.fromProps({
@@ -2492,11 +2494,13 @@ function migrateSettings() {
2492 `,2494 `,
2493 }));2495 }));
2494 SlashCommandParser.addCommandObject(SlashCommand.fromProps({2496 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2495 name: 'uploadsprite',2497 name: 'expression-upload',
2498 aliases: ['uploadsprite'],
2499 /** @type {(args: {name: string, label: string, folder: string?, spriteName: string?}, url: string) => Promise<string>} */
2496 callback: async (args, url) => {2500 callback: async (args, url) => {
2497 await uploadSpriteCommand(args, url);2501 return await uploadSpriteCommand(args, url);
2498 return '';
2499 },2502 },
2503 returns: 'the resulting sprite name',
2500 unnamedArgumentList: [2504 unnamedArgumentList: [
2501 SlashCommandArgument.fromProps({2505 SlashCommandArgument.fromProps({
2502 description: 'URL of the image to upload',2506 description: 'URL of the image to upload',
@@ -2510,7 +2514,6 @@ function migrateSettings() {
2510 description: 'Character name or avatar key (default is current character)',2514 description: 'Character name or avatar key (default is current character)',
2511 typeList: [ARGUMENT_TYPE.STRING],2515 typeList: [ARGUMENT_TYPE.STRING],
2512 isRequired: false,2516 isRequired: false,
2513 acceptsMultiple: false,
2514 }),2517 }),
2515 SlashCommandNamedArgument.fromProps({2518 SlashCommandNamedArgument.fromProps({
2516 name: 'label',2519 name: 'label',
@@ -2518,16 +2521,32 @@ function migrateSettings() {
2518 typeList: [ARGUMENT_TYPE.STRING],2521 typeList: [ARGUMENT_TYPE.STRING],
2519 enumProvider: localEnumProviders.expressions,2522 enumProvider: localEnumProviders.expressions,
2520 isRequired: true,2523 isRequired: true,
2521 acceptsMultiple: false,
2522 }),2524 }),
2523 SlashCommandNamedArgument.fromProps({2525 SlashCommandNamedArgument.fromProps({
2524 name: 'folder',2526 name: 'folder',
2525 description: 'Override folder to upload into',2527 description: 'Override folder to upload into',
2526 typeList: [ARGUMENT_TYPE.STRING],2528 typeList: [ARGUMENT_TYPE.STRING],
2527 isRequired: false,2529 isRequired: false,
2528 acceptsMultiple: false,2530 }),
2531 SlashCommandNamedArgument.fromProps({
2532 name: 'spriteName',
2533 description: 'Override sprite name to allow multiple sprites per expressions. Has to follow the naming pattern. If unspecified, the label will be used as sprite name.',
2534 typeList: [ARGUMENT_TYPE.STRING],
2535 isRequired: false,
2529 }),2536 }),
2530 ],2537 ],
2531 helpString: '<div>Upload a sprite from a URL.</div><div>Example:</div><pre><code>/uploadsprite name=Seraphina label=joy /user/images/Seraphina/Seraphina_2024-12-22@12h37m57s.png</code></pre>',2538 helpString: `
2539 <div>
2540 Upload a sprite from a URL.
2541 </div>
2542 <div>
2543 <strong>Example:</strong>
2544 <ul>
2545 <li>
2546 <pre><code>/uploadsprite name=Seraphina label=joy /user/images/Seraphina/Seraphina_2024-12-22@12h37m57s.png</code></pre>
2547 </li>
2548 </ul>
2549 </div>
2550 `,
2532 }));2551 }));
2533})();2552})();