diff --git a/public/script.js b/public/script.js index d8e58bac7..aca4bd8b7 100644 --- a/public/script.js +++ b/public/script.js @@ -4673,7 +4673,7 @@ function addChatsSeparator(mesSendString) { async function DupeChar() { if (!this_chid) { toastr.warning('You must first select a character to duplicate!'); - return; + return ''; } const confirmMessage = ` @@ -4684,7 +4684,7 @@ async function DupeChar() { if (!confirm) { console.log('User cancelled duplication'); - return; + return ''; } const body = { avatar_url: characters[this_chid].avatar }; @@ -4699,6 +4699,8 @@ async function DupeChar() { await eventSource.emit(event_types.CHARACTER_DUPLICATED, { oldAvatar: body.avatar_url, newAvatar: data.path }); getCharacters(); } + + return ''; } export async function itemizedParams(itemizedPrompts, thisPromptSet) { @@ -8261,10 +8263,12 @@ async function selectInstructCallback(_, name) { async function enableInstructCallback() { $('#instruct_enabled').prop('checked', true).trigger('change'); + return ''; } async function disableInstructCallback() { $('#instruct_enabled').prop('checked', false).trigger('change'); + return ''; } /** @@ -8470,23 +8474,25 @@ async function doDeleteChat() { $(currentChatDeleteButton).trigger('click'); await delay(1); $('#dialogue_popup_ok').trigger('click', { fromSlashCommand: true }); + return ''; } async function doRenameChat(_, chatName) { if (!chatName) { toastr.warning('Name must be provided as an argument to rename this chat.'); - return; + return ''; } const currentChatName = getCurrentChatId(); if (!currentChatName) { toastr.warning('No chat selected that can be renamed.'); - return; + return ''; } await renameChat(currentChatName, chatName); toastr.success(`Successfully renamed chat to: ${chatName}`); + return ''; } /** @@ -8560,6 +8566,7 @@ function doCharListDisplaySwitch() { function doCloseChat() { $('#option_close_chat').trigger('click'); + return ''; } /** @@ -8655,6 +8662,7 @@ async function removeCharacterFromUI(name, avatar, reloadCharacters = true) { function doTogglePanels() { $('#option_settings').trigger('click'); + return ''; } function addDebugFunctions() { @@ -8742,6 +8750,7 @@ jQuery(async function () { await saveSettings(); await saveChatConditional(); toastr.success('Chat and settings saved.'); + return ''; } SlashCommandParser.addCommandObject(SlashCommand.fromProps({ @@ -8893,7 +8902,10 @@ jQuery(async function () { })); SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'chat-manager', - callback: () => $('#option_select_chat').trigger('click'), + callback: () => { + $('#option_select_chat').trigger('click'); + return ''; + }, aliases: ['chat-history', 'manage-chats'], helpString: 'Opens the chat manager for the current character/group.', })); diff --git a/public/scripts/autocomplete/AutoComplete.js b/public/scripts/autocomplete/AutoComplete.js index 79cdf68e4..ba401c68e 100644 --- a/public/scripts/autocomplete/AutoComplete.js +++ b/public/scripts/autocomplete/AutoComplete.js @@ -386,11 +386,15 @@ export class AutoComplete { // no result and no input? hide autocomplete return this.hide(); } + if (this.effectiveParserResult instanceof AutoCompleteSecondaryNameResult && !this.effectiveParserResult.forceMatch) { + // no result and matching is no forced? hide autocomplete + return this.hide(); + } // otherwise add "no match" notice const option = new BlankAutoCompleteOption( this.name.length ? this.effectiveParserResult.makeNoMatchText() - : this.effectiveParserResult.makeNoOptionstext() + : this.effectiveParserResult.makeNoOptionsText() , ); this.result.push(option); diff --git a/public/scripts/autocomplete/AutoCompleteNameResult.js b/public/scripts/autocomplete/AutoCompleteNameResult.js index ffe6a0ed7..41c19cf9f 100644 --- a/public/scripts/autocomplete/AutoCompleteNameResult.js +++ b/public/scripts/autocomplete/AutoCompleteNameResult.js @@ -10,7 +10,7 @@ export class AutoCompleteNameResult { /**@type {AutoCompleteOption[]} */ optionList = []; /**@type {boolean} */ canBeQuoted = false; /**@type {()=>string} */ makeNoMatchText = ()=>`No matches found for "${this.name}"`; - /**@type {()=>string} */ makeNoOptionstext = ()=>'No options'; + /**@type {()=>string} */ makeNoOptionsText = ()=>'No options'; /** @@ -27,7 +27,7 @@ export class AutoCompleteNameResult { this.optionList = optionList; this.canBeQuoted = canBeQuoted; this.noMatchText = makeNoMatchText ?? this.makeNoMatchText; - this.noOptionstext = makeNoOptionsText ?? this.makeNoOptionstext; + this.noOptionstext = makeNoOptionsText ?? this.makeNoOptionsText; } diff --git a/public/scripts/autocomplete/AutoCompleteOption.js b/public/scripts/autocomplete/AutoCompleteOption.js index c3c9e497a..7a12d74b2 100644 --- a/public/scripts/autocomplete/AutoCompleteOption.js +++ b/public/scripts/autocomplete/AutoCompleteOption.js @@ -6,6 +6,7 @@ import { AutoCompleteFuzzyScore } from './AutoCompleteFuzzyScore.js'; export class AutoCompleteOption { /**@type {string}*/ name; /**@type {string}*/ typeIcon; + /**@type {string}*/ type; /**@type {number}*/ nameOffset = 0; /**@type {AutoCompleteFuzzyScore}*/ score; /**@type {string}*/ replacer; @@ -24,9 +25,10 @@ export class AutoCompleteOption { /** * @param {string} name */ - constructor(name, typeIcon = ' ') { + constructor(name, typeIcon = ' ', type = '') { this.name = name; this.typeIcon = typeIcon; + this.type = type; } @@ -181,6 +183,7 @@ export class AutoCompleteOption { let li; li = this.makeItem(this.name, this.typeIcon, true); li.setAttribute('data-name', this.name); + li.setAttribute('data-option-type', this.type); return li; } diff --git a/public/scripts/autocomplete/AutoCompleteSecondaryNameResult.js b/public/scripts/autocomplete/AutoCompleteSecondaryNameResult.js index f9fe6382d..63eccf99f 100644 --- a/public/scripts/autocomplete/AutoCompleteSecondaryNameResult.js +++ b/public/scripts/autocomplete/AutoCompleteSecondaryNameResult.js @@ -2,4 +2,5 @@ import { AutoCompleteNameResult } from './AutoCompleteNameResult.js'; export class AutoCompleteSecondaryNameResult extends AutoCompleteNameResult { /**@type {boolean}*/ isRequired = false; + /**@type {boolean}*/ forceMatch = true; } diff --git a/public/scripts/slash-commands.js b/public/scripts/slash-commands.js index 6f7729ce9..f5a89eb45 100644 --- a/public/scripts/slash-commands.js +++ b/public/scripts/slash-commands.js @@ -56,13 +56,12 @@ import { background_settings } from './backgrounds.js'; import { SlashCommandScope } from './slash-commands/SlashCommandScope.js'; import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js'; import { SlashCommandClosureResult } from './slash-commands/SlashCommandClosureResult.js'; -import { AutoCompleteNameResult } from './autocomplete/AutoCompleteNameResult.js'; -import { AutoCompleteOption } from './autocomplete/AutoCompleteOption.js'; import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js'; import { AutoComplete } from './autocomplete/AutoComplete.js'; import { SlashCommand } from './slash-commands/SlashCommand.js'; import { SlashCommandAbortController } from './slash-commands/SlashCommandAbortController.js'; import { SlashCommandNamedArgumentAssignment } from './slash-commands/SlashCommandNamedArgumentAssignment.js'; +import { SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js'; export { executeSlashCommands, executeSlashCommandsWithOptions, getSlashCommandsHelp, registerSlashCommand, }; @@ -117,9 +116,14 @@ SlashCommandParser.addCommandObject(SlashCommand.fromProps({ aliases: ['background'], returns: 'the current background', unnamedArgumentList: [ - new SlashCommandArgument( - 'filename', [ARGUMENT_TYPE.STRING], true, - ), + SlashCommandArgument.fromProps({ description: 'filename', + typeList: [ARGUMENT_TYPE.STRING], + isRequired: true, + enumProvider: ()=>[...document.querySelectorAll('.bg_example')] + .map((it)=>new SlashCommandEnumValue(it.getAttribute('bgfile'))) + .filter(it=>it.value?.length) + , + }), ], helpString: `
@@ -323,9 +327,14 @@ SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'go', callback: goToCharacterCallback, unnamedArgumentList: [ - new SlashCommandArgument( - 'name', [ARGUMENT_TYPE.STRING], true, - ), + SlashCommandArgument.fromProps({ description: 'name', + typeList: [ARGUMENT_TYPE.STRING], + isRequired: true, + enumProvider: ()=>[ + ...characters.map(it=>new SlashCommandEnumValue(it.name, null, 'qr', 'C')), + ...groups.map(it=>new SlashCommandEnumValue(it.name, null, 'variable', 'G')), + ], + }), ], helpString: 'Opens up a chat with the character or group by its name', aliases: ['char'], @@ -367,9 +376,12 @@ SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'ask', callback: askCharacter, namedArgumentList: [ - new SlashCommandNamedArgument( - 'name', 'character name', [ARGUMENT_TYPE.STRING], true, false, '', - ), + SlashCommandNamedArgument.fromProps({ name: 'name', + description: 'character name', + typeList: [ARGUMENT_TYPE.STRING], + isRequired: true, + enumProvider: ()=>characters.map(it=>new SlashCommandEnumValue(it.name, null, 'qr', 'C')), + }), ], unnamedArgumentList: [ new SlashCommandArgument( @@ -1572,7 +1584,7 @@ function abortCallback({ _abortController, quiet }, reason) { async function delayCallback(_, amount) { if (!amount) { console.warn('WARN: No amount provided for /delay command'); - return; + return ''; } amount = Number(amount); @@ -1581,6 +1593,7 @@ async function delayCallback(_, amount) { } await delay(amount); + return ''; } async function inputCallback(args, prompt) { @@ -1767,27 +1780,27 @@ async function addSwipeCallback(_, arg) { if (!lastMessage) { toastr.warning('No messages to add swipes to.'); - return; + return ''; } if (!arg) { console.warn('WARN: No argument provided for /addswipe command'); - return; + return ''; } if (lastMessage.is_user) { toastr.warning('Can\'t add swipes to user messages.'); - return; + return ''; } if (lastMessage.is_system) { toastr.warning('Can\'t add swipes to system messages.'); - return; + return ''; } if (lastMessage.extra?.image) { toastr.warning('Can\'t add swipes to message containing an image.'); - return; + return ''; } if (!Array.isArray(lastMessage.swipes)) { @@ -1811,6 +1824,8 @@ async function addSwipeCallback(_, arg) { await saveChatConditional(); await reloadCurrentChat(); + + return ''; } async function deleteSwipeCallback(_, arg) { @@ -1818,19 +1833,19 @@ async function deleteSwipeCallback(_, arg) { if (!lastMessage || !Array.isArray(lastMessage.swipes) || !lastMessage.swipes.length) { toastr.warning('No messages to delete swipes from.'); - return; + return ''; } if (lastMessage.swipes.length <= 1) { toastr.warning('Can\'t delete the last swipe.'); - return; + return ''; } const swipeId = arg && !isNaN(Number(arg)) ? (Number(arg) - 1) : lastMessage.swipe_id; if (swipeId < 0 || swipeId >= lastMessage.swipes.length) { toastr.warning(`Invalid swipe ID: ${swipeId + 1}`); - return; + return ''; } lastMessage.swipes.splice(swipeId, 1); @@ -1845,6 +1860,8 @@ async function deleteSwipeCallback(_, arg) { await saveChatConditional(); await reloadCurrentChat(); + + return ''; } async function askCharacter(args, text) { @@ -1855,13 +1872,13 @@ async function askCharacter(args, text) { // TODO: Maybe support group chats? if (selected_group) { toastr.error('Cannot run this command in a group chat!'); - return; + return ''; } if (!text) { console.warn('WARN: No text provided for /ask command'); toastr.warning('No text provided for /ask command'); - return; + return ''; } let name = ''; @@ -1873,7 +1890,7 @@ async function askCharacter(args, text) { if (!name && !mesText) { toastr.warning('You must specify a name and text to ask.'); - return; + return ''; } } @@ -1885,7 +1902,7 @@ async function askCharacter(args, text) { const chId = characters.findIndex((e) => e.name === name); if (!characters[chId] || chId === -1) { toastr.error('Character not found.'); - return; + return ''; } // Override character and send a user message @@ -1935,22 +1952,24 @@ async function askCharacter(args, text) { // Restore previous character once message renders // Hack for generate eventSource.on(event_types.CHARACTER_MESSAGE_RENDERED, restoreCharacter); + return ''; } async function hideMessageCallback(_, arg) { if (!arg) { console.warn('WARN: No argument provided for /hide command'); - return; + return ''; } const range = stringToRange(arg, 0, chat.length - 1); if (!range) { console.warn(`WARN: Invalid range provided for /hide command: ${arg}`); - return; + return ''; } await hideChatMessageRange(range.start, range.end, false); + return ''; } async function unhideMessageCallback(_, arg) { @@ -2347,7 +2366,7 @@ export async function generateSystemMessage(_, prompt) { if (!prompt) { console.warn('WARN: No prompt provided for /sysgen command'); toastr.warning('You must provide a prompt for the system message'); - return; + return ''; } // Generate and regex the output if applicable @@ -2356,43 +2375,49 @@ export async function generateSystemMessage(_, prompt) { message = getRegexedString(message, regex_placement.SLASH_COMMAND); sendNarratorMessage(_, message); + return ''; } function syncCallback() { $('#sync_name_button').trigger('click'); + return ''; } function bindCallback() { $('#lock_user_name').trigger('click'); + return ''; } function setStoryModeCallback() { $('#chat_display').val(chat_styles.DOCUMENT).trigger('change'); + return ''; } function setBubbleModeCallback() { $('#chat_display').val(chat_styles.BUBBLES).trigger('change'); + return ''; } function setFlatModeCallback() { $('#chat_display').val(chat_styles.DEFAULT).trigger('change'); + return ''; } /** * Sets a persona name and optionally an avatar. * @param {{mode: 'lookup' | 'temp' | 'all'}} namedArgs Named arguments * @param {string} name Name to set - * @returns {void} + * @returns {string} */ function setNameCallback({ mode = 'all' }, name) { if (!name) { toastr.warning('You must specify a name to change to'); - return; + return ''; } if (!['lookup', 'temp', 'all'].includes(mode)) { toastr.warning('Mode must be one of "lookup", "temp" or "all"'); - return; + return ''; } name = name.trim(); @@ -2404,10 +2429,10 @@ function setNameCallback({ mode = 'all' }, name) { if (persona) { autoSelectPersona(persona); retriggerFirstMessageOnEmptyChat(); - return; + return ''; } else if (mode === 'lookup') { toastr.warning(`Persona ${name} not found`); - return; + return ''; } } @@ -2416,6 +2441,8 @@ function setNameCallback({ mode = 'all' }, name) { setUserName(name); //this prevented quickReply usage retriggerFirstMessageOnEmptyChat(); } + + return ''; } async function setNarratorName(_, text) { @@ -2423,11 +2450,12 @@ async function setNarratorName(_, text) { chat_metadata[NARRATOR_NAME_KEY] = name; toastr.info(`System narrator name set to ${name}`); await saveChatConditional(); + return ''; } export async function sendMessageAs(args, text) { if (!text) { - return; + return ''; } let name; @@ -2439,7 +2467,7 @@ export async function sendMessageAs(args, text) { if (!name && !text) { toastr.warning('You must specify a name and text to send as'); - return; + return ''; } } else { const namelessWarningKey = 'sendAsNamelessWarningShown'; @@ -2500,11 +2528,13 @@ export async function sendMessageAs(args, text) { await eventSource.emit(event_types.CHARACTER_MESSAGE_RENDERED, (chat.length - 1)); await saveChatConditional(); } + + return ''; } export async function sendNarratorMessage(args, text) { if (!text) { - return; + return ''; } const name = chat_metadata[NARRATOR_NAME_KEY] || NARRATOR_NAME_DEFAULT; @@ -2543,6 +2573,8 @@ export async function sendNarratorMessage(args, text) { await eventSource.emit(event_types.USER_MESSAGE_RENDERED, (chat.length - 1)); await saveChatConditional(); } + + return ''; } export async function promptQuietForLoudResponse(who, text) { @@ -2586,7 +2618,7 @@ export async function promptQuietForLoudResponse(who, text) { async function sendCommentMessage(args, text) { if (!text) { - return; + return ''; } const compact = isTrueBoolean(args?.compact); @@ -2619,6 +2651,8 @@ async function sendCommentMessage(args, text) { await eventSource.emit(event_types.USER_MESSAGE_RENDERED, (chat.length - 1)); await saveChatConditional(); } + + return ''; } /** @@ -2656,6 +2690,8 @@ function helpCommandCallback(_, type) { sendSystemMessage(system_message_types.HELP); break; } + + return ''; } $(document).on('click', '[data-displayHelp]', function (e) { @@ -2680,7 +2716,7 @@ function setBackgroundCallback(_, bg) { if (!result.length) { toastr.error(`No background found with name "${bg}"`); - return; + return ''; } const bgElement = result[0].item.element; @@ -2688,6 +2724,8 @@ function setBackgroundCallback(_, bg) { if (bgElement instanceof HTMLElement) { bgElement.click(); } + + return ''; } /** @@ -2910,6 +2948,8 @@ export async function executeSlashCommandsOnChatInput(text, options = {}) { result = await executeSlashCommandsWithOptions(text, { abortController: commandsFromChatInputAbortController, onProgress: (done, total) => ta.style.setProperty('--prog', `${done / total * 100}%`), + parserFlags: options.parserFlags, + scope: options.scope, }); if (commandsFromChatInputAbortController.signal.aborted) { document.querySelector('#form_sheld').classList.add('script_aborted'); @@ -3006,7 +3046,7 @@ async function executeSlashCommandsWithOptions(text, options = {}) { * @param {boolean} handleParserErrors Whether to handle parser errors (show toast on error) or throw * @param {SlashCommandScope} scope The scope to be used when executing the commands. * @param {boolean} handleExecutionErrors Whether to handle execution errors (show toast on error) or throw - * @param {PARSER_FLAG[]} parserFlags Parser flags to apply + * @param {{[id:PARSER_FLAG]:boolean}} parserFlags Parser flags to apply * @param {SlashCommandAbortController} abortController Controller used to abort or pause command execution * @param {(done:number, total:number)=>void} onProgress Callback to handle progress events * @returns {Promise} diff --git a/public/scripts/slash-commands/SlashCommand.js b/public/scripts/slash-commands/SlashCommand.js index bb0e2f9af..d04934924 100644 --- a/public/scripts/slash-commands/SlashCommand.js +++ b/public/scripts/slash-commands/SlashCommand.js @@ -9,10 +9,10 @@ import { SlashCommandScope } from './SlashCommandScope.js'; /** * @typedef {{ - * _pipe:string|SlashCommandClosure, * _scope:SlashCommandScope, * _parserFlags:{[id:PARSER_FLAG]:boolean}, * _abortController:SlashCommandAbortController, + * _hasUnnamedArgument:boolean, * [id:string]:string|SlashCommandClosure, * }} NamedArguments */ @@ -33,7 +33,7 @@ export class SlashCommand { * Creates a SlashCommand from a properties object. * @param {Object} props * @param {string} [props.name] - * @param {(namedArguments:NamedArguments|NamedArgumentsCapture, unnamedArguments:string|SlashCommandClosure|(string|SlashCommandClosure)[])=>string|SlashCommandClosure|void|Promise} [props.callback] + * @param {(namedArguments:NamedArguments|NamedArgumentsCapture, unnamedArguments:string|SlashCommandClosure|(string|SlashCommandClosure)[])=>string|SlashCommandClosure|Promise} [props.callback] * @param {string} [props.helpString] * @param {boolean} [props.splitUnnamedArgument] * @param {string[]} [props.aliases] diff --git a/public/scripts/slash-commands/SlashCommandArgument.js b/public/scripts/slash-commands/SlashCommandArgument.js index 12d1c3b1a..324d5b9d6 100644 --- a/public/scripts/slash-commands/SlashCommandArgument.js +++ b/public/scripts/slash-commands/SlashCommandArgument.js @@ -1,5 +1,6 @@ import { SlashCommandClosure } from './SlashCommandClosure.js'; import { SlashCommandEnumValue } from './SlashCommandEnumValue.js'; +import { SlashCommandExecutor } from './SlashCommandExecutor.js'; @@ -29,6 +30,8 @@ export class SlashCommandArgument { * @param {boolean} [props.acceptsMultiple] default: false - whether argument accepts multiple values * @param {string|SlashCommandClosure} [props.defaultValue] default value if no value is provided * @param {string|SlashCommandEnumValue|(string|SlashCommandEnumValue)[]} [props.enumList] list of accepted values + * @param {(executor:SlashCommandExecutor)=>SlashCommandEnumValue[]} [props.enumProvider] function that returns auto complete options + * @param {boolean} [props.forceEnum] default: true - whether the input must match one of the enum values */ static fromProps(props) { return new SlashCommandArgument( @@ -38,6 +41,8 @@ export class SlashCommandArgument { props.acceptsMultiple ?? false, props.defaultValue ?? null, props.enumList ?? [], + props.enumProvider ?? null, + props.forceEnum ?? true, ); } @@ -50,6 +55,8 @@ export class SlashCommandArgument { /**@type {boolean}*/ acceptsMultiple = false; /**@type {string|SlashCommandClosure}*/ defaultValue; /**@type {SlashCommandEnumValue[]}*/ enumList = []; + /**@type {(executor:SlashCommandExecutor)=>SlashCommandEnumValue[]}*/ enumProvider = null; + /**@type {boolean}*/ forceEnum = true; /** @@ -57,8 +64,9 @@ export class SlashCommandArgument { * @param {ARGUMENT_TYPE|ARGUMENT_TYPE[]} types * @param {string|SlashCommandClosure} defaultValue * @param {string|SlashCommandEnumValue|(string|SlashCommandEnumValue)[]} enums + * @param {(executor:SlashCommandExecutor)=>SlashCommandEnumValue[]} enumProvider function that returns auto complete options */ - constructor(description, types, isRequired = false, acceptsMultiple = false, defaultValue = null, enums = []) { + constructor(description, types, isRequired = false, acceptsMultiple = false, defaultValue = null, enums = [], enumProvider = null, forceEnum = true) { this.description = description; this.typeList = types ? Array.isArray(types) ? types : [types] : []; this.isRequired = isRequired ?? false; @@ -68,6 +76,8 @@ export class SlashCommandArgument { if (it instanceof SlashCommandEnumValue) return it; return new SlashCommandEnumValue(it); }); + this.enumProvider = enumProvider; + this.forceEnum = forceEnum; } } @@ -85,6 +95,8 @@ export class SlashCommandNamedArgument extends SlashCommandArgument { * @param {boolean} [props.acceptsMultiple] default: false - whether argument accepts multiple values * @param {string|SlashCommandClosure} [props.defaultValue] default value if no value is provided * @param {string|SlashCommandEnumValue|(string|SlashCommandEnumValue)[]} [props.enumList] list of accepted values + * @param {(executor:SlashCommandExecutor)=>SlashCommandEnumValue[]} [props.enumProvider] function that returns auto complete options + * @param {boolean} [props.forceEnum] default: true - whether the input must match one of the enum values */ static fromProps(props) { return new SlashCommandNamedArgument( @@ -96,6 +108,8 @@ export class SlashCommandNamedArgument extends SlashCommandArgument { props.defaultValue ?? null, props.enumList ?? [], props.aliasList ?? [], + props.enumProvider ?? null, + props.forceEnum ?? true, ); } @@ -112,9 +126,11 @@ export class SlashCommandNamedArgument extends SlashCommandArgument { * @param {ARGUMENT_TYPE|ARGUMENT_TYPE[]} types * @param {string|SlashCommandClosure} defaultValue * @param {string|SlashCommandEnumValue|(string|SlashCommandEnumValue)[]} enums + * @param {(executor:SlashCommandExecutor)=>SlashCommandEnumValue[]} enumProvider function that returns auto complete options + * @param {boolean} forceEnum */ - constructor(name, description, types, isRequired = false, acceptsMultiple = false, defaultValue = null, enums = [], aliases = []) { - super(description, types, isRequired, acceptsMultiple, defaultValue, enums); + constructor(name, description, types, isRequired = false, acceptsMultiple = false, defaultValue = null, enums = [], aliases = [], enumProvider = null, forceEnum = true) { + super(description, types, isRequired, acceptsMultiple, defaultValue, enums, enumProvider, forceEnum); this.name = name; this.aliasList = aliases ? Array.isArray(aliases) ? aliases : [aliases] : []; } diff --git a/public/scripts/slash-commands/SlashCommandAutoCompleteNameResult.js b/public/scripts/slash-commands/SlashCommandAutoCompleteNameResult.js index 8a9c08653..d2c7852e3 100644 --- a/public/scripts/slash-commands/SlashCommandAutoCompleteNameResult.js +++ b/public/scripts/slash-commands/SlashCommandAutoCompleteNameResult.js @@ -43,6 +43,7 @@ export class SlashCommandAutoCompleteNameResult extends AutoCompleteNameResult { [...namedResult.optionList, ...unnamedResult.optionList], ); combinedResult.isRequired = namedResult.isRequired || unnamedResult.isRequired; + combinedResult.forceMatch = namedResult.forceMatch && unnamedResult.forceMatch; return combinedResult; } } @@ -102,18 +103,19 @@ export class SlashCommandAutoCompleteNameResult extends AutoCompleteNameResult { if (name.includes('=') && cmdArg) { // if cursor is already behind "=" check for enums - /**@type {SlashCommandNamedArgument} */ - if (cmdArg && cmdArg.enumList?.length) { - if (isSelect && cmdArg.enumList.includes(value) && argAssign && argAssign.end == index) { + const enumList = cmdArg?.enumProvider?.(this.executor) ?? cmdArg?.enumList; + if (cmdArg && enumList?.length) { + if (isSelect && enumList.find(it=>it.value == value) && argAssign && argAssign.end == index) { return null; } const result = new AutoCompleteSecondaryNameResult( value, start + name.length, - cmdArg.enumList.map(it=>new SlashCommandEnumAutoCompleteOption(this.executor.command, it)), + enumList.map(it=>new SlashCommandEnumAutoCompleteOption(this.executor.command, it)), true, ); result.isRequired = true; + result.forceMatch = cmdArg.forceEnum; return result; } } @@ -148,7 +150,8 @@ export class SlashCommandAutoCompleteNameResult extends AutoCompleteNameResult { if (idx > -1) { argAssign = this.executor.unnamedArgumentList[idx]; cmdArg = this.executor.command.unnamedArgumentList[idx]; - if (cmdArg && cmdArg.enumList.length > 0) { + const enumList = cmdArg?.enumProvider?.(this.executor) ?? cmdArg?.enumList; + if (cmdArg && enumList.length > 0) { value = argAssign.value.toString().slice(0, index - argAssign.start); start = argAssign.start; } else { @@ -163,17 +166,19 @@ export class SlashCommandAutoCompleteNameResult extends AutoCompleteNameResult { return null; } - if (cmdArg == null || cmdArg.enumList.length == 0) return null; + const enumList = cmdArg?.enumProvider?.(this.executor) ?? cmdArg?.enumList; + if (cmdArg == null || enumList.length == 0) return null; const result = new AutoCompleteSecondaryNameResult( value, start, - cmdArg.enumList.map(it=>new SlashCommandEnumAutoCompleteOption(this.executor.command, it)), + enumList.map(it=>new SlashCommandEnumAutoCompleteOption(this.executor.command, it)), false, ); - const isCompleteValue = cmdArg.enumList.find(it=>it.value == value); + const isCompleteValue = enumList.find(it=>it.value == value); const isSelectedValue = isSelect && isCompleteValue; result.isRequired = cmdArg.isRequired && !isSelectedValue && !isCompleteValue; + result.forceMatch = cmdArg.forceEnum; return result; } } diff --git a/public/scripts/slash-commands/SlashCommandClosure.js b/public/scripts/slash-commands/SlashCommandClosure.js index 27f81f38d..89eda369a 100644 --- a/public/scripts/slash-commands/SlashCommandClosure.js +++ b/public/scripts/slash-commands/SlashCommandClosure.js @@ -1,5 +1,6 @@ import { substituteParams } from '../../script.js'; import { delay, escapeRegex } from '../utils.js'; +import { SlashCommand } from './SlashCommand.js'; import { SlashCommandAbortController } from './SlashCommandAbortController.js'; import { SlashCommandClosureExecutor } from './SlashCommandClosureExecutor.js'; import { SlashCommandClosureResult } from './SlashCommandClosureResult.js'; @@ -17,6 +18,7 @@ export class SlashCommandClosure { /**@type {SlashCommandExecutor[]}*/ executorList = []; /**@type {SlashCommandAbortController}*/ abortController; /**@type {(done:number, total:number)=>void}*/ onProgress; + /**@type {string}*/ rawText; /**@type {number}*/ get commandCount() { @@ -148,6 +150,9 @@ export class SlashCommandClosure { } let done = 0; + if (this.executorList.length == 0) { + this.scope.pipe = ''; + } for (const executor of this.executorList) { this.onProgress?.(done, this.commandCount); if (executor instanceof SlashCommandClosureExecutor) { @@ -158,10 +163,12 @@ export class SlashCommandClosure { const result = await closure.execute(); this.scope.pipe = result.pipe; } else { + /**@type {import('./SlashCommand.js').NamedArguments} */ let args = { _scope: this.scope, _parserFlags: executor.parserFlags, _abortController: this.abortController, + _hasUnnamedArgument: executor.unnamedArgumentList.length > 0, }; let value; // substitute named arguments @@ -191,6 +198,7 @@ export class SlashCommandClosure { if (executor.unnamedArgumentList.length == 0) { if (executor.injectPipe) { value = this.scope.pipe; + args._hasUnnamedArgument = this.scope.pipe !== null && this.scope.pipe !== undefined; } } else { value = []; @@ -214,7 +222,7 @@ export class SlashCommandClosure { if (value.length == 1) { value = value[0]; } else if (!value.find(it=>it instanceof SlashCommandClosure)) { - value = value.join(' '); + value = value.join(''); } } } @@ -241,6 +249,7 @@ export class SlashCommandClosure { } executor.onProgress = (subDone, subTotal)=>this.onProgress?.(done + subDone, this.commandCount); this.scope.pipe = await executor.command.callback(args, value ?? ''); + this.#lintPipe(executor.command); done += executor.commandCount; this.onProgress?.(done, this.commandCount); abortResult = await this.testAbortController(); @@ -269,4 +278,15 @@ export class SlashCommandClosure { return result; } } + + /** + * Auto-fixes the pipe if it is not a valid result for STscript. + * @param {SlashCommand} command Command being executed + */ + #lintPipe(command) { + if (this.scope.pipe === undefined || this.scope.pipe === null) { + console.warn(`${command.name} returned undefined or null. Auto-fixing to empty string.`); + this.scope.pipe = ''; + } + } } diff --git a/public/scripts/slash-commands/SlashCommandEnumAutoCompleteOption.js b/public/scripts/slash-commands/SlashCommandEnumAutoCompleteOption.js index 12d9c64ad..748454b11 100644 --- a/public/scripts/slash-commands/SlashCommandEnumAutoCompleteOption.js +++ b/public/scripts/slash-commands/SlashCommandEnumAutoCompleteOption.js @@ -13,7 +13,7 @@ export class SlashCommandEnumAutoCompleteOption extends AutoCompleteOption { * @param {SlashCommandEnumValue} enumValue */ constructor(cmd, enumValue) { - super(enumValue.value, '◊'); + super(enumValue.value, enumValue.typeIcon, enumValue.type); this.cmd = cmd; this.enumValue = enumValue; } @@ -21,9 +21,9 @@ export class SlashCommandEnumAutoCompleteOption extends AutoCompleteOption { renderItem() { let li; - li = this.makeItem(this.name, '◊', true, [], [], null, this.enumValue.description); + li = this.makeItem(this.name, this.typeIcon, true, [], [], null, this.enumValue.description); li.setAttribute('data-name', this.name); - li.setAttribute('data-option-type', 'enum'); + li.setAttribute('data-option-type', this.type); return li; } diff --git a/public/scripts/slash-commands/SlashCommandEnumValue.js b/public/scripts/slash-commands/SlashCommandEnumValue.js index 742c843b0..1fa610c56 100644 --- a/public/scripts/slash-commands/SlashCommandEnumValue.js +++ b/public/scripts/slash-commands/SlashCommandEnumValue.js @@ -1,10 +1,14 @@ export class SlashCommandEnumValue { /**@type {string}*/ value; /**@type {string}*/ description; + /**@type {string}*/ type = 'enum'; + /**@type {string}*/ typeIcon = '◊'; - constructor(value, description = null) { + constructor(value, description = null, type = 'enum', typeIcon = '◊') { this.value = value; this.description = description; + this.type = type; + this.typeIcon = typeIcon; } toString() { diff --git a/public/scripts/slash-commands/SlashCommandParser.js b/public/scripts/slash-commands/SlashCommandParser.js index 9b839ca23..8dffa666e 100644 --- a/public/scripts/slash-commands/SlashCommandParser.js +++ b/public/scripts/slash-commands/SlashCommandParser.js @@ -598,6 +598,7 @@ export class SlashCommandParser { this.closureIndex.push(closureIndexEntry); let injectPipe = true; if (!isRoot) this.take(2); // discard opening {: + const textStart = this.index; let closure = new SlashCommandClosure(this.scope); closure.abortController = this.abortController; this.scope = closure.scope; @@ -638,13 +639,13 @@ export class SlashCommandParser { } this.discardWhitespace(); // discard further whitespace } + closure.rawText = this.text.slice(textStart, this.index); if (!isRoot) this.take(2); // discard closing :} if (this.testSymbol('()')) { this.take(2); // discard () closure.executeNow = true; } closureIndexEntry.end = this.index - 1; - this.discardWhitespace(); // discard trailing whitespace this.scope = closure.scope.parent; return closure; } @@ -820,9 +821,8 @@ export class SlashCommandParser { if (this.testClosure()) { isList = true; if (value.length > 0) { - assignment.end = assignment.end - (value.length - value.trim().length); this.indexMacros(this.index - value.length, value); - assignment.value = value.trim(); + assignment.value = value; listValues.push(assignment); assignment = new SlashCommandUnnamedArgumentAssignment(); assignment.start = this.index; @@ -834,6 +834,7 @@ export class SlashCommandParser { listValues.push(assignment); assignment = new SlashCommandUnnamedArgumentAssignment(); assignment.start = this.index; + if (split) this.discardWhitespace(); } else if (split) { if (this.testQuotedValue()) { assignment.start = this.index; @@ -862,8 +863,8 @@ export class SlashCommandParser { assignment.end = this.index; } } - if (isList && value.trim().length > 0) { - assignment.value = value.trim(); + if (isList && value.length > 0) { + assignment.value = value; listValues.push(assignment); } if (isList) { diff --git a/public/scripts/slash-commands/SlashCommandScope.js b/public/scripts/slash-commands/SlashCommandScope.js index f6a7100b9..7db2572c5 100644 --- a/public/scripts/slash-commands/SlashCommandScope.js +++ b/public/scripts/slash-commands/SlashCommandScope.js @@ -92,7 +92,7 @@ export class SlashCommandScope { v = v[numIndex]; } if (typeof v == 'object') return JSON.stringify(v); - return v; + return v ?? ''; } else { const value = this.variables[key]; return (value === '' || isNaN(Number(value))) ? (value || '') : Number(value); diff --git a/public/scripts/tags.js b/public/scripts/tags.js index 336742986..05015c686 100644 --- a/public/scripts/tags.js +++ b/public/scripts/tags.js @@ -20,6 +20,8 @@ import { power_user } from './power-user.js'; import { SlashCommandParser } from './slash-commands/SlashCommandParser.js'; import { SlashCommand } from './slash-commands/SlashCommand.js'; import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js'; +import { SlashCommandEnumValue } from './slash-commands/SlashCommandEnumValue.js'; +import { SlashCommandExecutor } from './slash-commands/SlashCommandExecutor.js'; export { TAG_FOLDER_TYPES, @@ -1607,10 +1609,28 @@ function registerTagsSlashCommands() { return String(result); }, namedArgumentList: [ - new SlashCommandNamedArgument('name', 'Character name', [ARGUMENT_TYPE.STRING], false, false, '{{char}}'), + SlashCommandNamedArgument.fromProps({ name: 'name', + description: 'Character name', + typeList: [ARGUMENT_TYPE.STRING], + defaultValue: '{{char}}', + enumProvider: ()=>[ + ...characters.map(it=>new SlashCommandEnumValue(it.name, null, 'qr', 'C')), + ...groups.map(it=>new SlashCommandEnumValue(it.name, null, 'variable', 'G')), + ], + }), ], unnamedArgumentList: [ - new SlashCommandArgument('tag name', [ARGUMENT_TYPE.STRING], true), + SlashCommandArgument.fromProps({ description: 'tag name', + typeList: [ARGUMENT_TYPE.STRING], + isRequired: true, + enumProvider: (executor)=>{ + const key = paraGetCharKey(/**@type {string}*/(executor.namedArgumentList.find(it=>it.name == 'name')?.value)); + if (!key) return tags.map(it=>new SlashCommandEnumValue(it.name, it.title)); + const assigned = getTagsList(key); + return tags.filter(it=>!assigned.includes(it)).map(it=>new SlashCommandEnumValue(it.name, it.title)); + }, + forceEnum: false, + }), ], helpString: `
@@ -1642,10 +1662,27 @@ function registerTagsSlashCommands() { return String(result); }, namedArgumentList: [ - new SlashCommandNamedArgument('name', 'Character name', [ARGUMENT_TYPE.STRING], false, false, '{{char}}'), + SlashCommandNamedArgument.fromProps({ name: 'name', + description: 'Character name', + typeList: [ARGUMENT_TYPE.STRING], + defaultValue: '{{char}}', + enumProvider: ()=>[ + ...characters.map(it=>new SlashCommandEnumValue(it.name, null, 'qr', 'C')), + ...groups.map(it=>new SlashCommandEnumValue(it.name, null, 'variable', 'G')), + ], + }), ], unnamedArgumentList: [ - new SlashCommandArgument('tag name', [ARGUMENT_TYPE.STRING], true), + SlashCommandArgument.fromProps({ description: 'tag name', + typeList: [ARGUMENT_TYPE.STRING], + isRequired: true, + /**@param {SlashCommandExecutor} executor */ + enumProvider: (executor)=>{ + const key = paraGetCharKey(/**@type {string}*/(executor.namedArgumentList.find(it=>it.name == 'name')?.value)); + if (!key) return tags.map(it=>new SlashCommandEnumValue(it.name, it.title)); + return getTagsList(key).map(it=>new SlashCommandEnumValue(it.name, it.title)); + }, + }), ], helpString: `
diff --git a/public/scripts/variables.js b/public/scripts/variables.js index c8e47c77f..de8ff390b 100644 --- a/public/scripts/variables.js +++ b/public/scripts/variables.js @@ -795,7 +795,7 @@ function letCallback(args, value) { /** * Set or retrieve a variable in the current scope or nearest ancestor scope. - * @param {{_scope:SlashCommandScope, key?:string, index?:string|number}} args Named arguments. + * @param {{_hasUnnamedArgument:boolean, _scope:SlashCommandScope, key?:string, index?:string|number}} args Named arguments. * @param {string|SlashCommandClosure|(string|SlashCommandClosure)[]} value Name and optional value for the variable. * @returns The variable's value */ @@ -803,9 +803,13 @@ function varCallback(args, value) { if (!Array.isArray(value)) value = [value]; if (args.key !== undefined) { const key = args.key; - const val = value.join(' '); - args._scope.setVariable(key, val, args.index); - return val; + if (args._hasUnnamedArgument) { + const val = value.join(' '); + args._scope.setVariable(key, val, args.index); + return val; + } else { + return args._scope.getVariable(key, args.index); + } } const key = value.shift(); if (value.length > 0) { @@ -817,6 +821,30 @@ function varCallback(args, value) { } } +/** + * @param {import('./slash-commands/SlashCommand.js').NamedArguments} args + * @param {SlashCommandClosure} value + * @returns {string} + */ +function closureSerializeCallback(args, value) { + if (!(value instanceof SlashCommandClosure)) { + throw new Error('unnamed argument must be a closure'); + } + return value.rawText; +} + +/** + * @param {import('./slash-commands/SlashCommand.js').NamedArguments} args + * @param {import('./slash-commands/SlashCommand.js').UnnamedArguments} value + * @returns {SlashCommandClosure} + */ +function closureDeserializeCallback(args, value) { + const parser = new SlashCommandParser(); + const closure = parser.parse(value, true, args._parserFlags, args._abortController); + closure.scope.parent = args._scope; + return closure; +} + export function registerVariableCommands() { SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'listvar', callback: listVariablesCallback, @@ -1811,4 +1839,61 @@ export function registerVariableCommands() {
`, })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'closure-serialize', + /** + * + * @param {import('./slash-commands/SlashCommand.js').NamedArguments} args + * @param {SlashCommandClosure} value + * @returns {string} + */ + callback: (args, value)=>closureSerializeCallback(args, value), + unnamedArgumentList: [ + SlashCommandArgument.fromProps({ description: 'the closure to serialize', + typeList: [ARGUMENT_TYPE.CLOSURE], + isRequired: true, + }), + ], + returns: 'serialized closure as string', + helpString: ` +
+ Serialize a closure as text that can be stored in global and chat variables. +
+
+ Examples: +
    +
  • +
    /closure-serialize {: x=1 /echo x is {{var::x}} and y is {{var::y}} :} |\n/setvar key=myClosure
    +
  • +
+
+ `, + })); + SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'closure-deserialize', + /** + * @param {import('./slash-commands/SlashCommand.js').NamedArguments} args + * @param {import('./slash-commands/SlashCommand.js').UnnamedArguments} value + * @returns {SlashCommandClosure} + */ + callback: (args, value)=>closureDeserializeCallback(args, value), + unnamedArgumentList: [ + SlashCommandArgument.fromProps({ description: 'serialized closure', + typeList: [ARGUMENT_TYPE.STRING], + isRequired: true, + }), + ], + returns: 'deserialized closure', + helpString: ` +
+ Deserialize a closure from text. +
+
+ Examples: +
    +
  • +
    /closure-deserialize {{getvar::myClosure}} |\n/let myClosure {{pipe}} |\n/let y bar |\n/:myClosure x=foo
    +
  • +
+
+ `, + })); } diff --git a/public/scripts/world-info.js b/public/scripts/world-info.js index 7a1c01e43..76bb3ab3f 100644 --- a/public/scripts/world-info.js +++ b/public/scripts/world-info.js @@ -645,7 +645,7 @@ function registerWorldInfoSlashCommands() { await saveWorldInfo(file, data, true); reloadEditor(file); - return entry.uid; + return String(entry.uid); } async function setEntryFieldCallback(args, value) { @@ -3541,6 +3541,7 @@ function onWorldInfoChange(args, text) { saveSettingsDebounced(); eventSource.emit(event_types.WORLDINFO_SETTINGS_UPDATED); + return ''; } export async function importWorldInfo(file) { diff --git a/public/style.css b/public/style.css index 45ee4c5e4..baff95149 100644 --- a/public/style.css +++ b/public/style.css @@ -1230,6 +1230,10 @@ select { z-index: 10000; pointer-events: none; + .autoComplete { + pointer-events: all; + } + &.isFloating { --direction: row; @@ -1246,7 +1250,6 @@ select { .autoComplete { flex: 0 0 auto; width: 50vw; - pointer-events: all; } &:after { @@ -5174,4 +5177,4 @@ body:not(.movingUI) .drawer-content.maximized { color: #FAF8F6; } -/* Pastel White */ \ No newline at end of file +/* Pastel White */