| 1 | import { characters, eventSource, event_types, getCurrentChatId, messageFormatting, reloadCurrentChat, saveSettingsDebounced, this_chid } from '../../../script.js'; |
| 2 | import { extension_settings, renderExtensionTemplateAsync } from '../../extensions.js'; |
| 3 | import { selected_group } from '../../group-chats.js'; |
| 4 | import { callGenericPopup, Popup, POPUP_TYPE } from '../../popup.js'; |
| 5 | import { SlashCommand } from '../../slash-commands/SlashCommand.js'; |
| 6 | import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js'; |
| 7 | import { commonEnumProviders, enumIcons } from '../../slash-commands/SlashCommandCommonEnumsProvider.js'; |
| 8 | import { SlashCommandEnumValue, enumTypes } from '../../slash-commands/SlashCommandEnumValue.js'; |
| 9 | import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js'; |
| 10 | import { download, equalsIgnoreCaseAndAccents, escapeHtml, getFileText, getSortableDelay, isFalseBoolean, isTrueBoolean, regexFromString, setInfoBlock, uuidv4 } from '../../utils.js'; |
| 11 | import { 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'; |
| 12 | import { t } from '../../i18n.js'; |
| 13 | import { accountStorage } from '../../util/AccountStorage.js'; |
| 14 | import { getPresetManager } from '../../preset-manager.js'; |
| 15 | |
| 16 | // Re-exports for legacy extensions |
| 17 | export { getRegexScripts }; |
| 18 | |
| 19 | const 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 | |
| 47 | class 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 | |
| 478 | const 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 | */ |
| 486 | function 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 | */ |
| 495 | function 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 | */ |
| 512 | async 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 | */ |
| 577 | async 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 | */ |
| 612 | async 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 | |
| 627 | async 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 | */ |
| 763 | async 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 | */ |
| 881 | function 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 | |
| 942 | function 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 | |
| 1050 | function 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 | */ |
| 1168 | async 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 | */ |
| 1342 | function 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? |
| 1374 | function 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 | */ |
| 1422 | function 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 | */ |
| 1453 | async 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 | */ |
| 1499 | async 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 | */ |
| 1540 | async 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 | */ |
| 1567 | function 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 | |
| 1577 | function 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 | |
| 1586 | function 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 | |
| 1598 | function 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 | |
| 1606 | async 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 | */ |
| 1639 | function 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 | |
| 1650 | async 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 | |
| 1679 | async 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 | |
| 1696 | function 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 |
| 1712 | export 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 | } |