| 330 | 335 | } |
| 331 | 336 | |
| 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 | + */ |
| 344 | +function 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 | + |
| 405 | +function 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 | + |
| 514 | +function 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 | + */ |
| 610 | +async 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 | 774 | * Updates the info block in the regex editor with hints regarding the find regex. |
| 334 | 775 | * @param {JQuery<HTMLElement>} editorHtml The editor HTML |
| 335 | 776 | */ |