import { callPopup, characters, eventSource, event_types, getCurrentChatId, reloadCurrentChat, saveSettingsDebounced, this_chid } from '../../../script.js'; import { extension_settings, renderExtensionTemplateAsync, writeExtensionField } from '../../extensions.js'; import { selected_group } from '../../group-chats.js'; import { SlashCommand } from '../../slash-commands/SlashCommand.js'; import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js'; import { enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js'; import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashCommandEnumValue.js'; import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js'; import { download, getFileText, getSortableDelay, uuidv4 } from '../../utils.js'; import { resolveVariable } from '../../variables.js'; import { regex_placement, runRegexScript } from './engine.js'; /** * @typedef {object} RegexScript * @property {string} scriptName - The name of the script * @property {boolean} disabled - Whether the script is disabled * @property {string} replaceString - The replace string * @property {string[]} trimStrings - The trim strings * @property {string?} findRegex - The find regex * @property {string?} substituteRegex - The substitute regex */ /** * Retrieves the list of regex scripts by combining the scripts from the extension settings and the character data * * @return {RegexScript[]} An array of regex scripts, where each script is an object containing the necessary information. */ export function getRegexScripts() { return [...(extension_settings.regex ?? []), ...(characters[this_chid]?.data?.extensions?.regex_scripts ?? [])]; } /** * Saves a regex script to the extension settings or character data. * @param {import('../../char-data.js').RegexScriptData} regexScript * @param {number} existingScriptIndex Index of the existing script * @param {boolean} isScoped Is the script scoped to a character? * @returns {Promise} */ async function saveRegexScript(regexScript, existingScriptIndex, isScoped) { // If not editing const array = (isScoped ? characters[this_chid]?.data?.extensions?.regex_scripts : extension_settings.regex) ?? []; // Assign a UUID if it doesn't exist if (!regexScript.id) { regexScript.id = uuidv4(); } // Is the script name undefined or empty? if (!regexScript.scriptName) { toastr.error('Could not save regex script: The script name was undefined or empty!'); return; } // Is a find regex present? if (regexScript.findRegex.length === 0) { toastr.warning('This regex script will not work, but was saved anyway: A find regex isn\'t present.'); } // Is there someplace to place results? if (regexScript.placement.length === 0) { toastr.warning('This regex script will not work, but was saved anyway: One "Affects" checkbox must be selected!'); } if (existingScriptIndex !== -1) { array[existingScriptIndex] = regexScript; } else { array.push(regexScript); } if (isScoped) { await writeExtensionField(this_chid, 'regex_scripts', array); // Add the character to the allowed list if (!extension_settings.character_allowed_regex.includes(characters[this_chid].avatar)) { extension_settings.character_allowed_regex.push(characters[this_chid].avatar); } } saveSettingsDebounced(); await loadRegexScripts(); // Reload the current chat to undo previous markdown const currentChatId = getCurrentChatId(); if (currentChatId !== undefined && currentChatId !== null) { await reloadCurrentChat(); } } async function deleteRegexScript({ id, isScoped }) { const array = (isScoped ? characters[this_chid]?.data?.extensions?.regex_scripts : extension_settings.regex) ?? []; const existingScriptIndex = array.findIndex((script) => script.id === id); if (!existingScriptIndex || existingScriptIndex !== -1) { array.splice(existingScriptIndex, 1); if (isScoped) { await writeExtensionField(this_chid, 'regex_scripts', array); } saveSettingsDebounced(); await loadRegexScripts(); } } async function loadRegexScripts() { $('#saved_regex_scripts').empty(); $('#saved_scoped_scripts').empty(); const scriptTemplate = $(await renderExtensionTemplateAsync('regex', 'scriptTemplate')); /** * Renders a script to the UI. * @param {string} container Container to render the script to * @param {import('../../char-data.js').RegexScriptData} script Script data * @param {boolean} isScoped Script is scoped to a character * @param {number} index Index of the script in the array */ function renderScript(container, script, isScoped, index) { // Have to clone here const scriptHtml = scriptTemplate.clone(); const save = () => saveRegexScript(script, index, isScoped); if (!script.id) { script.id = uuidv4(); } scriptHtml.attr('id', script.id); scriptHtml.find('.regex_script_name').text(script.scriptName); scriptHtml.find('.disable_regex').prop('checked', script.disabled ?? false) .on('input', async function () { script.disabled = !!$(this).prop('checked'); await save(); }); scriptHtml.find('.regex-toggle-on').on('click', function () { scriptHtml.find('.disable_regex').prop('checked', true).trigger('input'); }); scriptHtml.find('.regex-toggle-off').on('click', function () { scriptHtml.find('.disable_regex').prop('checked', false).trigger('input'); }); scriptHtml.find('.edit_existing_regex').on('click', async function () { await onRegexEditorOpenClick(scriptHtml.attr('id'), isScoped); }); scriptHtml.find('.move_to_global').on('click', async function () { const confirm = await callPopup('Are you sure you want to move this regex script to global?', 'confirm'); if (!confirm) { return; } await deleteRegexScript({ id: script.id, isScoped: true }); await saveRegexScript(script, -1, false); }); scriptHtml.find('.move_to_scoped').on('click', async function () { if (this_chid === undefined) { toastr.error('No character selected.'); return; } if (selected_group) { toastr.error('Cannot edit scoped scripts in group chats.'); return; } const confirm = await callPopup('Are you sure you want to move this regex script to scoped?', 'confirm'); if (!confirm) { return; } await deleteRegexScript({ id: script.id, isScoped: false }); await saveRegexScript(script, -1, true); }); scriptHtml.find('.export_regex').on('click', async function () { const fileName = `${script.scriptName.replace(/[\s.<>:"/\\|?*\x00-\x1F\x7F]/g, '_').toLowerCase()}.json`; const fileData = JSON.stringify(script, null, 4); download(fileData, fileName, 'application/json'); }); scriptHtml.find('.delete_regex').on('click', async function () { const confirm = await callPopup('Are you sure you want to delete this regex script?', 'confirm'); if (!confirm) { return; } await deleteRegexScript({ id: script.id, isScoped }); await reloadCurrentChat(); }); $(container).append(scriptHtml); } extension_settings?.regex?.forEach((script, index, array) => renderScript('#saved_regex_scripts', script, false, index, array)); characters[this_chid]?.data?.extensions?.regex_scripts?.forEach((script, index, array) => renderScript('#saved_scoped_scripts', script, true, index, array)); const isAllowed = extension_settings?.character_allowed_regex?.includes(characters?.[this_chid]?.avatar); $('#regex_scoped_toggle').prop('checked', isAllowed); } /** * Opens the regex editor. * @param {string|boolean} existingId Existing ID * @param {boolean} isScoped Is the script scoped to a character? * @returns {Promise} */ async function onRegexEditorOpenClick(existingId, isScoped) { const editorHtml = $(await renderExtensionTemplateAsync('regex', 'editor')); const array = (isScoped ? characters[this_chid]?.data?.extensions?.regex_scripts : extension_settings.regex) ?? []; // If an ID exists, fill in all the values let existingScriptIndex = -1; if (existingId) { existingScriptIndex = array.findIndex((script) => script.id === existingId); if (existingScriptIndex !== -1) { const existingScript = array[existingScriptIndex]; if (existingScript.scriptName) { editorHtml.find('.regex_script_name').val(existingScript.scriptName); } else { toastr.error('This script doesn\'t have a name! Please delete it.'); return; } editorHtml.find('.find_regex').val(existingScript.findRegex || ''); editorHtml.find('.regex_replace_string').val(existingScript.replaceString || ''); editorHtml.find('.regex_trim_strings').val(existingScript.trimStrings?.join('\n') || []); editorHtml.find('input[name="disabled"]').prop('checked', existingScript.disabled ?? false); editorHtml.find('input[name="only_format_display"]').prop('checked', existingScript.markdownOnly ?? false); editorHtml.find('input[name="only_format_prompt"]').prop('checked', existingScript.promptOnly ?? false); editorHtml.find('input[name="run_on_edit"]').prop('checked', existingScript.runOnEdit ?? false); editorHtml.find('input[name="substitute_regex"]').prop('checked', existingScript.substituteRegex ?? false); editorHtml.find('input[name="min_depth"]').val(existingScript.minDepth ?? ''); editorHtml.find('input[name="max_depth"]').val(existingScript.maxDepth ?? ''); existingScript.placement.forEach((element) => { editorHtml .find(`input[name="replace_position"][value="${element}"]`) .prop('checked', true); }); } } else { editorHtml .find('input[name="only_format_display"]') .prop('checked', true); editorHtml .find('input[name="run_on_edit"]') .prop('checked', true); editorHtml .find('input[name="replace_position"][value="1"]') .prop('checked', true); } editorHtml.find('#regex_test_mode_toggle').on('click', function () { editorHtml.find('#regex_test_mode').toggleClass('displayNone'); updateTestResult(); }); function updateTestResult() { if (!editorHtml.find('#regex_test_mode').is(':visible')) { return; } const testScript = { id: uuidv4(), scriptName: editorHtml.find('.regex_script_name').val(), findRegex: editorHtml.find('.find_regex').val(), replaceString: editorHtml.find('.regex_replace_string').val(), trimStrings: String(editorHtml.find('.regex_trim_strings').val()).split('\n').filter((e) => e.length !== 0) || [], substituteRegex: editorHtml.find('input[name="substitute_regex"]').prop('checked'), }; const rawTestString = String(editorHtml.find('#regex_test_input').val()); const result = runRegexScript(testScript, rawTestString); editorHtml.find('#regex_test_output').text(result); } editorHtml.find('input, textarea, select').on('input', updateTestResult); const popupResult = await callPopup(editorHtml, 'confirm', undefined, { okButton: 'Save' }); if (popupResult) { const newRegexScript = { id: existingId ? String(existingId) : uuidv4(), scriptName: String(editorHtml.find('.regex_script_name').val()), findRegex: String(editorHtml.find('.find_regex').val()), replaceString: String(editorHtml.find('.regex_replace_string').val()), trimStrings: editorHtml.find('.regex_trim_strings').val().split('\n').filter((e) => e.length !== 0) || [], placement: editorHtml .find('input[name="replace_position"]') .filter(':checked') .map(function () { return parseInt($(this).val()); }) .get() .filter((e) => !isNaN(e)) || [], disabled: editorHtml.find('input[name="disabled"]').prop('checked'), markdownOnly: editorHtml.find('input[name="only_format_display"]').prop('checked'), promptOnly: editorHtml.find('input[name="only_format_prompt"]').prop('checked'), runOnEdit: editorHtml.find('input[name="run_on_edit"]').prop('checked'), substituteRegex: editorHtml.find('input[name="substitute_regex"]').prop('checked'), minDepth: parseInt(String(editorHtml.find('input[name="min_depth"]').val())), maxDepth: parseInt(String(editorHtml.find('input[name="max_depth"]').val())), }; saveRegexScript(newRegexScript, existingScriptIndex, isScoped); } } // Common settings migration function. Some parts will eventually be removed // TODO: Maybe migrate placement to strings? function migrateSettings() { let performSave = false; // Current: If MD Display is present in placement, remove it and add new placements/MD option extension_settings.regex.forEach((script) => { if (!script.id) { script.id = uuidv4(); performSave = true; } if (script.placement.includes(regex_placement.MD_DISPLAY)) { script.placement = script.placement.length === 1 ? Object.values(regex_placement).filter((e) => e !== regex_placement.MD_DISPLAY) : script.placement = script.placement.filter((e) => e !== regex_placement.MD_DISPLAY); script.markdownOnly = true; script.promptOnly = true; performSave = true; } // Old system and sendas placement migration // 4 - sendAs if (script.placement.includes(4)) { script.placement = script.placement.length === 1 ? [regex_placement.SLASH_COMMAND] : script.placement = script.placement.filter((e) => e !== 4); performSave = true; } }); if (!extension_settings.character_allowed_regex) { extension_settings.character_allowed_regex = []; performSave = true; } if (performSave) { saveSettingsDebounced(); } } /** * /regex slash command callback * @param {object} args Named arguments * @param {string} value Unnamed argument * @returns {string} The regexed string */ function runRegexCallback(args, value) { if (!args.name) { toastr.warning('No regex script name provided.'); return value; } const scriptName = String(resolveVariable(args.name)); const scripts = getRegexScripts(); for (const script of scripts) { if (String(script.scriptName).toLowerCase() === String(scriptName).toLowerCase()) { if (script.disabled) { toastr.warning(`Regex script "${scriptName}" is disabled.`); return value; } console.debug(`Running regex callback for ${scriptName}`); return runRegexScript(script, value); } } toastr.warning(`Regex script "${scriptName}" not found.`); return value; } /** * Performs the import of the regex file. * @param {File} file Input file * @param {boolean} isScoped Is the script scoped to a character? */ async function onRegexImportFileChange(file, isScoped) { if (!file) { toastr.error('No file provided.'); return; } try { const fileText = await getFileText(file); const regexScript = JSON.parse(fileText); if (!regexScript.scriptName) { throw new Error('No script name provided.'); } // Assign a new UUID regexScript.id = uuidv4(); const array = (isScoped ? characters[this_chid]?.data?.extensions?.regex_scripts : extension_settings.regex) ?? []; array.push(regexScript); if (isScoped) { await writeExtensionField(this_chid, 'regex_scripts', array); } saveSettingsDebounced(); await loadRegexScripts(); toastr.success(`Regex script "${regexScript.scriptName}" imported.`); } catch (error) { console.log(error); toastr.error('Invalid JSON file.'); return; } } function purgeEmbeddedRegexScripts( { character }){ const avatar = character?.avatar; if (avatar && extension_settings.character_allowed_regex?.includes(avatar)) { const index = extension_settings.character_allowed_regex.indexOf(avatar); if (index !== -1) { extension_settings.character_allowed_regex.splice(index, 1); saveSettingsDebounced(); } } } async function checkEmbeddedRegexScripts() { const chid = this_chid; if (chid !== undefined && !selected_group) { const avatar = characters[chid]?.avatar; const scripts = characters[chid]?.data?.extensions?.regex_scripts; if (Array.isArray(scripts) && scripts.length > 0) { if (avatar && !extension_settings.character_allowed_regex.includes(avatar)) { const checkKey = `AlertRegex_${characters[chid].avatar}`; if (!localStorage.getItem(checkKey)) { localStorage.setItem(checkKey, 'true'); const template = await renderExtensionTemplateAsync('regex', 'embeddedScripts', {}); const result = await callPopup(template, 'confirm', '', { okButton: 'Yes' }); if (result) { extension_settings.character_allowed_regex.push(avatar); await reloadCurrentChat(); saveSettingsDebounced(); } } } } } loadRegexScripts(); } // Workaround for loading in sequence with other extensions // NOTE: Always puts extension at the top of the list, but this is fine since it's static jQuery(async () => { if (extension_settings.regex) { migrateSettings(); } // Manually disable the extension since static imports auto-import the JS file if (extension_settings.disabledExtensions.includes('regex')) { return; } const settingsHtml = $(await renderExtensionTemplateAsync('regex', 'dropdown')); $('#extensions_settings2').append(settingsHtml); $('#open_regex_editor').on('click', function () { onRegexEditorOpenClick(false, false); }); $('#open_scoped_editor').on('click', function () { if (this_chid === undefined) { toastr.error('No character selected.'); return; } if (selected_group) { toastr.error('Cannot edit scoped scripts in group chats.'); return; } onRegexEditorOpenClick(false, true); }); $('#import_regex_file').on('change', async function () { let target = 'global'; const template = $(await renderExtensionTemplateAsync('regex', 'importTarget')); template.find('#regex_import_target_global').on('input', () => target = 'global'); template.find('#regex_import_target_scoped').on('input', () => target = 'scoped'); await callPopup(template, 'text'); const inputElement = this instanceof HTMLInputElement && this; for (const file of inputElement.files) { await onRegexImportFileChange(file, target === 'scoped'); } inputElement.value = ''; }); $('#import_regex').on('click', function () { $('#import_regex_file').trigger('click'); }); let sortableDatas = [ { selector: '#saved_regex_scripts', setter: x => extension_settings.regex = x, getter: () => extension_settings.regex ?? [], }, { selector: '#saved_scoped_scripts', setter: x => writeExtensionField(this_chid, 'regex_scripts', x), getter: () => characters[this_chid]?.data?.extensions?.regex_scripts ?? [], }, ]; for (const { selector, setter, getter } of sortableDatas) { $(selector).sortable({ delay: getSortableDelay(), stop: async function () { const oldScripts = getter(); const newScripts = []; $(selector).children().each(function () { const id = $(this).attr('id'); const existingScript = oldScripts.find((e) => e.id === id); if (existingScript) { newScripts.push(existingScript); } }); await setter(newScripts); saveSettingsDebounced(); console.debug(`Regex scripts in ${selector} reordered`); await loadRegexScripts(); }, }); } $('#regex_scoped_toggle').on('input', function () { if (this_chid === undefined) { toastr.error('No character selected.'); return; } if (selected_group) { toastr.error('Cannot edit scoped scripts in group chats.'); return; } const isEnable = !!$(this).prop('checked'); const avatar = characters[this_chid].avatar; if (isEnable) { if (!extension_settings.character_allowed_regex.includes(avatar)) { extension_settings.character_allowed_regex.push(avatar); } } else { const index = extension_settings.character_allowed_regex.indexOf(avatar); if (index !== -1) { extension_settings.character_allowed_regex.splice(index, 1); } } saveSettingsDebounced(); reloadCurrentChat(); }); await loadRegexScripts(); $('#saved_regex_scripts').sortable('enable'); const localEnumProviders = { regexScripts: () => getRegexScripts().map(script => { const isGlobal = extension_settings.regex?.some(x => x.scriptName === script.scriptName); return new SlashCommandEnumValue(script.scriptName, `${enumIcons.getStateIcon(!script.disabled)} [${isGlobal ? 'global' : 'scoped'}] ${script.findRegex}`, isGlobal ? enumTypes.enum : enumTypes.name, isGlobal ? 'G' : 'S'); }), }; SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'regex', callback: runRegexCallback, returns: 'replaced text', namedArgumentList: [ SlashCommandNamedArgument.fromProps({ name: 'name', description: 'script name', typeList: [ARGUMENT_TYPE.STRING, ARGUMENT_TYPE.VARIABLE_NAME], isRequired: true, enumProvider: localEnumProviders.regexScripts, }), ], unnamedArgumentList: [ new SlashCommandArgument( 'input', [ARGUMENT_TYPE.STRING], false, ), ], helpString: 'Runs a Regex extension script by name on the provided string. The script must be enabled.', })); eventSource.on(event_types.CHAT_CHANGED, checkEmbeddedRegexScripts); eventSource.on(event_types.CHARACTER_DELETED, purgeEmbeddedRegexScripts); });