Merge pull request #2302 from Wolfsblvt/world-override-failsafe
Implement failsafe for world creation with same name
This commit is contained in:
commit
1f46d334b1
|
@ -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<Array>} - 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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -8455,11 +8460,28 @@ export async function handleDeleteCharacter(popup_type, this_chid, delete_chats)
|
|||
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<void>} - 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;
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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';
|
||||
|
@ -1021,6 +1021,36 @@ export function extractDataFromPng(data, identifier = 'chara') {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a request to the server to sanitize a given filename
|
||||
*
|
||||
* @param {string} fileName - The name of the file to sanitize
|
||||
* @returns {Promise<string>} A Promise that resolves to the sanitized filename if successful, or rejects with an error message if unsuccessful
|
||||
*/
|
||||
export async function getSanitizedFilename(fileName) {
|
||||
try {
|
||||
const result = await fetch('/api/files/sanitize-filename', {
|
||||
method: 'POST',
|
||||
headers: getRequestHeaders(),
|
||||
body: JSON.stringify({
|
||||
fileName: fileName,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
const error = await result.text();
|
||||
throw new Error(error);
|
||||
}
|
||||
|
||||
const responseData = await result.json();
|
||||
return responseData.fileName;
|
||||
} catch (error) {
|
||||
toastr.error(String(error), 'Could not sanitize fileName');
|
||||
console.error('Could not sanitize fileName', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a base64 encoded image to the backend to be saved as a file.
|
||||
*
|
||||
|
@ -1469,23 +1499,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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1666,3 +1720,38 @@ export function highlightRegex(regexStr) {
|
|||
|
||||
return `<span class="regex-highlight">${regexStr}</span>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<boolean>} 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(`<h3>${type} ${actionName}</h3><p>A ${type.toLowerCase()} with the same name already exists:<br />${existing}</p>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:<br />${existing}`, `${type} ${actionName}`, { escapeHtml: false });
|
||||
return false;
|
||||
}
|
||||
|
||||
toastr.info(`Overwriting Existing ${type}:<br />${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;
|
||||
}
|
||||
|
|
|
@ -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, 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';
|
||||
|
@ -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<boolean>} 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,22 +2615,40 @@ 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} 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<boolean>} - True if the world info was successfully created, false otherwise
|
||||
*/
|
||||
async function createNewWorldInfo(worldName, { interactive = false } = {}) {
|
||||
const worldInfoTemplate = { entries: {} };
|
||||
|
||||
if (!worldInfoName) {
|
||||
return;
|
||||
if (!worldName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await saveWorldInfo(worldInfoName, worldInfoTemplate, true);
|
||||
const sanitizedWorldName = await getSanitizedFilename(worldName);
|
||||
|
||||
const allowed = await checkOverwriteExistingData('World Info', world_names, sanitizedWorldName, { interactive: interactive, actionName: 'Create', deleteAction: (existingName) => deleteWorldInfo(existingName) });
|
||||
if (!allowed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
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 {
|
||||
hideWorldEditor();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function getCharacterLore() {
|
||||
|
@ -3552,6 +3581,13 @@ export async function importWorldInfo(file) {
|
|||
return;
|
||||
}
|
||||
|
||||
const worldName = file.name.substr(0, file.name.lastIndexOf("."));
|
||||
const sanitizedWorldName = await getSanitizedFilename(worldName);
|
||||
const allowed = await checkOverwriteExistingData('World Info', world_names, sanitizedWorldName, { interactive: true, actionName: 'Import', deleteAction: (existingName) => deleteWorldInfo(existingName) });
|
||||
if (!allowed) {
|
||||
return false;
|
||||
}
|
||||
|
||||
jQuery.ajax({
|
||||
type: 'POST',
|
||||
url: '/api/worldinfo/import',
|
||||
|
@ -3569,7 +3605,7 @@ export async function importWorldInfo(file) {
|
|||
$('#world_editor_select').val(newIndex).trigger('change');
|
||||
}
|
||||
|
||||
toastr.info(`World Info "${data.name}" imported successfully!`);
|
||||
toastr.success(`World Info "${data.name}" imported successfully!`);
|
||||
}
|
||||
},
|
||||
error: (_jqXHR, _exception) => { },
|
||||
|
@ -3644,7 +3680,7 @@ jQuery(() => {
|
|||
const finalName = await callPopup('<h3>Create a new World Info?</h3>Enter a name for the new file:', 'input', tempName);
|
||||
|
||||
if (finalName) {
|
||||
await createNewWorldInfo(finalName);
|
||||
await createNewWorldInfo(finalName, { interactive: true });
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -2,11 +2,27 @@ const path = require('path');
|
|||
const fs = require('fs');
|
||||
const writeFileSyncAtomic = require('write-file-atomic').sync;
|
||||
const express = require('express');
|
||||
const sanitize = require('sanitize-filename');
|
||||
const router = express.Router();
|
||||
const { validateAssetFileName } = require('./assets');
|
||||
const { jsonParser } = require('../express-common');
|
||||
const { clientRelativePath } = require('../util');
|
||||
|
||||
router.post('/sanitize-filename', jsonParser, async (request, response) => {
|
||||
try {
|
||||
const fileName = String(request.body.fileName);
|
||||
if (!fileName) {
|
||||
return response.status(400).send('No fileName specified');
|
||||
}
|
||||
|
||||
const sanitizedFilename = sanitize(fileName);
|
||||
return response.send({ fileName: sanitizedFilename });
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
return response.sendStatus(500);
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/upload', jsonParser, async (request, response) => {
|
||||
try {
|
||||
if (!request.body.name) {
|
||||
|
|
Loading…
Reference in New Issue