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 -12Ignore whitespace
public/scripts/popup.js+15 -0
@@ -101,6 +101,21 @@ const showPopupHelper = {
101101 if (typeof result === 'string' || typeof result === 'boolean') throw new Error(`Invalid popup result. CONFIRM popups only support numbers, or null. Result: ${result}`);
102102 return result;
103103 },
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+ },
104119};
105120
106121export class Popup {
public/scripts/tags.js+49 -12
@@ -21,7 +21,7 @@ import { SlashCommandParser } from './slash-commands/SlashCommandParser.js';
2121import { SlashCommand } from './slash-commands/SlashCommand.js';
2222import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js';
2323import { isMobile } from './RossAscends-mods.js';
2424import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js';
2525import { debounce_timeout } from './constants.js';
2626import { INTERACTABLE_CONTROL_CLASS } from './keyboard.js';
2727import { commonEnumProviders } from './slash-commands/SlashCommandCommonEnumsProvider.js';
@@ -1436,18 +1436,28 @@ async function onTagRestoreFileSelect(e) {
14361436 const data = await parseJsonFile(file);
14371437
14381438 if (!data) {
14391439 toastr.warning('Empty file data', 'Tag restoreRestore');
14401440 console.log('Tag restore: File data empty.');
14411441 return;
14421442 }
14431443
14441444 if (!data.tags || !data.tag_map || !Array.isArray(data.tags) || typeof data.tag_map !== 'object') {
14451445 toastr.warning('Invalid file format', 'Tag restoreRestore');
14461446 console.log('Tag restore: Invalid file format.');
14471447 return;
14481448 }
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+
14501458 const warnings = [];
1459+ /** @type {Map<string, string>} Map import tag ids with existing ids on overwrite */
1460+ const idToActualTagIdMap = new Map();
14511461
14521462 // Import tags
14531463 for (const tag of data.tags) {
@@ -1456,11 +1466,29 @@ async function onTagRestoreFileSelect(e) {
14561466 continue;
14571467 }
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);
14611480 continue;
14621481 }
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+
14641492 tags.push(tag);
14651493 }
14661494
@@ -1478,30 +1506,39 @@ async function onTagRestoreFileSelect(e) {
14781506 const groupExists = groups.some(x => String(x.id) === String(key));
14791507
14801508 if (!characterExists && !groupExists) {
14811509 warnings.push(`Tag map key ${key} does not exist as character or group.`);
14821510 continue;
14831511 }
14841512
14851513 // Get existing tag ids for this key or empty array.
14861514 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+
14891521 // Verify that all tags exist. Remove tags that don't exist.
14901522 tag_map[key] = tag_map[key]combinedTags.filter(xtagId => tags.some(y => String(y.id) === String(xtagId)));
14911523 }
14921524
14931525 if (warnings.length) {
14941526 toastr.successwarning('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+ });
14951530 console.warn(`TAG RESTORE REPORT\n====================\n${warnings.join('\n')}`);
14961531 } else {
14971532 toastr.success('Tags restored successfully.', 'Tag Restore');
14981533 }
14991534
15001535 $('#tag_view_restore_input').val('');
15011536 printCharactersDebounced();
15021537 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);
15051542}
15061543
15071544function onBackupRestoreClick() {
public/style.css+7 -0
@@ -470,6 +470,13 @@ kbd {
470470 line-height: 1;
471471}
472472
473+samp {
474+ display: block;
475+ font-family: var(--monoFontFamily);
476+ white-space: pre-wrap;
477+ text-align: start;
478+ justify-content: left;
479+}
473480
474481hr {
475482 background-image: linear-gradient(90deg, var(--transparent), var(--SmartThemeBodyColor), var(--transparent));