diff --git a/public/scripts/utils.js b/public/scripts/utils.js index f3db706e6..2484e0e8d 100644 --- a/public/scripts/utils.js +++ b/public/scripts/utils.js @@ -1469,23 +1469,47 @@ export function flashHighlight(element, timespan = 2000) { setTimeout(() => element.removeClass('flash animated'), timespan); } +/** + * A common base function for case-insensitive and accent-insensitive string comparisons. + * + * @param {string} a - The first string to compare. + * @param {string} b - The second string to compare. + * @param {(a:string,b:string)=>boolean} comparisonFunction - The function to use for the comparison. + * @returns {*} - The result of the comparison. + */ +export function compareIgnoreCaseAndAccents(a, b, comparisonFunction) { + if (!a || !b) return comparisonFunction(a, b); // Return the comparison result if either string is empty + + // Normalize and remove diacritics, then convert to lower case + const normalizedA = a.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase(); + const normalizedB = b.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase(); + + // Check if the normalized strings are equal + return comparisonFunction(normalizedA, normalizedB); +} + /** * Performs a case-insensitive and accent-insensitive substring search. * This function normalizes the strings to remove diacritical marks and converts them to lowercase to ensure the search is insensitive to case and accents. * - * @param {string} text - The text in which to search for the substring. - * @param {string} searchTerm - The substring to search for in the text. - * @returns {boolean} - Returns true if the searchTerm is found within the text, otherwise returns false. + * @param {string} text - The text in which to search for the substring + * @param {string} searchTerm - The substring to search for in the text + * @returns {boolean} true if the searchTerm is found within the text, otherwise returns false */ export function includesIgnoreCaseAndAccents(text, searchTerm) { - if (!text || !searchTerm) return false; // Return false if either string is empty + return compareIgnoreCaseAndAccents(text, searchTerm, (a, b) => a?.includes(b) === true); +} - // Normalize and remove diacritics, then convert to lower case - const normalizedText = text.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase(); - const normalizedSearchTerm = searchTerm.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase(); - - // Check if the normalized text includes the normalized search term - return normalizedText.includes(normalizedSearchTerm); +/** + * Performs a case-insensitive and accent-insensitive equality check. + * This function normalizes the strings to remove diacritical marks and converts them to lowercase to ensure the search is insensitive to case and accents. + * + * @param {string} a - The first string to compare + * @param {string} b - The second string to compare + * @returns {boolean} true if the strings are equal, otherwise returns false + */ +export function equalsIgnoreCaseAndAccents(a, b) { + return compareIgnoreCaseAndAccents(a, b, (a, b) => a === b); } /** diff --git a/public/scripts/world-info.js b/public/scripts/world-info.js index 723ea94c9..a553fffd8 100644 --- a/public/scripts/world-info.js +++ b/public/scripts/world-info.js @@ -1,5 +1,5 @@ import { saveSettings, callPopup, substituteParams, getRequestHeaders, chat_metadata, this_chid, characters, saveCharacterDebounced, menu_type, eventSource, event_types, getExtensionPromptByName, saveMetadata, getCurrentChatId, extension_prompt_roles } from '../script.js'; -import { download, debounce, initScrollHeight, resetScrollHeight, parseJsonFile, extractDataFromPng, getFileBuffer, getCharaFilename, getSortableDelay, escapeRegex, PAGINATION_TEMPLATE, navigation_option, waitUntilCondition, isTrueBoolean, setValueByPath, flashHighlight, select2ModifyOptions, getSelect2OptionId, dynamicSelect2DataViaAjax, highlightRegex, select2ChoiceClickSubscribe, isFalseBoolean } from './utils.js'; +import { download, debounce, initScrollHeight, resetScrollHeight, parseJsonFile, extractDataFromPng, getFileBuffer, getCharaFilename, getSortableDelay, escapeRegex, PAGINATION_TEMPLATE, navigation_option, waitUntilCondition, isTrueBoolean, setValueByPath, flashHighlight, select2ModifyOptions, getSelect2OptionId, dynamicSelect2DataViaAjax, highlightRegex, select2ChoiceClickSubscribe, isFalseBoolean, equalsIgnoreCaseAndAccents } from './utils.js'; import { extension_settings, getContext } from './extensions.js'; import { NOTE_MODULE_NAME, metadata_keys, shouldWIAddPrompt } from './authors-note.js'; import { isMobile } from './RossAscends-mods.js'; @@ -50,6 +50,7 @@ const WI_ENTRY_EDIT_TEMPLATE = $('#entry_edit_template .world_entry'); let world_info = {}; let selected_world_info = []; +/** @type {string[]} */ let world_names; let world_info_depth = 2; let world_info_min_activations = 0; // if > 0, will continue seeking chat until minimum world infos are activated @@ -2544,9 +2545,15 @@ async function renameWorldInfo(name, data) { } } +/** + * Deletes a world info with the given name + * + * @param {string} worldInfoName - The name of the world info to delete + * @returns {Promise} A promise that resolves to true if the world info was successfully deleted, false otherwise + */ async function deleteWorldInfo(worldInfoName) { if (!world_names.includes(worldInfoName)) { - return; + return false; } const response = await fetch('/api/worldinfo/delete', { @@ -2555,24 +2562,28 @@ async function deleteWorldInfo(worldInfoName) { body: JSON.stringify({ name: worldInfoName }), }); - if (response.ok) { - const existingWorldIndex = selected_world_info.findIndex((e) => e === worldInfoName); - if (existingWorldIndex !== -1) { - selected_world_info.splice(existingWorldIndex, 1); - saveSettingsDebounced(); - } + if (!response.ok) { + return false; + } - await updateWorldInfoList(); - $('#world_editor_select').trigger('change'); + const existingWorldIndex = selected_world_info.findIndex((e) => e === worldInfoName); + if (existingWorldIndex !== -1) { + selected_world_info.splice(existingWorldIndex, 1); + saveSettingsDebounced(); + } - if ($('#character_world').val() === worldInfoName) { - $('#character_world').val('').trigger('change'); - setWorldInfoButtonClass(undefined, false); - if (menu_type != 'create') { - saveCharacterDebounced(); - } + await updateWorldInfoList(); + $('#world_editor_select').trigger('change'); + + if ($('#character_world').val() === worldInfoName) { + $('#character_world').val('').trigger('change'); + setWorldInfoButtonClass(undefined, false); + if (menu_type != 'create') { + saveCharacterDebounced(); } } + + return true; } function getFreeWorldEntryUid(data) { @@ -2604,11 +2615,35 @@ function getFreeWorldName() { return undefined; } -async function createNewWorldInfo(worldInfoName) { +/** + * Creates a new world info/lorebook with the given name. + * Checks if a world with the same name already exists, providing a warning or optionally a user confirmation dialog. + * + * @param {string} worldInfoName - The name of the new world info + * @param {Object} options - Optional parameters + * @param {boolean} [options.interactive=false] - Whether to show a confirmation dialog when overwriting an existing world + * @returns {Promise} - True if the world info was successfully created, false otherwise + */ +async function createNewWorldInfo(worldInfoName, { interactive = false } = {}) { const worldInfoTemplate = { entries: {} }; if (!worldInfoName) { - return; + return false; + } + + const existingWorld = world_names.find(x => equalsIgnoreCaseAndAccents(x, worldInfoName)); + if (existingWorld) { + const overwrite = interactive ? await callPopup(`

Creating New World Info

A world with the same name already exists:
${existingWorld}

Do you want to overwrite it?`, 'confirm') : false; + + if (!overwrite) { + toastr.warning(`World creation cancelled. A world with the same name already exists:
${existingWorld}`, 'Creating New World Info', { escapeHtml: false }); + return false; + } + + toastr.info(`Overwriting Existing World Info:
${existingWorld}`, 'Creating New World Info', { escapeHtml: false }); + + // Manually delete, as we want to overwrite. The name might be slightly different so file name would not be the same. + await deleteWorldInfo(existingWorld); } await saveWorldInfo(worldInfoName, worldInfoTemplate, true); @@ -2620,6 +2655,8 @@ async function createNewWorldInfo(worldInfoName) { } else { hideWorldEditor(); } + + return true; } async function getCharacterLore() { @@ -3644,7 +3681,7 @@ jQuery(() => { const finalName = await callPopup('

Create a new World Info?

Enter a name for the new file:', 'input', tempName); if (finalName) { - await createNewWorldInfo(finalName); + await createNewWorldInfo(finalName, { interactive: true }); } });