import { characters, eventSource, event_types, getCurrentChatId, messageFormatting, reloadCurrentChat, saveSettingsDebounced, this_chid } from '../../../script.js'; import { extension_settings, renderExtensionTemplateAsync } from '../../extensions.js'; import { selected_group } from '../../group-chats.js'; import { callGenericPopup, Popup, POPUP_TYPE } from '../../popup.js'; import { SlashCommand } from '../../slash-commands/SlashCommand.js'; import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js'; import { commonEnumProviders, enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js'; import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashCommandEnumValue.js'; import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js'; import { download, equalsIgnoreCaseAndAccents, escapeHtml, getFileText, getSortableDelay, isFalseBoolean, isTrueBoolean, regexFromString, setInfoBlock, uuidv4 } from '../../utils.js'; import { allowPresetScripts, allowScopedScripts, disallowPresetScripts, disallowScopedScripts, getCurrentPresetAPI, getCurrentPresetName, getRegexScripts, getScriptsByType, isPresetScriptsAllowed, isScopedScriptsAllowed, regex_placement, runRegexScript, saveScriptsByType, SCRIPT_TYPE_UNKNOWN, SCRIPT_TYPES, substitute_find_regex } from './engine.js'; import { t } from '../../i18n.js'; import { accountStorage } from '../../util/AccountStorage.js'; import { getPresetManager } from '../../preset-manager.js'; // Re-exports for legacy extensions export { getRegexScripts }; const sanitizeFileName = name => name.replace(/[\s.<>:"/\\|?*\x00-\x1F\x7F]/g, '_').toLowerCase(); /** * @typedef {import('../../char-data.js').RegexScriptData} RegexScript */ /** * @typedef {object} RegexPresetItem * @property {string} id - UUID of the regex script */ /** * @typedef {object} RegexPreset * @property {string} id - UUID of the preset * @property {string} name - Name of the preset * @property {boolean} isSelected - Whether the preset is currently selected * @property {RegexPresetItem[]} global - The list of global preset items * @property {RegexPresetItem[]} scoped - The list of scoped preset items * @property {RegexPresetItem[]} preset - The list of preset preset items */ /** * @typedef {object} RegexPresetState * @property {string[]} global - List of enabled global regex script IDs * @property {string[]} scoped - List of enabled scoped regex script IDs * @property {string[]} preset - List of enabled preset regex script IDs */ class RegexPresetManager { /** @type {HTMLSelectElement} */ presetSelect = null; /** @type {HTMLElement} */ presetCreateButton = null; /** @type {HTMLElement} */ presetUpdateButton = null; /** @type {HTMLElement} */ presetApplyButton = null; /** @type {HTMLElement} */ presetDeleteButton = null; /** @type {string|null} */ currentPresetId = null; /** @type {RegexPresetState|null} */ lastKnownState = null; /** * Captures the current state of enabled regex scripts for change detection. * @returns {RegexPresetState} The current state object */ captureCurrentState() { const globalScripts = this.regexListToPresetItems(getScriptsByType(SCRIPT_TYPES.GLOBAL)); const scopedScripts = this.regexListToPresetItems(getScriptsByType(SCRIPT_TYPES.SCOPED)); const presetScripts = this.regexListToPresetItems(getScriptsByType(SCRIPT_TYPES.PRESET)); return { global: globalScripts.map(item => item.id).sort(), scoped: scopedScripts.map(item => item.id).sort(), preset: presetScripts.map(item => item.id).sort(), }; } /** * Compares two state objects to detect changes. * @param {RegexPresetState} state1 First state object * @param {RegexPresetState} state2 Second state object * @returns {boolean} True if states are different */ hasStateChanged(state1, state2) { if (!state1 || !state2) return false; const global1 = state1.global || []; const global2 = state2.global || []; const scoped1 = state1.scoped || []; const scoped2 = state2.scoped || []; const preset1 = state1.preset || []; const preset2 = state2.preset || []; if (global1.length !== global2.length || scoped1.length !== scoped2.length) { return true; } return !global1.every(id => global2.includes(id)) || !scoped1.every(id => scoped2.includes(id)) || !preset1.every(id => preset2.includes(id)); } /** * Updates the stored state after a preset is applied or saved. * @param {string} presetId - The current preset ID */ updateStoredState(presetId) { this.currentPresetId = presetId; this.lastKnownState = this.captureCurrentState(); } /** * Checks if there are unsaved changes and shows a confirmation dialog. * @returns {Promise} True if user wants to proceed without saving */ async checkUnsavedChanges() { if (!this.currentPresetId || !this.lastKnownState) { return true; // No current preset or state to compare } const currentState = this.captureCurrentState(); if (!this.hasStateChanged(this.lastKnownState, currentState)) { return true; // No changes detected } const currentPreset = extension_settings.regex_presets.find(p => p.id === this.currentPresetId); const presetName = currentPreset ? currentPreset.name : t`Unknown Preset`; const choice = await Popup.show.confirm( t`You have unsaved changes to the "${presetName}" preset.`, t`Do you want to save them before switching?`, { okButton: t`Save Changes`, cancelButton: t`Discard Changes`, }, ); if (choice) { // User chose to save changes await this.savePreset(this.currentPresetId, true); this.renderPresetList(); return true; } // User chose to discard changes return true; } /** * Sets up event listeners for the preset management UI. * @returns {void} */ setupEventListeners() { this.presetSelect = /** @type {HTMLSelectElement} */ (document.getElementById('regex_presets')); if (!this.presetSelect) { console.error('RegexPresetManager: Could not find preset select element in the DOM.'); return; } this.presetSelect.addEventListener('change', async (event) => { const selectedPresetId = this.presetSelect.value; const fromSlashCommand = event instanceof CustomEvent && event?.detail?.fromSlashCommand === true; // Check for unsaved changes before switching if (!fromSlashCommand) { const canProceed = await this.checkUnsavedChanges(); if (!canProceed) { // Revert the selection event.preventDefault(); const currentPreset = extension_settings.regex_presets.find(p => p.id === this.currentPresetId); if (currentPreset) { this.presetSelect.value = currentPreset.id; } return; } } await this.applyPreset(selectedPresetId); extension_settings.regex_presets.forEach(p => { p.isSelected = p.id === selectedPresetId; }); saveSettingsDebounced(); this.updateStoredState(selectedPresetId); }); this.presetCreateButton = document.getElementById('regex_preset_create'); if (!this.presetCreateButton) { console.error('RegexPresetManager: Could not find preset create button in the DOM.'); return; } this.presetCreateButton.addEventListener('click', async () => { const newId = uuidv4(); await this.savePreset(newId, false); this.renderPresetList(); this.updateStoredState(newId); }); this.presetUpdateButton = document.getElementById('regex_preset_update'); if (!this.presetUpdateButton) { console.error('RegexPresetManager: Could not find preset update button in the DOM.'); return; } this.presetUpdateButton.addEventListener('click', async () => { const selectedPresetId = this.presetSelect.value; await this.savePreset(selectedPresetId, true); this.renderPresetList(); this.updateStoredState(selectedPresetId); }); this.presetApplyButton = document.getElementById('regex_preset_apply'); if (!this.presetApplyButton) { console.error('RegexPresetManager: Could not find preset apply button in the DOM.'); return; } this.presetApplyButton.addEventListener('click', async () => { const selectedPresetId = this.presetSelect.value; await this.applyPreset(selectedPresetId); this.updateStoredState(selectedPresetId); }); this.presetDeleteButton = document.getElementById('regex_preset_delete'); if (!this.presetDeleteButton) { console.error('RegexPresetManager: Could not find preset delete button in the DOM.'); return; } this.presetDeleteButton.addEventListener('click', async () => { const selectedPresetId = this.presetSelect.value; await this.deletePreset(selectedPresetId); this.renderPresetList(); const newSelectedPresetId = extension_settings.regex_presets.find(p => p.isSelected)?.id; if (newSelectedPresetId) { await this.applyPreset(newSelectedPresetId); this.presetSelect.value = newSelectedPresetId; this.updateStoredState(newSelectedPresetId); } else { this.currentPresetId = null; this.lastKnownState = null; } }); this.renderPresetList(); // Initialize the stored state with the currently selected preset const selectedPreset = extension_settings.regex_presets?.find(p => p.isSelected); if (selectedPreset) { this.updateStoredState(selectedPreset.id); } } /** * Registers slash commands related to regex presets. * @returns {void} */ registerSlashCommands() { SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'regex-preset', helpString: t`Selects a regex preset by name or ID. Gets the current regex preset ID if no argument is provided.`, callback: (args, name) => { if (!this.presetSelect) { return ''; } name = String(name ?? '').trim(); if (name) { const quiet = isTrueBoolean(args?.quiet?.toString()); const foundId = extension_settings.regex_presets.find(p => equalsIgnoreCaseAndAccents(p.id, name) || equalsIgnoreCaseAndAccents(p.name, name))?.id; if (foundId) { this.presetSelect.value = foundId; this.presetSelect.dispatchEvent(new CustomEvent('change', { detail: { fromSlashCommand: true } })); return foundId; } !quiet && toastr.warning(`Regex preset "${name}" not found`); return ''; } return this.presetSelect.value; }, returns: 'current preset ID', namedArgumentList: [ SlashCommandNamedArgument.fromProps({ name: 'quiet', description: 'Suppress the toast message on preset change', typeList: [ARGUMENT_TYPE.BOOLEAN], defaultValue: 'false', enumList: commonEnumProviders.boolean('trueFalse')(), }), ], unnamedArgumentList: [ SlashCommandArgument.fromProps({ description: 'regex preset name or ID', typeList: [ARGUMENT_TYPE.STRING], enumProvider: () => extension_settings.regex_presets.map(x => new SlashCommandEnumValue(x.id, x.name, enumTypes.enum, enumIcons.preset)), }), ], })); } /** * Renders the list of regex presets in the UI. * @returns {void} */ renderPresetList() { if (!this.presetSelect) { return; } this.presetSelect.innerHTML = ''; if (!Array.isArray(extension_settings.regex_presets) || extension_settings.regex_presets.length === 0) { const fallbackOption = new Option(t`[No presets saved]`, '', true, true); this.presetSelect.appendChild(fallbackOption); this.presetSelect.disabled = true; return; } extension_settings.regex_presets.forEach(preset => { const option = new Option(preset.name, preset.id, preset.isSelected, preset.isSelected); this.presetSelect.appendChild(option); }); this.presetSelect.disabled = false; } /** * Applies a preset list to a target list of scripts. * @param {Object} params The parameters object * @param {RegexPresetItem[]} params.presetList The list of preset items * @param {RegexScript[]} params.targetList The list of target scripts to modify * @param {(targetList: RegexScript[]) => Promise} params.saveFunction Function to save the modified list */ async applyPresetList({ presetList, targetList, saveFunction }) { if (!Array.isArray(targetList) || !Array.isArray(presetList)) { return; } // Only enable scripts that are in the preset targetList.forEach((script => { script.disabled = !presetList.some(p => p.id === script.id); })); // First sort by the order in the preset, then the original order targetList.sort((a, b) => { const aIndex = presetList.findIndex(p => p.id === a.id); const bIndex = presetList.findIndex(p => p.id === b.id); return aIndex - bIndex || targetList.indexOf(a) - targetList.indexOf(b); }); await saveFunction(targetList); } /** * Applies a regex preset to the current context. * @param {string} presetId - The ID of the preset to apply * @returns {Promise} */ async applyPreset(presetId) { const preset = extension_settings.regex_presets.find(p => p.id === presetId); if (!preset) { toastr.error(t`Could not find the selected preset.`); return; } // Apply preset to all lists for (const scriptType of Object.values(SCRIPT_TYPES)) { await this.applyPresetList({ presetList: { [SCRIPT_TYPES.GLOBAL]: preset.global, [SCRIPT_TYPES.SCOPED]: preset.scoped, [SCRIPT_TYPES.PRESET]: preset.preset, }[scriptType], targetList: getScriptsByType(scriptType), saveFunction: scripts => saveScriptsByType(scripts, scriptType), }); } // Render the changes to the UI await loadRegexScripts(); // Apply the changes to the current chat await reloadCurrentChat(); } /** * Converts a list of regex scripts to preset items. * @param {RegexScript[]} list The list of regex scripts * @returns {RegexPresetItem[] | null} The list of preset items, or null if the input is invalid */ regexListToPresetItems(list) { if (!Array.isArray(list)) { return null; } return list.filter(x => !x.disabled).map(s => ({ id: s.id })); } /** * Saves a regex preset. * @param {string} presetId - The ID of the preset * @param {boolean} isUpdate - Whether this is an update operation * @returns {Promise} */ async savePreset(presetId, isUpdate) { const existingPreset = isUpdate ? extension_settings.regex_presets.find(p => p.id === presetId) : null; if (isUpdate && !existingPreset) { toastr.error(t`Could not find the preset to update.`); return; } const name = isUpdate ? existingPreset.name : await Popup.show.input(t`Enter a name for the new regex preset:`, ''); const id = isUpdate ? existingPreset.id : presetId; if (!name || !name.trim().length) { return; } const preset = { id: id, name: name, isSelected: false, global: this.regexListToPresetItems(getScriptsByType(SCRIPT_TYPES.GLOBAL)), scoped: this.regexListToPresetItems(getScriptsByType(SCRIPT_TYPES.SCOPED)), preset: this.regexListToPresetItems(getScriptsByType(SCRIPT_TYPES.PRESET)), }; if (isUpdate) { Object.assign(existingPreset, preset); } else { extension_settings.regex_presets.push(preset); } extension_settings.regex_presets.forEach(p => { p.isSelected = p.id === id; }); saveSettingsDebounced(); toastr.success(isUpdate ? t`Regex preset updated` : t`Regex preset saved`); } /** * Deletes a regex preset. * @param {string} presetId - The ID of the preset to delete * @returns {Promise} */ async deletePreset(presetId) { const presetIndex = extension_settings.regex_presets.findIndex(p => p.id === presetId); if (presetIndex === -1) { toastr.error(t`Could not find the preset to delete.`); return; } const presetName = extension_settings.regex_presets[presetIndex].name; const confirm = await Popup.show.confirm(t`Are you sure you want to delete this regex preset?`, presetName); if (!confirm) { return; } extension_settings.regex_presets.splice(presetIndex, 1); // Select the first preset if any exist extension_settings.regex_presets.forEach((p, i) => { p.isSelected = i === 0; }); saveSettingsDebounced(); toastr.success(t`Regex preset deleted`); } } const presetManager = new RegexPresetManager(); /** * Toggle the icon for the "select all" checkbox in the regex settings. * - Use `fa-check-double` when the checkbox is unchecked (indicating all scripts are not selected). * - Use `fa-minus` when the checkbox is checked (indicating all scripts are selected). * @param {boolean} allAreChecked Should the "select all" icon be in the checked state? */ function setToggleAllIcon(allAreChecked) { const selectAllIcon = $('#bulk_select_all_toggle').find('i'); selectAllIcon.toggleClass('fa-check-double', !allAreChecked); selectAllIcon.toggleClass('fa-minus', allAreChecked); } /** * Sets the visibility of the bulk move buttons based on selected scripts. */ function setMoveButtonsVisibility() { const hasGlobalScripts = $('#saved_regex_scripts .regex-script-label:has(.regex_bulk_checkbox:checked)').length > 0; const hasScopedScripts = $('#saved_scoped_scripts .regex-script-label:has(.regex_bulk_checkbox:checked)').length > 0; const hasPresetScripts = $('#saved_preset_scripts .regex-script-label:has(.regex_bulk_checkbox:checked)').length > 0; $('#bulk_regex_move_to_global').toggle(hasScopedScripts || hasPresetScripts); $('#bulk_regex_move_to_scoped').toggle(hasGlobalScripts || hasPresetScripts); $('#bulk_regex_move_to_preset').toggle(hasGlobalScripts || hasScopedScripts); } /** * 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 {SCRIPT_TYPES} scriptType Type of the script * @param {boolean} [saveSettings=true] Whether to save the settings immediately * @returns {Promise} */ async function saveRegexScript(regexScript, existingScriptIndex, scriptType, saveSettings = true) { // If not editing const array = getScriptsByType(scriptType); // 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(t`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(t`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(t`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 (scriptType === SCRIPT_TYPES.SCOPED) { await saveScriptsByType(array, SCRIPT_TYPES.SCOPED); allowScopedScripts(characters?.[this_chid]); } if (scriptType === SCRIPT_TYPES.PRESET) { await saveScriptsByType(array, SCRIPT_TYPES.PRESET); allowPresetScripts(getCurrentPresetAPI(), getCurrentPresetName()); } if (saveSettings) { saveSettingsDebounced(); await loadRegexScripts(); // Reload the current chat to undo previous markdown const currentChatId = getCurrentChatId(); if (currentChatId) { await reloadCurrentChat(); } } const debuggerPopup = $('#regex_debugger_popup'); if (debuggerPopup.length) { populateDebuggerRuleList(debuggerPopup.parent()); } } /** * Delete a regex script by ID * @param {string} id ID of the script to delete * @param {SCRIPT_TYPES} scriptType Type of the script * @param {boolean} saveSettings Whether to save the settings immediately * @returns {Promise} */ async function deleteRegexScript(id, scriptType, saveSettings = true) { const array = getScriptsByType(scriptType); const existingScriptIndex = array.findIndex(script => script.id === id); if (existingScriptIndex !== -1) { array.splice(existingScriptIndex, 1); switch (scriptType) { case SCRIPT_TYPES.GLOBAL: // will be handled by saveSettingsDebounced break; case SCRIPT_TYPES.SCOPED: await saveScriptsByType(array, SCRIPT_TYPES.SCOPED); break; case SCRIPT_TYPES.PRESET: await saveScriptsByType(array, SCRIPT_TYPES.PRESET); break; default: break; } if (saveSettings) { saveSettingsDebounced(); await loadRegexScripts(); } } } /** * Move a regex script from one type to another * @param {import('../../char-data.js').RegexScriptData} script The script to move * @param {SCRIPT_TYPES} toType Target type * @param {SCRIPT_TYPES|null} fromType Source type, if null it will be determined automatically * @param {boolean} saveSettings Whether to save the settings immediately * @returns {Promise} */ async function moveRegexScript(script, toType, fromType = null, saveSettings = true) { if (!Object.values(SCRIPT_TYPES).includes(toType)) { console.warn(`moveRegexScript: Invalid target script type ${toType}`); return; } if (!Object.values(SCRIPT_TYPES).includes(fromType)) { fromType = getScriptType(script); } if (fromType === toType || fromType === SCRIPT_TYPE_UNKNOWN || toType === SCRIPT_TYPE_UNKNOWN) { return; } await deleteRegexScript(script.id, fromType, false); await saveRegexScript(script, -1, toType, saveSettings); } async function loadRegexScripts() { $('#saved_regex_scripts').empty(); $('#saved_scoped_scripts').empty(); $('#saved_preset_scripts').empty(); setToggleAllIcon(false); 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 {SCRIPT_TYPES} scriptType Type of the script * @param {number} index Index of the script in the array */ function renderScript(container, script, scriptType, index) { // Have to clone here const scriptHtml = scriptTemplate.clone(); const save = () => saveRegexScript(script, index, scriptType); if (!script.id) { script.id = uuidv4(); } scriptHtml.attr('id', script.id); scriptHtml.find('.regex_script_name').text(script.scriptName).attr('title', 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'), scriptType); }); scriptHtml.find('.move_to_global').on('click', async function () { const confirm = await callGenericPopup(t`Are you sure you want to move this regex script to global?`, POPUP_TYPE.CONFIRM); if (!confirm) { return; } await moveRegexScript(script, SCRIPT_TYPES.GLOBAL, scriptType); }); scriptHtml.find('.move_to_scoped').on('click', async function () { if (this_chid === undefined) { toastr.error(t`No character selected.`); return; } if (selected_group) { toastr.error(t`Cannot edit scoped scripts in group chats.`); return; } const confirm = await callGenericPopup(t`Are you sure you want to move this regex script to scoped?`, POPUP_TYPE.CONFIRM); if (!confirm) { return; } await moveRegexScript(script, SCRIPT_TYPES.SCOPED, scriptType); }); scriptHtml.find('.move_to_preset').on('click', async function () { const confirm = await callGenericPopup( t`Are you sure you want to move this regex script to preset?`, POPUP_TYPE.CONFIRM, ); if (!confirm) { return; } await moveRegexScript(script, SCRIPT_TYPES.PRESET, scriptType); }); scriptHtml.find('.export_regex').on('click', async function () { const fileName = `regex-${sanitizeFileName(script.scriptName)}.json`; const fileData = JSON.stringify(script, null, 4); download(fileData, fileName, 'application/json'); }); scriptHtml.find('.delete_regex').on('click', async function () { const confirm = await callGenericPopup(t`Are you sure you want to delete this regex script?`, POPUP_TYPE.CONFIRM); if (!confirm) { return; } await deleteRegexScript(script.id, scriptType); await reloadCurrentChat(); }); scriptHtml.find('.regex_bulk_checkbox').on('change', function () { setMoveButtonsVisibility(); const checkboxes = $('#regex_container .regex_bulk_checkbox'); const allAreChecked = checkboxes.length === checkboxes.filter(':checked').length; setToggleAllIcon(allAreChecked); }); scriptHtml.find('input[name="regex_expand"]').on('change', function () { if (!(this instanceof HTMLInputElement)) { return; } if (!this.checked) { return; } const closeMenuHandler = (e) => { if (e.target instanceof HTMLElement) { if (e.target.closest('.regex-script-label')) { return; } this.checked = false; document.removeEventListener('click', closeMenuHandler); } }; // Use setTimeout to avoid closing immediately from the same click setTimeout(() => { document.addEventListener('click', closeMenuHandler, { passive: true, once: false }); }, 0); }); $(container).append(scriptHtml); } getScriptsByType(SCRIPT_TYPES.GLOBAL).forEach((script, index) => renderScript('#saved_regex_scripts', script, SCRIPT_TYPES.GLOBAL, index)); getScriptsByType(SCRIPT_TYPES.SCOPED).forEach((script, index) => renderScript('#saved_scoped_scripts', script, SCRIPT_TYPES.SCOPED, index)); getScriptsByType(SCRIPT_TYPES.PRESET).forEach((script, index) => renderScript('#saved_preset_scripts', script, SCRIPT_TYPES.PRESET, index)); $('#regex_scoped_toggle').prop('checked', isScopedScriptsAllowed(characters?.[this_chid])); $('#regex_preset_toggle').prop('checked', isPresetScriptsAllowed(getCurrentPresetAPI(), getCurrentPresetName())); setMoveButtonsVisibility(); } /** * Opens the regex editor. * @param {string|boolean} existingId Existing ID * @param {SCRIPT_TYPES} scriptType Type of the script * @returns {Promise} */ async function onRegexEditorOpenClick(existingId, scriptType) { const editorHtml = $(await renderExtensionTemplateAsync('regex', 'editor')); const array = getScriptsByType(scriptType); // 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('select[name="substitute_regex"]').val(existingScript.substituteRegex ?? substitute_find_regex.NONE); 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() { updateInfoBlock(editorHtml); if (!editorHtml.find('#regex_test_mode').is(':visible')) { return; } const testScript = { id: uuidv4(), scriptName: editorHtml.find('.regex_script_name').val().toString(), findRegex: editorHtml.find('.find_regex').val().toString(), replaceString: editorHtml.find('.regex_replace_string').val().toString(), trimStrings: String(editorHtml.find('.regex_trim_strings').val()).split('\n').filter((e) => e.length !== 0) || [], substituteRegex: Number(editorHtml.find('select[name="substitute_regex"]').val()), disabled: false, promptOnly: false, markdownOnly: false, runOnEdit: false, minDepth: null, maxDepth: null, placement: null, }; 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); updateInfoBlock(editorHtml); const popupResult = await callGenericPopup(editorHtml, POPUP_TYPE.CONFIRM, '', { okButton: t`Save`, cancelButton: t`Cancel`, allowVerticalScrolling: true }); 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: String(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().toString()); }) .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: Number(editorHtml.find('select[name="substitute_regex"]').val()), minDepth: parseInt(String(editorHtml.find('input[name="min_depth"]').val())), maxDepth: parseInt(String(editorHtml.find('input[name="max_depth"]').val())), }; saveRegexScript(newRegexScript, existingScriptIndex, scriptType); } } /** * Builds an HTML string for a replacement, highlighting literal parts in green * and keeping back-referenced parts plain. * @param {RegExpMatchArray} match The match object from `matchAll`. * @param {string} pattern The replacement pattern string (e.g., "new text $1"). * @returns {string} The constructed HTML string. */ function buildReplacementHtml(match, pattern) { const container = document.createDocumentFragment(); let lastIndex = 0; const backrefRegex = /\$\$|\$&|\$`|\$'|\$(\d{1,2})/g; let reMatch; while ((reMatch = backrefRegex.exec(pattern)) !== null) { // Part of the pattern before the back-reference is a literal. const literalPart = pattern.substring(lastIndex, reMatch.index); if (literalPart) { const mark = document.createElement('mark'); mark.className = 'green_hl'; mark.innerText = literalPart; container.appendChild(mark); } const backref = reMatch[0]; if (backref === '$$') { container.appendChild(document.createTextNode('$')); } else if (backref === '$&') { const mark = document.createElement('mark'); mark.className = 'yellow_hl'; mark.innerText = match[0]; container.appendChild(mark); } else if (backref === '$`') { container.appendChild(document.createTextNode(match.input.substring(0, match.index))); } else if (backref === '$\'') { container.appendChild(document.createTextNode(match.input.substring(match.index + match[0].length))); } else { // It's a numbered capture group, $n. const groupIndex = parseInt(reMatch[1], 10); if (groupIndex > 0 && groupIndex < match.length && match[groupIndex] !== undefined) { const mark = document.createElement('mark'); mark.className = 'yellow_hl'; mark.innerText = match[groupIndex]; container.appendChild(mark); } else { // Not a valid group index, treat it as a literal. const mark = document.createElement('mark'); mark.className = 'green_hl'; mark.innerText = backref; container.appendChild(mark); } } lastIndex = backrefRegex.lastIndex; } // The final part of the pattern after the last back-reference. const finalLiteralPart = pattern.substring(lastIndex); if (finalLiteralPart) { const mark = document.createElement('mark'); mark.className = 'green_hl'; mark.innerText = finalLiteralPart; container.appendChild(mark); } // To get the HTML content, we need a temporary parent element. const tempDiv = document.createElement('div'); tempDiv.appendChild(container); return tempDiv.innerHTML; } function executeRegexScriptForDebugging(script, text) { let err; let originalRegex; try { originalRegex = regexFromString(script.findRegex); if (!originalRegex) throw new Error('Invalid regex string'); } catch (e) { err = `Compile error: ${e.message}`; return { output: text, highlightedOutput: text, error: err, charsCaptured: 0, charsAdded: 0, charsRemoved: 0 }; } const globalRegex = new RegExp(originalRegex.source, originalRegex.flags.includes('g') ? originalRegex.flags : originalRegex.flags + 'g'); const matches = [...text.matchAll(globalRegex)]; if (matches.length === 0) { return { output: text, highlightedOutput: escapeHtml(text), error: null, charsCaptured: 0, charsAdded: 0, charsRemoved: 0 }; } let outputText = ''; let highlightedOutput = ''; // This will now be our "diff view" let lastIndex = 0; let totalCharsCaptured = 0; let totalCharsAdded = 0; let totalCharsRemoved = 0; try { for (const match of matches) { const originalMatchText = match[0]; totalCharsCaptured += originalMatchText.length; // Append text between matches (this part is unchanged) const precedingText = text.substring(lastIndex, match.index); outputText += precedingText; highlightedOutput += escapeHtml(precedingText); // --- Start of new diff and statistics logic --- let charsAddedInMatch = 0; let charsKeptFromMatch = 0; const backrefRegex = /\$\$|\$&|\$`|\$'|\$(\d{1,2})/g; let lastPatternIndex = 0; let reMatch; let replacementForPlainText = ''; // This loop calculates the stats accurately while ((reMatch = backrefRegex.exec(script.replaceString)) !== null) { const literalPart = script.replaceString.substring(lastPatternIndex, reMatch.index); charsAddedInMatch += literalPart.length; replacementForPlainText += literalPart; const backref = reMatch[0]; if (backref === '$$') { replacementForPlainText += '$'; } else if (backref === '$&') { charsKeptFromMatch += (match[0] || '').length; replacementForPlainText += (match[0] || ''); } else if (backref === '$`') { const part = match.input.substring(0, match.index); charsKeptFromMatch += part.length; replacementForPlainText += part; } else if (backref === '$\'') { const part = match.input.substring(match.index + match[0].length); charsKeptFromMatch += part.length; replacementForPlainText += part; } else { const groupIndex = parseInt(reMatch[1], 10); if (groupIndex > 0 && groupIndex < match.length && match[groupIndex] !== undefined) { charsKeptFromMatch += match[groupIndex].length; replacementForPlainText += match[groupIndex]; } } lastPatternIndex = backrefRegex.lastIndex; } const finalLiteralPart = script.replaceString.substring(lastPatternIndex); charsAddedInMatch += finalLiteralPart.length; replacementForPlainText += finalLiteralPart; totalCharsAdded += charsAddedInMatch; totalCharsRemoved += (originalMatchText.length - charsKeptFromMatch); outputText += replacementForPlainText; // --- End of statistics logic --- // --- Build the new Diff View HTML --- // 1. Show the entire original match as "removed" (red strikethrough) highlightedOutput += `${escapeHtml(originalMatchText)}`; // 2. Add an arrow to signify transformation highlightedOutput += ' → '; // 3. Build the replacement string with green (added) and yellow (kept) parts highlightedOutput += buildReplacementHtml(match, script.replaceString); lastIndex = match.index + originalMatchText.length; } // Append text after the last match const trailingText = text.substring(lastIndex); outputText += trailingText; highlightedOutput += escapeHtml(trailingText); } catch (e) { err = (err ? err + '; ' : '') + `Replace error: ${e.message}`; outputText = text; // Fallback highlightedOutput = escapeHtml(text); } return { output: outputText, highlightedOutput: highlightedOutput, error: err, charsCaptured: totalCharsCaptured, charsAdded: totalCharsAdded, charsRemoved: totalCharsRemoved, }; } function populateDebuggerRuleList(container) { const rulesContainer = container.find('#regex_debugger_rules'); const ruleTemplate = container.find('#regex_debugger_rule_template'); if (!rulesContainer.length || !ruleTemplate.length) { console.error('Regex Debugger: Could not find rule list or template in the DOM.'); return; } rulesContainer.empty(); const allScripts = getRegexScripts(); if (!allScripts || allScripts.length === 0) { rulesContainer.append('
' + t`No regex rules found.` + '
'); return; } const globalScriptIds = new Set(getScriptsByType(SCRIPT_TYPES.GLOBAL).map(s => s.id)); const scopedScriptIds = new Set(getScriptsByType(SCRIPT_TYPES.SCOPED).map(s => s.id)); const presetScriptIds = new Set(getScriptsByType(SCRIPT_TYPES.PRESET).map(s => s.id)); const globalScripts = []; const scopedScripts = []; const presetScripts = []; allScripts.forEach(script => { const scriptCopy = structuredClone(script); // Use structuredClone for deep copy if (globalScriptIds.has(script.id)) { // @ts-ignore scriptCopy.type = SCRIPT_TYPES.GLOBAL; globalScripts.push(scriptCopy); } else if (scopedScriptIds.has(script.id)) { // @ts-ignore scriptCopy.type = SCRIPT_TYPES.SCOPED; scopedScripts.push(scriptCopy); } else if (presetScriptIds.has(script.id)) { // @ts-ignore scriptCopy.type = SCRIPT_TYPES.PRESET; presetScripts.push(scriptCopy); } }); container.data('allScripts', [...globalScripts, ...presetScripts, ...scopedScripts]); const renderRule = (script) => { if (!script.id) script.id = uuidv4(); const ruleElementContent = $(ruleTemplate.prop('content')).clone(); const ruleElement = ruleElementContent.find('.regex-debugger-rule'); ruleElement.attr('data-id', script.id); // @ts-ignore ruleElement.find('.rule-name').text(script.scriptName); ruleElement.find('.rule-regex').text(script.findRegex); // @ts-ignore ruleElement .find('.rule-scope') .text( { [SCRIPT_TYPES.SCOPED]: t`Scoped`, [SCRIPT_TYPES.GLOBAL]: t`Global`, [SCRIPT_TYPES.PRESET]: t`Preset`, }[script.type], ); ruleElement.find('.rule-enabled').prop('checked', !script.disabled); // @ts-ignore ruleElement.find('.edit_rule').on('click', () => onRegexEditorOpenClick(script.id, script.type)); ruleElement.on('click', function (event) { if ($(event.target).is('input, .menu_button, .menu_button i')) { return; } const scriptId = $(this).data('id'); const stepElement = $(`#step-result-${scriptId}`); const container = $('#regex_debugger_steps_output'); if (stepElement.length && container.length) { // Replace scrollIntoView with scrollTop animation const targetTop = stepElement.position().top; const containerScrollTop = container.scrollTop(); const containerHeight = container.height(); // Center the element if possible let scrollTo = containerScrollTop + targetTop - (containerHeight / 2) + (stepElement.height() / 2); container.animate({ scrollTop: scrollTo }, 300); // 300ms smooth scroll stepElement.css('transition', 'background-color 0.5s').css('background-color', 'var(--highlight_color)'); setTimeout(() => stepElement.css('background-color', ''), 1000); } }); return ruleElementContent; }; if (globalScripts.length > 0) { rulesContainer.append('
' + t`Global Rules` + '
'); const globalList = $('
    '); globalScripts.forEach(script => globalList.append(renderRule(script))); rulesContainer.append(globalList); } if (presetScripts.length > 0) { rulesContainer.append('
    ' + t`Preset Rules` + '
    '); const presetList = $('
      '); presetScripts.forEach(script => presetList.append(renderRule(script))); rulesContainer.append(presetList); } if (scopedScripts.length > 0) { rulesContainer.append('
      ' + t`Scoped Rules` + '
      '); const scopedList = $('
        '); scopedScripts.forEach(script => scopedList.append(renderRule(script))); rulesContainer.append(scopedList); } } /** * Opens the regex debugger. * @returns {Promise} */ async function onRegexDebuggerOpenClick() { const templateContent = await renderExtensionTemplateAsync('regex', 'debugger'); const debuggerHtml = $('
        ').html(templateContent); const stepTemplate = debuggerHtml.find('#regex_debugger_step_template'); populateDebuggerRuleList(debuggerHtml); // @ts-ignore debuggerHtml.find('#regex_debugger_rules_global').sortable({ delay: getSortableDelay() }).disableSelection(); // @ts-ignore debuggerHtml.find('#regex_debugger_rules_scoped').sortable({ delay: getSortableDelay() }).disableSelection(); // @ts-ignore debuggerHtml.find('#regex_debugger_rules_preset').sortable({ delay: getSortableDelay() }).disableSelection(); debuggerHtml.find('#regex_debugger_run_test').on('click', function () { const allScripts = debuggerHtml.data('allScripts'); const orderedRuleIds = [ ...$('#regex_debugger_rules_global').find('li.regex-debugger-rule').map((i, el) => $(el).data('id')).get(), ...$('#regex_debugger_rules_scoped').find('li.regex-debugger-rule').map((i, el) => $(el).data('id')).get(), ...$('#regex_debugger_rules_preset').find('li.regex-debugger-rule').map((i, el) => $(el).data('id')).get(), ]; const rawInput = String($('#regex_debugger_raw_input').val()); const stepsOutput = $('#regex_debugger_steps_output'); const finalOutput = $('#regex_debugger_final_output'); if (!stepsOutput.length || !finalOutput.length) return; const displayMode = $('input[name="display_mode"]:checked').val(); stepsOutput.empty(); finalOutput.empty(); $('#regex_debugger_final_summary').remove(); if (!allScripts) return; let textForNextStep = rawInput; let totalCharsCaptured = 0; let totalCharsAdded = 0; let totalCharsRemoved = 0; orderedRuleIds.forEach(scriptId => { const ruleElement = $(`#regex_debugger_rules [data-id="${scriptId}"]`); if (!ruleElement.find('.rule-enabled').is(':checked')) return; const script = allScripts.find(s => s.id === scriptId); if (script) { const result = executeRegexScriptForDebugging(script, textForNextStep); totalCharsCaptured += result.charsCaptured; totalCharsAdded += result.charsAdded; totalCharsRemoved += result.charsRemoved; const stepElement = $(stepTemplate.prop('content')).clone(); // Set the ID on the TOP-LEVEL element that is being appended. stepElement.find('>:first-child').attr('id', `step-result-${script.id}`); const stepHeader = stepElement.find('.step-header'); stepHeader.find('strong').text(t`After:` + ` ${script.scriptName}`); const metricsHtml = '' + t`Captured:` + ` ${result.charsCaptured}, ` + t`Added:` + ` +${result.charsAdded}, ` + t`Removed:` + ` -${result.charsRemoved}`; stepHeader.append(metricsHtml); if (displayMode === 'highlight') { stepElement.find('.step-output').html(result.highlightedOutput); } else { stepElement.find('.step-output').text(result.output); } if (result.error) { stepHeader.append($(`
        ${result.error}
        `)); } stepsOutput.append(stepElement); textForNextStep = result.output; } }); const summaryHtml = `
        ` + t`Total Captured:` + ` ${totalCharsCaptured} | ` + t`Total Added:` + ` +${totalCharsAdded} | ` + t`Total Removed:` + ` -${totalCharsRemoved}
        `; finalOutput.before(summaryHtml); const renderMode = $('#regex_debugger_render_mode').val(); if (renderMode === 'message') { const formattedHtml = messageFormatting(textForNextStep, 'Debugger', true, false, null); const messageBlock = $('
        '); messageBlock.find('.mes_text').html(formattedHtml); finalOutput.append(messageBlock); } else { finalOutput.text(textForNextStep); } }); debuggerHtml.find('#regex_debugger_save_order').on('click', async function () { const allKnownScripts = getRegexScripts(); const newGlobalScripts = $('#regex_debugger_rules_global').children('li').map((_, el) => allKnownScripts.find(s => s.id === $(el).data('id'))).get().filter(Boolean); const newScopedScripts = $('#regex_debugger_rules_scoped').children('li').map((_, el) => allKnownScripts.find(s => s.id === $(el).data('id'))).get().filter(Boolean); const newPresetScripts = $('#regex_debugger_rules_preset').children('li').map((_, el) => allKnownScripts.find(s => s.id === $(el).data('id'))).get().filter(Boolean); extension_settings.regex = newGlobalScripts; if (this_chid !== undefined) { await saveScriptsByType(newScopedScripts, SCRIPT_TYPES.SCOPED); } await saveScriptsByType(newPresetScripts, SCRIPT_TYPES.PRESET); saveSettingsDebounced(); await loadRegexScripts(); toastr.success(t`Regex script order saved!`); const currentPopupContent = $('div:has(> #regex_debugger_rules)'); populateDebuggerRuleList(currentPopupContent); // @ts-ignore currentPopupContent.find('#regex_debugger_rules_global').sortable({ delay: getSortableDelay() }).disableSelection(); // @ts-ignore currentPopupContent.find('#regex_debugger_rules_scoped').sortable({ delay: getSortableDelay() }).disableSelection(); // @ts-ignore currentPopupContent.find('#regex_debugger_rules_preset').sortable({ delay: getSortableDelay() }).disableSelection(); }); debuggerHtml.find('#regex_debugger_expand_steps').on('click', function () { const popupContainer = $('
        '); const navPanel = $('

        Steps

        '); const contentPanel = $('
        '); const content = $('#regex_debugger_steps_output').clone().html(); contentPanel.html(content); $('#regex_debugger_rules .regex-debugger-rule').each(function () { const ruleElement = $(this); const scriptId = ruleElement.data('id'); const scriptName = ruleElement.find('.rule-name').text(); const link = $(`${escapeHtml(scriptName)}`); link.data('target-id', `step-result-${scriptId}`); link.on('click', function (e) { e.preventDefault(); navPanel.find('a').removeClass('active'); $(this).addClass('active'); const targetId = $(this).data('target-id'); // The selector is now correct for the structure. const targetElement = contentPanel.find(`#${targetId}`); if (targetElement.length) { const scrollTo = contentPanel.scrollTop() + targetElement.position().top; contentPanel.animate({ scrollTop: scrollTo }, 300); targetElement.css('transition', 'background-color 0.5s').css('background-color', 'var(--highlight_color)'); setTimeout(() => targetElement.css('background-color', ''), 1000); } }); navPanel.append(link); }); popupContainer.append(navPanel).append(contentPanel); callGenericPopup(popupContainer, POPUP_TYPE.TEXT, t`Step-by-step Transformation`, { wide: true, allowVerticalScrolling: false }); }); debuggerHtml.find('#regex_debugger_expand_final').on('click', function () { const content = $('#regex_debugger_final_output').html(); const popupContent = $('
        ').html(content); callGenericPopup(popupContent, POPUP_TYPE.TEXT, t`Final Output`, { wide: true, large: true, allowVerticalScrolling: true }); }); await callGenericPopup(debuggerHtml.children(), POPUP_TYPE.TEXT, '', { wide: true, allowVerticalScrolling: true }); } /** * Updates the info block in the regex editor with hints regarding the find regex. * @param {JQuery} editorHtml The editor HTML */ function updateInfoBlock(editorHtml) { const infoBlock = editorHtml.find('.info-block').get(0); const infoBlockFlagsHint = editorHtml.find('#regex_info_block_flags_hint'); const findRegex = String(editorHtml.find('.find_regex').val()); infoBlockFlagsHint.hide(); // Clear the info block if the find regex is empty if (!findRegex) { setInfoBlock(infoBlock, t`Find Regex is empty`, 'info'); return; } try { const regex = regexFromString(findRegex); if (!regex) { throw new Error(t`Invalid Find Regex`); } const flagInfo = []; flagInfo.push(regex.flags.includes('g') ? t`Applies to all matches` : t`Applies to the first match`); flagInfo.push(regex.flags.includes('i') ? t`Case insensitive` : t`Case sensitive`); setInfoBlock(infoBlock, flagInfo.join('. '), 'hint'); infoBlockFlagsHint.show(); } catch (error) { setInfoBlock(infoBlock, error.message, 'error'); } } // 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 (!Array.isArray(script.placement)) { script.placement = []; 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 (performSave) { saveSettingsDebounced(); } } /** * /regex slash command callback * @param {{name: string}} 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 = args.name; const scripts = getRegexScripts(); for (const script of scripts) { if (script.scriptName.toLowerCase() === scriptName.toLowerCase()) { if (script.disabled) { toastr.warning(t`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; } /** * /regex-toggle slash command callback * @param {{state: string, quiet: string}} args Named arguments * @param {string} scriptName The name of the script to toggle * @returns {Promise} The name of the script */ async function toggleRegexCallback(args, scriptName) { if (typeof scriptName !== 'string') throw new Error('Script name must be a string.'); const quiet = isTrueBoolean(args?.quiet); const action = isTrueBoolean(args?.state) ? 'enable' : isFalseBoolean(args?.state) ? 'disable' : 'toggle'; const scripts = getRegexScripts(); const script = scripts.find(s => equalsIgnoreCaseAndAccents(s.scriptName, scriptName)); if (!script) { toastr.warning(t`Regex script '${scriptName}' not found.`); return ''; } switch (action) { case 'enable': script.disabled = false; break; case 'disable': script.disabled = true; break; default: script.disabled = !script.disabled; break; } const scriptType = getScriptType(script); const index = getScriptsByType(scriptType).indexOf(script); await saveRegexScript(script, index, scriptType); if (script.disabled) { !quiet && toastr.success(t`Regex script '${scriptName}' has been disabled.`); } else { !quiet && toastr.success(t`Regex script '${scriptName}' has been enabled.`); } return script.scriptName || ''; } /** * Performs the import of the regex object. * @param {RegexScript} regexScript Input object * @param {SCRIPT_TYPES} scriptType The type of script to import as */ async function onRegexImportObjectChange(regexScript, scriptType) { try { if (!regexScript.scriptName) { throw new Error('No script name provided.'); } // Assign a new UUID regexScript.id = uuidv4(); const array = getScriptsByType(scriptType); array.push(regexScript); switch (scriptType) { case SCRIPT_TYPES.GLOBAL: // will be handled by saveSettingsDebounced break; case SCRIPT_TYPES.SCOPED: await saveScriptsByType(array, SCRIPT_TYPES.SCOPED); break; case SCRIPT_TYPES.PRESET: await saveScriptsByType(array, SCRIPT_TYPES.PRESET); break; default: break; } saveSettingsDebounced(); await loadRegexScripts(); toastr.success(t`Regex script "${regexScript.scriptName}" imported.`); } catch (error) { console.log(error); toastr.error(t`Invalid regex object.`); return; } } /** * Performs the import of the regex file. * @param {File} file Input file * @param {SCRIPT_TYPES} scriptType The type of script to import as */ async function onRegexImportFileChange(file, scriptType) { if (!file) { toastr.error('No file provided.'); return; } try { const regexScripts = JSON.parse(await getFileText(file)); if (Array.isArray(regexScripts)) { for (const regexScript of regexScripts) { await onRegexImportObjectChange(regexScript, scriptType); } } else { await onRegexImportObjectChange(regexScripts, scriptType); } } catch (error) { console.log(error); toastr.error('Invalid JSON file.'); return; } } /** * Determines the type of a given script. * @param {RegexScript} script The script to check * @returns {SCRIPT_TYPES} The script type. */ function getScriptType(script) { for (const scriptType of Object.values(SCRIPT_TYPES)) { const scripts = getScriptsByType(scriptType); if (scripts.some(s => s.id === script.id)) { return scriptType; } } return SCRIPT_TYPE_UNKNOWN; } function getSelectedScripts() { const scripts = getRegexScripts(); const selector = '#regex_container .regex-script-label:has(.regex_bulk_checkbox:checked)'; const selectedIds = Array.from(document.querySelectorAll(selector)) .map(e => e.getAttribute('id')) .filter(id => id); return scripts.filter(script => selectedIds.includes(script.id)); } function purgeEmbeddedRegexScripts({ character }) { const avatar = character?.avatar; if (!avatar) { return; } const checkKey = `AlertRegex_${avatar}`; if (accountStorage.getItem(checkKey)) { accountStorage.removeItem(checkKey); } disallowScopedScripts(characters?.[this_chid]); } function purgePresetEmbeddedRegexScripts({ apiId, name }) { const checkKey = `AlertRegex_${apiId}_${name}`; if (accountStorage.getItem(checkKey)) { accountStorage.removeItem(checkKey); } disallowPresetScripts(apiId, name); } async function checkCharEmbeddedRegexScripts() { const chid = this_chid; if (chid !== undefined && !selected_group) { const character = characters[chid]; const scripts = getScriptsByType(SCRIPT_TYPES.SCOPED); if (Array.isArray(scripts) && scripts.length > 0) { if (!isScopedScriptsAllowed(character)) { const checkKey = `AlertRegex_${character.avatar}`; if (!accountStorage.getItem(checkKey)) { accountStorage.setItem(checkKey, 'true'); const template = await renderExtensionTemplateAsync('regex', 'embeddedScripts', {}); const result = await callGenericPopup(template, POPUP_TYPE.CONFIRM, ''); if (result) { allowScopedScripts(character); await reloadCurrentChat(); } } } } } await loadRegexScripts(); } /** * Notify whether to reload current chat when preset is changed * @param {string} presetName The name of the preset */ function notifyReloadCurrentChat(presetName) { toastr.info( t`Reload the chat for regex to take effect` + '
        ' + t`Click here to reload immediately` + '', t`Preset '${presetName}' contains enabled regex scripts`, { timeOut: 5000, escapeHtml: false, onclick: reloadCurrentChat, }); } async function checkPresetEmbeddedRegexScripts() { const apiId = getCurrentPresetAPI(); const name = getCurrentPresetName(); const scripts = getScriptsByType(SCRIPT_TYPES.PRESET); if (Array.isArray(scripts) && scripts.length > 0) { if (!isPresetScriptsAllowed(apiId, name)) { const checkKey = `AlertRegex_${apiId}_${name}`; if (!accountStorage.getItem(checkKey)) { accountStorage.setItem(checkKey, 'true'); const template = await renderExtensionTemplateAsync('regex', 'presetEmbeddedScripts', {}); const result = await callGenericPopup(template, POPUP_TYPE.CONFIRM, ''); if (result) { allowPresetScripts(apiId, name); if (getCurrentChatId()) { await reloadCurrentChat(); } } } } else if (getCurrentChatId() && scripts.filter(script => !script.disabled).length > 0) { notifyReloadCurrentChat(name); } } await loadRegexScripts(); } async function onMainApiChanged({ apiId }) { const presetManager = getPresetManager(apiId); if (!presetManager) { return; } const presetName = presetManager.getSelectedPresetName(); const presetScripts = presetManager.readPresetExtensionField({ path: 'regex_scripts' }) ?? []; if (getCurrentChatId() && isPresetScriptsAllowed(apiId, presetName) && Array.isArray(presetScripts) && presetScripts.filter(script => !script.disabled).length > 0) { notifyReloadCurrentChat(presetName); } await loadRegexScripts(); } function onPresetRenamed({ apiId, oldName, newName }) { const oldCheckKey = `AlertRegex_${apiId}_${oldName}`; const checkKey = `AlertRegex_${apiId}_${newName}`; const value = accountStorage.getItem(oldCheckKey); if (value) { accountStorage.setItem(checkKey, value); accountStorage.removeItem(oldCheckKey); } if (isPresetScriptsAllowed(apiId, oldName)) { disallowPresetScripts(apiId, oldName); allowPresetScripts(apiId, newName); } } // 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 (!Array.isArray(extension_settings.regex)) { extension_settings.regex = []; } if (!Array.isArray(extension_settings.regex_presets)) { extension_settings.regex_presets = []; } // Manually disable the extension since static imports auto-import the JS file if (extension_settings.disabledExtensions.includes('regex')) { return; } migrateSettings(); const settingsHtml = $(await renderExtensionTemplateAsync('regex', 'dropdown')); $('#regex_container').append(settingsHtml); $('#open_regex_editor').on('click', function () { onRegexEditorOpenClick(false, SCRIPT_TYPES.GLOBAL); }); $('#open_regex_debugger').on('click', onRegexDebuggerOpenClick); $('#open_scoped_editor').on('click', function () { if (this_chid === undefined) { toastr.error(t`No character selected.`); return; } if (selected_group) { toastr.error(t`Cannot edit scoped scripts in group chats.`); return; } onRegexEditorOpenClick(false, SCRIPT_TYPES.SCOPED); }); $('#open_preset_editor').on('click', function () { onRegexEditorOpenClick(false, SCRIPT_TYPES.PRESET); }); $('#import_regex_file').on('change', async function () { let target = SCRIPT_TYPES.GLOBAL; const template = $(await renderExtensionTemplateAsync('regex', 'importTarget')); template.find('#regex_import_target_global').on('input', () => (target = SCRIPT_TYPES.GLOBAL)); template.find('#regex_import_target_scoped').on('input', () => (target = SCRIPT_TYPES.SCOPED)); template.find('#regex_import_target_preset').on('input', () => (target = SCRIPT_TYPES.PRESET)); await callGenericPopup(template, POPUP_TYPE.TEXT); const inputElement = this instanceof HTMLInputElement && this; for (const file of inputElement.files) { await onRegexImportFileChange(file, target); } inputElement.value = ''; }); $('#import_regex').on('click', function () { $('#import_regex_file').trigger('click'); }); $('#bulk_select_all_toggle').on('click', async function () { const checkboxes = $('#regex_container .regex_bulk_checkbox'); if (checkboxes.length === 0) { return; } const allAreChecked = checkboxes.length === checkboxes.filter(':checked').length; const newState = !allAreChecked; // true if we just checked all, false if we just unchecked all checkboxes.prop('checked', newState); setToggleAllIcon(newState); setMoveButtonsVisibility(); }); $('#bulk_enable_regex').on('click', async function () { await bulkToggleRegexScripts(true); }); $('#bulk_disable_regex').on('click', async function () { await bulkToggleRegexScripts(false); }); /** * Bulk enable or disable regex scripts * @param {boolean} newState New state to set (true = enable, false = disable) * @returns {Promise} */ async function bulkToggleRegexScripts(newState) { const scripts = getSelectedScripts().filter(script => script.disabled === newState); if (scripts.length === 0) { toastr.warning(newState ? t`No regex scripts selected for enabling.` : t`No regex scripts selected for disabling.`, ); return; } const scriptTypesToSave = new Set(); for (const script of scripts) { const scriptType = getScriptType(script); scriptTypesToSave.add(scriptType); script.disabled = !newState; } for (const scriptType of scriptTypesToSave) { const scriptsOfType = getScriptsByType(scriptType); await saveScriptsByType(scriptsOfType, scriptType); } saveSettingsDebounced(); await loadRegexScripts(); // Reload the current chat to undo previous markdown const currentChatId = getCurrentChatId(); if (currentChatId) { await reloadCurrentChat(); } } /** * Bulk move regex scripts to the specified type * @param {SCRIPT_TYPES} toType destination type */ async function bulkMoveRegexScript(toType) { const scripts = getSelectedScripts(); if (scripts.length === 0) { toastr.warning(t`No regex scripts selected for moving.`); return; } for (const script of scripts) { await moveRegexScript(script, toType, getScriptType(script), false); } saveSettingsDebounced(); await loadRegexScripts(); // Reload the current chat to undo previous markdown const currentChatId = getCurrentChatId(); if (currentChatId) { await reloadCurrentChat(); } } $('#bulk_regex_move_to_global').on('click', async () => { const confirm = await callGenericPopup(t`Are you sure you want to move the selected regex scripts to global?`, POPUP_TYPE.CONFIRM); if (!confirm) { return; } await bulkMoveRegexScript(SCRIPT_TYPES.GLOBAL); }); $('#bulk_regex_move_to_scoped').on('click', async () => { if (this_chid === undefined) { toastr.error(t`No character selected.`); return; } if (selected_group) { toastr.error(t`Cannot edit scoped scripts in group chats.`); return; } const confirm = await callGenericPopup(t`Are you sure you want to move the selected regex scripts to scoped?`, POPUP_TYPE.CONFIRM); if (!confirm) { return; } await bulkMoveRegexScript(SCRIPT_TYPES.SCOPED); }); $('#bulk_regex_move_to_preset').on('click', async function () { const confirm = await callGenericPopup(t`Are you sure you want to move the selected regex scripts to preset?`, POPUP_TYPE.CONFIRM); if (!confirm) { return; } await bulkMoveRegexScript(SCRIPT_TYPES.PRESET); }); $('#bulk_delete_regex').on('click', async function () { const scripts = getSelectedScripts(); if (scripts.length === 0) { toastr.warning(t`No regex scripts selected for deletion.`); return; } const confirm = await callGenericPopup(t`Are you sure you want to delete the selected regex scripts?`, POPUP_TYPE.CONFIRM); if (!confirm) { return; } for (const script of scripts) { await deleteRegexScript(script.id, getScriptType(script), false); } saveSettingsDebounced(); await loadRegexScripts(); await reloadCurrentChat(); }); $('#bulk_export_regex').on('click', async function () { const scripts = getSelectedScripts(); if (scripts.length === 0) { toastr.warning(t`No regex scripts selected for export.`); return; } const fileName = `regex-${new Date().toISOString()}.json`; const fileData = JSON.stringify(scripts, null, 4); download(fileData, fileName, 'application/json'); await loadRegexScripts(); }); let sortableDatas = [ { selector: '#saved_regex_scripts', setter: scripts => saveScriptsByType(scripts, SCRIPT_TYPES.GLOBAL), getter: () => getScriptsByType(SCRIPT_TYPES.GLOBAL), }, { selector: '#saved_scoped_scripts', setter: scripts => saveScriptsByType(scripts, SCRIPT_TYPES.SCOPED), getter: () => getScriptsByType(SCRIPT_TYPES.SCOPED), }, { selector: '#saved_preset_scripts', setter: scripts => saveScriptsByType(scripts, SCRIPT_TYPES.PRESET), getter: () => getScriptsByType(SCRIPT_TYPES.PRESET), }, ]; for (const { selector, setter, getter } of sortableDatas) { // @ts-ignore $(selector).sortable({ delay: getSortableDelay(), handle: '.drag-handle', 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 reloadCurrentChat(); await loadRegexScripts(); }, }); } $('#regex_scoped_toggle').on('input', function () { if (this_chid === undefined) { toastr.error(t`No character selected.`); return; } if (selected_group) { toastr.error(t`Cannot edit scoped scripts in group chats.`); return; } const isEnable = !!$(this).prop('checked'); const character = characters[this_chid]; if (isEnable) { allowScopedScripts(character); } else { disallowScopedScripts(character); } saveSettingsDebounced(); reloadCurrentChat(); }); $('#regex_preset_toggle').on('input', function () { const isEnable = !!$(this).prop('checked'); const name = getCurrentPresetName(); if (isEnable) { allowPresetScripts(getCurrentPresetAPI(), name); } else { disallowPresetScripts(getCurrentPresetAPI(), name); } saveSettingsDebounced(); reloadCurrentChat(); }); await loadRegexScripts(); // @ts-ignore $('#saved_regex_scripts').sortable('enable'); /** * @typedef {object} ScriptDecorators * @property {string} typename * @property {import('../../slash-commands/SlashCommandEnumValue.js').EnumType} color * @property {string} icon */ /** * @param {SCRIPT_TYPES} type The script type * @returns {ScriptDecorators} The decorators for the script type */ function getScriptDecorators(type) { switch (type) { case SCRIPT_TYPES.GLOBAL: return { typename: 'global', color: enumTypes.enum, icon: 'G', }; case SCRIPT_TYPES.SCOPED: return { typename: 'scoped', color: enumTypes.name, icon: 'S', }; case SCRIPT_TYPES.PRESET: return { typename: 'preset', color: enumTypes.name, icon: 'P', }; default: return { typename: 'Unknown', color: enumTypes.variable, icon: 'Unknown', }; } } const localEnumProviders = { regexScripts: () => getRegexScripts().map(script => { const type = getScriptType(script); const { typename, color, icon } = getScriptDecorators(type); return new SlashCommandEnumValue( script.scriptName, `${enumIcons.getStateIcon(!script.disabled)} [${typename}] ${script.findRegex}`, color, icon, ); }), }; SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'regex', callback: runRegexCallback, returns: 'replaced text', namedArgumentList: [ SlashCommandNamedArgument.fromProps({ name: 'name', description: 'script name', typeList: [ARGUMENT_TYPE.STRING], 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.', })); SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'regex-toggle', callback: toggleRegexCallback, returns: 'The name of the script that was toggled', namedArgumentList: [ SlashCommandNamedArgument.fromProps({ name: 'state', description: 'Explicitly set the state of the script (\'on\' to enable, \'off\' to disable). If not provided, the state will be toggled to the opposite of the current state.', typeList: [ARGUMENT_TYPE.BOOLEAN], defaultValue: 'toggle', enumList: commonEnumProviders.boolean('onOffToggle')(), }), SlashCommandNamedArgument.fromProps({ name: 'quiet', description: 'Suppress the toast message script toggled', typeList: [ARGUMENT_TYPE.BOOLEAN], defaultValue: 'false', enumList: commonEnumProviders.boolean('trueFalse')(), }), ], unnamedArgumentList: [ SlashCommandArgument.fromProps({ description: 'script name', typeList: [ARGUMENT_TYPE.STRING], isRequired: true, enumProvider: localEnumProviders.regexScripts, }), ], helpString: `
        Toggles the state of a specified regex script.
        Example:
        • /regex-toggle MyScript
        • /regex-toggle state=off Character-specific Script
        `, })); eventSource.on(event_types.MAIN_API_CHANGED, onMainApiChanged); eventSource.on(event_types.CHAT_CHANGED, checkCharEmbeddedRegexScripts); eventSource.on(event_types.CHARACTER_DELETED, purgeEmbeddedRegexScripts); eventSource.on(event_types.PRESET_RENAMED_BEFORE, onPresetRenamed); eventSource.on(event_types.PRESET_CHANGED, checkPresetEmbeddedRegexScripts); eventSource.on(event_types.PRESET_DELETED, purgePresetEmbeddedRegexScripts); presetManager.setupEventListeners(); presetManager.registerSlashCommands(); });