allow char scpoed regex (#2271)

* Update engine.js to allow char scpoed regex

no ui because i'm not good at it, but works.

* add typedef

* update

* little fix

* Rework scoped scripts UI

* Add locale attributes

* Purge allowance on delete

* add d&d for `saved_scoped_scripts`

* better code

* Save settings on regex scope toggle

* Fix reordering logic

* Fix scoped setter

* Add unique ids for regex scripts

* Wording

* Reload chat after deleting scripts

* Reload chat after toggling scoped regex

---------

Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>
This commit is contained in:
steve green
2024-05-26 22:19:00 +08:00
committed by GitHub
parent 31f4a34f5a
commit 00fc40408a
11 changed files with 423 additions and 78 deletions

View File

@ -68,6 +68,7 @@
* @property {number} depth_prompt.depth - The level of detail or nuance targeted by the prompt. * @property {number} depth_prompt.depth - The level of detail or nuance targeted by the prompt.
* @property {string} depth_prompt.prompt - The actual prompt text used for deeper character interaction. * @property {string} depth_prompt.prompt - The actual prompt text used for deeper character interaction.
* @property {"system" | "user" | "assistant"} depth_prompt.role - The role the character takes on during the prompted interaction (system, user, or assistant). * @property {"system" | "user" | "assistant"} depth_prompt.role - The role the character takes on during the prompted interaction (system, user, or assistant).
* @property {RegexScriptData[]} regex_scripts - Custom regex scripts for the character.
* // Non-standard extensions added by external tools * // Non-standard extensions added by external tools
* @property {string} [pygmalion_id] - The unique identifier assigned to the character by the Pygmalion.chat. * @property {string} [pygmalion_id] - The unique identifier assigned to the character by the Pygmalion.chat.
* @property {string} [github_repo] - The gitHub repository associated with the character. * @property {string} [github_repo] - The gitHub repository associated with the character.
@ -76,6 +77,23 @@
* @property {{source: string[]}} [risuai] - The RisuAI-specific data associated with the character. * @property {{source: string[]}} [risuai] - The RisuAI-specific data associated with the character.
*/ */
/**
* @typedef {object} RegexScriptData
* @property {string} id - UUID of the script
* @property {string} scriptName - The name of the script
* @property {string} findRegex - The regex to find
* @property {string} replaceString - The string to replace
* @property {string[]} trimStrings - The strings to trim
* @property {number[]} placement - The placement of the script
* @property {boolean} disabled - Whether the script is disabled
* @property {boolean} markdownOnly - Whether the script only applies to Markdown
* @property {boolean} promptOnly - Whether the script only applies to prompts
* @property {boolean} runOnEdit - Whether the script runs on edit
* @property {boolean} substituteRegex - Whether the regex should be substituted
* @property {number} minDepth - The minimum depth
* @property {number} maxDepth - The maximum depth
*/
/** /**
* @typedef {object} v1CharData * @typedef {object} v1CharData
* @property {string} name - the name of the character * @property {string} name - the name of the character

View File

@ -122,7 +122,9 @@ const extension_settings = {
custom: [], custom: [],
}, },
dice: {}, dice: {},
/** @type {import('./char-data.js').RegexScriptData[]} */
regex: [], regex: [],
character_allowed_regex: [],
tts: {}, tts: {},
sd: { sd: {
prompts: {}, prompts: {},

View File

@ -1,24 +1,52 @@
<div class="regex_settings"> <div class="regex_settings">
<div class="inline-drawer"> <div class="inline-drawer">
<div class="inline-drawer-toggle inline-drawer-header"> <div class="inline-drawer-toggle inline-drawer-header">
<b>Regex</b> <b data-i18n="ext_regex_title">
Regex
</b>
<div class="inline-drawer-icon fa-solid fa-circle-chevron-down down"></div> <div class="inline-drawer-icon fa-solid fa-circle-chevron-down down"></div>
</div> </div>
<div class="inline-drawer-content"> <div class="inline-drawer-content">
<div class="flex-container"> <div class="flex-container">
<div id="open_regex_editor" class="menu_button"> <div id="open_regex_editor" class="menu_button menu_button_icon" title="New global regex script">
<i class="fa-solid fa-pen-to-square"></i> <i class="fa-solid fa-pen-to-square"></i>
<span data-i18n="ext_regex_open_editor">Open Editor</span> <small data-i18n="ext_regex_new_global_script">+ Global</small>
</div> </div>
<div id="import_regex" class="menu_button"> <div id="open_scoped_editor" class="menu_button menu_button_icon" title="New scoped regex script">
<i class="fa-solid fa-address-card"></i>
<small data-i18n="ext_regex_new_scoped_script">+ Scoped</small>
</div>
<div id="import_regex" class="menu_button menu_button_icon">
<i class="fa-solid fa-file-import"></i> <i class="fa-solid fa-file-import"></i>
<span data-i18n="ext_regex_import_script">Import Script</span> <small data-i18n="ext_regex_import_script">Import</small>
</div> </div>
<input type="file" id="import_regex_file" hidden accept="*.json" multiple /> <input type="file" id="import_regex_file" hidden accept="*.json" multiple />
</div> </div>
<hr /> <hr />
<label data-i18n="ext_regex_saved_scripts">Saved Scripts</label> <div id="global_scripts_block" class="padding5">
<div>
<strong data-i18n="ext_regex_global_scripts">Global Scripts</strong>
</div>
<small data-i18n="ext_regex_global_scripts_desc">
Available for all characters. Saved to local settings.
</small>
<div id="saved_regex_scripts" class="flex-container regex-script-container flexFlowColumn"></div> <div id="saved_regex_scripts" class="flex-container regex-script-container flexFlowColumn"></div>
</div> </div>
<hr />
<div id="scoped_scripts_block" class="padding5">
<div class="flex-container alignItemsBaseline">
<strong class="flex1" data-i18n="ext_regex_scoped_scripts">Scoped Scripts</strong>
<label id="toggle_scoped_regex" class="checkbox flex-container" for="regex_scoped_toggle">
<input type="checkbox" id="regex_scoped_toggle" class="enable_scoped" />
<span class="regex-toggle-on fa-solid fa-toggle-on fa-lg" title="Disallow using scoped regex"></span>
<span class="regex-toggle-off fa-solid fa-toggle-off fa-lg" title="Allow using scoped regex"></span>
</label>
</div>
<small data-i18n="ext_regex_scoped_scripts_desc">
Only available for this character. Saved to the card data.
</small>
<div id="saved_scoped_scripts" class="flex-container regex-script-container flexFlowColumn"></div>
</div>
</div>
</div> </div>
</div> </div>

View File

@ -0,0 +1,5 @@
<div>
<h3>This character has embedded regex script(s).</h3>
<h3>Would you like to allow using them?</h3>
<div class="m-b-1">If you want to do it later, select "Regex" from the extensions menu.</div>
</div>

View File

@ -1,4 +1,4 @@
import { substituteParams } from '../../../script.js'; import { characters, substituteParams, this_chid } from '../../../script.js';
import { extension_settings } from '../../extensions.js'; import { extension_settings } from '../../extensions.js';
import { regexFromString } from '../../utils.js'; import { regexFromString } from '../../utils.js';
export { export {
@ -22,6 +22,22 @@ const regex_placement = {
WORLD_INFO: 5, WORLD_INFO: 5,
}; };
function getScopedRegex() {
const isAllowed = extension_settings?.character_allowed_regex?.includes(characters?.[this_chid]?.avatar);
if (!isAllowed) {
return [];
}
const scripts = characters[this_chid]?.data?.extensions?.regex_scripts;
if (!Array.isArray(scripts)) {
return [];
}
return scripts;
}
/** /**
* Parent function to fetch a regexed version of a raw string * Parent function to fetch a regexed version of a raw string
* @param {string} rawString The raw string to be regexed * @param {string} rawString The raw string to be regexed
@ -42,7 +58,8 @@ function getRegexedString(rawString, placement, { characterOverride, isMarkdown,
return finalString; return finalString;
} }
extension_settings.regex.forEach((script) => { const allRegex = [...(extension_settings.regex ?? []), ...(getScopedRegex() ?? [])];
allRegex.forEach((script) => {
if ( if (
// Script applies to Markdown and input is Markdown // Script applies to Markdown and input is Markdown
(script.markdownOnly && isMarkdown) || (script.markdownOnly && isMarkdown) ||
@ -95,7 +112,7 @@ function runRegexScript(regexScript, rawString, { characterOverride } = {}) {
} }
// Run replacement. Currently does not support the Overlay strategy // Run replacement. Currently does not support the Overlay strategy
newString = rawString.replace(findRegex, function(match) { newString = rawString.replace(findRegex, function (match) {
const args = [...arguments]; const args = [...arguments];
const replaceString = regexScript.replaceString.replace(/{{match}}/gi, '$0'); const replaceString = regexScript.replaceString.replace(/{{match}}/gi, '$0');
const replaceWithGroups = replaceString.replaceAll(/\$(\d+)/g, (_, num) => { const replaceWithGroups = replaceString.replaceAll(/\$(\d+)/g, (_, num) => {

View File

@ -0,0 +1,19 @@
<div>
<h3 data-i18n="ext_regex_import_target">
Import To:
</h3>
<div class="flex-container flexFlowColumn wide100p padding10 justifyLeft">
<label for="regex_import_target_global">
<input type="radio" name="regex_import_target" id="regex_import_target_global" value="global" checked />
<span data-i18n="ext_regex_global_scripts">
Global Scripts
</span>
</label>
<label for="regex_import_target_scoped">
<input type="radio" name="regex_import_target" id="regex_import_target_scoped" value="scoped" />
<span data-i18n="ext_regex_scoped_scripts">
Scoped Scripts
</span>
</label>
</div>
</div>

View File

@ -1,5 +1,6 @@
import { callPopup, getCurrentChatId, reloadCurrentChat, saveSettingsDebounced } from '../../../script.js'; import { callPopup, characters, eventSource, event_types, getCurrentChatId, reloadCurrentChat, saveSettingsDebounced, this_chid } from '../../../script.js';
import { extension_settings, renderExtensionTemplateAsync } from '../../extensions.js'; import { extension_settings, renderExtensionTemplateAsync, writeExtensionField } from '../../extensions.js';
import { selected_group } from '../../group-chats.js';
import { SlashCommand } from '../../slash-commands/SlashCommand.js'; import { SlashCommand } from '../../slash-commands/SlashCommand.js';
import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js'; import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from '../../slash-commands/SlashCommandArgument.js';
import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js'; import { SlashCommandParser } from '../../slash-commands/SlashCommandParser.js';
@ -7,8 +8,21 @@ import { download, getFileText, getSortableDelay, uuidv4 } from '../../utils.js'
import { resolveVariable } from '../../variables.js'; import { resolveVariable } from '../../variables.js';
import { regex_placement, runRegexScript } from './engine.js'; import { regex_placement, runRegexScript } from './engine.js';
async function saveRegexScript(regexScript, existingScriptIndex) { /**
* Saves a regex script to the extension settings or character data.
* @param {import('../../char-data.js').RegexScriptData} regexScript
* @param {number} existingScriptIndex Index of the existing script
* @param {boolean} isScoped Is the script scoped to a character?
* @returns {Promise<void>}
*/
async function saveRegexScript(regexScript, existingScriptIndex, isScoped) {
// If not editing // If not editing
const array = (isScoped ? characters[this_chid]?.data?.extensions?.regex_scripts : extension_settings.regex) ?? [];
// Assign a UUID if it doesn't exist
if (!regexScript.id) {
regexScript.id = uuidv4();
}
// Is the script name undefined or empty? // Is the script name undefined or empty?
if (!regexScript.scriptName) { if (!regexScript.scriptName) {
@ -16,22 +30,6 @@ async function saveRegexScript(regexScript, existingScriptIndex) {
return; return;
} }
if (existingScriptIndex === -1) {
// Does the script name already exist?
if (extension_settings.regex.find((e) => e.scriptName === regexScript.scriptName)) {
toastr.error(`Could not save regex script: A script with name ${regexScript.scriptName} already exists.`);
return;
}
} else {
// Does the script name already exist somewhere else?
// (If this fails, make it a .filter().map() to index array)
const foundIndex = extension_settings.regex.findIndex((e) => e.scriptName === regexScript.scriptName);
if (foundIndex !== existingScriptIndex && foundIndex !== -1) {
toastr.error(`Could not save regex script: A script with name ${regexScript.scriptName} already exists.`);
return;
}
}
// Is a find regex present? // Is a find regex present?
if (regexScript.findRegex.length === 0) { if (regexScript.findRegex.length === 0) {
toastr.warning('This regex script will not work, but was saved anyway: A find regex isn\'t present.'); toastr.warning('This regex script will not work, but was saved anyway: A find regex isn\'t present.');
@ -43,9 +41,18 @@ async function saveRegexScript(regexScript, existingScriptIndex) {
} }
if (existingScriptIndex !== -1) { if (existingScriptIndex !== -1) {
extension_settings.regex[existingScriptIndex] = regexScript; array[existingScriptIndex] = regexScript;
} else { } else {
extension_settings.regex.push(regexScript); array.push(regexScript);
}
if (isScoped) {
await writeExtensionField(this_chid, 'regex_scripts', array);
// Add the character to the allowed list
if (!extension_settings.character_allowed_regex.includes(characters[this_chid].avatar)) {
extension_settings.character_allowed_regex.push(characters[this_chid].avatar);
}
} }
saveSettingsDebounced(); saveSettingsDebounced();
@ -58,12 +65,16 @@ async function saveRegexScript(regexScript, existingScriptIndex) {
} }
} }
async function deleteRegexScript({ existingId }) { async function deleteRegexScript({ id, isScoped }) {
let scriptName = $(`#${existingId}`).find('.regex_script_name').text(); const array = (isScoped ? characters[this_chid]?.data?.extensions?.regex_scripts : extension_settings.regex) ?? [];
const existingScriptIndex = extension_settings.regex.findIndex((script) => script.scriptName === scriptName); const existingScriptIndex = array.findIndex((script) => script.id === id);
if (!existingScriptIndex || existingScriptIndex !== -1) { if (!existingScriptIndex || existingScriptIndex !== -1) {
extension_settings.regex.splice(existingScriptIndex, 1); array.splice(existingScriptIndex, 1);
if (isScoped) {
await writeExtensionField(this_chid, 'regex_scripts', array);
}
saveSettingsDebounced(); saveSettingsDebounced();
await loadRegexScripts(); await loadRegexScripts();
@ -72,19 +83,32 @@ async function deleteRegexScript({ existingId }) {
async function loadRegexScripts() { async function loadRegexScripts() {
$('#saved_regex_scripts').empty(); $('#saved_regex_scripts').empty();
$('#saved_scoped_scripts').empty();
const scriptTemplate = $(await renderExtensionTemplateAsync('regex', 'scriptTemplate')); const scriptTemplate = $(await renderExtensionTemplateAsync('regex', 'scriptTemplate'));
extension_settings.regex.forEach((script) => { /**
* Renders a script to the UI.
* @param {string} container Container to render the script to
* @param {import('../../char-data.js').RegexScriptData} script Script data
* @param {boolean} isScoped Script is scoped to a character
* @param {number} index Index of the script in the array
*/
function renderScript(container, script, isScoped, index) {
// Have to clone here // Have to clone here
const scriptHtml = scriptTemplate.clone(); const scriptHtml = scriptTemplate.clone();
scriptHtml.attr('id', uuidv4()); const save = () => saveRegexScript(script, index, isScoped);
if (!script.id) {
script.id = uuidv4();
}
scriptHtml.attr('id', script.id);
scriptHtml.find('.regex_script_name').text(script.scriptName); scriptHtml.find('.regex_script_name').text(script.scriptName);
scriptHtml.find('.disable_regex').prop('checked', script.disabled ?? false) scriptHtml.find('.disable_regex').prop('checked', script.disabled ?? false)
.on('input', function () { .on('input', async function () {
script.disabled = !!$(this).prop('checked'); script.disabled = !!$(this).prop('checked');
reloadCurrentChat(); await save();
saveSettingsDebounced();
}); });
scriptHtml.find('.regex-toggle-on').on('click', function () { scriptHtml.find('.regex-toggle-on').on('click', function () {
scriptHtml.find('.disable_regex').prop('checked', true).trigger('input'); scriptHtml.find('.disable_regex').prop('checked', true).trigger('input');
@ -93,7 +117,37 @@ async function loadRegexScripts() {
scriptHtml.find('.disable_regex').prop('checked', false).trigger('input'); scriptHtml.find('.disable_regex').prop('checked', false).trigger('input');
}); });
scriptHtml.find('.edit_existing_regex').on('click', async function () { scriptHtml.find('.edit_existing_regex').on('click', async function () {
await onRegexEditorOpenClick(scriptHtml.attr('id')); await onRegexEditorOpenClick(scriptHtml.attr('id'), isScoped);
});
scriptHtml.find('.move_to_global').on('click', async function () {
const confirm = await callPopup('Are you sure you want to move this regex script to global?', 'confirm');
if (!confirm) {
return;
}
await deleteRegexScript({ id: script.id, isScoped: true });
await saveRegexScript(script, -1, false);
});
scriptHtml.find('.move_to_scoped').on('click', async function () {
if (this_chid === undefined) {
toastr.error('No character selected.');
return;
}
if (selected_group) {
toastr.error('Cannot edit scoped scripts in group chats.');
return;
}
const confirm = await callPopup('Are you sure you want to move this regex script to scoped?', 'confirm');
if (!confirm) {
return;
}
await deleteRegexScript({ id: script.id, isScoped: false });
await saveRegexScript(script, -1, true);
}); });
scriptHtml.find('.export_regex').on('click', async function () { scriptHtml.find('.export_regex').on('click', async function () {
const fileName = `${script.scriptName.replace(/[\s.<>:"/\\|?*\x00-\x1F\x7F]/g, '_').toLowerCase()}.json`; const fileName = `${script.scriptName.replace(/[\s.<>:"/\\|?*\x00-\x1F\x7F]/g, '_').toLowerCase()}.json`;
@ -107,23 +161,36 @@ async function loadRegexScripts() {
return; return;
} }
await deleteRegexScript({ existingId: scriptHtml.attr('id') }); await deleteRegexScript({ id: script.id, isScoped });
await reloadCurrentChat();
}); });
$('#saved_regex_scripts').append(scriptHtml); $(container).append(scriptHtml);
}); }
extension_settings?.regex?.forEach((script, index, array) => renderScript('#saved_regex_scripts', script, false, index, array));
characters[this_chid]?.data?.extensions?.regex_scripts?.forEach((script, index, array) => renderScript('#saved_scoped_scripts', script, true, index, array));
const isAllowed = extension_settings?.character_allowed_regex?.includes(characters?.[this_chid]?.avatar);
$('#regex_scoped_toggle').prop('checked', isAllowed);
} }
async function onRegexEditorOpenClick(existingId) { /**
* Opens the regex editor.
* @param {string|boolean} existingId Existing ID
* @param {boolean} isScoped Is the script scoped to a character?
* @returns {Promise<void>}
*/
async function onRegexEditorOpenClick(existingId, isScoped) {
const editorHtml = $(await renderExtensionTemplateAsync('regex', 'editor')); const editorHtml = $(await renderExtensionTemplateAsync('regex', 'editor'));
const array = (isScoped ? characters[this_chid]?.data?.extensions?.regex_scripts : extension_settings.regex) ?? [];
// If an ID exists, fill in all the values // If an ID exists, fill in all the values
let existingScriptIndex = -1; let existingScriptIndex = -1;
if (existingId) { if (existingId) {
const existingScriptName = $(`#${existingId}`).find('.regex_script_name').text(); existingScriptIndex = array.findIndex((script) => script.id === existingId);
existingScriptIndex = extension_settings.regex.findIndex((script) => script.scriptName === existingScriptName);
if (existingScriptIndex !== -1) { if (existingScriptIndex !== -1) {
const existingScript = extension_settings.regex[existingScriptIndex]; const existingScript = array[existingScriptIndex];
if (existingScript.scriptName) { if (existingScript.scriptName) {
editorHtml.find('.regex_script_name').val(existingScript.scriptName); editorHtml.find('.regex_script_name').val(existingScript.scriptName);
} else { } else {
@ -173,6 +240,7 @@ async function onRegexEditorOpenClick(existingId) {
} }
const testScript = { const testScript = {
id: uuidv4(),
scriptName: editorHtml.find('.regex_script_name').val(), scriptName: editorHtml.find('.regex_script_name').val(),
findRegex: editorHtml.find('.find_regex').val(), findRegex: editorHtml.find('.find_regex').val(),
replaceString: editorHtml.find('.regex_replace_string').val(), replaceString: editorHtml.find('.regex_replace_string').val(),
@ -189,9 +257,10 @@ async function onRegexEditorOpenClick(existingId) {
const popupResult = await callPopup(editorHtml, 'confirm', undefined, { okButton: 'Save' }); const popupResult = await callPopup(editorHtml, 'confirm', undefined, { okButton: 'Save' });
if (popupResult) { if (popupResult) {
const newRegexScript = { const newRegexScript = {
scriptName: editorHtml.find('.regex_script_name').val(), id: existingId ? String(existingId) : uuidv4(),
findRegex: editorHtml.find('.find_regex').val(), scriptName: String(editorHtml.find('.regex_script_name').val()),
replaceString: editorHtml.find('.regex_replace_string').val(), findRegex: String(editorHtml.find('.find_regex').val()),
replaceString: String(editorHtml.find('.regex_replace_string').val()),
trimStrings: editorHtml.find('.regex_trim_strings').val().split('\n').filter((e) => e.length !== 0) || [], trimStrings: editorHtml.find('.regex_trim_strings').val().split('\n').filter((e) => e.length !== 0) || [],
placement: placement:
editorHtml editorHtml
@ -209,7 +278,7 @@ async function onRegexEditorOpenClick(existingId) {
maxDepth: parseInt(String(editorHtml.find('input[name="max_depth"]').val())), maxDepth: parseInt(String(editorHtml.find('input[name="max_depth"]').val())),
}; };
saveRegexScript(newRegexScript, existingScriptIndex); saveRegexScript(newRegexScript, existingScriptIndex, isScoped);
} }
} }
@ -220,6 +289,11 @@ function migrateSettings() {
// Current: If MD Display is present in placement, remove it and add new placements/MD option // Current: If MD Display is present in placement, remove it and add new placements/MD option
extension_settings.regex.forEach((script) => { extension_settings.regex.forEach((script) => {
if (!script.id) {
script.id = uuidv4();
performSave = true;
}
if (script.placement.includes(regex_placement.MD_DISPLAY)) { if (script.placement.includes(regex_placement.MD_DISPLAY)) {
script.placement = script.placement.length === 1 ? script.placement = script.placement.length === 1 ?
Object.values(regex_placement).filter((e) => e !== regex_placement.MD_DISPLAY) : Object.values(regex_placement).filter((e) => e !== regex_placement.MD_DISPLAY) :
@ -242,6 +316,11 @@ function migrateSettings() {
} }
}); });
if (!extension_settings.character_allowed_regex) {
extension_settings.character_allowed_regex = [];
performSave = true;
}
if (performSave) { if (performSave) {
saveSettingsDebounced(); saveSettingsDebounced();
} }
@ -260,8 +339,9 @@ function runRegexCallback(args, value) {
} }
const scriptName = String(resolveVariable(args.name)); const scriptName = String(resolveVariable(args.name));
const scripts = [...(extension_settings.regex ?? []), ...(characters[this_chid]?.data?.extensions?.regex_scripts ?? [])];
for (const script of extension_settings.regex) { for (const script of scripts) {
if (String(script.scriptName).toLowerCase() === String(scriptName).toLowerCase()) { if (String(script.scriptName).toLowerCase() === String(scriptName).toLowerCase()) {
if (script.disabled) { if (script.disabled) {
toastr.warning(`Regex script "${scriptName}" is disabled.`); toastr.warning(`Regex script "${scriptName}" is disabled.`);
@ -280,8 +360,9 @@ function runRegexCallback(args, value) {
/** /**
* Performs the import of the regex file. * Performs the import of the regex file.
* @param {File} file Input file * @param {File} file Input file
* @param {boolean} isScoped Is the script scoped to a character?
*/ */
async function onRegexImportFileChange(file) { async function onRegexImportFileChange(file, isScoped) {
if (!file) { if (!file) {
toastr.error('No file provided.'); toastr.error('No file provided.');
return; return;
@ -294,7 +375,15 @@ async function onRegexImportFileChange(file) {
throw new Error('No script name provided.'); throw new Error('No script name provided.');
} }
extension_settings.regex.push(regexScript); // Assign a new UUID
regexScript.id = uuidv4();
const array = (isScoped ? characters[this_chid]?.data?.extensions?.regex_scripts : extension_settings.regex) ?? [];
array.push(regexScript);
if (isScoped) {
await writeExtensionField(this_chid, 'regex_scripts', array);
}
saveSettingsDebounced(); saveSettingsDebounced();
await loadRegexScripts(); await loadRegexScripts();
@ -306,6 +395,47 @@ async function onRegexImportFileChange(file) {
} }
} }
function purgeEmbeddedRegexScripts( { character }){
const avatar = character?.avatar;
if (avatar && extension_settings.character_allowed_regex?.includes(avatar)) {
const index = extension_settings.character_allowed_regex.indexOf(avatar);
if (index !== -1) {
extension_settings.character_allowed_regex.splice(index, 1);
saveSettingsDebounced();
}
}
}
async function checkEmbeddedRegexScripts() {
const chid = this_chid;
if (chid !== undefined && !selected_group) {
const avatar = characters[chid]?.avatar;
const scripts = characters[chid]?.data?.extensions?.regex_scripts;
if (Array.isArray(scripts) && scripts.length > 0) {
if (avatar && !extension_settings.character_allowed_regex.includes(avatar)) {
const checkKey = `AlertRegex_${characters[chid].avatar}`;
if (!localStorage.getItem(checkKey)) {
localStorage.setItem(checkKey, 'true');
const template = await renderExtensionTemplateAsync('regex', 'embeddedScripts', {});
const result = await callPopup(template, 'confirm', '', { okButton: 'Yes' });
if (result) {
extension_settings.character_allowed_regex.push(avatar);
await reloadCurrentChat();
saveSettingsDebounced();
}
}
}
}
}
loadRegexScripts();
}
// Workaround for loading in sequence with other extensions // Workaround for loading in sequence with other extensions
// NOTE: Always puts extension at the top of the list, but this is fine since it's static // NOTE: Always puts extension at the top of the list, but this is fine since it's static
jQuery(async () => { jQuery(async () => {
@ -321,12 +451,32 @@ jQuery(async () => {
const settingsHtml = $(await renderExtensionTemplateAsync('regex', 'dropdown')); const settingsHtml = $(await renderExtensionTemplateAsync('regex', 'dropdown'));
$('#extensions_settings2').append(settingsHtml); $('#extensions_settings2').append(settingsHtml);
$('#open_regex_editor').on('click', function () { $('#open_regex_editor').on('click', function () {
onRegexEditorOpenClick(false); onRegexEditorOpenClick(false, false);
});
$('#open_scoped_editor').on('click', function () {
if (this_chid === undefined) {
toastr.error('No character selected.');
return;
}
if (selected_group) {
toastr.error('Cannot edit scoped scripts in group chats.');
return;
}
onRegexEditorOpenClick(false, true);
}); });
$('#import_regex_file').on('change', async function () { $('#import_regex_file').on('change', async function () {
let target = 'global';
const template = $(await renderExtensionTemplateAsync('regex', 'importTarget'));
template.find('#regex_import_target_global').on('input', () => target = 'global');
template.find('#regex_import_target_scoped').on('input', () => target = 'scoped');
await callPopup(template, 'text');
const inputElement = this instanceof HTMLInputElement && this; const inputElement = this instanceof HTMLInputElement && this;
for (const file of inputElement.files) { for (const file of inputElement.files) {
await onRegexImportFileChange(file); await onRegexImportFileChange(file, target === 'scoped');
} }
inputElement.value = ''; inputElement.value = '';
}); });
@ -334,30 +484,75 @@ jQuery(async () => {
$('#import_regex_file').trigger('click'); $('#import_regex_file').trigger('click');
}); });
$('#saved_regex_scripts').sortable({ let sortableDatas = [
{
selector: '#saved_regex_scripts',
setter: x => extension_settings.regex = x,
getter: () => extension_settings.regex ?? [],
},
{
selector: '#saved_scoped_scripts',
setter: x => writeExtensionField(this_chid, 'regex_scripts', x),
getter: () => characters[this_chid]?.data?.extensions?.regex_scripts ?? [],
},
];
for (const { selector, setter, getter } of sortableDatas) {
$(selector).sortable({
delay: getSortableDelay(), delay: getSortableDelay(),
stop: function () { stop: async function () {
let newScripts = []; const oldScripts = getter();
$('#saved_regex_scripts').children().each(function () { const newScripts = [];
const scriptName = $(this).find('.regex_script_name').text(); $(selector).children().each(function () {
const existingScript = extension_settings.regex.find((e) => e.scriptName === scriptName); const id = $(this).attr('id');
const existingScript = oldScripts.find((e) => e.id === id);
if (existingScript) { if (existingScript) {
newScripts.push(existingScript); newScripts.push(existingScript);
} }
}); });
extension_settings.regex = newScripts; await setter(newScripts);
saveSettingsDebounced(); saveSettingsDebounced();
console.debug('Regex scripts reordered'); console.debug(`Regex scripts in ${selector} reordered`);
// TODO: Maybe reload regex scripts after move await loadRegexScripts();
}, },
}); });
}
$('#regex_scoped_toggle').on('input', function () {
if (this_chid === undefined) {
toastr.error('No character selected.');
return;
}
if (selected_group) {
toastr.error('Cannot edit scoped scripts in group chats.');
return;
}
const isEnable = !!$(this).prop('checked');
const avatar = characters[this_chid].avatar;
if (isEnable) {
if (!extension_settings.character_allowed_regex.includes(avatar)) {
extension_settings.character_allowed_regex.push(avatar);
}
} else {
const index = extension_settings.character_allowed_regex.indexOf(avatar);
if (index !== -1) {
extension_settings.character_allowed_regex.splice(index, 1);
}
}
saveSettingsDebounced();
reloadCurrentChat();
});
await loadRegexScripts(); await loadRegexScripts();
$('#saved_regex_scripts').sortable('enable'); $('#saved_regex_scripts').sortable('enable');
SlashCommandParser.addCommandObject(SlashCommand.fromProps({ name: 'regex', SlashCommandParser.addCommandObject(SlashCommand.fromProps({
name: 'regex',
callback: runRegexCallback, callback: runRegexCallback,
returns: 'replaced text', returns: 'replaced text',
namedArgumentList: [ namedArgumentList: [
@ -373,4 +568,6 @@ jQuery(async () => {
helpString: 'Runs a Regex extension script by name on the provided string. The script must be enabled.', helpString: 'Runs a Regex extension script by name on the provided string. The script must be enabled.',
})); }));
eventSource.on(event_types.CHAT_CHANGED, checkEmbeddedRegexScripts);
eventSource.on(event_types.CHARACTER_DELETED, purgeEmbeddedRegexScripts);
}); });

View File

@ -10,6 +10,12 @@
<div class="edit_existing_regex menu_button" data-i18n="[title]ext_regex_edit_script" title="Edit script"> <div class="edit_existing_regex menu_button" data-i18n="[title]ext_regex_edit_script" title="Edit script">
<i class="fa-solid fa-pencil"></i> <i class="fa-solid fa-pencil"></i>
</div> </div>
<div class="move_to_global menu_button" data-i18n="[title]ext_regex_move_to_global" title="Move to global scripts">
<i class="fa-solid fa-arrow-up"></i>
</div>
<div class="move_to_scoped menu_button" data-i18n="[title]ext_regex_move_to_scoped" title="Move to scoped scripts">
<i class="fa-solid fa-arrow-down"></i>
</div>
<div class="export_regex menu_button" data-i18n="[title]ext_regex_export_script" title="Export script"> <div class="export_regex menu_button" data-i18n="[title]ext_regex_export_script" title="Export script">
<i class="fa-solid fa-file-export"></i> <i class="fa-solid fa-file-export"></i>
</div> </div>

View File

@ -14,6 +14,47 @@
margin-bottom: 10px; margin-bottom: 10px;
} }
.regex-script-container:empty::after {
content: "No scripts found";
font-size: 0.95em;
opacity: 0.7;
display: block;
text-align: center;
}
#scoped_scripts_block {
opacity: 1;
transition: opacity 0.2s ease-in-out;
}
#scoped_scripts_block .move_to_scoped {
display: none;
}
#global_scripts_block .move_to_global {
display: none;
}
#scoped_scripts_block:not(:has(#regex_scoped_toggle:checked)) {
opacity: 0.5;
}
.enable_scoped:checked ~ .regex-toggle-on {
display: block;
}
.enable_scoped:checked ~ .regex-toggle-off {
display: none;
}
.enable_scoped:not(:checked) ~ .regex-toggle-on {
display: none;
}
.enable_scoped:not(:checked) ~ .regex-toggle-off {
display: block;
}
.regex-script-label { .regex-script-label {
align-items: center; align-items: center;
border: 1px solid var(--SmartThemeBorderColor); border: 1px solid var(--SmartThemeBorderColor);
@ -23,7 +64,13 @@
margin-bottom: 1px; margin-bottom: 1px;
} }
input.disable_regex { .regex-script-label:has(.disable_regex:checked) .regex_script_name {
text-decoration: line-through;
filter: grayscale(0.5);
}
input.disable_regex,
input.enable_scoped {
display: none !important; display: none !important;
} }
@ -31,6 +78,12 @@ input.disable_regex {
cursor: pointer; cursor: pointer;
opacity: 0.5; opacity: 0.5;
filter: grayscale(0.5); filter: grayscale(0.5);
transition: opacity 0.2s ease-in-out;
}
.regex-toggle-off:hover {
opacity: 1;
filter: none;
} }
.regex-toggle-on { .regex-toggle-on {

View File

@ -3581,7 +3581,7 @@ export async function importWorldInfo(file) {
return; return;
} }
const worldName = file.name.substr(0, file.name.lastIndexOf(".")); const worldName = file.name.substr(0, file.name.lastIndexOf('.'));
const sanitizedWorldName = await getSanitizedFilename(worldName); const sanitizedWorldName = await getSanitizedFilename(worldName);
const allowed = await checkOverwriteExistingData('World Info', world_names, sanitizedWorldName, { interactive: true, actionName: 'Import', deleteAction: (existingName) => deleteWorldInfo(existingName) }); const allowed = await checkOverwriteExistingData('World Info', world_names, sanitizedWorldName, { interactive: true, actionName: 'Import', deleteAction: (existingName) => deleteWorldInfo(existingName) });
if (!allowed) { if (!allowed) {