import { characters, saveSettingsDebounced, substituteParams, substituteParamsExtended, this_chid } from '../../../script.js'; import { extension_settings, writeExtensionField } from '../../extensions.js'; import { getPresetManager } from '../../preset-manager.js'; import { regexFromString } from '../../utils.js'; import { lodash } from '../../../lib.js'; /** * @enum {number} Regex scripts types * @readonly */ export const SCRIPT_TYPES = { // ORDER MATTERS: defines the regex script priority GLOBAL: 0, PRESET: 2, SCOPED: 1, }; /** * Special type for unknown/invalid script types. */ export const SCRIPT_TYPE_UNKNOWN = -1; /** * @typedef {import('../../char-data.js').RegexScriptData} RegexScript */ /** * @typedef {object} GetRegexScriptsOptions * @property {boolean} allowedOnly Only return allowed scripts */ /** * @type {Readonly} */ const DEFAULT_GET_REGEX_SCRIPTS_OPTIONS = Object.freeze({ allowedOnly: false }); /** * Retrieves the list of regex scripts by combining the scripts from the extension settings and the character data * * @param {GetRegexScriptsOptions} options Options for retrieving the regex scripts * @returns {RegexScript[]} An array of regex scripts, where each script is an object containing the necessary information. */ export function getRegexScripts(options = DEFAULT_GET_REGEX_SCRIPTS_OPTIONS) { return [...Object.values(SCRIPT_TYPES).flatMap(type => getScriptsByType(type, options))]; } /** * Retrieves the regex scripts for a specific type. * @param {SCRIPT_TYPES} scriptType The type of regex scripts to retrieve. * @param {GetRegexScriptsOptions} options Options for retrieving the regex scripts * @returns {RegexScript[]} An array of regex scripts for the specified type. */ export function getScriptsByType(scriptType, { allowedOnly } = DEFAULT_GET_REGEX_SCRIPTS_OPTIONS) { switch (scriptType) { case SCRIPT_TYPE_UNKNOWN: return []; case SCRIPT_TYPES.GLOBAL: return extension_settings.regex ?? []; case SCRIPT_TYPES.SCOPED: { if (allowedOnly && !extension_settings?.character_allowed_regex?.includes(characters?.[this_chid]?.avatar)) { return []; } const scopedScripts = characters[this_chid]?.data?.extensions?.regex_scripts; return Array.isArray(scopedScripts) ? scopedScripts : []; } case SCRIPT_TYPES.PRESET: { if (allowedOnly && !extension_settings?.preset_allowed_regex?.[getCurrentPresetAPI()]?.includes(getCurrentPresetName())) { return []; } const presetManager = getPresetManager(); const presetScripts = presetManager?.readPresetExtensionField({ path: 'regex_scripts' }); return Array.isArray(presetScripts) ? presetScripts : []; } default: console.warn(`getScriptsByType: Invalid script type ${scriptType}`); return []; } } /** * Saves an array of regex scripts for a specific type. * @param {RegexScript[]} scripts An array of regex scripts to save. * @param {SCRIPT_TYPES} scriptType The type of regex scripts to save. * @returns {Promise} */ export async function saveScriptsByType(scripts, scriptType) { switch (scriptType) { case SCRIPT_TYPES.GLOBAL: extension_settings.regex = scripts; saveSettingsDebounced(); break; case SCRIPT_TYPES.SCOPED: await writeExtensionField(this_chid, 'regex_scripts', scripts); break; case SCRIPT_TYPES.PRESET: { const presetManager = getPresetManager(); await presetManager.writePresetExtensionField({ path: 'regex_scripts', value: scripts }); break; } default: console.warn(`saveScriptsByType: Invalid script type ${scriptType}`); break; } } /** * Check if character's regexes are allowed to be used; if character is undefined, returns false * @param {import('../../char-data.js').v1CharData|undefined} character * @returns {boolean} */ export function isScopedScriptsAllowed(character) { return !!extension_settings?.character_allowed_regex?.includes(character?.avatar); } /** * Allow character's regexes to be used; if character is undefined, do nothing * @param {import('../../char-data.js').v1CharData|undefined} character * @returns {void} */ export function allowScopedScripts(character) { const avatar = character?.avatar; if (!avatar) { return; } if (!Array.isArray(extension_settings?.character_allowed_regex)) { extension_settings.character_allowed_regex = []; } if (!extension_settings.character_allowed_regex.includes(avatar)) { extension_settings.character_allowed_regex.push(avatar); saveSettingsDebounced(); } } /** * Disallow character's regexes to be used; if character is undefined, do nothing * @param {import('../../char-data.js').v1CharData|undefined} character * @returns {void} */ export function disallowScopedScripts(character) { const avatar = character?.avatar; if (!avatar) { return; } if (!Array.isArray(extension_settings?.character_allowed_regex)) { return; } const index = extension_settings.character_allowed_regex.indexOf(avatar); if (index !== -1) { extension_settings.character_allowed_regex.splice(index, 1); saveSettingsDebounced(); } } /** * Check if preset's regexes are allowed to be used * @param {string} apiId API ID * @param {string} presetName Preset name * @returns {boolean} True if allowed, false if not */ export function isPresetScriptsAllowed(apiId, presetName) { if (!apiId || !presetName) { return false; } return !!extension_settings?.preset_allowed_regex?.[apiId]?.includes(presetName); } /** * Allow preset's regexes to be used * @param {string} apiId API ID * @param {string} presetName Preset name * @returns {void} */ export function allowPresetScripts(apiId, presetName) { if (!apiId || !presetName) { return; } if (!Array.isArray(extension_settings?.preset_allowed_regex?.[apiId])) { lodash.set(extension_settings, ['preset_allowed_regex', apiId], []); } if (!extension_settings.preset_allowed_regex[apiId].includes(presetName)) { extension_settings.preset_allowed_regex[apiId].push(presetName); saveSettingsDebounced(); } } /** * Disallow preset's regexes to be used * @param {string} apiId API ID * @param {string} presetName Preset name * @returns {void} */ export function disallowPresetScripts(apiId, presetName) { if (!apiId || !presetName) { return; } if (!Array.isArray(extension_settings?.preset_allowed_regex?.[apiId])) { return; } const index = extension_settings.preset_allowed_regex[apiId].indexOf(presetName); if (index !== -1) { extension_settings.preset_allowed_regex[apiId].splice(index, 1); saveSettingsDebounced(); } } /** * Gets the current API ID from the preset manager. * @returns {string|null} Current API ID, or null if no preset manager */ export function getCurrentPresetAPI() { return getPresetManager()?.apiId ?? null; } /** * Gets the name of the currently selected preset. * @returns {string|null} The name of the currently selected preset, or null if no preset manager */ export function getCurrentPresetName() { return getPresetManager()?.getSelectedPresetName() ?? null; } /** * @enum {number} Where the regex script should be applied * @readonly */ export const regex_placement = { /** * @deprecated MD Display is deprecated. Do not use. */ MD_DISPLAY: 0, USER_INPUT: 1, AI_OUTPUT: 2, SLASH_COMMAND: 3, // 4 - sendAs (legacy) WORLD_INFO: 5, REASONING: 6, }; /** * @enum {number} How to substitute parameters in the find regex * @readonly */ export const substitute_find_regex = { NONE: 0, RAW: 1, ESCAPED: 2, }; function sanitizeRegexMacro(x) { return (x && typeof x === 'string') ? x.replaceAll(/[\n\r\t\v\f\0.^$*+?{}[\]\\/|()]/gs, function (s) { switch (s) { case '\n': return '\\n'; case '\r': return '\\r'; case '\t': return '\\t'; case '\v': return '\\v'; case '\f': return '\\f'; case '\0': return '\\0'; default: return '\\' + s; } }) : x; } /** * Parent function to fetch a regexed version of a raw string * @param {string} rawString The raw string to be regexed * @param {regex_placement} placement The placement of the string * @param {RegexParams} params The parameters to use for the regex script * @returns {string} The regexed string * @typedef {{characterOverride?: string, isMarkdown?: boolean, isPrompt?: boolean, isEdit?: boolean, depth?: number }} RegexParams The parameters to use for the regex script */ export function getRegexedString(rawString, placement, { characterOverride, isMarkdown, isPrompt, isEdit, depth } = {}) { // WTF have you passed me? if (typeof rawString !== 'string') { console.warn('getRegexedString: rawString is not a string. Returning empty string.'); return ''; } let finalString = rawString; if (extension_settings.disabledExtensions.includes('regex') || !rawString || placement === undefined) { return finalString; } const allRegex = getRegexScripts({ allowedOnly: true }); allRegex.forEach((script) => { if ( // Script applies to Markdown and input is Markdown (script.markdownOnly && isMarkdown) || // Script applies to Generate and input is Generate (script.promptOnly && isPrompt) || // Script applies to all cases when neither "only"s are true, but there's no need to do it when `isMarkdown`, the as source (chat history) should already be changed beforehand (!script.markdownOnly && !script.promptOnly && !isMarkdown && !isPrompt) ) { if (isEdit && !script.runOnEdit) { console.debug(`getRegexedString: Skipping script ${script.scriptName} because it does not run on edit`); return; } // Check if the depth is within the min/max depth if (typeof depth === 'number') { if (!isNaN(script.minDepth) && script.minDepth !== null && script.minDepth >= -1 && depth < script.minDepth) { console.debug(`getRegexedString: Skipping script ${script.scriptName} because depth ${depth} is less than minDepth ${script.minDepth}`); return; } if (!isNaN(script.maxDepth) && script.maxDepth !== null && script.maxDepth >= 0 && depth > script.maxDepth) { console.debug(`getRegexedString: Skipping script ${script.scriptName} because depth ${depth} is greater than maxDepth ${script.maxDepth}`); return; } } if (script.placement.includes(placement)) { finalString = runRegexScript(script, finalString, { characterOverride }); } } }); return finalString; } /** * Runs the provided regex script on the given string * @param {RegexScript} regexScript The regex script to run * @param {string} rawString The string to run the regex script on * @param {RegexScriptParams} params The parameters to use for the regex script * @returns {string} The new string * @typedef {{characterOverride?: string}} RegexScriptParams The parameters to use for the regex script */ export function runRegexScript(regexScript, rawString, { characterOverride } = {}) { let newString = rawString; if (!regexScript || !!(regexScript.disabled) || !regexScript?.findRegex || !rawString) { return newString; } const getRegexString = () => { switch (Number(regexScript.substituteRegex)) { case substitute_find_regex.NONE: return regexScript.findRegex; case substitute_find_regex.RAW: return substituteParamsExtended(regexScript.findRegex); case substitute_find_regex.ESCAPED: return substituteParamsExtended(regexScript.findRegex, {}, sanitizeRegexMacro); default: console.warn(`runRegexScript: Unknown substituteRegex value ${regexScript.substituteRegex}. Using raw regex.`); return regexScript.findRegex; } }; const regexString = getRegexString(); const findRegex = regexFromString(regexString); // The user skill issued. Return with nothing. if (!findRegex) { return newString; } // Run replacement. Currently does not support the Overlay strategy newString = rawString.replace(findRegex, function (match) { const args = [...arguments]; const replaceString = regexScript.replaceString.replace(/{{match}}/gi, '$0'); const replaceWithGroups = replaceString.replaceAll(/\$(\d+)|\$<([^>]+)>/g, (_, num, groupName) => { if (num) { // Handle numbered capture groups ($1, $2, etc.) match = args[Number(num)]; } else if (groupName) { // Handle named capture groups ($) const groups = args[args.length - 1]; match = groups && typeof groups === 'object' && groups[groupName]; } // No match found - return the empty string if (!match) { return ''; } // Remove trim strings from the match const filteredMatch = filterString(match, regexScript.trimStrings, { characterOverride }); return filteredMatch; }); // Substitute at the end return substituteParams(replaceWithGroups); }); return newString; } /** * Filters anything to trim from the regex match * @param {string} rawString The raw string to filter * @param {string[]} trimStrings The strings to trim * @param {RegexScriptParams} params The parameters to use for the regex filter * @returns {string} The filtered string */ function filterString(rawString, trimStrings, { characterOverride } = {}) { let finalString = rawString; trimStrings.forEach((trimString) => { const subTrimString = substituteParams(trimString, undefined, characterOverride); finalString = finalString.replaceAll(subTrimString, ''); }); return finalString; }