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, +84 -65Ignore whitespace
public/scripts/extensions/expressions/index.js+84 -65
@@ -1,11 +1,11 @@
11import { Fuse } from '../../../lib.js';
22
33import { characters, eventSource, event_types, generateRaw, getRequestHeaders, main_api, online_status, saveSettingsDebounced, substituteParams, substituteParamsExtended, system_message_types, this_chid } from '../../../script.js';
44import { dragElement, isMobile } from '../../RossAscends-mods.js';
55import { getContext, getApiUrl, modules, extension_settings, ModuleWorkerWrapper, doExtrasFetch, renderExtensionTemplateAsync } from '../../extensions.js';
66import { loadMovingUIState, power_user } from '../../power-user.js';
77import { onlyUnique, debounce, getCharaFilename, trimToEndSentence, trimToStartSentence, waitUntilCondition, findChar } from '../../utils.js';
88import { hideMutedSprites, selected_group } from '../../group-chats.js';
99import { isJsonSchemaSupported } from '../../textgen-settings.js';
1010import { debounce_timeout } from '../../constants.js';
1111import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
@@ -92,6 +92,8 @@ let lastTalkingStateMessage = null; // last message as seen by `updateTalkingSt
9292let spriteCache = {};
9393let inApiCall = false;
9494let lastServerResponseTime = 0;
95+
96+/** @type {{[characterName: string]: string}} */
9597export let lastExpression = {};
9698
9799function isTalkingHeadEnabled() {
@@ -1033,17 +1035,21 @@ function spriteFolderNameFromCharacter(char) {
10331035 * @param {object} args
10341036 * @param {string} args.name Character name or avatar key, passed through findChar
10351037 * @param {string} args.label Expression label
10361038 * @param {string} [args.folder=null] SpriteOptional sprite folder path, processed using backslash rules
1039+ * @param {string?} [args.spriteName=null] Optional sprite name
10371040 * @param {string} imageUrl Image URI to fetch and upload
10381041 * @returns {Promise<voidstring>} the sprite name
10391042 */
10401043async function uploadSpriteCommand({ name, label, folder = null, spriteName = null }, imageUrl) {
10411044 if (!imageUrl) throw new Error('Image URL is required');
10421045 if (!label || typeof label !== 'string') throw new Error('Expression label is required');
10431046
10441047 label = label.replace(/[^a-z]/gi, '').toLowerCase().trim();
10451048 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+
10471053 name = name || getLastCharacterMessage().original_avatar || getLastCharacterMessage().name;
10481054 const char = findChar({ name });
10491055
@@ -1062,7 +1068,8 @@ async function uploadSpriteCommand({ name, label, folder }, imageUrl) {
10621068 const formData = new FormData();
10631069 formData.append('name', folder); // this is the folder or character name
10641070 formData.append('label', label); // this is the expression label
10651071 formData.append('avatar', file); // this is the image file
1072+ formData.append('spriteName', spriteName); // this is a redundant comment
10661073
10671074 await handleFileUpload('/api/sprites/upload', formData);
10681075 console.debug(`[${MODULE_NAME}] Upload of ${imageUrl} completed for ${name} with label ${label}`);
@@ -1070,6 +1077,8 @@ async function uploadSpriteCommand({ name, label, folder }, imageUrl) {
10701077 console.error(`[${MODULE_NAME}] Error uploading file:`, error);
10711078 throw error;
10721079 }
1080+
1081+ return spriteName;
10731082}
10741083
10751084/**
@@ -1876,6 +1885,12 @@ function withoutExtension(fileName) {
18761885 return fileName.replace(/\.[^/.]+$/, '');
18771886}
18781887
1888+function validateExpressionSpriteName(expression, spriteName) {
1889+ const filenameValidationRegex = new RegExp(`^${expression}(?:[-\\.].*?)?$`);
1890+ const validFileName = filenameValidationRegex.test(spriteName);
1891+ return validFileName;
1892+}
1893+
18791894async function onClickExpressionUpload(event) {
18801895 // Prevents the expression from being set
18811896 event.stopPropagation();
@@ -1900,8 +1915,7 @@ async function onClickExpressionUpload(event) {
19001915 if (extension_settings.expressions.allowMultiple) {
19011916 const matchesExisting = existingFiles.some(x => x.fileName === file.name);
19021917 const fileNameWithoutExtension = withoutExtension(file.name);
1903- const filenameValidationRegex = new RegExp(`^${expression}(?:[-\\.].*?)?$`);
1918+ const validFileName = validateExpressionSpriteName(expression, fileNameWithoutExtension);
1904- const validFileName = filenameValidationRegex.test(fileNameWithoutExtension);
19051919
19061920 // If there is no expression yet and it's a valid expression, we just take it
19071921 if (!clickedFileName && validFileName) {
@@ -1932,15 +1946,15 @@ async function onClickExpressionUpload(event) {
19321946 const message = await renderExtensionTemplateAsync(MODULE_NAME, 'templates/upload-expression', { expression, clickedFileName });
19331947
19341948 spriteName = null;
19351949 const resultinput = await Popup.show.input(t`Upload Expression Sprite`, message,
19361950 `${expression}-${existingFiles.length}`, { customButtons: customButtons });
19371951
19381952 if (resultinput) {
19391953 if (!filenameValidationRegex.testvalidateExpressionSpriteName(resultexpression, input)) {
19401954 toastr.warning(t`The name you entered does not follow the naming schema for the selected expression '${expression}'.`, t`Invalid Expression Sprite Name`);
19411955 return;
19421956 }
19431957 spriteName = resultinput;
19441958 }
19451959 }
19461960 } else {
@@ -2350,23 +2364,23 @@ function migrateSettings() {
23502364 };
23512365
23522366 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
23532367 name: 'spriteexpression-set',
23542368 aliases: ['sprite', 'emote'],
23552369 callback: setSpriteSlashCommand,
23562370 unnamedArgumentList: [
23572371 SlashCommandArgument.fromProps({
23582372 description: 'spriteIdexpression label to set',
23592373 typeList: [ARGUMENT_TYPE.STRING],
23602374 isRequired: true,
23612375 enumProvider: localEnumProviders.expressions,
23622376 }),
23632377 ],
23642378 helpString: 'Force sets the spriteexpression for the current character.',
23652379 returns: 'theThe currently set spriteexpression label after setting it.',
23662380 }));
23672381 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
23682382 name: 'spriteoverrideexpression-folder-override',
23692383 aliases: ['spriteoverride', 'costume'],
23702384 callback: setSpriteSetCommand,
23712385 unnamedArgumentList: [
23722386 new SlashCommandArgument(
@@ -2376,55 +2390,52 @@ function migrateSettings() {
23762390 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.',
23772391 }));
23782392 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
23792393 name: 'lastspriteexpression-last',
2380- callback: (_, name) => {
2394+ aliases: ['lastsprite'],
2395+ /** @type {(args: object, name: string) => Promise<string>} */
2396+ callback: async (_, name) => {
23812397 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+
23822406 const char = findChar({ name: name });
2407+ if (!char) toastr.warning(t`Couldn't find character ${name}.`, t`Character not found`);
2408+
23832409 const sprite = lastExpression[char?.name ?? name] ?? '';
23842410 return sprite;
23852411 },
23862412 returns: 'the last set sprite / expression for the named character.',
23872413 unnamedArgumentList: [
23882414 SlashCommandArgument.fromProps({
23892415 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)',
23902416 typeList: [ARGUMENT_TYPE.STRING],
2391- isRequired: true,
23922417 enumProvider: commonEnumProviders.characters('character'),
23932418 forceEnum: true,
23942419 }),
23952420 ],
23962421 helpString: 'Returns the last set sprite / expression for the named character.',
23972422 }));
23982423 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
23992424 name: 'thexpression-talkinghead',
24002425 callback: toggleTalkingHeadCommand,
24012426 aliases: ['th', 'talkinghead'],
24022427 helpString: 'Character Expressions: toggles <i>Image Type - talkinghead (extras)</i> on/off.',
24032428 returns: 'the current state of the <i>Image Type - talkinghead (extras)</i> on/off.',
24042429 }));
24052430 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
24062431 name: 'classifyexpression-expressionsclassify',
24072432 aliases: ['classify-expressions', 'expressions'],
2433+ /** @type {(args: {return: string}) => Promise<string>} */
24082434 callback: async (args) => {
2409- /** @type {import('../../slash-commands/SlashCommandReturnHelper.js').SlashCommandReturnType} */
2435+ let returnType =
2410- // @ts-ignore
2436+ /** @type {import('../../slash-commands/SlashCommandReturnHelper.js').SlashCommandReturnType} */
2411- let returnType = args.return;
2437+ (args.return);
2412-
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- }
24262438
2427- // Now the actual new return type handling
24282439 const list = await getExpressionsList();
24292440
24302441 return await slashCommandReturnHelper.doReturn(returnType ?? 'pipe', list, { objectToStringFunc: list => list.join(', ') });
@@ -2438,22 +2449,13 @@ function migrateSettings() {
24382449 enumList: slashCommandReturnHelper.enumList({ allowObject: true }),
24392450 forceEnum: true,
24402451 }),
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- }),
24512452 ],
24522453 returns: 'The comma-separated list of available expressions, including custom expressions.',
24532454 helpString: 'Returns a list of available expressions, including custom expressions.',
24542455 }));
24552456 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
24562457 name: 'expression-classify',
2458+ aliases: ['classify'],
24572459 callback: classifyCallback,
24582460 namedArgumentList: [
24592461 SlashCommandNamedArgument.fromProps({
@@ -2492,11 +2494,13 @@ function migrateSettings() {
24922494 `,
24932495 }));
24942496 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
24952497 name: 'uploadspriteexpression-upload',
2498+ aliases: ['uploadsprite'],
2499+ /** @type {(args: {name: string, label: string, folder: string?, spriteName: string?}, url: string) => Promise<string>} */
24962500 callback: async (args, url) => {
24972501 return await uploadSpriteCommand(args, url);
2498- return '';
24992502 },
2503+ returns: 'the resulting sprite name',
25002504 unnamedArgumentList: [
25012505 SlashCommandArgument.fromProps({
25022506 description: 'URL of the image to upload',
@@ -2510,7 +2514,6 @@ function migrateSettings() {
25102514 description: 'Character name or avatar key (default is current character)',
25112515 typeList: [ARGUMENT_TYPE.STRING],
25122516 isRequired: false,
2513- acceptsMultiple: false,
25142517 }),
25152518 SlashCommandNamedArgument.fromProps({
25162519 name: 'label',
@@ -2518,16 +2521,32 @@ function migrateSettings() {
25182521 typeList: [ARGUMENT_TYPE.STRING],
25192522 enumProvider: localEnumProviders.expressions,
25202523 isRequired: true,
2521- acceptsMultiple: false,
25222524 }),
25232525 SlashCommandNamedArgument.fromProps({
25242526 name: 'folder',
25252527 description: 'Override folder to upload into',
25262528 typeList: [ARGUMENT_TYPE.STRING],
25272529 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,
25292536 }),
25302537 ],
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+ `,
25322551 }));
25332552})();