Allow setting specific sprites as expressions - Update /expression-set command to allow setting specific sprites - Enhance enum completion for /expression-set to show expressions/sprites and more their info - Fix setting sprite folder reprinting stuff double - Fix not being able to unset expressions

198d10e7597ece5e444cc18cae0b94dce45d3466

Wolfsblvt <wolfsblvt@gmail.com>

3 files changed, +209 -67Ignore whitespace
public/scripts/extensions/expressions/index.js+201 -63
@@ -3,7 +3,7 @@ import { Fuse } from '../../../lib.js';
3import { characters, eventSource, event_types, generateRaw, getRequestHeaders, main_api, online_status, saveSettingsDebounced, substituteParams, substituteParamsExtended, system_message_types, this_chid } 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, performFuzzySearch, 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, selected_group } from '../../group-chats.js';8import { hideMutedSprites, selected_group } from '../../group-chats.js';
9import { isJsonSchemaSupported } from '../../textgen-settings.js';9import { isJsonSchemaSupported } from '../../textgen-settings.js';
@@ -12,7 +12,7 @@ import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
12import { SlashCommand } from '../../slash-commands/SlashCommand.js';12import { SlashCommand } from '../../slash-commands/SlashCommand.js';
13import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';13import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';
14import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashCommandEnumValue.js';14import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashCommandEnumValue.js';
15import { commonEnumProviders } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';15import { commonEnumProviders, enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
16import { slashCommandReturnHelper } from '../../slash-commands/SlashCommandReturnHelper.js';16import { slashCommandReturnHelper } from '../../slash-commands/SlashCommandReturnHelper.js';
17import { generateWebLlmChatPrompt, isWebLlmSupported } from '../shared.js';17import { generateWebLlmChatPrompt, isWebLlmSupported } from '../shared.js';
18import { Popup, POPUP_RESULT } from '../../popup.js';18import { Popup, POPUP_RESULT } from '../../popup.js';
@@ -27,8 +27,8 @@ export { MODULE_NAME };
2727
28/**28/**
29 * @typedef {object} ExpressionImage An expression image29 * @typedef {object} ExpressionImage An expression image
30 * @property {string?} [expression=null] - The expression30 * @property {string} expression - The expression
31 * @property {boolean?} [isCustom=null] - If the expression is added by user31 * @property {boolean} [isCustom=false] - If the expression is added by user
32 * @property {string} fileName - The filename with extension32 * @property {string} fileName - The filename with extension
33 * @property {string} title - The title for the image33 * @property {string} title - The title for the image
34 * @property {string} imageSrc - The image source / full path34 * @property {string} imageSrc - The image source / full path
@@ -78,9 +78,6 @@ const EXPRESSION_API = {
78 webllm: 3,78 webllm: 3,
79};79};
8080
81/** @type {ExpressionImage} */
82const NO_IMAGE_PLACEHOLDER = { title: 'No Image', type: 'failure', fileName: 'No-Image-Placeholder.svg', imageSrc: '/img/No-Image-Placeholder.svg' };
83
84let expressionsList = null;81let expressionsList = null;
85let lastCharacter = undefined;82let lastCharacter = undefined;
86let lastMessage = null;83let lastMessage = null;
@@ -93,6 +90,24 @@ let lastServerResponseTime = 0;
93export let lastExpression = {};90export let lastExpression = {};
9491
95/**92/**
93 * Returns a placeholder image object for a given expression
94 * @param {string} expression - The expression label
95 * @param {boolean} [isCustom=false] - Whether the expression is custom
96 * @returns {ExpressionImage} The placeholder image object
97 */
98function getPlaceholderImage(expression, isCustom = false) {
99 return {
100 expression: expression,
101 isCustom: isCustom,
102 title: 'No Image',
103 type: 'failure',
104 fileName: 'No-Image-Placeholder.svg',
105 imageSrc: '/img/No-Image-Placeholder.svg',
106 };
107}
108
109
110/**
96 * Returns the fallback expression if explicitly chosen, otherwise the default one111 * Returns the fallback expression if explicitly chosen, otherwise the default one
97 * @returns {string} expression name112 * @returns {string} expression name
98 */113 */
@@ -189,6 +204,7 @@ async function visualNovelSetCharacterSprites(container, name, expression) {
189 const sprites = spriteCache[spriteFolderName];204 const sprites = spriteCache[spriteFolderName];
190 const expressionImage = container.find(`.expression-holder[data-avatar="${avatar}"]`);205 const expressionImage = container.find(`.expression-holder[data-avatar="${avatar}"]`);
191 const defaultExpression = getFallbackExpression();206 const defaultExpression = getFallbackExpression();
207 // TODO: Visual novel sprites need fixing, currently do not update based on multiple sprites, etc
192 const defaultSpritePath = sprites.find(x => x.label === defaultExpression)?.path;208 const defaultSpritePath = sprites.find(x => x.label === defaultExpression)?.path;
193 const noSprites = sprites.length === 0;209 const noSprites = sprites.length === 0;
194210
@@ -460,7 +476,7 @@ async function moduleWorker() {
460 }476 }
461477
462 const currentLastMessage = getLastCharacterMessage();478 const currentLastMessage = getLastCharacterMessage();
463 let spriteFolderName = context.groupId ? getSpriteFolderName(currentLastMessage, currentLastMessage.name) : getSpriteFolderName();479 let spriteFolderName = getSpriteFolderName(currentLastMessage, currentLastMessage.name);
464480
465 // character has no expressions or it is not loaded481 // character has no expressions or it is not loaded
466 if (Object.keys(spriteCache).length === 0) {482 if (Object.keys(spriteCache).length === 0) {
@@ -550,7 +566,7 @@ async function moduleWorker() {
550 expression = getFallbackExpression();566 expression = getFallbackExpression();
551 }567 }
552568
553 await sendExpressionCall(spriteFolderName, expression, force, vnMode);569 await sendExpressionCall(spriteFolderName, expression, { force: force, vnMode: vnMode });
554 }570 }
555 catch (error) {571 catch (error) {
556 console.log(error);572 console.log(error);
@@ -596,48 +612,55 @@ function getFolderNameByMessage(message) {
596 return folderName;612 return folderName;
597}613}
598614
599async function sendExpressionCall(name, expression, force, vnMode) {615/**
616 * Update the expression for the given character.
617 *
618 * @param {string} name The character name, optionally with a sprite folder override, e.g. "folder/expression".
619 * @param {string} expression The expression label, e.g. "amusement", "joy", etc.
620 * @param {Object} [options] Additional options
621 * @param {boolean} [options.force=false] If true, the expression will be sent even if it is the same as the current expression.
622 * @param {boolean} [options.vnMode=null] If true, the expression will be sent in Visual Novel mode. If null, it will be determined by the current chat mode.
623 * @param {string?} [options.overrideSpriteFile=null] - Set if a specific sprite file should be used. Must be sprite file name.
624 */
625async function sendExpressionCall(name, expression, { force = false, vnMode = null, overrideSpriteFile = null } = {}) {
600 lastExpression[name.split('/')[0]] = expression;626 lastExpression[name.split('/')[0]] = expression;
601 if (!vnMode) {627 if (vnMode === null) {
602 vnMode = isVisualNovelMode();628 vnMode = isVisualNovelMode();
603 }629 }
604630
605 if (vnMode) {631 if (vnMode) {
606 await updateVisualNovelMode(name, expression);632 await updateVisualNovelMode(name, expression);
607 } else {633 } else {
608 setExpression(name, expression, force);634 setExpression(name, expression, { force: force, overrideSpriteFile: overrideSpriteFile });
609 }635 }
610}636}
611637
612async function setSpriteSetCommand(_, folder) {638async function setSpriteFolderCommand(_, folder) {
613 if (!folder) {639 if (!folder) {
614 console.log('Clearing sprite set');640 console.log('Clearing sprite set');
615 folder = '';641 folder = '';
616 }642 }
617643
618 if (folder.startsWith('/') || folder.startsWith('\\')) {644 if (folder.startsWith('/') || folder.startsWith('\\')) {
619 folder = folder.slice(1);
620
621 const currentLastMessage = getLastCharacterMessage();645 const currentLastMessage = getLastCharacterMessage();
646 folder = folder.slice(1);
622 folder = `${currentLastMessage.name}/${folder}`;647 folder = `${currentLastMessage.name}/${folder}`;
623 }648 }
624649
625 $('#expression_override').val(folder.trim());650 $('#expression_override').val(folder.trim());
626 onClickExpressionOverrideButton();651 onClickExpressionOverrideButton();
627 // removeExpression();652
628 // moduleWorker();653 // No need to resend the expression, the folder override will automatically update the currently displayed one.
629 const vnMode = isVisualNovelMode();
630 await sendExpressionCall(folder, lastExpression, true, vnMode);
631 return '';654 return '';
632}655}
633656
634async function classifyCallback(/** @type {{api: string?, prompt: string?}} */ { api = null, prompt = null }, text) {657async function classifyCallback(/** @type {{api: string?, prompt: string?}} */ { api = null, prompt = null }, text) {
635 if (!text) {658 if (!text) {
636 toastr.warning('No text provided');659 toastr.error('No text provided');
637 return '';660 return '';
638 }661 }
639 if (api && !Object.keys(EXPRESSION_API).includes(api)) {662 if (api && !Object.keys(EXPRESSION_API).includes(api)) {
640 toastr.warning('Invalid API provided');663 toastr.error('Invalid API provided');
641 return '';664 return '';
642 }665 }
643666
@@ -653,31 +676,68 @@ async function classifyCallback(/** @type {{api: string?, prompt: string?}} */ {
653 return label;676 return label;
654}677}
655678
656async function setSpriteSlashCommand(_, spriteId) {679/** @type {(args: {type: 'expression' | 'sprite'}, searchTerm: string) => Promise<string>} */
657 spriteId = spriteId.trim().toLowerCase();680async function setSpriteSlashCommand({ type }, searchTerm) {
658 if (!spriteId) {681 type ??= 'expression';
659 console.log('No sprite id provided');682 searchTerm = searchTerm.trim().toLowerCase();
683 if (!searchTerm) {
684 toastr.error(t`No expression or sprite name provided`, t`Set Sprite`);
660 return '';685 return '';
661 }686 }
662687
663 const spriteFolderName = getSpriteFolderName();688 const spriteFolderName = getSpriteFolderName();
664689
690 let label = searchTerm;
691
692 /** @type {string?} */
693 let spriteFile = null;
694
665 await validateImages(spriteFolderName);695 await validateImages(spriteFolderName);
666696
667 // Fuzzy search for sprite697 // Handle reset as a special term and just reset the sprite via expression call
668 const fuse = new Fuse(spriteCache[spriteFolderName], { keys: ['label'] });698 if (searchTerm === '#reset') {
669 const results = fuse.search(spriteId);699 await sendExpressionCall(spriteFolderName, label, { force: true });
670 const spriteItem = results[0]?.item;700 return lastExpression[spriteFolderName] ?? '';
701 }
702
703 switch (type) {
704 case 'expression': {
705 // Fuzzy search for expression
706 const existingExpressions = getCachedExpressions().map(x => ({ label: x }));
707 const results = performFuzzySearch('expression-expressions', existingExpressions, [
708 { name: 'label', weight: 1 },
709 ], searchTerm);
710 const matchedExpression = results[0]?.item;
711 if (!matchedExpression) {
712 toastr.warning(t`No expression found for search term ${searchTerm}`, t`Set Sprite`);
713 return '';
714 }
715
716 label = matchedExpression.label;
717 break;
718 }
719 case 'sprite': {
720 // Fuzzy search for sprite file
721 const sprites = spriteCache[spriteFolderName].map(x => x.files).flat();
722 const results = performFuzzySearch('expression-expressions', sprites, [
723 { name: 'title', weight: 1 },
724 { name: 'fileName', weight: 1 },
725 ], searchTerm);
726 const matchedSprite = results[0]?.item;
727 if (!matchedSprite) {
728 toastr.warning(t`No sprite file found for search term ${searchTerm}`, t`Set Sprite`);
729 return '';
730 }
671731
672 if (!spriteItem) {732 label = matchedSprite.expression;
673 console.log('No sprite found for search term ' + spriteId);733 spriteFile = matchedSprite.fileName;
674 return '';734 break;
735 }
736 default: throw Error('Invalid sprite set type: ' + type);
675 }737 }
676738
677 const label = spriteItem.label;739 await sendExpressionCall(spriteFolderName, label, { force: true, overrideSpriteFile: spriteFile });
678740
679 const vnMode = isVisualNovelMode();
680 await sendExpressionCall(spriteFolderName, label, true, vnMode);
681 return label;741 return label;
682}742}
683743
@@ -714,13 +774,22 @@ function spriteFolderNameFromCharacter(char) {
714 */774 */
715async function uploadSpriteCommand({ name, label, folder = null, spriteName = null }, imageUrl) {775async function uploadSpriteCommand({ name, label, folder = null, spriteName = null }, imageUrl) {
716 if (!imageUrl) throw new Error('Image URL is required');776 if (!imageUrl) throw new Error('Image URL is required');
717 if (!label || typeof label !== 'string') throw new Error('Expression label is required');777 if (!label || typeof label !== 'string') {
778 toastr.error(t`Expression label is required`, t`Error Uploading Sprite`);
779 return '';
780 }
718781
719 label = label.replace(/[^a-z]/gi, '').toLowerCase().trim();782 label = label.replace(/[^a-z]/gi, '').toLowerCase().trim();
720 if (!label) throw new Error('Expression label must contain at least one letter');783 if (!label) {
784 toastr.error(t`Expression label must contain at least one letter`, t`Error Uploading Sprite`);
785 return '';
786 }
721787
722 spriteName = spriteName || label;788 spriteName = spriteName || label;
723 if (!validateExpressionSpriteName(label, spriteName)) throw new Error('Invalid sprite name. Must follow the naming pattern for expression sprites.');789 if (!validateExpressionSpriteName(label, spriteName)) {
790 toastr.error(t`Invalid sprite name. Must follow the naming pattern for expression sprites.`, t`Error Uploading Sprite`);
791 return '';
792 }
724793
725 name = name || getLastCharacterMessage().original_avatar || getLastCharacterMessage().name;794 name = name || getLastCharacterMessage().original_avatar || getLastCharacterMessage().name;
726 const char = findChar({ name });795 const char = findChar({ name });
@@ -1066,7 +1135,7 @@ async function drawSpritesList(character, labels, sprites) {
1066 if (images.length === 0) {1135 if (images.length === 0) {
1067 const listItem = await getListItem(expression, {1136 const listItem = await getListItem(expression, {
1068 isCustom,1137 isCustom,
1069 images: [{ expression, isCustom, ...NO_IMAGE_PLACEHOLDER }],1138 images: [getPlaceholderImage(expression, isCustom)],
1070 });1139 });
1071 $('#image_list').append(listItem);1140 $('#image_list').append(listItem);
1072 continue;1141 continue;
@@ -1264,12 +1333,13 @@ export async function getExpressionsList() {
1264/**1333/**
1265 * Set the expression of a character.1334 * Set the expression of a character.
1266 * @param {string} character - The name of the character1335 * @param {string} character - The name of the character
1267 * @param {string} expression - The expression to set1336 * @param {string} expression - The expression or sprite name to set
1268 * @param {boolean} [force=false] - Whether to force the expression change even if Visual Novel mode is on.1337 * @param {Object} options - Optional parameters
1338 * @param {boolean} [options.force=false] - Whether to force the expression change even if Visual Novel mode is on
1339 * @param {string?} [options.overrideSpriteFile=null] - Set if a specific sprite file should be used. Must be sprite file name.
1269 * @returns {Promise<void>} A promise that resolves when the expression has been set.1340 * @returns {Promise<void>} A promise that resolves when the expression has been set.
1270 */1341 */
1271async function setExpression(character, expression, force = false) {1342async function setExpression(character, expression, { force = false, overrideSpriteFile = null } = {}) {
1272 console.debug('entered setExpressions');
1273 await validateImages(character);1343 await validateImages(character);
1274 const img = $('img.expression');1344 const img = $('img.expression');
1275 const prevExpressionSrc = img.attr('src');1345 const prevExpressionSrc = img.attr('src');
@@ -1277,14 +1347,17 @@ async function setExpression(character, expression, force = false) {
12771347
1278 /** @type {Expression} */1348 /** @type {Expression} */
1279 const sprite = (spriteCache[character] && spriteCache[character].find(x => x.label === expression));1349 const sprite = (spriteCache[character] && spriteCache[character].find(x => x.label === expression));
1280 console.debug('checking for expression images to show..');
1281 if (sprite && sprite.files.length > 0) {1350 if (sprite && sprite.files.length > 0) {
1282 console.debug('setting expression from character images folder');
1283
1284 let spriteFile = sprite.files[0];1351 let spriteFile = sprite.files[0];
12851352
1286 // Calculate next expression, if multiple are allowed1353 // If a specific sprite file should be set, we are looking it up here
1287 if (extension_settings.expressions.allowMultiple && sprite.files.length > 1) {1354 if (overrideSpriteFile) {
1355 const searched = sprite.files.find(x => x.fileName === overrideSpriteFile);
1356 if (searched) spriteFile = searched;
1357 else toastr.warning(t`Couldn't find sprite file ${overrideSpriteFile} for expression ${expression}.`, t`Sprite Not Found`);
1358 }
1359 // Else calculate next expression, if multiple are allowed
1360 else if (extension_settings.expressions.allowMultiple && sprite.files.length > 1) {
1288 let possibleFiles = sprite.files;1361 let possibleFiles = sprite.files;
1289 if (extension_settings.expressions.rerollIfSame) {1362 if (extension_settings.expressions.rerollIfSame) {
1290 possibleFiles = possibleFiles.filter(x => x.imageSrc !== prevExpressionSrc);1363 possibleFiles = possibleFiles.filter(x => x.imageSrc !== prevExpressionSrc);
@@ -1309,6 +1382,7 @@ async function setExpression(character, expression, force = false) {
1309 }1382 }
1310 }1383 }
1311 }1384 }
1385
1312 //only swap expressions when necessary1386 //only swap expressions when necessary
1313 if (prevExpressionSrc !== spriteFile.imageSrc1387 if (prevExpressionSrc !== spriteFile.imageSrc
1314 && !img.hasClass('expression-animating')) {1388 && !img.hasClass('expression-animating')) {
@@ -1360,7 +1434,6 @@ async function setExpression(character, expression, force = false) {
1360 expressionHolder.css('min-height', 100);1434 expressionHolder.css('min-height', 100);
1361 });1435 });
13621436
1363
1364 expressionClone.removeClass('expression-clone');1437 expressionClone.removeClass('expression-clone');
13651438
1366 expressionClone.removeClass('default');1439 expressionClone.removeClass('default');
@@ -1374,26 +1447,44 @@ async function setExpression(character, expression, force = false) {
1374 }1447 }
1375 });1448 });
1376 }1449 }
1450
1451 console.info('Expression set', { expression: spriteFile.expression, file: spriteFile.fileName });
1377 }1452 }
1378 else {1453 else {
1379 if (extension_settings.expressions.showDefault) {1454 if (extension_settings.expressions.showDefault) {
1380 setDefault();1455 setDefault();
1456 } else {
1457 setNone();
1381 }1458 }
1459 console.debug('Expression unset');
1382 }1460 }
13831461
1384 function setDefault() {1462 function setDefault() {
1385 console.debug('setting default');1463 console.debug('setting default expression');
1386 const defImgUrl = `/img/default-expressions/${expression}.png`;1464 const defImgUrl = `/img/default-expressions/${expression}.png`;
1387 //console.log(defImgUrl);1465 //console.log(defImgUrl);
1388 img.attr('src', defImgUrl);1466 img.attr('src', defImgUrl);
1389 img.addClass('default');1467 img.addClass('default');
1390 }1468 }
1469 function setNone() {
1470 console.debug('setting no expression');
1471 img.attr('src', '');
1472 img.removeClass('default');
1473 }
1474
1391 document.getElementById('expression-holder').style.display = '';1475 document.getElementById('expression-holder').style.display = '';
1392}1476}
13931477
1394function onClickExpressionImage() {1478function onClickExpressionImage() {
1395 const expression = $(this).data('expression');1479 // If there is no expression image and we clicked on the placeholder, we remove the sprite by calling via the expression label
1396 setSpriteSlashCommand({}, expression);1480 if ($(this).attr('data-expression-type') === 'failure') {
1481 const label = $(this).attr('data-expression');
1482 setSpriteSlashCommand({ type: 'expression' }, label);
1483 return;
1484 }
1485
1486 const spriteFile = $(this).attr('data-filename');
1487 setSpriteSlashCommand({ type: 'sprite' }, spriteFile);
1397}1488}
13981489
1399async function onClickExpressionAddCustom() {1490async function onClickExpressionAddCustom() {
@@ -1667,8 +1758,9 @@ async function onClickExpressionOverrideButton() {
1667 inApiCall = true;1758 inApiCall = true;
1668 $('#visual-novel-wrapper').empty();1759 $('#visual-novel-wrapper').empty();
1669 await validateImages(overridePath.length === 0 ? currentLastMessage.name : overridePath, true);1760 await validateImages(overridePath.length === 0 ? currentLastMessage.name : overridePath, true);
1761 const name = overridePath.length === 0 ? currentLastMessage.name : overridePath;
1670 const expression = await getExpressionLabel(currentLastMessage.mes);1762 const expression = await getExpressionLabel(currentLastMessage.mes);
1671 await sendExpressionCall(overridePath.length === 0 ? currentLastMessage.name : overridePath, expression, true);1763 await sendExpressionCall(name, expression, { force: true });
1672 forceUpdateVisualNovelMode();1764 forceUpdateVisualNovelMode();
1673 } catch (error) {1765 } catch (error) {
1674 console.debug(`Setting expression override for ${avatarFileName} failed with error: ${error}`);1766 console.debug(`Setting expression override for ${avatarFileName} failed with error: ${error}`);
@@ -1694,7 +1786,7 @@ async function onClickExpressionOverrideRemoveAllButton() {
1694 const currentLastMessage = getLastCharacterMessage();1786 const currentLastMessage = getLastCharacterMessage();
1695 await validateImages(currentLastMessage.name, true);1787 await validateImages(currentLastMessage.name, true);
1696 const expression = await getExpressionLabel(currentLastMessage.mes);1788 const expression = await getExpressionLabel(currentLastMessage.mes);
1697 await sendExpressionCall(currentLastMessage.name, expression, true);1789 await sendExpressionCall(currentLastMessage.name, expression, { force: true });
1698 forceUpdateVisualNovelMode();1790 forceUpdateVisualNovelMode();
16991791
1700 console.debug(extension_settings.expressionOverrides);1792 console.debug(extension_settings.expressionOverrides);
@@ -1933,22 +2025,60 @@ function migrateSettings() {
1933 eventSource.on(event_types.GROUP_UPDATED, updateVisualNovelModeDebounced);2025 eventSource.on(event_types.GROUP_UPDATED, updateVisualNovelModeDebounced);
19342026
1935 const localEnumProviders = {2027 const localEnumProviders = {
1936 expressions: () => getCachedExpressions().map(expression => {2028 expressions: () => {
1937 const isCustom = extension_settings.expressions.custom?.includes(expression);2029 const spriteFolderName = getSpriteFolderName();
1938 return new SlashCommandEnumValue(expression, null, isCustom ? enumTypes.name : enumTypes.enum, isCustom ? 'C' : 'D');2030 const expressions = getCachedExpressions();
1939 }),2031 return expressions.map(expression => {
2032 const spriteCount = spriteCache[spriteFolderName]?.find(x => x.label === expression)?.files.length ?? 0;
2033 const isCustom = extension_settings.expressions.custom?.includes(expression);
2034 const subtitle = spriteCount == 0 ? '❌ No sprites available for this expression' :
2035 spriteCount > 1 ? `${spriteCount} sprites` : null;
2036 return new SlashCommandEnumValue(expression,
2037 subtitle,
2038 isCustom ? enumTypes.name : enumTypes.enum,
2039 isCustom ? 'C' : 'D');
2040 });
2041 },
2042 sprites: () => {
2043 const spriteFolderName = getSpriteFolderName();
2044 const sprites = spriteCache[spriteFolderName]?.map(x => x.files)?.flat() ?? [];
2045 return sprites.map(x => {
2046 return new SlashCommandEnumValue(x.title,
2047 x.title !== x.expression ? x.expression : null,
2048 x.isCustom ? enumTypes.name : enumTypes.enum,
2049 x.isCustom ? 'C' : 'D');
2050 });
2051 },
1940 };2052 };
19412053
1942 SlashCommandParser.addCommandObject(SlashCommand.fromProps({2054 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
1943 name: 'expression-set',2055 name: 'expression-set',
1944 aliases: ['sprite', 'emote'],2056 aliases: ['sprite', 'emote'],
1945 callback: setSpriteSlashCommand,2057 callback: setSpriteSlashCommand,
2058 namedArgumentList: [
2059 SlashCommandNamedArgument.fromProps({
2060 name: 'type',
2061 description: 'Whether to set an expression or a specific sprite.',
2062 typeList: [ARGUMENT_TYPE.STRING],
2063 isRequired: false,
2064 defaultValue: 'expression',
2065 enumList: ['expression', 'sprite'],
2066 }),
2067 ],
1946 unnamedArgumentList: [2068 unnamedArgumentList: [
1947 SlashCommandArgument.fromProps({2069 SlashCommandArgument.fromProps({
1948 description: 'expression label to set',2070 description: 'expression label to set',
1949 typeList: [ARGUMENT_TYPE.STRING],2071 typeList: [ARGUMENT_TYPE.STRING],
1950 isRequired: true,2072 isRequired: true,
1951 enumProvider: localEnumProviders.expressions,2073 enumProvider: (executor, _) => {
2074 // Check if command is used to set a sprite, then use those enums
2075 const type = executor.namedArgumentList.find(it => it.name == 'type')?.value || 'expression';
2076 if (type == 'sprite') return localEnumProviders.sprites();
2077 else return [
2078 ...localEnumProviders.expressions(),
2079 new SlashCommandEnumValue('#reset', 'Resets the expression (to either default or no sprite)', enumTypes.enum, '❌'),
2080 ];
2081 },
1952 }),2082 }),
1953 ],2083 ],
1954 helpString: 'Force sets the expression for the current character.',2084 helpString: 'Force sets the expression for the current character.',
@@ -1957,13 +2087,21 @@ function migrateSettings() {
1957 SlashCommandParser.addCommandObject(SlashCommand.fromProps({2087 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
1958 name: 'expression-folder-override',2088 name: 'expression-folder-override',
1959 aliases: ['spriteoverride', 'costume'],2089 aliases: ['spriteoverride', 'costume'],
1960 callback: setSpriteSetCommand,2090 callback: setSpriteFolderCommand,
1961 unnamedArgumentList: [2091 unnamedArgumentList: [
1962 new SlashCommandArgument(2092 new SlashCommandArgument(
1963 'optional folder', [ARGUMENT_TYPE.STRING], false,2093 'optional folder', [ARGUMENT_TYPE.STRING], false,
1964 ),2094 ),
1965 ],2095 ],
1966 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.',2096 helpString: `
2097 <div>
2098 Sets an override sprite folder for the current character.<br />
2099 In groups, this will apply to the character who last sent a message.
2100 </div>
2101 <div>
2102 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.
2103 </div>
2104 `,
1967 }));2105 }));
1968 SlashCommandParser.addCommandObject(SlashCommand.fromProps({2106 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
1969 name: 'expression-last',2107 name: 'expression-last',
@@ -1997,8 +2135,8 @@ function migrateSettings() {
1997 helpString: 'Returns the last set expression for the named character.',2135 helpString: 'Returns the last set expression for the named character.',
1998 }));2136 }));
1999 SlashCommandParser.addCommandObject(SlashCommand.fromProps({2137 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2000 name: 'expression-classify',2138 name: 'expression-list',
2001 aliases: ['classify-expressions', 'expressions'],2139 aliases: ['expressions'],
2002 /** @type {(args: {return: string}) => Promise<string>} */2140 /** @type {(args: {return: string}) => Promise<string>} */
2003 callback: async (args) => {2141 callback: async (args) => {
2004 let returnType =2142 let returnType =
public/scripts/extensions/expressions/style.css+3 -0
@@ -109,6 +109,9 @@ img.expression.default {
109 flex-direction: column;109 flex-direction: column;
110 align-items: center;110 align-items: center;
111 justify-content: center;111 justify-content: center;
112}
113
114.expression_list_image_container {
112 overflow: hidden;115 overflow: hidden;
113}116}
114117
public/scripts/power-user.js+5 -4
@@ -1833,14 +1833,15 @@ async function loadContextSettings() {
18331833
1834/**1834/**
1835 * Common function to perform fuzzy search with optional caching1835 * Common function to perform fuzzy search with optional caching
1836 * @template T
1836 * @param {string} type - Type of search from fuzzySearchCategories1837 * @param {string} type - Type of search from fuzzySearchCategories
1837 * @param {any[]} data - Data array to search in1838 * @param {T[]} data - Data array to search in
1838 * @param {Array<{name: string, weight: number, getFn?: (obj: any) => string}>} keys - Fuse.js keys configuration1839 * @param {Array<{name: string, weight: number, getFn?: (obj: T) => string}>} keys - Fuse.js keys configuration
1839 * @param {string} searchValue - The search term1840 * @param {string} searchValue - The search term
1840 * @param {Object.<string, { resultMap: Map<string, any> }>} [fuzzySearchCaches=null] - Optional fuzzy search caches1841 * @param {Object.<string, { resultMap: Map<string, any> }>} [fuzzySearchCaches=null] - Optional fuzzy search caches
1841 * @returns {import('fuse.js').FuseResult<any>[]} Results as items with their score1842 * @returns {import('fuse.js').FuseResult<T>[]} Results as items with their score
1842 */1843 */
1843function performFuzzySearch(type, data, keys, searchValue, fuzzySearchCaches = null) {1844export function performFuzzySearch(type, data, keys, searchValue, fuzzySearchCaches = null) {
1844 // Check cache if provided1845 // Check cache if provided
1845 if (fuzzySearchCaches) {1846 if (fuzzySearchCaches) {
1846 const cache = fuzzySearchCaches[type];1847 const cache = fuzzySearchCaches[type];