Merge pull request #2582 from SillyTavern/improve-tag-backup-restore Improve Tag Backup Restore functionality
Signed| @@ -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 | }; |
| 105 | 120 | ||
| 106 | export class Popup { | 121 | export class Popup { |
| @@ -21,7 +21,7 @@ import { SlashCommandParser } from './slash-commands/SlashCommandParser.js'; | |||
| 21 | import { SlashCommand } from './slash-commands/SlashCommand.js'; | 21 | import { SlashCommand } from './slash-commands/SlashCommand.js'; |
| 22 | import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js'; | 22 | import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js'; |
| 23 | import { isMobile } from './RossAscends-mods.js'; | 23 | import { isMobile } from './RossAscends-mods.js'; |
| 24 | import { POPUP_RESULT, POPUP_TYPE, callGenericPopup } from './popup.js'; | 24 | import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js'; |
| 25 | import { debounce_timeout } from './constants.js'; | 25 | import { debounce_timeout } from './constants.js'; |
| 26 | import { INTERACTABLE_CONTROL_CLASS } from './keyboard.js'; | 26 | import { INTERACTABLE_CONTROL_CLASS } from './keyboard.js'; |
| 27 | import { commonEnumProviders } from './slash-commands/SlashCommandCommonEnumsProvider.js'; | 27 | import { 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); |
| 1437 | 1437 | ||
| 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 | } |
| 1443 | 1443 | ||
| 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 | } |
| 1449 | 1449 | ||
| 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(); | ||
| 1451 | 1461 | ||
| 1452 | // Import tags | 1462 | // 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 | } |
| 1458 | 1468 | ||
| 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 | } |
| 1463 | 1482 | ||
| 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 | } |
| 1466 | 1494 | ||
| @@ -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)); |
| 1479 | 1507 | ||
| 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 | } |
| 1484 | 1512 | ||
| 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 | } |
| 1492 | 1524 | ||
| 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 | } |
| 1499 | 1534 | ||
| 1500 | $('#tag_view_restore_input').val(''); | 1535 | $('#tag_view_restore_input').val(''); |
| 1501 | printCharactersDebounced(); | 1536 | printCharactersDebounced(); |
| 1502 | saveSettingsDebounced(); | 1537 | saveSettingsDebounced(); |
| 1503 | 1538 | ||
| 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 | } |
| 1506 | 1543 | ||
| 1507 | function onBackupRestoreClick() { | 1544 | function onBackupRestoreClick() { |
| @@ -470,6 +470,13 @@ kbd { | |||
| 470 | line-height: 1; | 470 | line-height: 1; |
| 471 | } | 471 | } |
| 472 | 472 | ||
| 473 | samp { | ||
| 474 | display: block; | ||
| 475 | font-family: var(--monoFontFamily); | ||
| 476 | white-space: pre-wrap; | ||
| 477 | text-align: start; | ||
| 478 | justify-content: left; | ||
| 479 | } | ||
| 473 | 480 | ||
| 474 | hr { | 481 | hr { |
| 475 | background-image: linear-gradient(90deg, var(--transparent), var(--SmartThemeBodyColor), var(--transparent)); | 482 | background-image: linear-gradient(90deg, var(--transparent), var(--SmartThemeBodyColor), var(--transparent)); |