Merge pull request #2582 from SillyTavern/improve-tag-backup-restore Improve Tag Backup Restore functionality

880f98684825d33137acf10ff1e69773d841fcd6

Cohee <18619528+Cohee1207@users.noreply.github.com>

Signed
3 files changed, +71 -12Showing whitespace changes
public/scripts/popup.js+15 -0
@@ -101,6 +101,21 @@ const showPopupHelper = {
101 if (typeof result === 'string' || typeof result === 'boolean') throw new Error(`Invalid popup result. CONFIRM popups only support numbers, or null. Result: ${result}`);101 if (typeof result === 'string' || typeof result === 'boolean') throw new Error(`Invalid popup result. CONFIRM popups only support numbers, or null. Result: ${result}`);
102 return result;102 return result;
103 },103 },
104 /**
105 * Asynchronously displays a text popup with the given header and text, returning the clicked result button value.
106 *
107 * @param {string?} header - The header text for the popup.
108 * @param {string?} text - The main text for the popup.
109 * @param {PopupOptions} [popupOptions={}] - Options for the popup.
110 * @return {Promise<POPUP_RESULT>} A Promise that resolves with the result of the user's interaction.
111 */
112 text: async (header, text, popupOptions = {}) => {
113 const content = PopupUtils.BuildTextWithHeader(header, text);
114 const popup = new Popup(content, POPUP_TYPE.TEXT, null, popupOptions);
115 const result = await popup.show();
116 if (typeof result === 'string' || typeof result === 'boolean') throw new Error(`Invalid popup result. TEXT popups only support numbers, or null. Result: ${result}`);
117 return result;
118 },
104};119};
105120
106export class Popup {121export class Popup {
public/scripts/tags.js+49 -12
@@ -21,7 +21,7 @@ import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
21import { SlashCommand } from './slash-commands/SlashCommand.js';21import { SlashCommand } from './slash-commands/SlashCommand.js';
22import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js';22import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js';
23import { isMobile } from './RossAscends-mods.js';23import { isMobile } from './RossAscends-mods.js';
24import { POPUP_RESULT, POPUP_TYPE, callGenericPopup } from './popup.js';24import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js';
25import { debounce_timeout } from './constants.js';25import { debounce_timeout } from './constants.js';
26import { INTERACTABLE_CONTROL_CLASS } from './keyboard.js';26import { INTERACTABLE_CONTROL_CLASS } from './keyboard.js';
27import { commonEnumProviders } from './slash-commands/SlashCommandCommonEnumsProvider.js';27import { commonEnumProviders } from './slash-commands/SlashCommandCommonEnumsProvider.js';
@@ -1436,18 +1436,28 @@ async function onTagRestoreFileSelect(e) {
1436 const data = await parseJsonFile(file);1436 const data = await parseJsonFile(file);
14371437
1438 if (!data) {1438 if (!data) {
1439 toastr.warning('Empty file data', 'Tag restore');1439 toastr.warning('Empty file data', 'Tag Restore');
1440 console.log('Tag restore: File data empty.');1440 console.log('Tag restore: File data empty.');
1441 return;1441 return;
1442 }1442 }
14431443
1444 if (!data.tags || !data.tag_map || !Array.isArray(data.tags) || typeof data.tag_map !== 'object') {1444 if (!data.tags || !data.tag_map || !Array.isArray(data.tags) || typeof data.tag_map !== 'object') {
1445 toastr.warning('Invalid file format', 'Tag restore');1445 toastr.warning('Invalid file format', 'Tag Restore');
1446 console.log('Tag restore: Invalid file format.');1446 console.log('Tag restore: Invalid file format.');
1447 return;1447 return;
1448 }1448 }
14491449
1450 // Prompt user if they want to overwrite existing tags
1451 let overwrite = false;
1452 if (tags.length > 0) {
1453 const result = await Popup.show.confirm('Tag Restore', 'You have existing tags. If the backup contains any of those tags, do you want the backup to overwrite their settings (Name, color, folder state, etc)?',
1454 { okButton: 'Overwrite', cancelButton: 'Keep Existing' });
1455 overwrite = result === POPUP_RESULT.AFFIRMATIVE;
1456 }
1457
1450 const warnings = [];1458 const warnings = [];
1459 /** @type {Map<string, string>} Map import tag ids with existing ids on overwrite */
1460 const idToActualTagIdMap = new Map();
14511461
1452 // Import tags1462 // Import tags
1453 for (const tag of data.tags) {1463 for (const tag of data.tags) {
@@ -1456,11 +1466,29 @@ async function onTagRestoreFileSelect(e) {
1456 continue;1466 continue;
1457 }1467 }
14581468
1459 if (tags.find(x => x.id === tag.id)) {1469 // Check against both existing id (direct match) and tag with the same name, which is not allowed.
1460 warnings.push(`Tag with id ${tag.id} already exists.`);1470 let existingTag = tags.find(x => x.id === tag.id);
1471 if (existingTag && !overwrite) {
1472 warnings.push(`Tag '${tag.name}' with id ${tag.id} already exists.`);
1473 continue;
1474 }
1475 existingTag = getTag(tag.name);
1476 if (existingTag && !overwrite) {
1477 warnings.push(`Tag with name '${tag.name}' already exists.`);
1478 // Remember the tag id, so we can still import the tag map entries for this
1479 idToActualTagIdMap.set(tag.id, existingTag.id);
1461 continue;1480 continue;
1462 }1481 }
14631482
1483 if (existingTag) {
1484 // On overwrite, we remove and re-add the tag
1485 removeFromArray(tags, existingTag);
1486 // And remember the ID if it was different, so we can update the tag map accordingly
1487 if (existingTag.id !== tag.id) {
1488 idToActualTagIdMap.set(existingTag.id, tag.id);
1489 }
1490 }
1491
1464 tags.push(tag);1492 tags.push(tag);
1465 }1493 }
14661494
@@ -1478,30 +1506,39 @@ async function onTagRestoreFileSelect(e) {
1478 const groupExists = groups.some(x => String(x.id) === String(key));1506 const groupExists = groups.some(x => String(x.id) === String(key));
14791507
1480 if (!characterExists && !groupExists) {1508 if (!characterExists && !groupExists) {
1481 warnings.push(`Tag map key ${key} does not exist.`);1509 warnings.push(`Tag map key ${key} does not exist as character or group.`);
1482 continue;1510 continue;
1483 }1511 }
14841512
1485 // Get existing tag ids for this key or empty array.1513 // Get existing tag ids for this key or empty array.
1486 const existingTagIds = tag_map[key] || [];1514 const existingTagIds = tag_map[key] || [];
1487 // Merge existing and new tag ids. Remove duplicates.1515
1488 tag_map[key] = existingTagIds.concat(tagIds).filter(onlyUnique);1516 // Merge existing and new tag ids. Replace the ones mapped to a new id. Remove duplicates.
1517 const combinedTags = existingTagIds.concat(tagIds)
1518 .map(tagId => (idToActualTagIdMap.has(tagId)) ? idToActualTagIdMap.get(tagId) : tagId)
1519 .filter(onlyUnique);
1520
1489 // Verify that all tags exist. Remove tags that don't exist.1521 // Verify that all tags exist. Remove tags that don't exist.
1490 tag_map[key] = tag_map[key].filter(x => tags.some(y => String(y.id) === String(x)));1522 tag_map[key] = combinedTags.filter(tagId => tags.some(y => String(y.id) === String(tagId)));
1491 }1523 }
14921524
1493 if (warnings.length) {1525 if (warnings.length) {
1494 toastr.success('Tags restored with warnings. Check console for details.');1526 toastr.warning('Tags restored with warnings. Check console or click on this message for details.', 'Tag Restore', {
1527 timeOut: toastr.options.timeOut * 2, // Display double the time
1528 onclick: () => Popup.show.text('Tag Restore Warnings', `<samp class="justifyLeft">${DOMPurify.sanitize(warnings.join('\n'))}<samp>`, { allowVerticalScrolling: true }),
1529 });
1495 console.warn(`TAG RESTORE REPORT\n====================\n${warnings.join('\n')}`);1530 console.warn(`TAG RESTORE REPORT\n====================\n${warnings.join('\n')}`);
1496 } else {1531 } else {
1497 toastr.success('Tags restored successfully.');1532 toastr.success('Tags restored successfully.', 'Tag Restore');
1498 }1533 }
14991534
1500 $('#tag_view_restore_input').val('');1535 $('#tag_view_restore_input').val('');
1501 printCharactersDebounced();1536 printCharactersDebounced();
1502 saveSettingsDebounced();1537 saveSettingsDebounced();
15031538
1504 await onViewTagsListClick();1539 // Reprint the tag management popup, without having it to be opened again
1540 const tagContainer = $('#tag_view_list .tag_view_list_tags');
1541 printViewTagList(tagContainer);
1505}1542}
15061543
1507function onBackupRestoreClick() {1544function onBackupRestoreClick() {
public/style.css+7 -0
@@ -470,6 +470,13 @@ kbd {
470 line-height: 1;470 line-height: 1;
471}471}
472472
473samp {
474 display: block;
475 font-family: var(--monoFontFamily);
476 white-space: pre-wrap;
477 text-align: start;
478 justify-content: left;
479}
473480
474hr {481hr {
475 background-image: linear-gradient(90deg, var(--transparent), var(--SmartThemeBodyColor), var(--transparent));482 background-image: linear-gradient(90deg, var(--transparent), var(--SmartThemeBodyColor), var(--transparent));