diff --git a/public/script.js b/public/script.js index fb0fa5546..20bcc166c 100644 --- a/public/script.js +++ b/public/script.js @@ -154,6 +154,7 @@ import { isValidUrl, ensureImageFormatSupported, flashHighlight, + checkOverwriteExistingData, } from './scripts/utils.js'; import { debounce_timeout } from './scripts/constants.js'; @@ -6465,7 +6466,8 @@ export async function getChatsFromFiles(data, isGroupChat) { * @param {null|number} [characterId=null] - When set, the function will use this character id instead of this_chid. * * @returns {Promise} - An array containing metadata of all past chats of the character, sorted - * in descending order by file name. Returns `undefined` if the fetch request is unsuccessful. + * in descending order by file name. Returns an empty array if the fetch request is unsuccessful or the + * response is an object with an `error` property set to `true`. */ export async function getPastCharacterChats(characterId = null) { characterId = characterId ?? this_chid; @@ -6481,10 +6483,13 @@ export async function getPastCharacterChats(characterId = null) { return []; } - let data = await response.json(); - data = Object.values(data); - data = data.sort((a, b) => a['file_name'].localeCompare(b['file_name'])).reverse(); - return data; + const data = await response.json(); + if (typeof data === 'object' && data.error === true) { + return []; + } + + const chats = Object.values(data); + return chats.sort((a, b) => a['file_name'].localeCompare(b['file_name'])).reverse(); } /** @@ -8449,17 +8454,34 @@ function doCloseChat() { * @param {string} this_chid - The character ID to be deleted. * @param {boolean} delete_chats - Whether to delete chats or not. */ -export async function handleDeleteCharacter(popup_type, this_chid, delete_chats) { +async function handleDeleteCharacter(popup_type, this_chid, delete_chats) { if (popup_type !== 'del_ch' || !characters[this_chid]) { return; } - const avatar = characters[this_chid].avatar; - const name = characters[this_chid].name; - const pastChats = await getPastCharacterChats(); + await deleteCharacter(characters[this_chid].avatar, { deleteChats: delete_chats }); +} - const msg = { avatar_url: avatar, delete_chats: delete_chats }; +/** + * Deletes a character completely, including associated chats if specified + * + * @param {string} characterKey - The key (avatar) of the character to be deleted + * @param {Object} [options] - Optional parameters for the deletion + * @param {boolean} [options.deleteChats=true] - Whether to delete associated chats or not + * @return {Promise} - A promise that resolves when the character is successfully deleted + */ +export async function deleteCharacter(characterKey, { deleteChats = true } = {}) { + const character = characters.find(x => x.avatar == characterKey);; + if (!character) { + toastr.warning(`Character ${characterKey} not found. Cannot be deleted.`); + return; + } + + const chid = characters.indexOf(character); + const pastChats = await getPastCharacterChats(chid); + + const msg = { avatar_url: character.avatar, delete_chats: deleteChats }; const response = await fetch('/api/characters/delete', { method: 'POST', @@ -8468,17 +8490,17 @@ export async function handleDeleteCharacter(popup_type, this_chid, delete_chats) cache: 'no-cache', }); - if (response.ok) { - await deleteCharacter(name, avatar); + if (!response.ok) { + throw new Error(`Failed to delete character: ${response.status} ${response.statusText}`); + } - if (delete_chats) { - for (const chat of pastChats) { - const name = chat.file_name.replace('.jsonl', ''); - await eventSource.emit(event_types.CHAT_DELETED, name); - } + await removeCharacterFromUI(character.name, character.avatar); + + if (deleteChats) { + for (const chat of pastChats) { + const name = chat.file_name.replace('.jsonl', ''); + await eventSource.emit(event_types.CHAT_DELETED, name); } - } else { - console.error('Failed to delete character: ', response.status, response.statusText); } } @@ -8495,7 +8517,7 @@ export async function handleDeleteCharacter(popup_type, this_chid, delete_chats) * @param {string} avatar - The avatar URL of the character to be deleted. * @param {boolean} reloadCharacters - Whether the character list should be refreshed after deletion. */ -export async function deleteCharacter(name, avatar, reloadCharacters = true) { +async function removeCharacterFromUI(name, avatar, reloadCharacters = true) { await clearChat(); $('#character_cross').click(); this_chid = undefined; diff --git a/public/scripts/BulkEditOverlay.js b/public/scripts/BulkEditOverlay.js index 26b9fc5b3..d121d0fc6 100644 --- a/public/scripts/BulkEditOverlay.js +++ b/public/scripts/BulkEditOverlay.js @@ -4,7 +4,6 @@ import { characterGroupOverlay, callPopup, characters, - deleteCharacter, event_types, eventSource, getCharacters, @@ -13,6 +12,7 @@ import { buildAvatarList, characterToEntity, printCharactersDebounced, + deleteCharacter, } from '../script.js'; import { favsToHotswap } from './RossAscends-mods.js'; @@ -115,24 +115,7 @@ class CharacterContextMenu { static delete = async (characterId, deleteChats = false) => { const character = CharacterContextMenu.#getCharacter(characterId); - return fetch('/api/characters/delete', { - method: 'POST', - headers: getRequestHeaders(), - body: JSON.stringify({ avatar_url: character.avatar, delete_chats: deleteChats }), - cache: 'no-cache', - }).then(response => { - if (response.ok) { - eventSource.emit(event_types.CHARACTER_DELETED, { id: characterId, character: character }); - return deleteCharacter(character.name, character.avatar, false).then(() => { - if (deleteChats) getPastCharacterChats(characterId).then(pastChats => { - for (const chat of pastChats) { - const name = chat.file_name.replace('.jsonl', ''); - eventSource.emit(event_types.CHAT_DELETED, name); - } - }); - }); - } - }); + await deleteCharacter(character.avatar, { deleteChats: deleteChats }); }; static #getCharacter = (characterId) => characters[characterId] ?? null; diff --git a/public/scripts/utils.js b/public/scripts/utils.js index 10abc1645..516a70412 100644 --- a/public/scripts/utils.js +++ b/public/scripts/utils.js @@ -1,5 +1,5 @@ import { getContext } from './extensions.js'; -import { getRequestHeaders } from '../script.js'; +import { callPopup, getRequestHeaders } from '../script.js'; import { isMobile } from './RossAscends-mods.js'; import { collapseNewlines } from './power-user.js'; import { debounce_timeout } from './constants.js'; @@ -1720,3 +1720,38 @@ export function highlightRegex(regexStr) { return `${regexStr}`; } + +/** + * Confirms if the user wants to overwrite an existing data object (like character, world info, etc) if one exists. + * If no data with the name exists, this simply returns true. + * + * @param {string} type - The type of the check ("World Info", "Character", etc) + * @param {string[]} existingNames - The list of existing names to check against + * @param {string} name - The new name + * @param {object} options - Optional parameters + * @param {boolean} [options.interactive=false] - Whether to show a confirmation dialog when needing to overwrite an existing data object + * @param {string} [options.actionName='overwrite'] - The action name to display in the confirmation dialog + * @param {(existingName:string)=>void} [options.deleteAction=null] - Optional action to execute wen deleting an existing data object on overwrite + * @returns {Promise} True if the user confirmed the overwrite or there is no overwrite needed, false otherwise + */ +export async function checkOverwriteExistingData(type, existingNames, name, { interactive = false, actionName = 'Overwrite', deleteAction = null } = {}) { + const existing = existingNames.find(x => equalsIgnoreCaseAndAccents(x, name)); + if (!existing) { + return true; + } + + const overwrite = interactive ? await callPopup(`

${type} ${actionName}

A ${type.toLowerCase()} with the same name already exists:
${existing}

Do you want to overwrite it?`, 'confirm') : false; + if (!overwrite) { + toastr.warning(`${type} ${actionName.toLowerCase()} cancelled. A ${type.toLowerCase()} with the same name already exists:
${existing}`, `${type} ${actionName}`, { escapeHtml: false }); + return false; + } + + toastr.info(`Overwriting Existing ${type}:
${existing}`, `${type} ${actionName}`, { escapeHtml: false }); + + // If there is an action to delete the existing data, do it, as the name might be slightly different so file name would not be the same + if (deleteAction) { + deleteAction(existing); + } + + return true; +} diff --git a/public/scripts/world-info.js b/public/scripts/world-info.js index 6f282d6dd..2537681e9 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, equalsIgnoreCaseAndAccents, getSanitizedFilename } 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, getSanitizedFilename, checkOverwriteExistingData } 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'; @@ -2619,27 +2619,27 @@ function getFreeWorldName() { * 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 {string} worldName - 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 } = {}) { +async function createNewWorldInfo(worldName, { interactive = false } = {}) { const worldInfoTemplate = { entries: {} }; - if (!worldInfoName) { + if (!worldName) { return false; } - const allowed = await checkCanOverwriteWorldInfo(worldInfoName, { interactive: interactive, actionName: 'Create' }); + const allowed = await checkOverwriteExistingData('World Info', world_names, worldName, { interactive: interactive, actionName: 'Create', deleteAction: (existingName) => deleteWorldInfo(existingName) }); if (!allowed) { return false; } - await saveWorldInfo(worldInfoName, worldInfoTemplate, true); + await saveWorldInfo(worldName, worldInfoTemplate, true); await updateWorldInfoList(); - const selectedIndex = world_names.indexOf(worldInfoName); + const selectedIndex = world_names.indexOf(worldName); if (selectedIndex !== -1) { $('#world_editor_select').val(selectedIndex).trigger('change'); } else { @@ -2649,37 +2649,6 @@ async function createNewWorldInfo(worldInfoName, { interactive = false } = {}) { return true; } - -/** - * Confirms if the user wants to overwrite an existing world info with the same name. - * If no world info with the name exists, this simply returns true - * - * @param {string} name - The name of the world info to create - * @param {Object} options - Optional parameters - * @param {boolean} [options.interactive=false] - Whether to show a confirmation dialog when overwriting an existing world - * @param {string} [options.actionName='overwrite'] - The action name to display in the confirmation dialog - * @returns {Promise} True if the user confirmed the overwrite, false otherwise - */ -async function checkCanOverwriteWorldInfo(name, { interactive = false, actionName = 'Overwrite' } = {}) { - const existingWorld = world_names.find(x => equalsIgnoreCaseAndAccents(x, name)); - if (!existingWorld) { - return true; - } - - const overwrite = interactive ? await callPopup(`

World Info ${actionName}

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

Do you want to overwrite it?`, 'confirm') : false; - if (!overwrite) { - toastr.warning(`World ${actionName.toLowerCase()} cancelled. A world with the same name already exists:
${existingWorld}`, `World Info ${actionName}`, { escapeHtml: false }); - return false; - } - - toastr.info(`Overwriting Existing World Info:
${existingWorld}`, `World Info ${actionName}`, { 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); - - return true; -} - async function getCharacterLore() { const character = characters[this_chid]; const name = character?.name; @@ -3612,7 +3581,7 @@ export async function importWorldInfo(file) { const worldName = file.name.substr(0, file.name.lastIndexOf(".")); const sanitizedWorldName = await getSanitizedFilename(worldName); - const allowed = await checkCanOverwriteWorldInfo(sanitizedWorldName, { interactive: true, actionName: 'Import' }); + const allowed = await checkOverwriteExistingData('World Info', world_names, sanitizedWorldName, { interactive: true, actionName: 'Import', deleteAction: (existingName) => deleteWorldInfo(existingName) }); if (!allowed) { return false; }