feat: Advanced Regex Chain Debugger and Testing Panel (#4418) * feat: Advanced Regex Chain Debugger and Testing Panel * fix * fix_2 * fix_3 * Address code review suggestions for debugger feature * feat: Add match statistics to debugger * ESLint * feat: Enhance debugger with diff view and nav popup * ESLint * Fix styles and full results expand * Diff clean-up --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>

176825bdb87faa33a296e4d54ab25a09dc657f5f

MAX-TAB <60381043+MAX-TAB@users.noreply.github.com>

Signed
5 files changed, +882 -2Showing whitespace changes
public/scripts/extensions/regex/debugger.css+255 -0
@@ -0,0 +1,255 @@
1/* Styles for the debugger UI */
2#regex_debugger_rules {
3 margin: 10px 0;
4}
5
6#regex_debugger_rules,
7#regex_debugger_rules .sortable-list {
8 padding-left: 0;
9}
10
11.regex-debugger-rules-list {
12 position: relative;
13}
14
15.regex-debugger-rule {
16 display: flex;
17 align-items: center;
18 padding: 8px 10px;
19 border: 1px solid var(--SmartThemeBorderColor);
20 border-radius: 5px;
21 cursor: pointer;
22 background-color: var(--black30a);
23 gap: 5px;
24 margin-bottom: 5px;
25}
26
27#regex_debugger_run_test_header {
28 justify-content: space-between;
29 align-items: center;
30}
31
32#regex_debugger_expand_steps,
33#regex_debugger_expand_final,
34#regex_debugger_save_order {
35 position: absolute;
36 top: 0;
37 right: 0;
38 margin: 0;
39}
40
41.regex-debugger-rule:hover {
42 filter: brightness(1.1);
43}
44
45.regex-debugger-rule .handle {
46 cursor: grab;
47 margin-right: 5px;
48}
49
50.regex-debugger-rule .rule-details {
51 flex-grow: 1;
52 text-align: left;
53 display: flex;
54 align-items: baseline;
55 gap: 5px;
56}
57
58.regex-debugger-rule .rule-name {
59 font-weight: bold;
60}
61
62.regex-debugger-rule .rule-regex {
63 font-size: 0.8em;
64 opacity: 0.8;
65 font-family: var(--monoFontFamily);
66}
67
68.regex-debugger-rule .rule-scope {
69 font-size: 0.8em;
70 padding: 2px 6px;
71 border-radius: 5px;
72 background-color: var(--black30a);
73 margin-left: auto;
74 margin-right: 10px;
75}
76
77.regex-debugger-rule .menu_button {
78 margin: 0;
79}
80
81#regex_debugger_raw_input {
82 min-height: 1.8em;
83}
84
85#regex_debugger_steps_output {
86 min-height: 2em;
87 max-height: 300px;
88 overflow-y: auto;
89 border: 1px solid var(--SmartThemeBorderColor);
90 border-radius: 5px;
91 padding: 5px;
92 background-color: var(--black30a);
93 font-family: var(--monoFontFamily);
94 font-size: 0.9em;
95}
96
97#regex_debugger_final_output {
98 min-height: 2em;
99 max-height: 300px;
100 overflow-y: auto;
101 border: 1px solid var(--SmartThemeBorderColor);
102 border-radius: 5px;
103 padding: 5px;
104 background-color: var(--black30a);
105 white-space: pre-wrap;
106 word-break: break-word;
107}
108
109.step-header {
110 margin-top: 10px;
111 margin-bottom: 5px;
112}
113
114.step-output {
115 white-space: pre-wrap;
116 word-break: break-all;
117 padding: 5px;
118 background-color: var(--black30a);
119 border-radius: 5px;
120}
121
122/* Classes to replace inline styles */
123.regex-debugger-no-rules {
124 padding: 10px;
125 text-align: center;
126 opacity: 0.8;
127}
128
129.regex-debugger-list-header {
130 font-weight: bold;
131 padding: 10px;
132}
133
134/* Styles for statistics */
135.step-header {
136 display: flex;
137 justify-content: space-between;
138 align-items: center;
139}
140
141.step-metrics {
142 font-size: 0.8em;
143 opacity: 0.8;
144 font-weight: normal;
145}
146
147.regex-debugger-summary {
148 padding: 8px;
149 margin-bottom: 10px;
150 border: 1px solid var(--SmartThemeBorderColor);
151 background-color: var(--black30a);
152 border-radius: 5px;
153 text-align: center;
154 font-size: 0.9em;
155}
156
157.regex-debugger-tester .results-header {
158 position: relative;
159 margin: 10px 0;
160}
161
162.regex-debugger-tester .radio_group {
163 text-align: left;
164}
165
166/* Styles for statistics and highlighting additions */
167.step-header {
168 display: flex;
169 justify-content: space-between;
170 align-items: center;
171 flex-wrap: wrap;
172 /* Allow wrapping on small screens */
173}
174
175.step-metrics {
176 font-size: 0.8em;
177 opacity: 0.8;
178 font-weight: normal;
179 white-space: nowrap;
180 /* Prevent metrics from breaking line */
181 margin-left: 10px;
182}
183
184.regex-debugger-summary {
185 padding: 8px;
186 margin-bottom: 10px;
187 border: 1px solid var(--SmartThemeBorderColor);
188 background-color: var(--black30a);
189 border-radius: 5px;
190 text-align: center;
191 font-size: 0.9em;
192}
193
194/* New highlight color for added text */
195mark.green_hl {
196 background-color: #28a745;
197 /* A standard green color */
198 color: white;
199}
200
201/* New highlight color for deleted text */
202mark.red_hl {
203 background-color: #dc3545;
204 /* A standard red color */
205 color: white;
206 text-decoration: line-through;
207}
208
209/* Styles for the expanded view with navigation */
210.expanded-regex-container {
211 display: flex;
212 height: 75vh;
213 /* Give the container a good height */
214 overflow: hidden;
215}
216
217.expanded-regex-nav {
218 flex: 0 0 200px;
219 /* Fixed width for the nav bar */
220 border-right: 1px solid var(--SmartThemeBorderColor);
221 padding: 5px;
222 overflow-y: auto;
223 background-color: var(--black30a);
224}
225
226.expanded-regex-nav a {
227 display: block;
228 padding: 6px 8px;
229 text-decoration: none;
230 color: var(--SmartThemeMainColor);
231 border-radius: 5px;
232 white-space: nowrap;
233 overflow: hidden;
234 text-overflow: ellipsis;
235}
236
237.expanded-regex-nav a:hover {
238 background-color: var(--background_hover_color);
239}
240
241.expanded-regex-nav a.active {
242 background-color: var(--highlight_color);
243 color: var(--text_color_black);
244}
245
246.expanded-regex-content {
247 flex-grow: 1;
248 overflow-y: auto;
249 padding-left: 10px;
250}
251
252#regex_debugger_render_mode {
253 padding-right: 20px;
254 margin-top: 5px;
255}
public/scripts/extensions/regex/debugger.html+175 -0
@@ -0,0 +1,175 @@
1<div class="regex-debugger-container">
2 <!-- Rules List Column -->
3 <div class="regex-debugger-rules-list">
4 <h3>
5 <i class="fa-solid fa-list-ol"></i>
6 <span data-i18n="ext_regex_debugger_active_rules"
7 >Active Rules</span
8 >
9 </h3>
10 <div class="flex-container">
11 <button
12 id="regex_debugger_save_order"
13 class="menu_button menu_button_icon interactable"
14 title="Save current rule order"
15 tabindex="0"
16 >
17 <i class="fa-solid fa-floppy-disk"></i>
18 <span data-i18n="ext_regex_debugger_save_order"
19 >Save Order</span
20 >
21 </button>
22 </div>
23 <ul id="regex_debugger_rules" class="sortable-list">
24 <!-- Rules will be populated here by JavaScript -->
25 </ul>
26 </div>
27
28 <!-- Testing Area Column -->
29 <div class="regex-debugger-tester">
30 <h3>
31 <i class="fa-solid fa-vial"></i>
32 <span data-i18n="ext_regex_debugger_testing_area"
33 >Testing Area</span
34 >
35 </h3>
36 <div class="regex-debugger-io">
37 <div class="regex-debugger-input">
38 <label
39 for="regex_debugger_raw_input"
40 data-i18n="ext_regex_debugger_raw_input"
41 >Raw Input</label
42 >
43 <textarea
44 id="regex_debugger_raw_input"
45 class="text_pole autoSetHeight"
46 rows="4"
47 ></textarea>
48 </div>
49 <div
50 id="regex_debugger_run_test_header"
51 class="flex-container"
52 >
53 <button
54 id="regex_debugger_run_test"
55 class="menu_button menu_button_icon interactable"
56 title="Run the test pipeline"
57 tabindex="0"
58 >
59 <i class="fa-solid fa-play"></i>
60 <span data-i18n="ext_regex_debugger_run_test"
61 >Run Test</span
62 >
63 </button>
64 <div class="flex-container gap10px">
65 <div class="radio_group">
66 <label
67 ><input
68 type="radio"
69 name="display_mode"
70 value="replace"
71 checked
72 />
73 <span data-i18n="ext_regex_debugger_display_replace"
74 >Replace</span
75 ></label
76 >
77 <label
78 ><input
79 type="radio"
80 name="display_mode"
81 value="highlight"
82 />
83 <span
84 data-i18n="ext_regex_debugger_display_highlight"
85 >Highlight</span
86 ></label
87 >
88 </div>
89 <select
90 id="regex_debugger_render_mode"
91 >
92 <option
93 value="text"
94 data-i18n="ext_regex_debugger_render_text"
95 >
96 Render as Text
97 </option>
98 <option
99 value="message"
100 data-i18n="ext_regex_debugger_render_message"
101 >
102 Render as Message
103 </option>
104 </select>
105 </div>
106 </div>
107 <div class="regex-debugger-results">
108 <div class="results-header">
109 <h4>
110 <i class="fa-solid fa-shoe-prints"></i>
111 <span data-i18n="ext_regex_debugger_step_by_step"
112 >Step-by-step Transformation</span
113 >
114 </h4>
115 <div
116 id="regex_debugger_expand_steps"
117 class="menu_button menu_button_icon"
118 title="Expand view"
119 >
120 <i class="fa-solid fa-expand"></i>
121 </div>
122 </div>
123 <div id="regex_debugger_steps_output" class="results-box"></div>
124
125 <div class="results-header">
126 <h4>
127 <i class="fa-solid fa-flag-checkered"></i>
128 <span data-i18n="ext_regex_debugger_final_output"
129 >Final Output</span
130 >
131 </h4>
132 <div
133 id="regex_debugger_expand_final"
134 class="menu_button menu_button_icon"
135 title="Expand view"
136 >
137 <i class="fa-solid fa-expand"></i>
138 </div>
139 </div>
140 <div
141 id="regex_debugger_final_output"
142 class="results-box final-output"
143 ></div>
144 </div>
145 </div>
146 </div>
147</div>
148
149<!-- Template for a single rule item -->
150<template id="regex_debugger_rule_template">
151 <li class="regex-debugger-rule" draggable="true">
152 <i class="fa-solid fa-grip-vertical handle"></i>
153 <label class="checkbox">
154 <input type="checkbox" class="rule-enabled" checked />
155 </label>
156 <div class="rule-details">
157 <span class="rule-name"></span>
158 <code class="rule-regex"></code>
159 <small class="rule-scope"></small>
160 </div>
161 <div class="menu_button menu_button_icon edit_rule" title="Edit Rule">
162 <i class="fa-solid fa-pencil"></i>
163 </div>
164 </li>
165</template>
166
167<!-- Template for a single transformation step -->
168<template id="regex_debugger_step_template">
169 <div class="step-result">
170 <div class="step-header">
171 <strong></strong>
172 </div>
173 <pre class="step-output"></pre>
174 </div>
175</template>
public/scripts/extensions/regex/dropdown.html+4 -0
@@ -26,6 +26,10 @@
26 <i class="fa-solid fa-edit"></i>26 <i class="fa-solid fa-edit"></i>
27 <small data-i18n="ext_regex_bulk_edit">Bulk Edit</small>27 <small data-i18n="ext_regex_bulk_edit">Bulk Edit</small>
28 </label>28 </label>
29 <div id="open_regex_debugger" class="menu_button menu_button_icon" data-i18n="[title]ext_regex_debugger_desc" title="Advanced Regex Debugger">
30 <i class="fa-solid fa-bug-slash"></i>
31 <small data-i18n="ext_regex_debugger">Debugger</small>
32 </div>
29 </div>33 </div>
30 <div class="regex_bulk_operations flex-container justifyCenter">34 <div class="regex_bulk_operations flex-container justifyCenter">
31 <div id="bulk_select_all_toggle" class="menu_button menu_button_icon" title="Toggle Select All">35 <div id="bulk_select_all_toggle" class="menu_button menu_button_icon" title="Toggle Select All">
public/scripts/extensions/regex/index.js+446 -2
@@ -1,4 +1,4 @@
1import { characters, eventSource, event_types, getCurrentChatId, reloadCurrentChat, saveSettingsDebounced, this_chid } from '../../../script.js';1import { characters, eventSource, event_types, getCurrentChatId, messageFormatting, reloadCurrentChat, saveSettingsDebounced, this_chid } from '../../../script.js';
2import { extension_settings, renderExtensionTemplateAsync, writeExtensionField } from '../../extensions.js';2import { extension_settings, renderExtensionTemplateAsync, writeExtensionField } from '../../extensions.js';
3import { selected_group } from '../../group-chats.js';3import { selected_group } from '../../group-chats.js';
4import { callGenericPopup, POPUP_TYPE } from '../../popup.js';4import { callGenericPopup, POPUP_TYPE } from '../../popup.js';
@@ -7,7 +7,7 @@ import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '
7import { commonEnumProviders, enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';7import { commonEnumProviders, enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js';
8import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashCommandEnumValue.js';8import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashCommandEnumValue.js';
9import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';9import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
10import { download, equalsIgnoreCaseAndAccents, getFileText, getSortableDelay, isFalseBoolean, isTrueBoolean, regexFromString, setInfoBlock, uuidv4 } from '../../utils.js';10import { download, equalsIgnoreCaseAndAccents, getFileText, getSortableDelay, isFalseBoolean, isTrueBoolean, regexFromString, setInfoBlock, uuidv4, escapeHtml } from '../../utils.js';
11import { regex_placement, runRegexScript, substitute_find_regex } from './engine.js';11import { regex_placement, runRegexScript, substitute_find_regex } from './engine.js';
12import { t } from '../../i18n.js';12import { t } from '../../i18n.js';
13import { accountStorage } from '../../util/AccountStorage.js';13import { accountStorage } from '../../util/AccountStorage.js';
@@ -94,6 +94,11 @@ async function saveRegexScript(regexScript, existingScriptIndex, isScoped) {
94 if (currentChatId !== undefined && currentChatId !== null) {94 if (currentChatId !== undefined && currentChatId !== null) {
95 await reloadCurrentChat();95 await reloadCurrentChat();
96 }96 }
97
98 const debuggerPopup = $('#regex_debugger_popup');
99 if (debuggerPopup.length) {
100 populateDebuggerRuleList(debuggerPopup.parent());
101 }
97}102}
98103
99async function deleteRegexScript({ id, isScoped }) {104async function deleteRegexScript({ id, isScoped }) {
@@ -330,6 +335,442 @@ async function onRegexEditorOpenClick(existingId, isScoped) {
330}335}
331336
332/**337/**
338 * Builds an HTML string for a replacement, highlighting literal parts in green
339 * and keeping back-referenced parts plain.
340 * @param {RegExpMatchArray} match The match object from `matchAll`.
341 * @param {string} pattern The replacement pattern string (e.g., "new text $1").
342 * @returns {string} The constructed HTML string.
343 */
344function buildReplacementHtml(match, pattern) {
345 const container = document.createDocumentFragment();
346 let lastIndex = 0;
347 const backrefRegex = /\$\$|\$&|\$`|\$'|\$(\d{1,2})/g;
348
349 let reMatch;
350 while ((reMatch = backrefRegex.exec(pattern)) !== null) {
351 // Part of the pattern before the back-reference is a literal.
352 const literalPart = pattern.substring(lastIndex, reMatch.index);
353 if (literalPart) {
354 const mark = document.createElement('mark');
355 mark.className = 'green_hl';
356 mark.innerText = literalPart;
357 container.appendChild(mark);
358 }
359
360 const backref = reMatch[0];
361 if (backref === '$$') {
362 container.appendChild(document.createTextNode('$'));
363 } else if (backref === '$&') {
364 const mark = document.createElement('mark');
365 mark.className = 'yellow_hl';
366 mark.innerText = match[0];
367 container.appendChild(mark);
368 } else if (backref === '$`') {
369 container.appendChild(document.createTextNode(match.input.substring(0, match.index)));
370 } else if (backref === '$\'') {
371 container.appendChild(document.createTextNode(match.input.substring(match.index + match[0].length)));
372 } else { // It's a numbered capture group, $n.
373 const groupIndex = parseInt(reMatch[1], 10);
374 if (groupIndex > 0 && groupIndex < match.length && match[groupIndex] !== undefined) {
375 const mark = document.createElement('mark');
376 mark.className = 'yellow_hl';
377 mark.innerText = match[groupIndex];
378 container.appendChild(mark);
379 } else {
380 // Not a valid group index, treat it as a literal.
381 const mark = document.createElement('mark');
382 mark.className = 'green_hl';
383 mark.innerText = backref;
384 container.appendChild(mark);
385 }
386 }
387 lastIndex = backrefRegex.lastIndex;
388 }
389
390 // The final part of the pattern after the last back-reference.
391 const finalLiteralPart = pattern.substring(lastIndex);
392 if (finalLiteralPart) {
393 const mark = document.createElement('mark');
394 mark.className = 'green_hl';
395 mark.innerText = finalLiteralPart;
396 container.appendChild(mark);
397 }
398
399 // To get the HTML content, we need a temporary parent element.
400 const tempDiv = document.createElement('div');
401 tempDiv.appendChild(container);
402 return tempDiv.innerHTML;
403}
404
405function executeRegexScriptForDebugging(script, text) {
406 let err;
407 let originalRegex;
408
409 try {
410 originalRegex = regexFromString(script.findRegex);
411 if (!originalRegex) throw new Error('Invalid regex string');
412 } catch (e) {
413 err = `Compile error: ${e.message}`;
414 return { output: text, highlightedOutput: text, error: err, charsCaptured: 0, charsAdded: 0, charsRemoved: 0 };
415 }
416
417 const globalRegex = new RegExp(originalRegex.source, originalRegex.flags.includes('g') ? originalRegex.flags : originalRegex.flags + 'g');
418 const matches = [...text.matchAll(globalRegex)];
419
420 if (matches.length === 0) {
421 return { output: text, highlightedOutput: escapeHtml(text), error: null, charsCaptured: 0, charsAdded: 0, charsRemoved: 0 };
422 }
423
424 let outputText = '';
425 let highlightedOutput = ''; // This will now be our "diff view"
426 let lastIndex = 0;
427 let totalCharsCaptured = 0;
428 let totalCharsAdded = 0;
429 let totalCharsRemoved = 0;
430
431 try {
432 for (const match of matches) {
433 const originalMatchText = match[0];
434 totalCharsCaptured += originalMatchText.length;
435
436 // Append text between matches (this part is unchanged)
437 const precedingText = text.substring(lastIndex, match.index);
438 outputText += precedingText;
439 highlightedOutput += escapeHtml(precedingText);
440
441 // --- Start of new diff and statistics logic ---
442 let charsAddedInMatch = 0;
443 let charsKeptFromMatch = 0;
444 const backrefRegex = /\$\$|\$&|\$`|\$'|\$(\d{1,2})/g;
445 let lastPatternIndex = 0;
446 let reMatch;
447 let replacementForPlainText = '';
448
449 // This loop calculates the stats accurately
450 while ((reMatch = backrefRegex.exec(script.replaceString)) !== null) {
451 const literalPart = script.replaceString.substring(lastPatternIndex, reMatch.index);
452 charsAddedInMatch += literalPart.length;
453 replacementForPlainText += literalPart;
454 const backref = reMatch[0];
455 if (backref === '$$') {
456 replacementForPlainText += '$';
457 } else if (backref === '$&') {
458 charsKeptFromMatch += (match[0] || '').length; replacementForPlainText += (match[0] || '');
459 } else if (backref === '$`') {
460 const part = match.input.substring(0, match.index); charsKeptFromMatch += part.length; replacementForPlainText += part;
461 } else if (backref === '$\'') {
462 const part = match.input.substring(match.index + match[0].length); charsKeptFromMatch += part.length; replacementForPlainText += part;
463 } else {
464 const groupIndex = parseInt(reMatch[1], 10);
465 if (groupIndex > 0 && groupIndex < match.length && match[groupIndex] !== undefined) {
466 charsKeptFromMatch += match[groupIndex].length;
467 replacementForPlainText += match[groupIndex];
468 }
469 }
470 lastPatternIndex = backrefRegex.lastIndex;
471 }
472 const finalLiteralPart = script.replaceString.substring(lastPatternIndex);
473 charsAddedInMatch += finalLiteralPart.length;
474 replacementForPlainText += finalLiteralPart;
475
476 totalCharsAdded += charsAddedInMatch;
477 totalCharsRemoved += (originalMatchText.length - charsKeptFromMatch);
478
479 outputText += replacementForPlainText;
480 // --- End of statistics logic ---
481
482 // --- Build the new Diff View HTML ---
483 // 1. Show the entire original match as "removed" (red strikethrough)
484 highlightedOutput += `<mark class='red_hl'>${escapeHtml(originalMatchText)}</mark>`;
485 // 2. Add an arrow to signify transformation
486 highlightedOutput += ' → ';
487 // 3. Build the replacement string with green (added) and yellow (kept) parts
488 highlightedOutput += buildReplacementHtml(match, script.replaceString);
489
490 lastIndex = match.index + originalMatchText.length;
491 }
492
493 // Append text after the last match
494 const trailingText = text.substring(lastIndex);
495 outputText += trailingText;
496 highlightedOutput += escapeHtml(trailingText);
497
498 } catch (e) {
499 err = (err ? err + '; ' : '') + `Replace error: ${e.message}`;
500 outputText = text; // Fallback
501 highlightedOutput = escapeHtml(text);
502 }
503
504 return {
505 output: outputText,
506 highlightedOutput: highlightedOutput,
507 error: err,
508 charsCaptured: totalCharsCaptured,
509 charsAdded: totalCharsAdded,
510 charsRemoved: totalCharsRemoved,
511 };
512}
513
514function populateDebuggerRuleList(container) {
515 const rulesContainer = container.find('#regex_debugger_rules');
516 const ruleTemplate = container.find('#regex_debugger_rule_template');
517 if (!rulesContainer.length || !ruleTemplate.length) {
518 console.error('Regex Debugger: Could not find rule list or template in the DOM.');
519 return;
520 }
521
522 rulesContainer.empty();
523
524 const allScripts = getRegexScripts();
525 if (!allScripts || allScripts.length === 0) {
526 rulesContainer.append('<div class="regex-debugger-no-rules">No regex rules found.</div>');
527 return;
528 }
529
530 const globalScriptIds = new Set((extension_settings.regex ?? []).map(s => s.id));
531 const globalScripts = [];
532 const scopedScripts = [];
533
534 allScripts.forEach(script => {
535 const scriptCopy = structuredClone(script); // Use structuredClone for deep copy
536 if (globalScriptIds.has(script.id)) {
537 // @ts-ignore
538 scriptCopy.isScoped = false;
539 globalScripts.push(scriptCopy);
540 } else {
541 // @ts-ignore
542 scriptCopy.isScoped = true;
543 scopedScripts.push(scriptCopy);
544 }
545 });
546
547 container.data('allScripts', [...globalScripts, ...scopedScripts]);
548
549 const renderRule = (script) => {
550 if (!script.id) script.id = uuidv4();
551 const ruleElementContent = $(ruleTemplate.prop('content')).clone();
552 const ruleElement = ruleElementContent.find('.regex-debugger-rule');
553
554 ruleElement.attr('data-id', script.id);
555 // @ts-ignore
556 ruleElement.find('.rule-name').text(script.scriptName);
557 ruleElement.find('.rule-regex').text(script.findRegex);
558 // @ts-ignore
559 ruleElement.find('.rule-scope').text(script.isScoped ? 'Scoped' : 'Global');
560 ruleElement.find('.rule-enabled').prop('checked', !script.disabled);
561 // @ts-ignore
562 ruleElement.find('.edit_rule').on('click', () => onRegexEditorOpenClick(script.id, script.isScoped));
563
564 ruleElement.on('click', function (event) {
565 if ($(event.target).is('input, .menu_button, .menu_button i')) {
566 return;
567 }
568 const scriptId = $(this).data('id');
569 const stepElement = $(`#step-result-${scriptId}`);
570 const container = $('#regex_debugger_steps_output');
571
572 if (stepElement.length && container.length) {
573 // Replace scrollIntoView with scrollTop animation
574 const targetTop = stepElement.position().top;
575 const containerScrollTop = container.scrollTop();
576 const containerHeight = container.height();
577
578 // Center the element if possible
579 let scrollTo = containerScrollTop + targetTop - (containerHeight / 2) + (stepElement.height() / 2);
580
581 container.animate({ scrollTop: scrollTo }, 300); // 300ms smooth scroll
582
583 stepElement.css('transition', 'background-color 0.5s').css('background-color', 'var(--highlight_color)');
584 setTimeout(() => stepElement.css('background-color', ''), 1000);
585 }
586 });
587
588 return ruleElementContent;
589 };
590
591 if (globalScripts.length > 0) {
592 rulesContainer.append('<div class="list-header regex-debugger-list-header">Global Rules</div>');
593 const globalList = $('<ul id="regex_debugger_rules_global" class="sortable-list"></ul>');
594 globalScripts.forEach(script => globalList.append(renderRule(script)));
595 rulesContainer.append(globalList);
596 }
597
598 if (scopedScripts.length > 0) {
599 rulesContainer.append('<div class="list-header regex-debugger-list-header">Scoped Rules</div>');
600 const scopedList = $('<ul id="regex_debugger_rules_scoped" class="sortable-list"></ul>');
601 scopedScripts.forEach(script => scopedList.append(renderRule(script)));
602 rulesContainer.append(scopedList);
603 }
604}
605
606/**
607 * Opens the regex debugger.
608 * @returns {Promise<void>}
609 */
610async function onRegexDebuggerOpenClick() {
611 const templateContent = await renderExtensionTemplateAsync('regex', 'debugger');
612 const debuggerHtml = $('<div>').html(templateContent);
613
614 const stepTemplate = debuggerHtml.find('#regex_debugger_step_template');
615
616 populateDebuggerRuleList(debuggerHtml);
617
618 // @ts-ignore
619 debuggerHtml.find('#regex_debugger_rules_global').sortable({ delay: getSortableDelay() }).disableSelection();
620 // @ts-ignore
621 debuggerHtml.find('#regex_debugger_rules_scoped').sortable({ delay: getSortableDelay() }).disableSelection();
622
623 debuggerHtml.find('#regex_debugger_run_test').on('click', function () {
624 const allScripts = debuggerHtml.data('allScripts');
625 const orderedRuleIds = [
626 ...$('#regex_debugger_rules_global').find('li.regex-debugger-rule').map((i, el) => $(el).data('id')).get(),
627 ...$('#regex_debugger_rules_scoped').find('li.regex-debugger-rule').map((i, el) => $(el).data('id')).get(),
628 ];
629
630 const rawInput = String($('#regex_debugger_raw_input').val());
631 const stepsOutput = $('#regex_debugger_steps_output');
632 const finalOutput = $('#regex_debugger_final_output');
633
634 if (!stepsOutput.length || !finalOutput.length) return;
635
636 const displayMode = $('input[name="display_mode"]:checked').val();
637 stepsOutput.empty();
638 finalOutput.empty();
639 $('#regex_debugger_final_summary').remove();
640
641 if (!allScripts) return;
642 let textForNextStep = rawInput;
643 let totalCharsCaptured = 0;
644 let totalCharsAdded = 0;
645 let totalCharsRemoved = 0;
646
647 orderedRuleIds.forEach(scriptId => {
648 const ruleElement = $(`#regex_debugger_rules [data-id="${scriptId}"]`);
649 if (!ruleElement.find('.rule-enabled').is(':checked')) return;
650
651 const script = allScripts.find(s => s.id === scriptId);
652
653 if (script) {
654 const result = executeRegexScriptForDebugging(script, textForNextStep);
655 totalCharsCaptured += result.charsCaptured;
656 totalCharsAdded += result.charsAdded;
657 totalCharsRemoved += result.charsRemoved;
658
659 const stepElement = $(stepTemplate.prop('content')).clone();
660 // Set the ID on the TOP-LEVEL element that is being appended.
661 stepElement.find('>:first-child').attr('id', `step-result-${script.id}`);
662 const stepHeader = stepElement.find('.step-header');
663 stepHeader.find('strong').text(`After: ${script.scriptName}`);
664
665 const metricsHtml = `<span class="step-metrics">Captured: ${result.charsCaptured}, Added: +${result.charsAdded}, Removed: -${result.charsRemoved}</span>`;
666 stepHeader.append(metricsHtml);
667
668 if (displayMode === 'highlight') {
669 stepElement.find('.step-output').html(result.highlightedOutput);
670 } else {
671 stepElement.find('.step-output').text(result.output);
672 }
673
674 if (result.error) {
675 stepHeader.append($(`<div class='warning_text text_rose-500'>${result.error}</div>`));
676 }
677
678 stepsOutput.append(stepElement);
679 textForNextStep = result.output;
680 }
681 });
682
683 const summaryHtml = `
684 <div id="regex_debugger_final_summary" class="regex-debugger-summary">
685 <strong>Total Captured:</strong> ${totalCharsCaptured} | <strong>Total Added:</strong> +${totalCharsAdded} | <strong>Total Removed:</strong> -${totalCharsRemoved}
686 </div>
687 `;
688 finalOutput.before(summaryHtml);
689
690 const renderMode = $('#regex_debugger_render_mode').val();
691 if (renderMode === 'message') {
692 const formattedHtml = messageFormatting(textForNextStep, 'Debugger', true, false, null);
693 const messageBlock = $('<div class="mes"><div class="mes_text"></div></div>');
694 messageBlock.find('.mes_text').html(formattedHtml);
695 finalOutput.append(messageBlock);
696 } else {
697 finalOutput.text(textForNextStep);
698 }
699 });
700
701 debuggerHtml.find('#regex_debugger_save_order').on('click', async function () {
702 const allKnownScripts = getRegexScripts();
703 const newGlobalScripts = $('#regex_debugger_rules_global').children('li').map((_, el) => allKnownScripts.find(s => s.id === $(el).data('id'))).get().filter(Boolean);
704 const newScopedScripts = $('#regex_debugger_rules_scoped').children('li').map((_, el) => allKnownScripts.find(s => s.id === $(el).data('id'))).get().filter(Boolean);
705
706 extension_settings.regex = newGlobalScripts;
707 if (this_chid !== undefined) {
708 await writeExtensionField(this_chid, 'regex_scripts', newScopedScripts);
709 }
710
711 saveSettingsDebounced();
712 await loadRegexScripts();
713 toastr.success(t`Regex script order saved!`);
714
715 const currentPopupContent = $('div:has(> #regex_debugger_rules)');
716 populateDebuggerRuleList(currentPopupContent);
717 // @ts-ignore
718 currentPopupContent.find('#regex_debugger_rules_global').sortable({ delay: getSortableDelay() }).disableSelection();
719 // @ts-ignore
720 currentPopupContent.find('#regex_debugger_rules_scoped').sortable({ delay: getSortableDelay() }).disableSelection();
721 });
722
723 debuggerHtml.find('#regex_debugger_expand_steps').on('click', function () {
724 const popupContainer = $('<div class="expanded-regex-container"></div>');
725 const navPanel = $('<div class="expanded-regex-nav"><h4>Steps</h4></div>');
726 const contentPanel = $('<div class="expanded-regex-content"></div>');
727
728 const content = $('#regex_debugger_steps_output').clone().html();
729 contentPanel.html(content);
730
731 $('#regex_debugger_rules .regex-debugger-rule').each(function () {
732 const ruleElement = $(this);
733 const scriptId = ruleElement.data('id');
734 const scriptName = ruleElement.find('.rule-name').text();
735
736 const link = $(`<a href="#">${escapeHtml(scriptName)}</a>`);
737 link.data('target-id', `step-result-${scriptId}`);
738
739 link.on('click', function (e) {
740 e.preventDefault();
741 navPanel.find('a').removeClass('active');
742 $(this).addClass('active');
743
744 const targetId = $(this).data('target-id');
745 // The selector is now correct for the structure.
746 const targetElement = contentPanel.find(`#${targetId}`);
747
748 if (targetElement.length) {
749 const scrollTo = contentPanel.scrollTop() + targetElement.position().top;
750 contentPanel.animate({ scrollTop: scrollTo }, 300);
751
752 targetElement.css('transition', 'background-color 0.5s').css('background-color', 'var(--highlight_color)');
753 setTimeout(() => targetElement.css('background-color', ''), 1000);
754 }
755 });
756
757 navPanel.append(link);
758 });
759
760 popupContainer.append(navPanel).append(contentPanel);
761 callGenericPopup(popupContainer, POPUP_TYPE.TEXT, 'Step-by-step Transformation', { wide: true, allowVerticalScrolling: false });
762 });
763
764 debuggerHtml.find('#regex_debugger_expand_final').on('click', function () {
765 const content = $('#regex_debugger_final_output').html();
766 const popupContent = $('<div style="height: 70vh; overflow-y: auto;"></div>').html(content);
767 callGenericPopup(popupContent, POPUP_TYPE.TEXT, 'Final Output', { wide: true, allowVerticalScrolling: true });
768 });
769
770 await callGenericPopup(debuggerHtml.children(), POPUP_TYPE.TEXT, '', { wide: true, allowVerticalScrolling: true });
771}
772
773/**
333 * Updates the info block in the regex editor with hints regarding the find regex.774 * Updates the info block in the regex editor with hints regarding the find regex.
334 * @param {JQuery<HTMLElement>} editorHtml The editor HTML775 * @param {JQuery<HTMLElement>} editorHtml The editor HTML
335 */776 */
@@ -606,6 +1047,7 @@ jQuery(async () => {
606 $('#open_regex_editor').on('click', function () {1047 $('#open_regex_editor').on('click', function () {
607 onRegexEditorOpenClick(false, false);1048 onRegexEditorOpenClick(false, false);
608 });1049 });
1050 $('#open_regex_debugger').on('click', onRegexDebuggerOpenClick);
609 $('#open_scoped_editor').on('click', function () {1051 $('#open_scoped_editor').on('click', function () {
610 if (this_chid === undefined) {1052 if (this_chid === undefined) {
611 toastr.error(t`No character selected.`);1053 toastr.error(t`No character selected.`);
@@ -726,6 +1168,7 @@ jQuery(async () => {
726 },1168 },
727 ];1169 ];
728 for (const { selector, setter, getter } of sortableDatas) {1170 for (const { selector, setter, getter } of sortableDatas) {
1171 // @ts-ignore
729 $(selector).sortable({1172 $(selector).sortable({
730 delay: getSortableDelay(),1173 delay: getSortableDelay(),
731 stop: async function () {1174 stop: async function () {
@@ -778,6 +1221,7 @@ jQuery(async () => {
778 });1221 });
7791222
780 await loadRegexScripts();1223 await loadRegexScripts();
1224 // @ts-ignore
781 $('#saved_regex_scripts').sortable('enable');1225 $('#saved_regex_scripts').sortable('enable');
7821226
783 const localEnumProviders = {1227 const localEnumProviders = {
public/scripts/extensions/regex/style.css+2 -0
@@ -1,3 +1,5 @@
1@import "debugger.css";
2
1.regex_settings .menu_button {3.regex_settings .menu_button {
2 width: fit-content;4 width: fit-content;
3 display: flex;5 display: flex;