Blame Raw
Cohee · 51ad27fb · · 2157 lines (83.5 KB)
2 contributors
1import { characters, eventSource, event_types, getCurrentChatId, messageFormatting, reloadCurrentChat, saveSettingsDebounced, this_chid } from '../../../script.js';
2import { extension_settings, renderExtensionTemplateAsync } from '../../extensions.js';
3import { selected_group } from '../../group-chats.js';
4import { callGenericPopup, Popup, POPUP_TYPE } from '../../popup.js';
5import { SlashCommand } from '../../slash-commands/SlashCommand.js';
6import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';
7import { commonEnumProviders, enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
8import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashCommandEnumValue.js';
9import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
10import { download, equalsIgnoreCaseAndAccents, escapeHtml, getFileText, getSortableDelay, isFalseBoolean, isTrueBoolean, regexFromString, setInfoBlock, uuidv4 } from '../../utils.js';
11import { allowPresetScripts, allowScopedScripts, disallowPresetScripts, disallowScopedScripts, getCurrentPresetAPI, getCurrentPresetName, getRegexScripts, getScriptsByType, isPresetScriptsAllowed, isScopedScriptsAllowed, regex_placement, RegexProvider, runRegexScript, saveScriptsByType, SCRIPT_TYPE_UNKNOWN, SCRIPT_TYPES, substitute_find_regex } from './engine.js';
12import { t } from '../../i18n.js';
13import { accountStorage } from '../../util/AccountStorage.js';
14import { getPresetManager } from '../../preset-manager.js';
15
16// Re-exports for legacy extensions
17export { getRegexScripts };
18
19const sanitizeFileName = name => name.replace(/[\s.<>:"/\\|?*\x00-\x1F\x7F]/g, '_').toLowerCase();
20
21/**
22 * @typedef {import('../../char-data.js').RegexScriptData} RegexScript
23 */
24
25/**
26 * @typedef {object} RegexPresetItem
27 * @property {string} id - UUID of the regex script
28 */
29
30/**
31 * @typedef {object} RegexPreset
32 * @property {string} id - UUID of the preset
33 * @property {string} name - Name of the preset
34 * @property {boolean} isSelected - Whether the preset is currently selected
35 * @property {RegexPresetItem[]} global - The list of global preset items
36 * @property {RegexPresetItem[]} scoped - The list of scoped preset items
37 * @property {RegexPresetItem[]} preset - The list of preset preset items
38 */
39
40/**
41 * @typedef {object} RegexPresetState
42 * @property {string[]} global - List of enabled global regex script IDs
43 * @property {string[]} scoped - List of enabled scoped regex script IDs
44 * @property {string[]} preset - List of enabled preset regex script IDs
45 */
46
47class RegexPresetManager {
48 /** @type {HTMLSelectElement} */
49 presetSelect = null;
50
51 /** @type {HTMLElement} */
52 presetCreateButton = null;
53
54 /** @type {HTMLElement} */
55 presetUpdateButton = null;
56
57 /** @type {HTMLElement} */
58 presetApplyButton = null;
59
60 /** @type {HTMLElement} */
61 presetDeleteButton = null;
62
63 /** @type {string|null} */
64 currentPresetId = null;
65
66 /** @type {RegexPresetState|null} */
67 lastKnownState = null;
68
69 /**
70 * Captures the current state of enabled regex scripts for change detection.
71 * @returns {RegexPresetState} The current state object
72 */
73 captureCurrentState() {
74 const globalScripts = this.regexListToPresetItems(getScriptsByType(SCRIPT_TYPES.GLOBAL));
75 const scopedScripts = this.regexListToPresetItems(getScriptsByType(SCRIPT_TYPES.SCOPED));
76 const presetScripts = this.regexListToPresetItems(getScriptsByType(SCRIPT_TYPES.PRESET));
77
78 return {
79 global: globalScripts.map(item => item.id).sort(),
80 scoped: scopedScripts.map(item => item.id).sort(),
81 preset: presetScripts.map(item => item.id).sort(),
82 };
83 }
84
85 /**
86 * Compares two state objects to detect changes.
87 * @param {RegexPresetState} state1 First state object
88 * @param {RegexPresetState} state2 Second state object
89 * @returns {boolean} True if states are different
90 */
91 hasStateChanged(state1, state2) {
92 if (!state1 || !state2) return false;
93
94 const global1 = state1.global || [];
95 const global2 = state2.global || [];
96 const scoped1 = state1.scoped || [];
97 const scoped2 = state2.scoped || [];
98 const preset1 = state1.preset || [];
99 const preset2 = state2.preset || [];
100
101 if (global1.length !== global2.length || scoped1.length !== scoped2.length) {
102 return true;
103 }
104
105 return !global1.every(id => global2.includes(id)) ||
106 !scoped1.every(id => scoped2.includes(id)) ||
107 !preset1.every(id => preset2.includes(id));
108 }
109
110 /**
111 * Updates the stored state after a preset is applied or saved.
112 * @param {string} presetId - The current preset ID
113 */
114 updateStoredState(presetId) {
115 this.currentPresetId = presetId;
116 this.lastKnownState = this.captureCurrentState();
117 }
118
119 /**
120 * Checks if there are unsaved changes and shows a confirmation dialog.
121 * @returns {Promise<boolean>} True if user wants to proceed without saving
122 */
123 async checkUnsavedChanges() {
124 if (!this.currentPresetId || !this.lastKnownState) {
125 return true; // No current preset or state to compare
126 }
127
128 const currentState = this.captureCurrentState();
129 if (!this.hasStateChanged(this.lastKnownState, currentState)) {
130 return true; // No changes detected
131 }
132
133 const currentPreset = extension_settings.regex_presets.find(p => p.id === this.currentPresetId);
134 const presetName = currentPreset ? currentPreset.name : t`Unknown Preset`;
135
136 const choice = await Popup.show.confirm(
137 t`You have unsaved changes to the "${presetName}" preset.`,
138 t`Do you want to save them before switching?`,
139 {
140 okButton: t`Save Changes`,
141 cancelButton: t`Discard Changes`,
142 },
143 );
144
145 if (choice) {
146 // User chose to save changes
147 await this.savePreset(this.currentPresetId, true);
148 this.renderPresetList();
149 return true;
150 }
151
152 // User chose to discard changes
153 return true;
154 }
155
156 /**
157 * Sets up event listeners for the preset management UI.
158 * @returns {void}
159 */
160 setupEventListeners() {
161 this.presetSelect = /** @type {HTMLSelectElement} */ (document.getElementById('regex_presets'));
162 if (!this.presetSelect) {
163 console.error('RegexPresetManager: Could not find preset select element in the DOM.');
164 return;
165 }
166
167 this.presetSelect.addEventListener('change', async (event) => {
168 const selectedPresetId = this.presetSelect.value;
169 const fromSlashCommand = event instanceof CustomEvent && event?.detail?.fromSlashCommand === true;
170
171 // Check for unsaved changes before switching
172 if (!fromSlashCommand) {
173 const canProceed = await this.checkUnsavedChanges();
174 if (!canProceed) {
175 // Revert the selection
176 event.preventDefault();
177 const currentPreset = extension_settings.regex_presets.find(p => p.id === this.currentPresetId);
178 if (currentPreset) {
179 this.presetSelect.value = currentPreset.id;
180 }
181 return;
182 }
183 }
184
185 await this.applyPreset(selectedPresetId);
186 extension_settings.regex_presets.forEach(p => { p.isSelected = p.id === selectedPresetId; });
187 saveSettingsDebounced();
188 this.updateStoredState(selectedPresetId);
189 });
190
191 this.presetCreateButton = document.getElementById('regex_preset_create');
192 if (!this.presetCreateButton) {
193 console.error('RegexPresetManager: Could not find preset create button in the DOM.');
194 return;
195 }
196
197 this.presetCreateButton.addEventListener('click', async () => {
198 const newId = uuidv4();
199 await this.savePreset(newId, false);
200 this.renderPresetList();
201 this.updateStoredState(newId);
202 });
203
204 this.presetUpdateButton = document.getElementById('regex_preset_update');
205 if (!this.presetUpdateButton) {
206 console.error('RegexPresetManager: Could not find preset update button in the DOM.');
207 return;
208 }
209
210 this.presetUpdateButton.addEventListener('click', async () => {
211 const selectedPresetId = this.presetSelect.value;
212 await this.savePreset(selectedPresetId, true);
213 this.renderPresetList();
214 this.updateStoredState(selectedPresetId);
215 });
216
217 this.presetApplyButton = document.getElementById('regex_preset_apply');
218 if (!this.presetApplyButton) {
219 console.error('RegexPresetManager: Could not find preset apply button in the DOM.');
220 return;
221 }
222
223 this.presetApplyButton.addEventListener('click', async () => {
224 const selectedPresetId = this.presetSelect.value;
225 await this.applyPreset(selectedPresetId);
226 this.updateStoredState(selectedPresetId);
227 });
228
229 this.presetDeleteButton = document.getElementById('regex_preset_delete');
230 if (!this.presetDeleteButton) {
231 console.error('RegexPresetManager: Could not find preset delete button in the DOM.');
232 return;
233 }
234
235 this.presetDeleteButton.addEventListener('click', async () => {
236 const selectedPresetId = this.presetSelect.value;
237 await this.deletePreset(selectedPresetId);
238 this.renderPresetList();
239
240 const newSelectedPresetId = extension_settings.regex_presets.find(p => p.isSelected)?.id;
241 if (newSelectedPresetId) {
242 await this.applyPreset(newSelectedPresetId);
243 this.presetSelect.value = newSelectedPresetId;
244 this.updateStoredState(newSelectedPresetId);
245 } else {
246 this.currentPresetId = null;
247 this.lastKnownState = null;
248 }
249 });
250
251 this.renderPresetList();
252
253 // Initialize the stored state with the currently selected preset
254 const selectedPreset = extension_settings.regex_presets?.find(p => p.isSelected);
255 if (selectedPreset) {
256 this.updateStoredState(selectedPreset.id);
257 }
258 }
259
260 /**
261 * Registers slash commands related to regex presets.
262 * @returns {void}
263 */
264 registerSlashCommands() {
265 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
266 name: 'regex-preset',
267 helpString: t`Selects a regex preset by name or ID. Gets the current regex preset ID if no argument is provided.`,
268 callback: (args, name) => {
269 if (!this.presetSelect) {
270 return '';
271 }
272
273 name = String(name ?? '').trim();
274
275 if (name) {
276 const quiet = isTrueBoolean(args?.quiet?.toString());
277 const foundId = extension_settings.regex_presets.find(p => equalsIgnoreCaseAndAccents(p.id, name) || equalsIgnoreCaseAndAccents(p.name, name))?.id;
278
279 if (foundId) {
280 this.presetSelect.value = foundId;
281 this.presetSelect.dispatchEvent(new CustomEvent('change', { detail: { fromSlashCommand: true } }));
282 return foundId;
283 }
284
285 !quiet && toastr.warning(`Regex preset "${name}" not found`);
286 return '';
287 }
288
289 return this.presetSelect.value;
290 },
291 returns: 'current preset ID',
292 namedArgumentList: [
293 SlashCommandNamedArgument.fromProps({
294 name: 'quiet',
295 description: 'Suppress the toast message on preset change',
296 typeList: [ARGUMENT_TYPE.BOOLEAN],
297 defaultValue: 'false',
298 enumList: commonEnumProviders.boolean('trueFalse')(),
299 }),
300 ],
301 unnamedArgumentList: [
302 SlashCommandArgument.fromProps({
303 description: 'regex preset name or ID',
304 typeList: [ARGUMENT_TYPE.STRING],
305 enumProvider: () => extension_settings.regex_presets.map(x => new SlashCommandEnumValue(x.id, x.name, enumTypes.enum, enumIcons.preset)),
306 }),
307 ],
308 }));
309 }
310
311 /**
312 * Renders the list of regex presets in the UI.
313 * @returns {void}
314 */
315 renderPresetList() {
316 if (!this.presetSelect) {
317 return;
318 }
319
320 this.presetSelect.innerHTML = '';
321
322 if (!Array.isArray(extension_settings.regex_presets) || extension_settings.regex_presets.length === 0) {
323 const fallbackOption = new Option(t`[No presets saved]`, '', true, true);
324 this.presetSelect.appendChild(fallbackOption);
325 this.presetSelect.disabled = true;
326 return;
327 }
328
329 extension_settings.regex_presets.forEach(preset => {
330 const option = new Option(preset.name, preset.id, preset.isSelected, preset.isSelected);
331 this.presetSelect.appendChild(option);
332 });
333
334 this.presetSelect.disabled = false;
335 }
336
337 /**
338 * Applies a preset list to a target list of scripts.
339 * @param {Object} params The parameters object
340 * @param {RegexPresetItem[]} params.presetList The list of preset items
341 * @param {RegexScript[]} params.targetList The list of target scripts to modify
342 * @param {(targetList: RegexScript[]) => Promise<any>} params.saveFunction Function to save the modified list
343 */
344 async applyPresetList({ presetList, targetList, saveFunction }) {
345 if (!Array.isArray(targetList) || !Array.isArray(presetList)) {
346 return;
347 }
348
349 // Only enable scripts that are in the preset
350 targetList.forEach((script => {
351 script.disabled = !presetList.some(p => p.id === script.id);
352 }));
353
354 // First sort by the order in the preset, then the original order
355 targetList.sort((a, b) => {
356 const aIndex = presetList.findIndex(p => p.id === a.id);
357 const bIndex = presetList.findIndex(p => p.id === b.id);
358 return aIndex - bIndex || targetList.indexOf(a) - targetList.indexOf(b);
359 });
360
361 await saveFunction(targetList);
362 }
363
364 /**
365 * Applies a regex preset to the current context.
366 * @param {string} presetId - The ID of the preset to apply
367 * @returns {Promise<void>}
368 */
369 async applyPreset(presetId) {
370 const preset = extension_settings.regex_presets.find(p => p.id === presetId);
371 if (!preset) {
372 toastr.error(t`Could not find the selected preset.`);
373 return;
374 }
375
376 // Apply preset to all lists
377 for (const scriptType of Object.values(SCRIPT_TYPES)) {
378 await this.applyPresetList({
379 presetList: {
380 [SCRIPT_TYPES.GLOBAL]: preset.global,
381 [SCRIPT_TYPES.SCOPED]: preset.scoped,
382 [SCRIPT_TYPES.PRESET]: preset.preset,
383 }[scriptType],
384 targetList: getScriptsByType(scriptType),
385 saveFunction: scripts => saveScriptsByType(scripts, scriptType),
386 });
387 }
388
389 // Render the changes to the UI
390 await loadRegexScripts();
391 // Apply the changes to the current chat
392 await reloadCurrentChat();
393 }
394
395 /**
396 * Converts a list of regex scripts to preset items.
397 * @param {RegexScript[]} list The list of regex scripts
398 * @returns {RegexPresetItem[] | null} The list of preset items, or null if the input is invalid
399 */
400 regexListToPresetItems(list) {
401 if (!Array.isArray(list)) {
402 return null;
403 }
404
405 return list.filter(x => !x.disabled).map(s => ({ id: s.id }));
406 }
407
408 /**
409 * Saves a regex preset.
410 * @param {string} presetId - The ID of the preset
411 * @param {boolean} isUpdate - Whether this is an update operation
412 * @returns {Promise<void>}
413 */
414 async savePreset(presetId, isUpdate) {
415 const existingPreset = isUpdate ? extension_settings.regex_presets.find(p => p.id === presetId) : null;
416
417 if (isUpdate && !existingPreset) {
418 toastr.error(t`Could not find the preset to update.`);
419 return;
420 }
421
422 const name = isUpdate ? existingPreset.name : await Popup.show.input(t`Enter a name for the new regex preset:`, '');
423 const id = isUpdate ? existingPreset.id : presetId;
424
425 if (!name || !name.trim().length) {
426 return;
427 }
428
429 const preset = {
430 id: id,
431 name: name,
432 isSelected: false,
433 global: this.regexListToPresetItems(getScriptsByType(SCRIPT_TYPES.GLOBAL)),
434 scoped: this.regexListToPresetItems(getScriptsByType(SCRIPT_TYPES.SCOPED)),
435 preset: this.regexListToPresetItems(getScriptsByType(SCRIPT_TYPES.PRESET)),
436 };
437
438 if (isUpdate) {
439 Object.assign(existingPreset, preset);
440 } else {
441 extension_settings.regex_presets.push(preset);
442 }
443
444 extension_settings.regex_presets.forEach(p => { p.isSelected = p.id === id; });
445 saveSettingsDebounced();
446
447 toastr.success(isUpdate ? t`Regex preset updated` : t`Regex preset saved`);
448 }
449
450 /**
451 * Deletes a regex preset.
452 * @param {string} presetId - The ID of the preset to delete
453 * @returns {Promise<void>}
454 */
455 async deletePreset(presetId) {
456 const presetIndex = extension_settings.regex_presets.findIndex(p => p.id === presetId);
457 if (presetIndex === -1) {
458 toastr.error(t`Could not find the preset to delete.`);
459 return;
460 }
461
462 const presetName = extension_settings.regex_presets[presetIndex].name;
463 const confirm = await Popup.show.confirm(t`Are you sure you want to delete this regex preset?`, presetName);
464 if (!confirm) {
465 return;
466 }
467
468 extension_settings.regex_presets.splice(presetIndex, 1);
469
470 // Select the first preset if any exist
471 extension_settings.regex_presets.forEach((p, i) => { p.isSelected = i === 0; });
472 saveSettingsDebounced();
473
474 toastr.success(t`Regex preset deleted`);
475 }
476}
477
478const presetManager = new RegexPresetManager();
479
480/**
481 * Toggle the icon for the "select all" checkbox in the regex settings.
482 * - Use `fa-check-double` when the checkbox is unchecked (indicating all scripts are not selected).
483 * - Use `fa-minus` when the checkbox is checked (indicating all scripts are selected).
484 * @param {boolean} allAreChecked Should the "select all" icon be in the checked state?
485 */
486function setToggleAllIcon(allAreChecked) {
487 const selectAllIcon = $('#bulk_select_all_toggle').find('i');
488 selectAllIcon.toggleClass('fa-check-double', !allAreChecked);
489 selectAllIcon.toggleClass('fa-minus', allAreChecked);
490}
491
492/**
493 * Sets the visibility of the bulk move buttons based on selected scripts.
494 */
495function setMoveButtonsVisibility() {
496 const hasGlobalScripts = $('#saved_regex_scripts .regex-script-label:has(.regex_bulk_checkbox:checked)').length > 0;
497 const hasScopedScripts = $('#saved_scoped_scripts .regex-script-label:has(.regex_bulk_checkbox:checked)').length > 0;
498 const hasPresetScripts = $('#saved_preset_scripts .regex-script-label:has(.regex_bulk_checkbox:checked)').length > 0;
499 $('#bulk_regex_move_to_global').toggle(hasScopedScripts || hasPresetScripts);
500 $('#bulk_regex_move_to_scoped').toggle(hasGlobalScripts || hasPresetScripts);
501 $('#bulk_regex_move_to_preset').toggle(hasGlobalScripts || hasScopedScripts);
502}
503
504/**
505 * Saves a regex script to the extension settings or character data.
506 * @param {import('../../char-data.js').RegexScriptData} regexScript
507 * @param {number} existingScriptIndex Index of the existing script
508 * @param {SCRIPT_TYPES} scriptType Type of the script
509 * @param {boolean} [saveSettings=true] Whether to save the settings immediately
510 * @returns {Promise<void>}
511 */
512async function saveRegexScript(regexScript, existingScriptIndex, scriptType, saveSettings = true) {
513 // If not editing
514 const array = getScriptsByType(scriptType);
515
516 // Assign a UUID if it doesn't exist
517 if (!regexScript.id) {
518 regexScript.id = uuidv4();
519 }
520
521 // Is the script name undefined or empty?
522 if (!regexScript.scriptName) {
523 toastr.error(t`Could not save regex script: The script name was undefined or empty!`);
524 return;
525 }
526
527 // Is a find regex present?
528 if (regexScript.findRegex.length === 0) {
529 toastr.warning(t`This regex script will not work, but was saved anyway: A find regex isn't present.`);
530 }
531
532 // Is there someplace to place results?
533 if (regexScript.placement.length === 0) {
534 toastr.warning(t`This regex script will not work, but was saved anyway: One "Affects" checkbox must be selected!`);
535 }
536
537 if (existingScriptIndex !== -1) {
538 array[existingScriptIndex] = regexScript;
539 } else {
540 array.push(regexScript);
541 }
542
543 if (scriptType === SCRIPT_TYPES.SCOPED) {
544 await saveScriptsByType(array, SCRIPT_TYPES.SCOPED);
545 allowScopedScripts(characters?.[this_chid]);
546 }
547
548 if (scriptType === SCRIPT_TYPES.PRESET) {
549 await saveScriptsByType(array, SCRIPT_TYPES.PRESET);
550 allowPresetScripts(getCurrentPresetAPI(), getCurrentPresetName());
551 }
552
553 if (saveSettings) {
554 saveSettingsDebounced();
555 await loadRegexScripts();
556
557 // Reload the current chat to undo previous markdown
558 const currentChatId = getCurrentChatId();
559 if (currentChatId) {
560 await reloadCurrentChat();
561 }
562 }
563
564 const debuggerPopup = $('#regex_debugger_popup');
565 if (debuggerPopup.length) {
566 populateDebuggerRuleList(debuggerPopup.parent());
567 }
568}
569
570/**
571 * Delete a regex script by ID
572 * @param {string} id ID of the script to delete
573 * @param {SCRIPT_TYPES} scriptType Type of the script
574 * @param {boolean} saveSettings Whether to save the settings immediately
575 * @returns {Promise<void>}
576 */
577async function deleteRegexScript(id, scriptType, saveSettings = true) {
578 const array = getScriptsByType(scriptType);
579
580 const existingScriptIndex = array.findIndex(script => script.id === id);
581 if (existingScriptIndex !== -1) {
582 array.splice(existingScriptIndex, 1);
583
584 switch (scriptType) {
585 case SCRIPT_TYPES.GLOBAL:
586 // will be handled by saveSettingsDebounced
587 break;
588 case SCRIPT_TYPES.SCOPED:
589 await saveScriptsByType(array, SCRIPT_TYPES.SCOPED);
590 break;
591 case SCRIPT_TYPES.PRESET:
592 await saveScriptsByType(array, SCRIPT_TYPES.PRESET);
593 break;
594 default:
595 break;
596 }
597 if (saveSettings) {
598 saveSettingsDebounced();
599 await loadRegexScripts();
600 }
601 }
602}
603
604/**
605 * Move a regex script from one type to another
606 * @param {import('../../char-data.js').RegexScriptData} script The script to move
607 * @param {SCRIPT_TYPES} toType Target type
608 * @param {SCRIPT_TYPES|null} fromType Source type, if null it will be determined automatically
609 * @param {boolean} saveSettings Whether to save the settings immediately
610 * @returns {Promise<void>}
611 */
612async function moveRegexScript(script, toType, fromType = null, saveSettings = true) {
613 if (!Object.values(SCRIPT_TYPES).includes(toType)) {
614 console.warn(`moveRegexScript: Invalid target script type ${toType}`);
615 return;
616 }
617 if (!Object.values(SCRIPT_TYPES).includes(fromType)) {
618 fromType = getScriptType(script);
619 }
620 if (fromType === toType || fromType === SCRIPT_TYPE_UNKNOWN || toType === SCRIPT_TYPE_UNKNOWN) {
621 return;
622 }
623 await deleteRegexScript(script.id, fromType, false);
624 await saveRegexScript(script, -1, toType, saveSettings);
625}
626
627async function loadRegexScripts() {
628 $('#saved_regex_scripts').empty();
629 $('#saved_scoped_scripts').empty();
630 $('#saved_preset_scripts').empty();
631 setToggleAllIcon(false);
632
633 const scriptTemplate = $(await renderExtensionTemplateAsync('regex', 'scriptTemplate'));
634
635 /**
636 * Renders a script to the UI.
637 * @param {string} container Container to render the script to
638 * @param {import('../../char-data.js').RegexScriptData} script Script data
639 * @param {SCRIPT_TYPES} scriptType Type of the script
640 * @param {number} index Index of the script in the array
641 */
642 function renderScript(container, script, scriptType, index) {
643 // Have to clone here
644 const scriptHtml = scriptTemplate.clone();
645 const save = () => saveRegexScript(script, index, scriptType);
646
647 if (!script.id) {
648 script.id = uuidv4();
649 }
650
651 scriptHtml.attr('id', script.id);
652 scriptHtml.find('.regex_script_name').text(script.scriptName).attr('title', script.scriptName);
653 scriptHtml.find('.disable_regex').prop('checked', script.disabled ?? false)
654 .on('input', async function () {
655 script.disabled = !!$(this).prop('checked');
656 await save();
657 });
658 scriptHtml.find('.regex-toggle-on').on('click', function () {
659 scriptHtml.find('.disable_regex').prop('checked', true).trigger('input');
660 });
661 scriptHtml.find('.regex-toggle-off').on('click', function () {
662 scriptHtml.find('.disable_regex').prop('checked', false).trigger('input');
663 });
664 scriptHtml.find('.edit_existing_regex').on('click', async function () {
665 await onRegexEditorOpenClick(scriptHtml.attr('id'), scriptType);
666 });
667 scriptHtml.find('.move_to_global').on('click', async function () {
668 const confirm = await callGenericPopup(t`Are you sure you want to move this regex script to global?`, POPUP_TYPE.CONFIRM);
669
670 if (!confirm) {
671 return;
672 }
673 await moveRegexScript(script, SCRIPT_TYPES.GLOBAL, scriptType);
674 });
675 scriptHtml.find('.move_to_scoped').on('click', async function () {
676 if (this_chid === undefined) {
677 toastr.error(t`No character selected.`);
678 return;
679 }
680 if (selected_group) {
681 toastr.error(t`Cannot edit scoped scripts in group chats.`);
682 return;
683 }
684 const confirm = await callGenericPopup(t`Are you sure you want to move this regex script to scoped?`, POPUP_TYPE.CONFIRM);
685 if (!confirm) {
686 return;
687 }
688 await moveRegexScript(script, SCRIPT_TYPES.SCOPED, scriptType);
689 });
690 scriptHtml.find('.move_to_preset').on('click', async function () {
691 const confirm = await callGenericPopup(
692 t`Are you sure you want to move this regex script to preset?`,
693 POPUP_TYPE.CONFIRM,
694 );
695 if (!confirm) {
696 return;
697 }
698 await moveRegexScript(script, SCRIPT_TYPES.PRESET, scriptType);
699 });
700 scriptHtml.find('.export_regex').on('click', async function () {
701 const fileName = `regex-${sanitizeFileName(script.scriptName)}.json`;
702 const fileData = JSON.stringify(script, null, 4);
703 download(fileData, fileName, 'application/json');
704 });
705 scriptHtml.find('.delete_regex').on('click', async function () {
706 const confirm = await callGenericPopup(t`Are you sure you want to delete this regex script?`, POPUP_TYPE.CONFIRM);
707 if (!confirm) {
708 return;
709 }
710 await deleteRegexScript(script.id, scriptType);
711 await reloadCurrentChat();
712 });
713 scriptHtml.find('.regex_bulk_checkbox').on('change', function () {
714 setMoveButtonsVisibility();
715 const checkboxes = $('#regex_container .regex_bulk_checkbox');
716 const allAreChecked = checkboxes.length === checkboxes.filter(':checked').length;
717 setToggleAllIcon(allAreChecked);
718 });
719 scriptHtml.find('input[name="regex_expand"]').on('change', function () {
720 if (!(this instanceof HTMLInputElement)) {
721 return;
722 }
723
724 if (!this.checked) {
725 return;
726 }
727
728 const closeMenuHandler = (e) => {
729 if (e.target instanceof HTMLElement) {
730 if (e.target.closest('.regex-script-label')) {
731 return;
732 }
733 this.checked = false;
734 document.removeEventListener('click', closeMenuHandler);
735 }
736 };
737
738 // Use setTimeout to avoid closing immediately from the same click
739 setTimeout(() => {
740 document.addEventListener('click', closeMenuHandler, { passive: true, once: false });
741 }, 0);
742 });
743
744 $(container).append(scriptHtml);
745 }
746
747 getScriptsByType(SCRIPT_TYPES.GLOBAL).forEach((script, index) => renderScript('#saved_regex_scripts', script, SCRIPT_TYPES.GLOBAL, index));
748 getScriptsByType(SCRIPT_TYPES.SCOPED).forEach((script, index) => renderScript('#saved_scoped_scripts', script, SCRIPT_TYPES.SCOPED, index));
749 getScriptsByType(SCRIPT_TYPES.PRESET).forEach((script, index) => renderScript('#saved_preset_scripts', script, SCRIPT_TYPES.PRESET, index));
750
751 $('#regex_scoped_toggle').prop('checked', isScopedScriptsAllowed(characters?.[this_chid]));
752 $('#regex_preset_toggle').prop('checked', isPresetScriptsAllowed(getCurrentPresetAPI(), getCurrentPresetName()));
753
754 setMoveButtonsVisibility();
755}
756
757/**
758 * Opens the regex editor.
759 * @param {string|boolean} existingId Existing ID
760 * @param {SCRIPT_TYPES} scriptType Type of the script
761 * @returns {Promise<void>}
762 */
763async function onRegexEditorOpenClick(existingId, scriptType) {
764 const editorHtml = $(await renderExtensionTemplateAsync('regex', 'editor'));
765 const array = getScriptsByType(scriptType);
766
767 // If an ID exists, fill in all the values
768 let existingScriptIndex = -1;
769 if (existingId) {
770 existingScriptIndex = array.findIndex((script) => script.id === existingId);
771 if (existingScriptIndex !== -1) {
772 const existingScript = array[existingScriptIndex];
773 if (existingScript.scriptName) {
774 editorHtml.find('.regex_script_name').val(existingScript.scriptName);
775 } else {
776 toastr.error('This script doesn\'t have a name! Please delete it.');
777 return;
778 }
779
780 editorHtml.find('.find_regex').val(existingScript.findRegex || '');
781 editorHtml.find('.regex_replace_string').val(existingScript.replaceString || '');
782 editorHtml.find('.regex_trim_strings').val(existingScript.trimStrings?.join('\n') || []);
783 editorHtml.find('input[name="disabled"]').prop('checked', existingScript.disabled ?? false);
784 editorHtml.find('input[name="only_format_display"]').prop('checked', existingScript.markdownOnly ?? false);
785 editorHtml.find('input[name="only_format_prompt"]').prop('checked', existingScript.promptOnly ?? false);
786 editorHtml.find('input[name="run_on_edit"]').prop('checked', existingScript.runOnEdit ?? false);
787 editorHtml.find('select[name="substitute_regex"]').val(existingScript.substituteRegex ?? substitute_find_regex.NONE);
788 editorHtml.find('input[name="min_depth"]').val(existingScript.minDepth ?? '');
789 editorHtml.find('input[name="max_depth"]').val(existingScript.maxDepth ?? '');
790
791 existingScript.placement.forEach((element) => {
792 editorHtml
793 .find(`input[name="replace_position"][value="${element}"]`)
794 .prop('checked', true);
795 });
796 }
797 } else {
798 editorHtml
799 .find('input[name="only_format_display"]')
800 .prop('checked', true);
801
802 editorHtml
803 .find('input[name="run_on_edit"]')
804 .prop('checked', true);
805
806 editorHtml
807 .find('input[name="replace_position"][value="1"]')
808 .prop('checked', true);
809 }
810
811 editorHtml.find('#regex_test_mode_toggle').on('click', function () {
812 editorHtml.find('#regex_test_mode').toggleClass('displayNone');
813 updateTestResult();
814 });
815
816 function updateTestResult() {
817 updateInfoBlock(editorHtml);
818
819 if (!editorHtml.find('#regex_test_mode').is(':visible')) {
820 return;
821 }
822
823 const testScript = {
824 id: uuidv4(),
825 scriptName: editorHtml.find('.regex_script_name').val().toString(),
826 findRegex: editorHtml.find('.find_regex').val().toString(),
827 replaceString: editorHtml.find('.regex_replace_string').val().toString(),
828 trimStrings: String(editorHtml.find('.regex_trim_strings').val()).split('\n').filter((e) => e.length !== 0) || [],
829 substituteRegex: Number(editorHtml.find('select[name="substitute_regex"]').val()),
830 disabled: false,
831 promptOnly: false,
832 markdownOnly: false,
833 runOnEdit: false,
834 minDepth: null,
835 maxDepth: null,
836 placement: null,
837 };
838 const rawTestString = String(editorHtml.find('#regex_test_input').val());
839 const result = runRegexScript(testScript, rawTestString);
840 editorHtml.find('#regex_test_output').text(result);
841 }
842
843 editorHtml.find('input, textarea, select').on('input', updateTestResult);
844 updateInfoBlock(editorHtml);
845
846 const popupResult = await callGenericPopup(editorHtml, POPUP_TYPE.CONFIRM, '', { okButton: t`Save`, cancelButton: t`Cancel`, allowVerticalScrolling: true });
847 if (popupResult) {
848 const newRegexScript = {
849 id: existingId ? String(existingId) : uuidv4(),
850 scriptName: String(editorHtml.find('.regex_script_name').val()),
851 findRegex: String(editorHtml.find('.find_regex').val()),
852 replaceString: String(editorHtml.find('.regex_replace_string').val()),
853 trimStrings: String(editorHtml.find('.regex_trim_strings').val()).split('\n').filter((e) => e.length !== 0) || [],
854 placement:
855 editorHtml
856 .find('input[name="replace_position"]')
857 .filter(':checked')
858 .map(function () { return parseInt($(this).val().toString()); })
859 .get()
860 .filter((e) => !isNaN(e)) || [],
861 disabled: editorHtml.find('input[name="disabled"]').prop('checked'),
862 markdownOnly: editorHtml.find('input[name="only_format_display"]').prop('checked'),
863 promptOnly: editorHtml.find('input[name="only_format_prompt"]').prop('checked'),
864 runOnEdit: editorHtml.find('input[name="run_on_edit"]').prop('checked'),
865 substituteRegex: Number(editorHtml.find('select[name="substitute_regex"]').val()),
866 minDepth: parseInt(String(editorHtml.find('input[name="min_depth"]').val())),
867 maxDepth: parseInt(String(editorHtml.find('input[name="max_depth"]').val())),
868 };
869
870 saveRegexScript(newRegexScript, existingScriptIndex, scriptType);
871 }
872}
873
874/**
875 * Builds an HTML string for a replacement, highlighting literal parts in green
876 * and keeping back-referenced parts plain.
877 * @param {RegExpMatchArray} match The match object from `matchAll`.
878 * @param {string} pattern The replacement pattern string (e.g., "new text $1").
879 * @returns {string} The constructed HTML string.
880 */
881function buildReplacementHtml(match, pattern) {
882 const container = document.createDocumentFragment();
883 let lastIndex = 0;
884 const backrefRegex = /\$\$|\$&|\$`|\$'|\$(\d{1,2})/g;
885
886 let reMatch;
887 while ((reMatch = backrefRegex.exec(pattern)) !== null) {
888 // Part of the pattern before the back-reference is a literal.
889 const literalPart = pattern.substring(lastIndex, reMatch.index);
890 if (literalPart) {
891 const mark = document.createElement('mark');
892 mark.className = 'green_hl';
893 mark.innerText = literalPart;
894 container.appendChild(mark);
895 }
896
897 const backref = reMatch[0];
898 if (backref === '$$') {
899 container.appendChild(document.createTextNode('$'));
900 } else if (backref === '$&') {
901 const mark = document.createElement('mark');
902 mark.className = 'yellow_hl';
903 mark.innerText = match[0];
904 container.appendChild(mark);
905 } else if (backref === '$`') {
906 container.appendChild(document.createTextNode(match.input.substring(0, match.index)));
907 } else if (backref === '$\'') {
908 container.appendChild(document.createTextNode(match.input.substring(match.index + match[0].length)));
909 } else { // It's a numbered capture group, $n.
910 const groupIndex = parseInt(reMatch[1], 10);
911 if (groupIndex > 0 && groupIndex < match.length && match[groupIndex] !== undefined) {
912 const mark = document.createElement('mark');
913 mark.className = 'yellow_hl';
914 mark.innerText = match[groupIndex];
915 container.appendChild(mark);
916 } else {
917 // Not a valid group index, treat it as a literal.
918 const mark = document.createElement('mark');
919 mark.className = 'green_hl';
920 mark.innerText = backref;
921 container.appendChild(mark);
922 }
923 }
924 lastIndex = backrefRegex.lastIndex;
925 }
926
927 // The final part of the pattern after the last back-reference.
928 const finalLiteralPart = pattern.substring(lastIndex);
929 if (finalLiteralPart) {
930 const mark = document.createElement('mark');
931 mark.className = 'green_hl';
932 mark.innerText = finalLiteralPart;
933 container.appendChild(mark);
934 }
935
936 // To get the HTML content, we need a temporary parent element.
937 const tempDiv = document.createElement('div');
938 tempDiv.appendChild(container);
939 return tempDiv.innerHTML;
940}
941
942function executeRegexScriptForDebugging(script, text) {
943 let err;
944 let originalRegex;
945
946 try {
947 originalRegex = regexFromString(script.findRegex);
948 if (!originalRegex) throw new Error('Invalid regex string');
949 } catch (e) {
950 err = `Compile error: ${e.message}`;
951 return { output: text, highlightedOutput: text, error: err, charsCaptured: 0, charsAdded: 0, charsRemoved: 0 };
952 }
953
954 const globalRegex = new RegExp(originalRegex.source, originalRegex.flags.includes('g') ? originalRegex.flags : originalRegex.flags + 'g');
955 const matches = [...text.matchAll(globalRegex)];
956
957 if (matches.length === 0) {
958 return { output: text, highlightedOutput: escapeHtml(text), error: null, charsCaptured: 0, charsAdded: 0, charsRemoved: 0 };
959 }
960
961 let outputText = '';
962 let highlightedOutput = ''; // This will now be our "diff view"
963 let lastIndex = 0;
964 let totalCharsCaptured = 0;
965 let totalCharsAdded = 0;
966 let totalCharsRemoved = 0;
967
968 try {
969 for (const match of matches) {
970 const originalMatchText = match[0];
971 totalCharsCaptured += originalMatchText.length;
972
973 // Append text between matches (this part is unchanged)
974 const precedingText = text.substring(lastIndex, match.index);
975 outputText += precedingText;
976 highlightedOutput += escapeHtml(precedingText);
977
978 // --- Start of new diff and statistics logic ---
979 let charsAddedInMatch = 0;
980 let charsKeptFromMatch = 0;
981 const backrefRegex = /\$\$|\$&|\$`|\$'|\$(\d{1,2})/g;
982 let lastPatternIndex = 0;
983 let reMatch;
984 let replacementForPlainText = '';
985
986 // This loop calculates the stats accurately
987 while ((reMatch = backrefRegex.exec(script.replaceString)) !== null) {
988 const literalPart = script.replaceString.substring(lastPatternIndex, reMatch.index);
989 charsAddedInMatch += literalPart.length;
990 replacementForPlainText += literalPart;
991 const backref = reMatch[0];
992 if (backref === '$$') {
993 replacementForPlainText += '$';
994 } else if (backref === '$&') {
995 charsKeptFromMatch += (match[0] || '').length; replacementForPlainText += (match[0] || '');
996 } else if (backref === '$`') {
997 const part = match.input.substring(0, match.index); charsKeptFromMatch += part.length; replacementForPlainText += part;
998 } else if (backref === '$\'') {
999 const part = match.input.substring(match.index + match[0].length); charsKeptFromMatch += part.length; replacementForPlainText += part;
1000 } else {
1001 const groupIndex = parseInt(reMatch[1], 10);
1002 if (groupIndex > 0 && groupIndex < match.length && match[groupIndex] !== undefined) {
1003 charsKeptFromMatch += match[groupIndex].length;
1004 replacementForPlainText += match[groupIndex];
1005 }
1006 }
1007 lastPatternIndex = backrefRegex.lastIndex;
1008 }
1009 const finalLiteralPart = script.replaceString.substring(lastPatternIndex);
1010 charsAddedInMatch += finalLiteralPart.length;
1011 replacementForPlainText += finalLiteralPart;
1012
1013 totalCharsAdded += charsAddedInMatch;
1014 totalCharsRemoved += (originalMatchText.length - charsKeptFromMatch);
1015
1016 outputText += replacementForPlainText;
1017 // --- End of statistics logic ---
1018
1019 // --- Build the new Diff View HTML ---
1020 // 1. Show the entire original match as "removed" (red strikethrough)
1021 highlightedOutput += `<mark class='red_hl'>${escapeHtml(originalMatchText)}</mark>`;
1022 // 2. Add an arrow to signify transformation
1023 highlightedOutput += ' → ';
1024 // 3. Build the replacement string with green (added) and yellow (kept) parts
1025 highlightedOutput += buildReplacementHtml(match, script.replaceString);
1026
1027 lastIndex = match.index + originalMatchText.length;
1028 }
1029
1030 // Append text after the last match
1031 const trailingText = text.substring(lastIndex);
1032 outputText += trailingText;
1033 highlightedOutput += escapeHtml(trailingText);
1034 } catch (e) {
1035 err = (err ? err + '; ' : '') + `Replace error: ${e.message}`;
1036 outputText = text; // Fallback
1037 highlightedOutput = escapeHtml(text);
1038 }
1039
1040 return {
1041 output: outputText,
1042 highlightedOutput: highlightedOutput,
1043 error: err,
1044 charsCaptured: totalCharsCaptured,
1045 charsAdded: totalCharsAdded,
1046 charsRemoved: totalCharsRemoved,
1047 };
1048}
1049
1050function populateDebuggerRuleList(container) {
1051 const rulesContainer = container.find('#regex_debugger_rules');
1052 const ruleTemplate = container.find('#regex_debugger_rule_template');
1053 if (!rulesContainer.length || !ruleTemplate.length) {
1054 console.error('Regex Debugger: Could not find rule list or template in the DOM.');
1055 return;
1056 }
1057
1058 rulesContainer.empty();
1059
1060 const allScripts = getRegexScripts();
1061 if (!allScripts || allScripts.length === 0) {
1062 rulesContainer.append('<div class="regex-debugger-no-rules">' + t`No regex rules found.` + '</div>');
1063 return;
1064 }
1065
1066 const globalScriptIds = new Set(getScriptsByType(SCRIPT_TYPES.GLOBAL).map(s => s.id));
1067 const scopedScriptIds = new Set(getScriptsByType(SCRIPT_TYPES.SCOPED).map(s => s.id));
1068 const presetScriptIds = new Set(getScriptsByType(SCRIPT_TYPES.PRESET).map(s => s.id));
1069 const globalScripts = [];
1070 const scopedScripts = [];
1071 const presetScripts = [];
1072
1073 allScripts.forEach(script => {
1074 const scriptCopy = structuredClone(script); // Use structuredClone for deep copy
1075 if (globalScriptIds.has(script.id)) {
1076 // @ts-ignore
1077 scriptCopy.type = SCRIPT_TYPES.GLOBAL;
1078 globalScripts.push(scriptCopy);
1079 } else if (scopedScriptIds.has(script.id)) {
1080 // @ts-ignore
1081 scriptCopy.type = SCRIPT_TYPES.SCOPED;
1082 scopedScripts.push(scriptCopy);
1083 } else if (presetScriptIds.has(script.id)) {
1084 // @ts-ignore
1085 scriptCopy.type = SCRIPT_TYPES.PRESET;
1086 presetScripts.push(scriptCopy);
1087 }
1088 });
1089
1090 container.data('allScripts', [...globalScripts, ...presetScripts, ...scopedScripts]);
1091
1092 const renderRule = (script) => {
1093 if (!script.id) script.id = uuidv4();
1094 const ruleElementContent = $(ruleTemplate.prop('content')).clone();
1095 const ruleElement = ruleElementContent.find('.regex-debugger-rule');
1096
1097 ruleElement.attr('data-id', script.id);
1098 // @ts-ignore
1099 ruleElement.find('.rule-name').text(script.scriptName);
1100 ruleElement.find('.rule-regex').text(script.findRegex);
1101 // @ts-ignore
1102 ruleElement
1103 .find('.rule-scope')
1104 .text(
1105 {
1106 [SCRIPT_TYPES.SCOPED]: t`Scoped`,
1107 [SCRIPT_TYPES.GLOBAL]: t`Global`,
1108 [SCRIPT_TYPES.PRESET]: t`Preset`,
1109 }[script.type],
1110 );
1111 ruleElement.find('.rule-enabled').prop('checked', !script.disabled);
1112 // @ts-ignore
1113 ruleElement.find('.edit_rule').on('click', () => onRegexEditorOpenClick(script.id, script.type));
1114
1115 ruleElement.on('click', function (event) {
1116 if ($(event.target).is('input, .menu_button, .menu_button i')) {
1117 return;
1118 }
1119 const scriptId = $(this).data('id');
1120 const stepElement = $(`#step-result-${scriptId}`);
1121 const container = $('#regex_debugger_steps_output');
1122
1123 if (stepElement.length && container.length) {
1124 // Replace scrollIntoView with scrollTop animation
1125 const targetTop = stepElement.position().top;
1126 const containerScrollTop = container.scrollTop();
1127 const containerHeight = container.height();
1128
1129 // Center the element if possible
1130 let scrollTo = containerScrollTop + targetTop - (containerHeight / 2) + (stepElement.height() / 2);
1131
1132 container.animate({ scrollTop: scrollTo }, 300); // 300ms smooth scroll
1133
1134 stepElement.css('transition', 'background-color 0.5s').css('background-color', 'var(--highlight_color)');
1135 setTimeout(() => stepElement.css('background-color', ''), 1000);
1136 }
1137 });
1138
1139 return ruleElementContent;
1140 };
1141
1142 if (globalScripts.length > 0) {
1143 rulesContainer.append('<div class="list-header regex-debugger-list-header">' + t`Global Rules` + '</div>');
1144 const globalList = $('<ul id="regex_debugger_rules_global" class="sortable-list"></ul>');
1145 globalScripts.forEach(script => globalList.append(renderRule(script)));
1146 rulesContainer.append(globalList);
1147 }
1148
1149 if (presetScripts.length > 0) {
1150 rulesContainer.append('<div class="list-header regex-debugger-list-header">' + t`Preset Rules` + '</div>');
1151 const presetList = $('<ul id="regex_debugger_rules_preset" class="sortable-list"></ul>');
1152 presetScripts.forEach(script => presetList.append(renderRule(script)));
1153 rulesContainer.append(presetList);
1154 }
1155
1156 if (scopedScripts.length > 0) {
1157 rulesContainer.append('<div class="list-header regex-debugger-list-header">' + t`Scoped Rules` + '</div>');
1158 const scopedList = $('<ul id="regex_debugger_rules_scoped" class="sortable-list"></ul>');
1159 scopedScripts.forEach(script => scopedList.append(renderRule(script)));
1160 rulesContainer.append(scopedList);
1161 }
1162}
1163
1164/**
1165 * Opens the regex debugger.
1166 * @returns {Promise<void>}
1167 */
1168async function onRegexDebuggerOpenClick() {
1169 const templateContent = await renderExtensionTemplateAsync('regex', 'debugger');
1170 const debuggerHtml = $('<div>').html(templateContent);
1171
1172 const stepTemplate = debuggerHtml.find('#regex_debugger_step_template');
1173
1174 populateDebuggerRuleList(debuggerHtml);
1175
1176 // @ts-ignore
1177 debuggerHtml.find('#regex_debugger_rules_global').sortable({ delay: getSortableDelay() }).disableSelection();
1178 // @ts-ignore
1179 debuggerHtml.find('#regex_debugger_rules_scoped').sortable({ delay: getSortableDelay() }).disableSelection();
1180 // @ts-ignore
1181 debuggerHtml.find('#regex_debugger_rules_preset').sortable({ delay: getSortableDelay() }).disableSelection();
1182
1183 debuggerHtml.find('#regex_debugger_run_test').on('click', function () {
1184 const allScripts = debuggerHtml.data('allScripts');
1185 const orderedRuleIds = [
1186 ...$('#regex_debugger_rules_global').find('li.regex-debugger-rule').map((i, el) => $(el).data('id')).get(),
1187 ...$('#regex_debugger_rules_scoped').find('li.regex-debugger-rule').map((i, el) => $(el).data('id')).get(),
1188 ...$('#regex_debugger_rules_preset').find('li.regex-debugger-rule').map((i, el) => $(el).data('id')).get(),
1189 ];
1190
1191 const rawInput = String($('#regex_debugger_raw_input').val());
1192 const stepsOutput = $('#regex_debugger_steps_output');
1193 const finalOutput = $('#regex_debugger_final_output');
1194
1195 if (!stepsOutput.length || !finalOutput.length) return;
1196
1197 const displayMode = $('input[name="display_mode"]:checked').val();
1198 stepsOutput.empty();
1199 finalOutput.empty();
1200 $('#regex_debugger_final_summary').remove();
1201
1202 if (!allScripts) return;
1203 let textForNextStep = rawInput;
1204 let totalCharsCaptured = 0;
1205 let totalCharsAdded = 0;
1206 let totalCharsRemoved = 0;
1207
1208 orderedRuleIds.forEach(scriptId => {
1209 const ruleElement = $(`#regex_debugger_rules [data-id="${scriptId}"]`);
1210 if (!ruleElement.find('.rule-enabled').is(':checked')) return;
1211
1212 const script = allScripts.find(s => s.id === scriptId);
1213
1214 if (script) {
1215 const result = executeRegexScriptForDebugging(script, textForNextStep);
1216 totalCharsCaptured += result.charsCaptured;
1217 totalCharsAdded += result.charsAdded;
1218 totalCharsRemoved += result.charsRemoved;
1219
1220 const stepElement = $(stepTemplate.prop('content')).clone();
1221 // Set the ID on the TOP-LEVEL element that is being appended.
1222 stepElement.find('>:first-child').attr('id', `step-result-${script.id}`);
1223 const stepHeader = stepElement.find('.step-header');
1224 stepHeader.find('strong').text(t`After:` + ` ${script.scriptName}`);
1225
1226 const metricsHtml = '<span class="step-metrics">' + t`Captured:` + ` ${result.charsCaptured}, ` + t`Added:` + ` +${result.charsAdded}, ` + t`Removed:` + ` -${result.charsRemoved}</span>`;
1227 stepHeader.append(metricsHtml);
1228
1229 if (displayMode === 'highlight') {
1230 stepElement.find('.step-output').html(result.highlightedOutput);
1231 } else {
1232 stepElement.find('.step-output').text(result.output);
1233 }
1234
1235 if (result.error) {
1236 stepHeader.append($(`<div class='warning_text text_rose-500'>${result.error}</div>`));
1237 }
1238
1239 stepsOutput.append(stepElement);
1240 textForNextStep = result.output;
1241 }
1242 });
1243
1244 const summaryHtml = `
1245 <div id="regex_debugger_final_summary" class="regex-debugger-summary">
1246 <strong>` + t`Total Captured:` + `</strong> ${totalCharsCaptured} | <strong>` + t`Total Added:` + `</strong> +${totalCharsAdded} | <strong>` + t`Total Removed:` + `</strong> -${totalCharsRemoved}
1247 </div>
1248 `;
1249 finalOutput.before(summaryHtml);
1250
1251 const renderMode = $('#regex_debugger_render_mode').val();
1252 if (renderMode === 'message') {
1253 const formattedHtml = messageFormatting(textForNextStep, 'Debugger', true, false, null);
1254 const messageBlock = $('<div class="mes"><div class="mes_text"></div></div>');
1255 messageBlock.find('.mes_text').html(formattedHtml);
1256 finalOutput.append(messageBlock);
1257 } else {
1258 finalOutput.text(textForNextStep);
1259 }
1260 });
1261
1262 debuggerHtml.find('#regex_debugger_save_order').on('click', async function () {
1263 const allKnownScripts = getRegexScripts();
1264 const newGlobalScripts = $('#regex_debugger_rules_global').children('li').map((_, el) => allKnownScripts.find(s => s.id === $(el).data('id'))).get().filter(Boolean);
1265 const newScopedScripts = $('#regex_debugger_rules_scoped').children('li').map((_, el) => allKnownScripts.find(s => s.id === $(el).data('id'))).get().filter(Boolean);
1266 const newPresetScripts = $('#regex_debugger_rules_preset').children('li').map((_, el) => allKnownScripts.find(s => s.id === $(el).data('id'))).get().filter(Boolean);
1267
1268 extension_settings.regex = newGlobalScripts;
1269 if (this_chid !== undefined) {
1270 await saveScriptsByType(newScopedScripts, SCRIPT_TYPES.SCOPED);
1271 }
1272 await saveScriptsByType(newPresetScripts, SCRIPT_TYPES.PRESET);
1273
1274 saveSettingsDebounced();
1275 await loadRegexScripts();
1276 toastr.success(t`Regex script order saved!`);
1277
1278 const currentPopupContent = $('div:has(> #regex_debugger_rules)');
1279 populateDebuggerRuleList(currentPopupContent);
1280 // @ts-ignore
1281 currentPopupContent.find('#regex_debugger_rules_global').sortable({ delay: getSortableDelay() }).disableSelection();
1282 // @ts-ignore
1283 currentPopupContent.find('#regex_debugger_rules_scoped').sortable({ delay: getSortableDelay() }).disableSelection();
1284 // @ts-ignore
1285 currentPopupContent.find('#regex_debugger_rules_preset').sortable({ delay: getSortableDelay() }).disableSelection();
1286 });
1287
1288 debuggerHtml.find('#regex_debugger_expand_steps').on('click', function () {
1289 const popupContainer = $('<div class="expanded-regex-container"></div>');
1290 const navPanel = $('<div class="expanded-regex-nav"><h4>Steps</h4></div>');
1291 const contentPanel = $('<div class="expanded-regex-content"></div>');
1292
1293 const content = $('#regex_debugger_steps_output').clone().html();
1294 contentPanel.html(content);
1295
1296 $('#regex_debugger_rules .regex-debugger-rule').each(function () {
1297 const ruleElement = $(this);
1298 const scriptId = ruleElement.data('id');
1299 const scriptName = ruleElement.find('.rule-name').text();
1300
1301 const link = $(`<a href="#">${escapeHtml(scriptName)}</a>`);
1302 link.data('target-id', `step-result-${scriptId}`);
1303
1304 link.on('click', function (e) {
1305 e.preventDefault();
1306 navPanel.find('a').removeClass('active');
1307 $(this).addClass('active');
1308
1309 const targetId = $(this).data('target-id');
1310 // The selector is now correct for the structure.
1311 const targetElement = contentPanel.find(`#${targetId}`);
1312
1313 if (targetElement.length) {
1314 const scrollTo = contentPanel.scrollTop() + targetElement.position().top;
1315 contentPanel.animate({ scrollTop: scrollTo }, 300);
1316
1317 targetElement.css('transition', 'background-color 0.5s').css('background-color', 'var(--highlight_color)');
1318 setTimeout(() => targetElement.css('background-color', ''), 1000);
1319 }
1320 });
1321
1322 navPanel.append(link);
1323 });
1324
1325 popupContainer.append(navPanel).append(contentPanel);
1326 callGenericPopup(popupContainer, POPUP_TYPE.TEXT, t`Step-by-step Transformation`, { wide: true, allowVerticalScrolling: false });
1327 });
1328
1329 debuggerHtml.find('#regex_debugger_expand_final').on('click', function () {
1330 const content = $('#regex_debugger_final_output').html();
1331 const popupContent = $('<div class="regex-popup-content"></div>').html(content);
1332 callGenericPopup(popupContent, POPUP_TYPE.TEXT, t`Final Output`, { wide: true, large: true, allowVerticalScrolling: true });
1333 });
1334
1335 await callGenericPopup(debuggerHtml.children(), POPUP_TYPE.TEXT, '', { wide: true, allowVerticalScrolling: true });
1336}
1337
1338/**
1339 * Updates the info block in the regex editor with hints regarding the find regex.
1340 * @param {JQuery<HTMLElement>} editorHtml The editor HTML
1341 */
1342function updateInfoBlock(editorHtml) {
1343 const infoBlock = editorHtml.find('.info-block').get(0);
1344 const infoBlockFlagsHint = editorHtml.find('#regex_info_block_flags_hint');
1345 const findRegex = String(editorHtml.find('.find_regex').val());
1346
1347 infoBlockFlagsHint.hide();
1348
1349 // Clear the info block if the find regex is empty
1350 if (!findRegex) {
1351 setInfoBlock(infoBlock, t`Find Regex is empty`, 'info');
1352 return;
1353 }
1354
1355 try {
1356 const regex = regexFromString(findRegex);
1357 if (!regex) {
1358 throw new Error(t`Invalid Find Regex`);
1359 }
1360
1361 const flagInfo = [];
1362 flagInfo.push(regex.flags.includes('g') ? t`Applies to all matches` : t`Applies to the first match`);
1363 flagInfo.push(regex.flags.includes('i') ? t`Case insensitive` : t`Case sensitive`);
1364
1365 setInfoBlock(infoBlock, flagInfo.join('. '), 'hint');
1366 infoBlockFlagsHint.show();
1367 } catch (error) {
1368 setInfoBlock(infoBlock, error.message, 'error');
1369 }
1370}
1371
1372// Common settings migration function. Some parts will eventually be removed
1373// TODO: Maybe migrate placement to strings?
1374function migrateSettings() {
1375 let performSave = false;
1376
1377 // Current: If MD Display is present in placement, remove it and add new placements/MD option
1378 extension_settings.regex.forEach((script) => {
1379 if (!script.id) {
1380 script.id = uuidv4();
1381 performSave = true;
1382 }
1383
1384 if (!Array.isArray(script.placement)) {
1385 script.placement = [];
1386 performSave = true;
1387 }
1388
1389 if (script.placement.includes(regex_placement.MD_DISPLAY)) {
1390 script.placement = script.placement.length === 1 ?
1391 Object.values(regex_placement).filter((e) => e !== regex_placement.MD_DISPLAY) :
1392 script.placement = script.placement.filter((e) => e !== regex_placement.MD_DISPLAY);
1393
1394 script.markdownOnly = true;
1395 script.promptOnly = true;
1396
1397 performSave = true;
1398 }
1399
1400 // Old system and sendas placement migration
1401 // 4 - sendAs
1402 if (script.placement.includes(4)) {
1403 script.placement = script.placement.length === 1 ?
1404 [regex_placement.SLASH_COMMAND] :
1405 script.placement = script.placement.filter((e) => e !== 4);
1406
1407 performSave = true;
1408 }
1409 });
1410
1411 if (performSave) {
1412 saveSettingsDebounced();
1413 }
1414}
1415
1416/**
1417 * /regex slash command callback
1418 * @param {{name: string}} args Named arguments
1419 * @param {string} value Unnamed argument
1420 * @returns {string} The regexed string
1421 */
1422function runRegexCallback(args, value) {
1423 if (!args.name) {
1424 toastr.warning('No regex script name provided.');
1425 return value;
1426 }
1427
1428 const scriptName = args.name;
1429 const scripts = getRegexScripts();
1430
1431 for (const script of scripts) {
1432 if (script.scriptName.toLowerCase() === scriptName.toLowerCase()) {
1433 if (script.disabled) {
1434 toastr.warning(t`Regex script "${scriptName}" is disabled.`);
1435 return value;
1436 }
1437
1438 console.debug(`Running regex callback for ${scriptName}`);
1439 return runRegexScript(script, value);
1440 }
1441 }
1442
1443 toastr.warning(`Regex script "${scriptName}" not found.`);
1444 return value;
1445}
1446
1447/**
1448 * /regex-toggle slash command callback
1449 * @param {{state: string, quiet: string}} args Named arguments
1450 * @param {string} scriptName The name of the script to toggle
1451 * @returns {Promise<string>} The name of the script
1452 */
1453async function toggleRegexCallback(args, scriptName) {
1454 if (typeof scriptName !== 'string') throw new Error('Script name must be a string.');
1455
1456 const quiet = isTrueBoolean(args?.quiet);
1457 const action = isTrueBoolean(args?.state) ? 'enable' :
1458 isFalseBoolean(args?.state) ? 'disable' :
1459 'toggle';
1460
1461 const scripts = getRegexScripts();
1462 const script = scripts.find(s => equalsIgnoreCaseAndAccents(s.scriptName, scriptName));
1463
1464 if (!script) {
1465 toastr.warning(t`Regex script '${scriptName}' not found.`);
1466 return '';
1467 }
1468
1469 switch (action) {
1470 case 'enable':
1471 script.disabled = false;
1472 break;
1473 case 'disable':
1474 script.disabled = true;
1475 break;
1476 default:
1477 script.disabled = !script.disabled;
1478 break;
1479 }
1480
1481 const scriptType = getScriptType(script);
1482 const index = getScriptsByType(scriptType).indexOf(script);
1483
1484 await saveRegexScript(script, index, scriptType);
1485 if (script.disabled) {
1486 !quiet && toastr.success(t`Regex script '${scriptName}' has been disabled.`);
1487 } else {
1488 !quiet && toastr.success(t`Regex script '${scriptName}' has been enabled.`);
1489 }
1490
1491 return script.scriptName || '';
1492}
1493
1494/**
1495 * Performs the import of the regex object.
1496 * @param {RegexScript} regexScript Input object
1497 * @param {SCRIPT_TYPES} scriptType The type of script to import as
1498 */
1499async function onRegexImportObjectChange(regexScript, scriptType) {
1500 try {
1501 if (!regexScript.scriptName) {
1502 throw new Error('No script name provided.');
1503 }
1504
1505 // Assign a new UUID
1506 regexScript.id = uuidv4();
1507
1508 const array = getScriptsByType(scriptType);
1509 array.push(regexScript);
1510
1511 switch (scriptType) {
1512 case SCRIPT_TYPES.GLOBAL:
1513 // will be handled by saveSettingsDebounced
1514 break;
1515 case SCRIPT_TYPES.SCOPED:
1516 await saveScriptsByType(array, SCRIPT_TYPES.SCOPED);
1517 break;
1518 case SCRIPT_TYPES.PRESET:
1519 await saveScriptsByType(array, SCRIPT_TYPES.PRESET);
1520 break;
1521 default:
1522 break;
1523 }
1524
1525 saveSettingsDebounced();
1526 await loadRegexScripts();
1527 toastr.success(t`Regex script "${regexScript.scriptName}" imported.`);
1528 } catch (error) {
1529 console.log(error);
1530 toastr.error(t`Invalid regex object.`);
1531 return;
1532 }
1533}
1534
1535/**
1536 * Performs the import of the regex file.
1537 * @param {File} file Input file
1538 * @param {SCRIPT_TYPES} scriptType The type of script to import as
1539 */
1540async function onRegexImportFileChange(file, scriptType) {
1541 if (!file) {
1542 toastr.error('No file provided.');
1543 return;
1544 }
1545
1546 try {
1547 const regexScripts = JSON.parse(await getFileText(file));
1548 if (Array.isArray(regexScripts)) {
1549 for (const regexScript of regexScripts) {
1550 await onRegexImportObjectChange(regexScript, scriptType);
1551 }
1552 } else {
1553 await onRegexImportObjectChange(regexScripts, scriptType);
1554 }
1555 } catch (error) {
1556 console.log(error);
1557 toastr.error('Invalid JSON file.');
1558 return;
1559 }
1560}
1561
1562/**
1563 * Determines the type of a given script.
1564 * @param {RegexScript} script The script to check
1565 * @returns {SCRIPT_TYPES} The script type.
1566 */
1567function getScriptType(script) {
1568 for (const scriptType of Object.values(SCRIPT_TYPES)) {
1569 const scripts = getScriptsByType(scriptType);
1570 if (scripts.some(s => s.id === script.id)) {
1571 return scriptType;
1572 }
1573 }
1574 return SCRIPT_TYPE_UNKNOWN;
1575}
1576
1577function getSelectedScripts() {
1578 const scripts = getRegexScripts();
1579 const selector = '#regex_container .regex-script-label:has(.regex_bulk_checkbox:checked)';
1580 const selectedIds = Array.from(document.querySelectorAll(selector))
1581 .map(e => e.getAttribute('id'))
1582 .filter(id => id);
1583 return scripts.filter(script => selectedIds.includes(script.id));
1584}
1585
1586function purgeEmbeddedRegexScripts({ character }) {
1587 const avatar = character?.avatar;
1588 if (!avatar) {
1589 return;
1590 }
1591 const checkKey = `AlertRegex_${avatar}`;
1592 if (accountStorage.getItem(checkKey)) {
1593 accountStorage.removeItem(checkKey);
1594 }
1595 disallowScopedScripts(characters?.[this_chid]);
1596}
1597
1598function purgePresetEmbeddedRegexScripts({ apiId, name }) {
1599 const checkKey = `AlertRegex_${apiId}_${name}`;
1600 if (accountStorage.getItem(checkKey)) {
1601 accountStorage.removeItem(checkKey);
1602 }
1603 disallowPresetScripts(apiId, name);
1604}
1605
1606async function checkCharEmbeddedRegexScripts() {
1607 const chid = this_chid;
1608
1609 if (chid !== undefined && !selected_group) {
1610 const character = characters[chid];
1611 const scripts = getScriptsByType(SCRIPT_TYPES.SCOPED);
1612
1613 if (Array.isArray(scripts) && scripts.length > 0) {
1614 if (!isScopedScriptsAllowed(character)) {
1615 const checkKey = `AlertRegex_${character.avatar}`;
1616 if (!accountStorage.getItem(checkKey)) {
1617 accountStorage.setItem(checkKey, 'true');
1618 const template = await renderExtensionTemplateAsync('regex', 'embeddedScripts', {});
1619 const result = await callGenericPopup(template, POPUP_TYPE.CONFIRM, '');
1620
1621 if (result) {
1622 allowScopedScripts(character);
1623 await reloadCurrentChat();
1624 }
1625 }
1626 }
1627 }
1628 }
1629
1630 // Clear cache and reload scripts
1631 RegexProvider.instance.clear();
1632 await loadRegexScripts();
1633}
1634
1635/**
1636 * Notify whether to reload current chat when preset is changed
1637 * @param {string} presetName The name of the preset
1638 */
1639function notifyReloadCurrentChat(presetName) {
1640 toastr.info(
1641 t`Reload the chat for regex to take effect` + '<br><u>' + t`Click here to reload immediately` + '</u>',
1642 t`Preset '${escapeHtml(presetName)}' contains enabled regex scripts`,
1643 {
1644 timeOut: 5000,
1645 escapeHtml: false,
1646 onclick: reloadCurrentChat,
1647 });
1648}
1649
1650async function checkPresetEmbeddedRegexScripts() {
1651 const apiId = getCurrentPresetAPI();
1652 const name = getCurrentPresetName();
1653 const scripts = getScriptsByType(SCRIPT_TYPES.PRESET);
1654
1655 if (Array.isArray(scripts) && scripts.length > 0) {
1656 if (!isPresetScriptsAllowed(apiId, name)) {
1657 const checkKey = `AlertRegex_${apiId}_${name}`;
1658
1659 if (!accountStorage.getItem(checkKey)) {
1660 accountStorage.setItem(checkKey, 'true');
1661 const template = await renderExtensionTemplateAsync('regex', 'presetEmbeddedScripts', {});
1662 const result = await callGenericPopup(template, POPUP_TYPE.CONFIRM, '');
1663
1664 if (result) {
1665 allowPresetScripts(apiId, name);
1666 if (getCurrentChatId()) {
1667 await reloadCurrentChat();
1668 }
1669 }
1670 }
1671 } else if (getCurrentChatId() && scripts.filter(script => !script.disabled).length > 0) {
1672 notifyReloadCurrentChat(name);
1673 }
1674 }
1675
1676 await loadRegexScripts();
1677}
1678
1679async function onMainApiChanged({ apiId }) {
1680 const presetManager = getPresetManager(apiId);
1681 if (!presetManager) {
1682 return;
1683 }
1684 const presetName = presetManager.getSelectedPresetName();
1685 const presetScripts = presetManager.readPresetExtensionField({ path: 'regex_scripts' }) ?? [];
1686 if (getCurrentChatId() &&
1687 isPresetScriptsAllowed(apiId, presetName) &&
1688 Array.isArray(presetScripts) &&
1689 presetScripts.filter(script => !script.disabled).length > 0) {
1690 notifyReloadCurrentChat(presetName);
1691 }
1692
1693 await loadRegexScripts();
1694}
1695
1696function onPresetRenamed({ apiId, oldName, newName }) {
1697 const oldCheckKey = `AlertRegex_${apiId}_${oldName}`;
1698 const checkKey = `AlertRegex_${apiId}_${newName}`;
1699 const value = accountStorage.getItem(oldCheckKey);
1700 if (value) {
1701 accountStorage.setItem(checkKey, value);
1702 accountStorage.removeItem(oldCheckKey);
1703 }
1704 if (isPresetScriptsAllowed(apiId, oldName)) {
1705 disallowPresetScripts(apiId, oldName);
1706 allowPresetScripts(apiId, newName);
1707 }
1708}
1709
1710// Workaround for loading in sequence with other extensions
1711// NOTE: Always puts extension at the top of the list, but this is fine since it's static
1712export async function init() {
1713 if (!Array.isArray(extension_settings.regex)) {
1714 extension_settings.regex = [];
1715 }
1716
1717 if (!Array.isArray(extension_settings.regex_presets)) {
1718 extension_settings.regex_presets = [];
1719 }
1720
1721 // Manually disable the extension since static imports auto-import the JS file
1722 if (extension_settings.disabledExtensions.includes('regex')) {
1723 return;
1724 }
1725
1726 migrateSettings();
1727
1728 const settingsHtml = $(await renderExtensionTemplateAsync('regex', 'dropdown'));
1729 $('#regex_container').append(settingsHtml);
1730 $('#open_regex_editor').on('click', function () {
1731 onRegexEditorOpenClick(false, SCRIPT_TYPES.GLOBAL);
1732 });
1733 $('#open_regex_debugger').on('click', onRegexDebuggerOpenClick);
1734 $('#open_scoped_editor').on('click', function () {
1735 if (this_chid === undefined) {
1736 toastr.error(t`No character selected.`);
1737 return;
1738 }
1739
1740 if (selected_group) {
1741 toastr.error(t`Cannot edit scoped scripts in group chats.`);
1742 return;
1743 }
1744
1745 onRegexEditorOpenClick(false, SCRIPT_TYPES.SCOPED);
1746 });
1747 $('#open_preset_editor').on('click', function () {
1748 onRegexEditorOpenClick(false, SCRIPT_TYPES.PRESET);
1749 });
1750 $('#import_regex_file').on('change', async function () {
1751 let target = SCRIPT_TYPES.GLOBAL;
1752 const template = $(await renderExtensionTemplateAsync('regex', 'importTarget'));
1753 template.find('#regex_import_target_global').on('input', () => (target = SCRIPT_TYPES.GLOBAL));
1754 template.find('#regex_import_target_scoped').on('input', () => (target = SCRIPT_TYPES.SCOPED));
1755 template.find('#regex_import_target_preset').on('input', () => (target = SCRIPT_TYPES.PRESET));
1756
1757 await callGenericPopup(template, POPUP_TYPE.TEXT);
1758
1759 const inputElement = this instanceof HTMLInputElement && this;
1760 for (const file of inputElement.files) {
1761 await onRegexImportFileChange(file, target);
1762 }
1763 inputElement.value = '';
1764 });
1765 $('#import_regex').on('click', function () {
1766 $('#import_regex_file').trigger('click');
1767 });
1768
1769 $('#bulk_select_all_toggle').on('click', async function () {
1770 const checkboxes = $('#regex_container .regex_bulk_checkbox');
1771 if (checkboxes.length === 0) {
1772 return;
1773 }
1774
1775 const allAreChecked = checkboxes.length === checkboxes.filter(':checked').length;
1776 const newState = !allAreChecked; // true if we just checked all, false if we just unchecked all
1777
1778 checkboxes.prop('checked', newState);
1779 setToggleAllIcon(newState);
1780 setMoveButtonsVisibility();
1781 });
1782
1783 $('#bulk_enable_regex').on('click', async function () {
1784 await bulkToggleRegexScripts(true);
1785 });
1786
1787 $('#bulk_disable_regex').on('click', async function () {
1788 await bulkToggleRegexScripts(false);
1789 });
1790
1791 /**
1792 * Bulk enable or disable regex scripts
1793 * @param {boolean} newState New state to set (true = enable, false = disable)
1794 * @returns {Promise<void>}
1795 */
1796 async function bulkToggleRegexScripts(newState) {
1797 const scripts = getSelectedScripts().filter(script => script.disabled === newState);
1798 if (scripts.length === 0) {
1799 toastr.warning(newState
1800 ? t`No regex scripts selected for enabling.`
1801 : t`No regex scripts selected for disabling.`,
1802 );
1803 return;
1804 }
1805 const scriptTypesToSave = new Set();
1806 for (const script of scripts) {
1807 const scriptType = getScriptType(script);
1808 scriptTypesToSave.add(scriptType);
1809 script.disabled = !newState;
1810 }
1811 for (const scriptType of scriptTypesToSave) {
1812 const scriptsOfType = getScriptsByType(scriptType);
1813 await saveScriptsByType(scriptsOfType, scriptType);
1814 }
1815
1816 saveSettingsDebounced();
1817 await loadRegexScripts();
1818
1819 // Reload the current chat to undo previous markdown
1820 const currentChatId = getCurrentChatId();
1821 if (currentChatId) {
1822 await reloadCurrentChat();
1823 }
1824 }
1825
1826 /**
1827 * Bulk move regex scripts to the specified type
1828 * @param {SCRIPT_TYPES} toType destination type
1829 */
1830 async function bulkMoveRegexScript(toType) {
1831 const scripts = getSelectedScripts();
1832 if (scripts.length === 0) {
1833 toastr.warning(t`No regex scripts selected for moving.`);
1834 return;
1835 }
1836 for (const script of scripts) {
1837 await moveRegexScript(script, toType, getScriptType(script), false);
1838 }
1839
1840 saveSettingsDebounced();
1841 await loadRegexScripts();
1842
1843 // Reload the current chat to undo previous markdown
1844 const currentChatId = getCurrentChatId();
1845 if (currentChatId) {
1846 await reloadCurrentChat();
1847 }
1848 }
1849
1850 $('#bulk_regex_move_to_global').on('click', async () => {
1851 const confirm = await callGenericPopup(t`Are you sure you want to move the selected regex scripts to global?`, POPUP_TYPE.CONFIRM);
1852 if (!confirm) {
1853 return;
1854 }
1855 await bulkMoveRegexScript(SCRIPT_TYPES.GLOBAL);
1856 });
1857
1858 $('#bulk_regex_move_to_scoped').on('click', async () => {
1859 if (this_chid === undefined) {
1860 toastr.error(t`No character selected.`);
1861 return;
1862 }
1863 if (selected_group) {
1864 toastr.error(t`Cannot edit scoped scripts in group chats.`);
1865 return;
1866 }
1867 const confirm = await callGenericPopup(t`Are you sure you want to move the selected regex scripts to scoped?`, POPUP_TYPE.CONFIRM);
1868 if (!confirm) {
1869 return;
1870 }
1871 await bulkMoveRegexScript(SCRIPT_TYPES.SCOPED);
1872 });
1873
1874 $('#bulk_regex_move_to_preset').on('click', async function () {
1875 const confirm = await callGenericPopup(t`Are you sure you want to move the selected regex scripts to preset?`, POPUP_TYPE.CONFIRM);
1876 if (!confirm) {
1877 return;
1878 }
1879 await bulkMoveRegexScript(SCRIPT_TYPES.PRESET);
1880 });
1881
1882 $('#bulk_delete_regex').on('click', async function () {
1883 const scripts = getSelectedScripts();
1884 if (scripts.length === 0) {
1885 toastr.warning(t`No regex scripts selected for deletion.`);
1886 return;
1887 }
1888 const confirm = await callGenericPopup(t`Are you sure you want to delete the selected regex scripts?`, POPUP_TYPE.CONFIRM);
1889 if (!confirm) {
1890 return;
1891 }
1892 for (const script of scripts) {
1893 await deleteRegexScript(script.id, getScriptType(script), false);
1894 }
1895 saveSettingsDebounced();
1896 await loadRegexScripts();
1897 await reloadCurrentChat();
1898 });
1899
1900 $('#bulk_export_regex').on('click', async function () {
1901 const scripts = getSelectedScripts();
1902 if (scripts.length === 0) {
1903 toastr.warning(t`No regex scripts selected for export.`);
1904 return;
1905 }
1906 const fileName = `regex-${new Date().toISOString()}.json`;
1907 const fileData = JSON.stringify(scripts, null, 4);
1908 download(fileData, fileName, 'application/json');
1909 await loadRegexScripts();
1910 });
1911
1912 let sortableDatas = [
1913 {
1914 selector: '#saved_regex_scripts',
1915 setter: scripts => saveScriptsByType(scripts, SCRIPT_TYPES.GLOBAL),
1916 getter: () => getScriptsByType(SCRIPT_TYPES.GLOBAL),
1917 },
1918 {
1919 selector: '#saved_scoped_scripts',
1920 setter: scripts => saveScriptsByType(scripts, SCRIPT_TYPES.SCOPED),
1921 getter: () => getScriptsByType(SCRIPT_TYPES.SCOPED),
1922 },
1923 {
1924 selector: '#saved_preset_scripts',
1925 setter: scripts => saveScriptsByType(scripts, SCRIPT_TYPES.PRESET),
1926 getter: () => getScriptsByType(SCRIPT_TYPES.PRESET),
1927 },
1928 ];
1929 for (const { selector, setter, getter } of sortableDatas) {
1930 // @ts-ignore
1931 $(selector).sortable({
1932 delay: getSortableDelay(),
1933 handle: '.drag-handle',
1934 stop: async function () {
1935 const oldScripts = getter();
1936 const newScripts = [];
1937 $(selector).children().each(function () {
1938 const id = $(this).attr('id');
1939 const existingScript = oldScripts.find((e) => e.id === id);
1940 if (existingScript) {
1941 newScripts.push(existingScript);
1942 }
1943 });
1944
1945 await setter(newScripts);
1946 saveSettingsDebounced();
1947
1948 console.debug(`Regex scripts in ${selector} reordered`);
1949 await reloadCurrentChat();
1950 await loadRegexScripts();
1951 },
1952 });
1953 }
1954
1955 $('#regex_scoped_toggle').on('input', function () {
1956 if (this_chid === undefined) {
1957 toastr.error(t`No character selected.`);
1958 return;
1959 }
1960
1961 if (selected_group) {
1962 toastr.error(t`Cannot edit scoped scripts in group chats.`);
1963 return;
1964 }
1965
1966 const isEnable = !!$(this).prop('checked');
1967 const character = characters[this_chid];
1968
1969 if (isEnable) {
1970 allowScopedScripts(character);
1971 } else {
1972 disallowScopedScripts(character);
1973 }
1974
1975 saveSettingsDebounced();
1976 reloadCurrentChat();
1977 });
1978
1979 $('#regex_preset_toggle').on('input', function () {
1980 const isEnable = !!$(this).prop('checked');
1981 const name = getCurrentPresetName();
1982
1983 if (isEnable) {
1984 allowPresetScripts(getCurrentPresetAPI(), name);
1985 } else {
1986 disallowPresetScripts(getCurrentPresetAPI(), name);
1987 }
1988
1989 saveSettingsDebounced();
1990 reloadCurrentChat();
1991 });
1992
1993 await loadRegexScripts();
1994 // @ts-ignore
1995 $('#saved_regex_scripts').sortable('enable');
1996
1997 /**
1998 * @typedef {object} ScriptDecorators
1999 * @property {string} typename
2000 * @property {import('../../slash-commands/SlashCommandEnumValue.js').EnumType} color
2001 * @property {string} icon
2002 */
2003
2004 /**
2005 * @param {SCRIPT_TYPES} type The script type
2006 * @returns {ScriptDecorators} The decorators for the script type
2007 */
2008 function getScriptDecorators(type) {
2009 switch (type) {
2010 case SCRIPT_TYPES.GLOBAL:
2011 return {
2012 typename: 'global',
2013 color: enumTypes.enum,
2014 icon: 'G',
2015 };
2016 case SCRIPT_TYPES.SCOPED:
2017 return {
2018 typename: 'scoped',
2019 color: enumTypes.name,
2020 icon: 'S',
2021 };
2022 case SCRIPT_TYPES.PRESET:
2023 return {
2024 typename: 'preset',
2025 color: enumTypes.name,
2026 icon: 'P',
2027 };
2028 default:
2029 return {
2030 typename: 'Unknown',
2031 color: enumTypes.variable,
2032 icon: 'Unknown',
2033 };
2034 }
2035 }
2036
2037 const localEnumProviders = {
2038 regexScripts: () =>
2039 getRegexScripts().map(script => {
2040 const type = getScriptType(script);
2041 const { typename, color, icon } = getScriptDecorators(type);
2042 return new SlashCommandEnumValue(
2043 script.scriptName,
2044 `${enumIcons.getStateIcon(!script.disabled)} [${typename}] ${script.findRegex}`,
2045 color,
2046 icon,
2047 );
2048 }),
2049 };
2050
2051 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2052 name: 'regex',
2053 callback: runRegexCallback,
2054 returns: 'replaced text',
2055 namedArgumentList: [
2056 SlashCommandNamedArgument.fromProps({
2057 name: 'name',
2058 description: 'script name',
2059 typeList: [ARGUMENT_TYPE.STRING],
2060 isRequired: true,
2061 enumProvider: localEnumProviders.regexScripts,
2062 }),
2063 ],
2064 unnamedArgumentList: [
2065 new SlashCommandArgument(
2066 'input', [ARGUMENT_TYPE.STRING], false,
2067 ),
2068 ],
2069 helpString: 'Runs a Regex extension script by name on the provided string. The script must be enabled.',
2070 }));
2071 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2072 name: 'regex-state',
2073 /** @param {object} _ @param {string} name */
2074 callback: (_, name) => {
2075 if (!name) {
2076 toastr.warning('No regex script name provided.');
2077 return '';
2078 }
2079
2080 const scripts = getRegexScripts();
2081 const script = scripts.find(s => equalsIgnoreCaseAndAccents(s.scriptName, name));
2082
2083 if (!script) {
2084 toastr.warning(`Regex script "${name}" not found.`);
2085 return '';
2086 }
2087
2088 return script.disabled ? 'false' : 'true';
2089 },
2090 returns: 'true (for enabled) or false (for disabled)',
2091 unnamedArgumentList: [
2092 SlashCommandArgument.fromProps({
2093 description: 'script name',
2094 typeList: [ARGUMENT_TYPE.STRING],
2095 isRequired: true,
2096 enumProvider: localEnumProviders.regexScripts,
2097 }),
2098 ],
2099 helpString: 'Returns the current state of a regex script.',
2100 }));
2101
2102 SlashCommandParser.addCommandObject(SlashCommand.fromProps({
2103 name: 'regex-toggle',
2104 callback: toggleRegexCallback,
2105 returns: 'The name of the script that was toggled',
2106 namedArgumentList: [
2107 SlashCommandNamedArgument.fromProps({
2108 name: 'state',
2109 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.',
2110 typeList: [ARGUMENT_TYPE.BOOLEAN],
2111 defaultValue: 'toggle',
2112 enumList: commonEnumProviders.boolean('onOffToggle')(),
2113 }),
2114 SlashCommandNamedArgument.fromProps({
2115 name: 'quiet',
2116 description: 'Suppress the toast message script toggled',
2117 typeList: [ARGUMENT_TYPE.BOOLEAN],
2118 defaultValue: 'false',
2119 enumList: commonEnumProviders.boolean('trueFalse')(),
2120 }),
2121 ],
2122 unnamedArgumentList: [
2123 SlashCommandArgument.fromProps({
2124 description: 'script name',
2125 typeList: [ARGUMENT_TYPE.STRING],
2126 isRequired: true,
2127 enumProvider: localEnumProviders.regexScripts,
2128 }),
2129 ],
2130 helpString: `
2131 <div>
2132 Toggles the state of a specified regex script.
2133 </div>
2134 <div>
2135 <strong>Example:</strong>
2136 <ul>
2137 <li>
2138 <pre><code class="language-stscript">/regex-toggle MyScript</code></pre>
2139 </li>
2140 <li>
2141 <pre><code class="language-stscript">/regex-toggle state=off Character-specific Script</code></pre>
2142 </li>
2143 </ul>
2144 </div>
2145 `,
2146 }));
2147
2148 eventSource.on(event_types.MAIN_API_CHANGED, onMainApiChanged);
2149 eventSource.on(event_types.CHAT_CHANGED, checkCharEmbeddedRegexScripts);
2150 eventSource.on(event_types.CHARACTER_DELETED, purgeEmbeddedRegexScripts);
2151 eventSource.on(event_types.PRESET_RENAMED_BEFORE, onPresetRenamed);
2152 eventSource.on(event_types.PRESET_CHANGED, checkPresetEmbeddedRegexScripts);
2153 eventSource.on(event_types.PRESET_DELETED, purgePresetEmbeddedRegexScripts);
2154
2155 presetManager.setupEventListeners();
2156 presetManager.registerSlashCommands();
2157}