Blame Raw
· · · 465 lines (15.8 KB)
0 contributors
1import { characters, saveSettingsDebounced, substituteParams, substituteParamsExtended, this_chid } from '../../../script.js';
2import { extension_settings, writeExtensionField } from '../../extensions.js';
3import { getPresetManager } from '../../preset-manager.js';
4import { regexFromString } from '../../utils.js';
5import { lodash } from '../../../lib.js';
6
7/**
8 * @readonly
9 * @enum {number} Regex scripts types
10 */
11export const SCRIPT_TYPES = {
12 // ORDER MATTERS: defines the regex script priority
13 GLOBAL: 0,
14 PRESET: 2,
15 SCOPED: 1,
16};
17
18/**
19 * Special type for unknown/invalid script types.
20 */
21export const SCRIPT_TYPE_UNKNOWN = -1;
22
23/**
24 * @typedef {import('../../char-data.js').RegexScriptData} RegexScript
25 */
26
27/**
28 * @typedef {object} GetRegexScriptsOptions
29 * @property {boolean} allowedOnly Only return allowed scripts
30 */
31
32/**
33 * @type {Readonly<GetRegexScriptsOptions>}
34 */
35const DEFAULT_GET_REGEX_SCRIPTS_OPTIONS = Object.freeze({ allowedOnly: false });
36
37/**
38 * Manages the compiled regex cache with LRU eviction.
39 */
40export class RegexProvider {
41 /** @type {Map<string, RegExp>} */
42 #cache = new Map();
43 /** @type {number} */
44 #maxSize = 1000;
45
46 static instance = new RegexProvider();
47
48 /**
49 * Gets a regex instance by its string representation.
50 * @param {string} regexString The regex string to retrieve
51 * @returns {RegExp?} Compiled regex or null if invalid
52 */
53 get(regexString) {
54 const isCached = this.#cache.has(regexString);
55 const regex = isCached
56 ? this.#cache.get(regexString)
57 : regexFromString(regexString);
58
59 if (!regex) {
60 return null;
61 }
62
63 if (isCached) {
64 // LRU: Move to end by re-inserting
65 this.#cache.delete(regexString);
66 this.#cache.set(regexString, regex);
67 } else {
68 // Evict oldest if at capacity
69 if (this.#cache.size >= this.#maxSize) {
70 const firstKey = this.#cache.keys().next().value;
71 this.#cache.delete(firstKey);
72 }
73 this.#cache.set(regexString, regex);
74 }
75
76 // Reset lastIndex for global/sticky regexes
77 if (regex.global || regex.sticky) {
78 regex.lastIndex = 0;
79 }
80
81 return regex;
82 }
83
84 /**
85 * Clears the entire cache.
86 */
87 clear() {
88 this.#cache.clear();
89 }
90}
91
92/**
93 * Retrieves the list of regex scripts by combining the scripts from the extension settings and the character data
94 *
95 * @param {GetRegexScriptsOptions} options Options for retrieving the regex scripts
96 * @returns {RegexScript[]} An array of regex scripts, where each script is an object containing the necessary information.
97 */
98export function getRegexScripts(options = DEFAULT_GET_REGEX_SCRIPTS_OPTIONS) {
99 return [...Object.values(SCRIPT_TYPES).flatMap(type => getScriptsByType(type, options))];
100}
101
102/**
103 * Retrieves the regex scripts for a specific type.
104 * @param {SCRIPT_TYPES} scriptType The type of regex scripts to retrieve.
105 * @param {GetRegexScriptsOptions} options Options for retrieving the regex scripts
106 * @returns {RegexScript[]} An array of regex scripts for the specified type.
107 */
108export function getScriptsByType(scriptType, { allowedOnly } = DEFAULT_GET_REGEX_SCRIPTS_OPTIONS) {
109 switch (scriptType) {
110 case SCRIPT_TYPE_UNKNOWN:
111 return [];
112 case SCRIPT_TYPES.GLOBAL:
113 return extension_settings.regex ?? [];
114 case SCRIPT_TYPES.SCOPED: {
115 if (allowedOnly && !extension_settings?.character_allowed_regex?.includes(characters?.[this_chid]?.avatar)) {
116 return [];
117 }
118 const scopedScripts = characters[this_chid]?.data?.extensions?.regex_scripts;
119 return Array.isArray(scopedScripts) ? scopedScripts : [];
120 }
121 case SCRIPT_TYPES.PRESET: {
122 if (allowedOnly && !extension_settings?.preset_allowed_regex?.[getCurrentPresetAPI()]?.includes(getCurrentPresetName())) {
123 return [];
124 }
125 const presetManager = getPresetManager();
126 const presetScripts = presetManager?.readPresetExtensionField({ path: 'regex_scripts' });
127 return Array.isArray(presetScripts) ? presetScripts : [];
128 }
129 default:
130 console.warn(`getScriptsByType: Invalid script type ${scriptType}`);
131 return [];
132 }
133}
134
135/**
136 * Saves an array of regex scripts for a specific type.
137 * @param {RegexScript[]} scripts An array of regex scripts to save.
138 * @param {SCRIPT_TYPES} scriptType The type of regex scripts to save.
139 * @returns {Promise<void>}
140 */
141export async function saveScriptsByType(scripts, scriptType) {
142 switch (scriptType) {
143 case SCRIPT_TYPES.GLOBAL:
144 extension_settings.regex = scripts;
145 saveSettingsDebounced();
146 break;
147 case SCRIPT_TYPES.SCOPED:
148 await writeExtensionField(this_chid, 'regex_scripts', scripts);
149 break;
150 case SCRIPT_TYPES.PRESET: {
151 const presetManager = getPresetManager();
152 await presetManager.writePresetExtensionField({ path: 'regex_scripts', value: scripts });
153 break;
154 }
155 default:
156 console.warn(`saveScriptsByType: Invalid script type ${scriptType}`);
157 break;
158 }
159}
160
161/**
162 * Check if character's regexes are allowed to be used; if character is undefined, returns false
163 * @param {Character|undefined} character
164 * @returns {boolean}
165 */
166export function isScopedScriptsAllowed(character) {
167 return !!extension_settings?.character_allowed_regex?.includes(character?.avatar);
168}
169
170/**
171 * Allow character's regexes to be used; if character is undefined, do nothing
172 * @param {Character|undefined} character
173 * @returns {void}
174 */
175export function allowScopedScripts(character) {
176 const avatar = character?.avatar;
177 if (!avatar) {
178 return;
179 }
180 if (!Array.isArray(extension_settings?.character_allowed_regex)) {
181 extension_settings.character_allowed_regex = [];
182 }
183 if (!extension_settings.character_allowed_regex.includes(avatar)) {
184 extension_settings.character_allowed_regex.push(avatar);
185 saveSettingsDebounced();
186 }
187}
188
189/**
190 * Disallow character's regexes to be used; if character is undefined, do nothing
191 * @param {Character|undefined} character
192 * @returns {void}
193 */
194export function disallowScopedScripts(character) {
195 const avatar = character?.avatar;
196 if (!avatar) {
197 return;
198 }
199 if (!Array.isArray(extension_settings?.character_allowed_regex)) {
200 return;
201 }
202 const index = extension_settings.character_allowed_regex.indexOf(avatar);
203 if (index !== -1) {
204 extension_settings.character_allowed_regex.splice(index, 1);
205 saveSettingsDebounced();
206 }
207}
208
209/**
210 * Check if preset's regexes are allowed to be used
211 * @param {string} apiId API ID
212 * @param {string} presetName Preset name
213 * @returns {boolean} True if allowed, false if not
214 */
215export function isPresetScriptsAllowed(apiId, presetName) {
216 if (!apiId || !presetName) {
217 return false;
218 }
219 return !!extension_settings?.preset_allowed_regex?.[apiId]?.includes(presetName);
220}
221
222/**
223 * Allow preset's regexes to be used
224 * @param {string} apiId API ID
225 * @param {string} presetName Preset name
226 * @returns {void}
227 */
228export function allowPresetScripts(apiId, presetName) {
229 if (!apiId || !presetName) {
230 return;
231 }
232 if (!Array.isArray(extension_settings?.preset_allowed_regex?.[apiId])) {
233 lodash.set(extension_settings, ['preset_allowed_regex', apiId], []);
234 }
235 if (!extension_settings.preset_allowed_regex[apiId].includes(presetName)) {
236 extension_settings.preset_allowed_regex[apiId].push(presetName);
237 saveSettingsDebounced();
238 }
239}
240
241/**
242 * Disallow preset's regexes to be used
243 * @param {string} apiId API ID
244 * @param {string} presetName Preset name
245 * @returns {void}
246 */
247export function disallowPresetScripts(apiId, presetName) {
248 if (!apiId || !presetName) {
249 return;
250 }
251 if (!Array.isArray(extension_settings?.preset_allowed_regex?.[apiId])) {
252 return;
253 }
254 const index = extension_settings.preset_allowed_regex[apiId].indexOf(presetName);
255 if (index !== -1) {
256 extension_settings.preset_allowed_regex[apiId].splice(index, 1);
257 saveSettingsDebounced();
258 }
259}
260
261/**
262 * Gets the current API ID from the preset manager.
263 * @returns {string|null} Current API ID, or null if no preset manager
264 */
265export function getCurrentPresetAPI() {
266 return getPresetManager()?.apiId ?? null;
267}
268
269/**
270 * Gets the name of the currently selected preset.
271 * @returns {string|null} The name of the currently selected preset, or null if no preset manager
272 */
273export function getCurrentPresetName() {
274 return getPresetManager()?.getSelectedPresetName() ?? null;
275}
276
277/**
278 * @readonly
279 * @enum {number} Where the regex script should be applied
280 */
281export const regex_placement = {
282 /**
283 * @deprecated MD Display is deprecated. Do not use.
284 */
285 MD_DISPLAY: 0,
286 USER_INPUT: 1,
287 AI_OUTPUT: 2,
288 SLASH_COMMAND: 3,
289 // 4 - sendAs (legacy)
290 WORLD_INFO: 5,
291 REASONING: 6,
292};
293
294/**
295 * @readonly
296 * @enum {number} How to substitute parameters in the find regex
297 */
298export const substitute_find_regex = {
299 NONE: 0,
300 RAW: 1,
301 ESCAPED: 2,
302};
303
304function sanitizeRegexMacro(x) {
305 return (x && typeof x === 'string') ?
306 x.replaceAll(/[\n\r\t\v\f\0.^$*+?{}[\]\\/|()]/gs, function (s) {
307 switch (s) {
308 case '\n':
309 return '\\n';
310 case '\r':
311 return '\\r';
312 case '\t':
313 return '\\t';
314 case '\v':
315 return '\\v';
316 case '\f':
317 return '\\f';
318 case '\0':
319 return '\\0';
320 default:
321 return '\\' + s;
322 }
323 }) : x;
324}
325
326/**
327 * Parent function to fetch a regexed version of a raw string
328 * @param {string} rawString The raw string to be regexed
329 * @param {regex_placement} placement The placement of the string
330 * @param {RegexParams} params The parameters to use for the regex script
331 * @returns {string} The regexed string
332 * @typedef {{characterOverride?: string, isMarkdown?: boolean, isPrompt?: boolean, isEdit?: boolean, depth?: number }} RegexParams The parameters to use for the regex script
333 */
334export function getRegexedString(rawString, placement, { characterOverride, isMarkdown, isPrompt, isEdit, depth } = {}) {
335 // WTF have you passed me?
336 if (typeof rawString !== 'string') {
337 console.warn('getRegexedString: rawString is not a string. Returning empty string.');
338 return '';
339 }
340
341 let finalString = rawString;
342 if (extension_settings.disabledExtensions.includes('regex') || !rawString || placement === undefined) {
343 return finalString;
344 }
345
346 const allRegex = getRegexScripts({ allowedOnly: true });
347 allRegex.forEach((script) => {
348 if (
349 // Script applies to Markdown and input is Markdown
350 (script.markdownOnly && isMarkdown) ||
351 // Script applies to Generate and input is Generate
352 (script.promptOnly && isPrompt) ||
353 // 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
354 (!script.markdownOnly && !script.promptOnly && !isMarkdown && !isPrompt)
355 ) {
356 if (isEdit && !script.runOnEdit) {
357 console.debug(`getRegexedString: Skipping script ${script.scriptName} because it does not run on edit`);
358 return;
359 }
360
361 // Check if the depth is within the min/max depth
362 if (typeof depth === 'number') {
363 if (!isNaN(script.minDepth) && script.minDepth !== null && script.minDepth >= -1 && depth < script.minDepth) {
364 console.debug(`getRegexedString: Skipping script ${script.scriptName} because depth ${depth} is less than minDepth ${script.minDepth}`);
365 return;
366 }
367
368 if (!isNaN(script.maxDepth) && script.maxDepth !== null && script.maxDepth >= 0 && depth > script.maxDepth) {
369 console.debug(`getRegexedString: Skipping script ${script.scriptName} because depth ${depth} is greater than maxDepth ${script.maxDepth}`);
370 return;
371 }
372 }
373
374 if (script.placement.includes(placement)) {
375 finalString = runRegexScript(script, finalString, { characterOverride });
376 }
377 }
378 });
379
380 return finalString;
381}
382
383/**
384 * Runs the provided regex script on the given string
385 * @param {RegexScript} regexScript The regex script to run
386 * @param {string} rawString The string to run the regex script on
387 * @param {RegexScriptParams} params The parameters to use for the regex script
388 * @returns {string} The new string
389 * @typedef {{characterOverride?: string}} RegexScriptParams The parameters to use for the regex script
390 */
391export function runRegexScript(regexScript, rawString, { characterOverride } = {}) {
392 let newString = rawString;
393 if (!regexScript || !!(regexScript.disabled) || !regexScript?.findRegex || !rawString) {
394 return newString;
395 }
396
397 const getRegexString = () => {
398 switch (Number(regexScript.substituteRegex)) {
399 case substitute_find_regex.NONE:
400 return regexScript.findRegex;
401 case substitute_find_regex.RAW:
402 return substituteParamsExtended(regexScript.findRegex);
403 case substitute_find_regex.ESCAPED:
404 return substituteParamsExtended(regexScript.findRegex, {}, sanitizeRegexMacro);
405 default:
406 console.warn(`runRegexScript: Unknown substituteRegex value ${regexScript.substituteRegex}. Using raw regex.`);
407 return regexScript.findRegex;
408 }
409 };
410 const regexString = getRegexString();
411 const findRegex = RegexProvider.instance.get(regexString);
412
413 // The user skill issued. Return with nothing.
414 if (!findRegex) {
415 return newString;
416 }
417
418 // Run replacement. Currently does not support the Overlay strategy
419 newString = rawString.replace(findRegex, function (match) {
420 const args = [...arguments];
421 const replaceString = regexScript.replaceString.replace(/{{match}}/gi, '$0');
422 const replaceWithGroups = replaceString.replaceAll(/\$(\d+)|\$<([^>]+)>/g, (_, num, groupName) => {
423 if (num) {
424 // Handle numbered capture groups ($1, $2, etc.)
425 match = args[Number(num)];
426 } else if (groupName) {
427 // Handle named capture groups ($<name>)
428 const groups = args[args.length - 1];
429 match = groups && typeof groups === 'object' && groups[groupName];
430 }
431
432 // No match found - return the empty string
433 if (!match) {
434 return '';
435 }
436
437 // Remove trim strings from the match
438 const filteredMatch = filterString(match, regexScript.trimStrings, { characterOverride });
439
440 return filteredMatch;
441 });
442
443 // Substitute at the end
444 return substituteParams(replaceWithGroups);
445 });
446
447 return newString;
448}
449
450/**
451 * Filters anything to trim from the regex match
452 * @param {string} rawString The raw string to filter
453 * @param {string[]} trimStrings The strings to trim
454 * @param {RegexScriptParams} params The parameters to use for the regex filter
455 * @returns {string} The filtered string
456 */
457function filterString(rawString, trimStrings, { characterOverride } = {}) {
458 let finalString = rawString;
459 trimStrings.forEach((trimString) => {
460 const subTrimString = substituteParams(trimString, { name2Override: characterOverride });
461 finalString = finalString.replaceAll(subTrimString, '');
462 });
463
464 return finalString;
465}