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
| @@ -1,11 +1,11 @@ | ||
| 1 | 1 | import { Fuse } from '../../../lib.js'; |
| 2 | 2 | |
| 3 | 3 | import { characters, eventSource, event_types, generateRaw, getRequestHeaders, main_api, online_status, saveSettingsDebounced, substituteParams, substituteParamsExtended, system_message_types, this_chid } from '../../../script.js'; |
| 4 | 4 | import { dragElement, isMobile } from '../../RossAscends-mods.js'; |
| 5 | 5 | import { getContext, getApiUrl, modules, extension_settings, ModuleWorkerWrapper, doExtrasFetch, renderExtensionTemplateAsync } from '../../extensions.js'; |
| 6 | 6 | import { loadMovingUIState, power_user } from '../../power-user.js'; |
| 7 | 7 | import { onlyUnique, debounce, getCharaFilename, trimToEndSentence, trimToStartSentence, waitUntilCondition, findChar } from '../../utils.js'; |
| 8 | 8 | import { hideMutedSprites, selected_group } from '../../group-chats.js'; |
| 9 | 9 | import { isJsonSchemaSupported } from '../../textgen-settings.js'; |
| 10 | 10 | import { debounce_timeout } from '../../constants.js'; |
| 11 | 11 | import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js'; |
| @@ -92,6 +92,8 @@ let lastTalkingStateMessage = null; // last message as seen by `updateTalkingSt | ||
| 92 | 92 | let spriteCache = {}; |
| 93 | 93 | let inApiCall = false; |
| 94 | 94 | let lastServerResponseTime = 0; |
| 95 | + | |
| 96 | +/** @type {{[characterName: string]: string}} */ | |
| 95 | 97 | export let lastExpression = {}; |
| 96 | 98 | |
| 97 | 99 | function isTalkingHeadEnabled() { |
| @@ -1033,17 +1035,21 @@ function spriteFolderNameFromCharacter(char) { | ||
| 1033 | 1035 | * @param {object} args |
| 1034 | 1036 | * @param {string} args.name Character name or avatar key, passed through findChar |
| 1035 | 1037 | * @param {string} args.label Expression label |
| 1036 | 1038 | * @param {string} [args.folder=null] SpriteOptional sprite folder path, processed using backslash rules |
| 1039 | + * @param {string?} [args.spriteName=null] Optional sprite name | |
| 1037 | 1040 | * @param {string} imageUrl Image URI to fetch and upload |
| 1038 | 1041 | * @returns {Promise<voidstring>} the sprite name |
| 1039 | 1042 | */ |
| 1040 | 1043 | async function uploadSpriteCommand({ name, label, folder = null, spriteName = null }, imageUrl) { |
| 1041 | 1044 | if (!imageUrl) throw new Error('Image URL is required'); |
| 1042 | 1045 | if (!label || typeof label !== 'string') throw new Error('Expression label is required'); |
| 1043 | 1046 | |
| 1044 | 1047 | label = label.replace(/[^a-z]/gi, '').toLowerCase().trim(); |
| 1045 | 1048 | if (!label) throw new Error('Expression label must contain at least one letter'); |
| 1046 | 1049 | |
| 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 | 1053 | name = name || getLastCharacterMessage().original_avatar || getLastCharacterMessage().name; |
| 1048 | 1054 | const char = findChar({ name }); |
| 1049 | 1055 | |
| @@ -1063,6 +1069,7 @@ async function uploadSpriteCommand({ name, label, folder }, imageUrl) { | ||
| 1063 | 1069 | formData.append('name', folder); // this is the folder or character name |
| 1064 | 1070 | formData.append('label', label); // this is the expression label |
| 1065 | 1071 | formData.append('avatar', file); // this is the image file |
| 1072 | + formData.append('spriteName', spriteName); // this is a redundant comment | |
| 1066 | 1073 | |
| 1067 | 1074 | await handleFileUpload('/api/sprites/upload', formData); |
| 1068 | 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 | 1077 | console.error(`[${MODULE_NAME}] Error uploading file:`, error); |
| 1071 | 1078 | throw error; |
| 1072 | 1079 | } |
| 1080 | + | |
| 1081 | + return spriteName; | |
| 1073 | 1082 | } |
| 1074 | 1083 | |
| 1075 | 1084 | /** |
| @@ -1876,6 +1885,12 @@ function withoutExtension(fileName) { | ||
| 1876 | 1885 | return fileName.replace(/\.[^/.]+$/, ''); |
| 1877 | 1886 | } |
| 1878 | 1887 | |
| 1888 | +function validateExpressionSpriteName(expression, spriteName) { | |
| 1889 | + const filenameValidationRegex = new RegExp(`^${expression}(?:[-\\.].*?)?$`); | |
| 1890 | + const validFileName = filenameValidationRegex.test(spriteName); | |
| 1891 | + return validFileName; | |
| 1892 | +} | |
| 1893 | + | |
| 1879 | 1894 | async function onClickExpressionUpload(event) { |
| 1880 | 1895 | // Prevents the expression from being set |
| 1881 | 1896 | event.stopPropagation(); |
| @@ -1900,8 +1915,7 @@ async function onClickExpressionUpload(event) { | ||
| 1900 | 1915 | if (extension_settings.expressions.allowMultiple) { |
| 1901 | 1916 | const matchesExisting = existingFiles.some(x => x.fileName === file.name); |
| 1902 | 1917 | const fileNameWithoutExtension = withoutExtension(file.name); |
| 1903 | - const filenameValidationRegex = new RegExp(`^${expression}(?:[-\\.].*?)?$`); | |
| 1918 | + const validFileName = validateExpressionSpriteName(expression, fileNameWithoutExtension); | |
| 1904 | - const validFileName = filenameValidationRegex.test(fileNameWithoutExtension); | |
| 1905 | 1919 | |
| 1906 | 1920 | // If there is no expression yet and it's a valid expression, we just take it |
| 1907 | 1921 | if (!clickedFileName && validFileName) { |
| @@ -1932,15 +1946,15 @@ async function onClickExpressionUpload(event) { | ||
| 1932 | 1946 | const message = await renderExtensionTemplateAsync(MODULE_NAME, 'templates/upload-expression', { expression, clickedFileName }); |
| 1933 | 1947 | |
| 1934 | 1948 | spriteName = null; |
| 1935 | 1949 | const resultinput = await Popup.show.input(t`Upload Expression Sprite`, message, |
| 1936 | 1950 | `${expression}-${existingFiles.length}`, { customButtons: customButtons }); |
| 1937 | 1951 | |
| 1938 | 1952 | if (resultinput) { |
| 1939 | 1953 | if (!filenameValidationRegex.testvalidateExpressionSpriteName(resultexpression, input)) { |
| 1940 | 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 | 1955 | return; |
| 1942 | 1956 | } |
| 1943 | 1957 | spriteName = resultinput; |
| 1944 | 1958 | } |
| 1945 | 1959 | } |
| 1946 | 1960 | } else { |
| @@ -2350,23 +2364,23 @@ function migrateSettings() { | ||
| 2350 | 2364 | }; |
| 2351 | 2365 | |
| 2352 | 2366 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 2353 | 2367 | name: 'spriteexpression-set', |
| 2354 | 2368 | aliases: ['sprite', 'emote'], |
| 2355 | 2369 | callback: setSpriteSlashCommand, |
| 2356 | 2370 | unnamedArgumentList: [ |
| 2357 | 2371 | SlashCommandArgument.fromProps({ |
| 2358 | 2372 | description: 'spriteIdexpression label to set', |
| 2359 | 2373 | typeList: [ARGUMENT_TYPE.STRING], |
| 2360 | 2374 | isRequired: true, |
| 2361 | 2375 | enumProvider: localEnumProviders.expressions, |
| 2362 | 2376 | }), |
| 2363 | 2377 | ], |
| 2364 | 2378 | helpString: 'Force sets the spriteexpression for the current character.', |
| 2365 | 2379 | returns: 'theThe currently set spriteexpression label after setting it.', |
| 2366 | 2380 | })); |
| 2367 | 2381 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 2368 | 2382 | name: 'spriteoverrideexpression-folder-override', |
| 2369 | 2383 | aliases: ['spriteoverride', 'costume'], |
| 2370 | 2384 | callback: setSpriteSetCommand, |
| 2371 | 2385 | unnamedArgumentList: [ |
| 2372 | 2386 | new SlashCommandArgument( |
| @@ -2376,55 +2390,52 @@ function migrateSettings() { | ||
| 2376 | 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 | 2392 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 2379 | 2393 | name: 'lastspriteexpression-last', |
| 2380 | - callback: (_, name) => { | |
| 2394 | + aliases: ['lastsprite'], | |
| 2395 | + /** @type {(args: object, name: string) => Promise<string>} */ | |
| 2396 | + callback: async (_, name) => { | |
| 2381 | 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 | 2406 | const char = findChar({ name: name }); |
| 2407 | + if (!char) toastr.warning(t`Couldn't find character ${name}.`, t`Character not found`); | |
| 2408 | + | |
| 2383 | 2409 | const sprite = lastExpression[char?.name ?? name] ?? ''; |
| 2384 | 2410 | return sprite; |
| 2385 | 2411 | }, |
| 2386 | 2412 | returns: 'the last set sprite / expression for the named character.', |
| 2387 | 2413 | unnamedArgumentList: [ |
| 2388 | 2414 | SlashCommandArgument.fromProps({ |
| 2389 | 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 | 2416 | typeList: [ARGUMENT_TYPE.STRING], |
| 2391 | - isRequired: true, | |
| 2392 | 2417 | enumProvider: commonEnumProviders.characters('character'), |
| 2393 | 2418 | forceEnum: true, |
| 2394 | 2419 | }), |
| 2395 | 2420 | ], |
| 2396 | 2421 | helpString: 'Returns the last set sprite / expression for the named character.', |
| 2397 | 2422 | })); |
| 2398 | 2423 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 2399 | 2424 | name: 'thexpression-talkinghead', |
| 2400 | 2425 | callback: toggleTalkingHeadCommand, |
| 2401 | 2426 | aliases: ['th', 'talkinghead'], |
| 2402 | 2427 | helpString: 'Character Expressions: toggles <i>Image Type - talkinghead (extras)</i> on/off.', |
| 2403 | 2428 | returns: 'the current state of the <i>Image Type - talkinghead (extras)</i> on/off.', |
| 2404 | 2429 | })); |
| 2405 | 2430 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 2406 | 2431 | name: 'classifyexpression-expressionsclassify', |
| 2407 | 2432 | aliases: ['classify-expressions', 'expressions'], |
| 2433 | + /** @type {(args: {return: string}) => Promise<string>} */ | |
| 2408 | 2434 | callback: async (args) => { |
| 2435 | + let returnType = | |
| 2409 | 2436 | /** @type {import('../../slash-commands/SlashCommandReturnHelper.js').SlashCommandReturnType} */ |
| 2410 | - // @ts-ignore | |
| 2437 | + (args.return); | |
| 2411 | - let returnType = args.return; | |
| 2412 | 2438 | |
| 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 | 2439 | const list = await getExpressionsList(); |
| 2429 | 2440 | |
| 2430 | 2441 | return await slashCommandReturnHelper.doReturn(returnType ?? 'pipe', list, { objectToStringFunc: list => list.join(', ') }); |
| @@ -2438,22 +2449,13 @@ function migrateSettings() { | ||
| 2438 | 2449 | enumList: slashCommandReturnHelper.enumList({ allowObject: true }), |
| 2439 | 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 | 2453 | returns: 'The comma-separated list of available expressions, including custom expressions.', |
| 2453 | 2454 | helpString: 'Returns a list of available expressions, including custom expressions.', |
| 2454 | 2455 | })); |
| 2455 | 2456 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 2456 | 2457 | name: 'expression-classify', |
| 2458 | + aliases: ['classify'], | |
| 2457 | 2459 | callback: classifyCallback, |
| 2458 | 2460 | namedArgumentList: [ |
| 2459 | 2461 | SlashCommandNamedArgument.fromProps({ |
| @@ -2492,11 +2494,13 @@ function migrateSettings() { | ||
| 2492 | 2494 | `, |
| 2493 | 2495 | })); |
| 2494 | 2496 | SlashCommandParser.addCommandObject(SlashCommand.fromProps({ |
| 2495 | 2497 | name: 'uploadspriteexpression-upload', |
| 2498 | + aliases: ['uploadsprite'], | |
| 2499 | + /** @type {(args: {name: string, label: string, folder: string?, spriteName: string?}, url: string) => Promise<string>} */ | |
| 2496 | 2500 | callback: async (args, url) => { |
| 2497 | 2501 | return await uploadSpriteCommand(args, url); |
| 2498 | - return ''; | |
| 2499 | 2502 | }, |
| 2503 | + returns: 'the resulting sprite name', | |
| 2500 | 2504 | unnamedArgumentList: [ |
| 2501 | 2505 | SlashCommandArgument.fromProps({ |
| 2502 | 2506 | description: 'URL of the image to upload', |
| @@ -2510,7 +2514,6 @@ function migrateSettings() { | ||
| 2510 | 2514 | description: 'Character name or avatar key (default is current character)', |
| 2511 | 2515 | typeList: [ARGUMENT_TYPE.STRING], |
| 2512 | 2516 | isRequired: false, |
| 2513 | - acceptsMultiple: false, | |
| 2514 | 2517 | }), |
| 2515 | 2518 | SlashCommandNamedArgument.fromProps({ |
| 2516 | 2519 | name: 'label', |
| @@ -2518,16 +2521,32 @@ function migrateSettings() { | ||
| 2518 | 2521 | typeList: [ARGUMENT_TYPE.STRING], |
| 2519 | 2522 | enumProvider: localEnumProviders.expressions, |
| 2520 | 2523 | isRequired: true, |
| 2521 | - acceptsMultiple: false, | |
| 2522 | 2524 | }), |
| 2523 | 2525 | SlashCommandNamedArgument.fromProps({ |
| 2524 | 2526 | name: 'folder', |
| 2525 | 2527 | description: 'Override folder to upload into', |
| 2526 | 2528 | typeList: [ARGUMENT_TYPE.STRING], |
| 2527 | 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 | })(); |