Merge pull request #2582 from SillyTavern/improve-tag-backup-restore Improve Tag Backup Restore functionality
Signed| @@ -101,6 +101,21 @@ const showPopupHelper = { | ||
| 101 | 101 | if (typeof result === 'string' || typeof result === 'boolean') throw new Error(`Invalid popup result. CONFIRM popups only support numbers, or null. Result: ${result}`); |
| 102 | 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 | 121 | export class Popup { |
| @@ -21,7 +21,7 @@ import { SlashCommandParser } from './slash-commands/SlashCommandParser.js'; | ||
| 21 | 21 | import { SlashCommand } from './slash-commands/SlashCommand.js'; |
| 22 | 22 | import { ARGUMENT_TYPE, SlashCommandArgument, SlashCommandNamedArgument } from './slash-commands/SlashCommandArgument.js'; |
| 23 | 23 | import { isMobile } from './RossAscends-mods.js'; |
| 24 | 24 | import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js'; |
| 25 | 25 | import { debounce_timeout } from './constants.js'; |
| 26 | 26 | import { INTERACTABLE_CONTROL_CLASS } from './keyboard.js'; |
| 27 | 27 | import { commonEnumProviders } from './slash-commands/SlashCommandCommonEnumsProvider.js'; |
| @@ -1436,18 +1436,28 @@ async function onTagRestoreFileSelect(e) { | ||
| 1436 | 1436 | const data = await parseJsonFile(file); |
| 1437 | 1437 | |
| 1438 | 1438 | if (!data) { |
| 1439 | 1439 | toastr.warning('Empty file data', 'Tag restoreRestore'); |
| 1440 | 1440 | console.log('Tag restore: File data empty.'); |
| 1441 | 1441 | return; |
| 1442 | 1442 | } |
| 1443 | 1443 | |
| 1444 | 1444 | if (!data.tags || !data.tag_map || !Array.isArray(data.tags) || typeof data.tag_map !== 'object') { |
| 1445 | 1445 | toastr.warning('Invalid file format', 'Tag restoreRestore'); |
| 1446 | 1446 | console.log('Tag restore: Invalid file format.'); |
| 1447 | 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 | 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 | 1462 | // Import tags |
| 1453 | 1463 | for (const tag of data.tags) { |
| @@ -1456,11 +1466,29 @@ async function onTagRestoreFileSelect(e) { | ||
| 1456 | 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 | 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 | 1492 | tags.push(tag); |
| 1465 | 1493 | } |
| 1466 | 1494 | |
| @@ -1478,30 +1506,39 @@ async function onTagRestoreFileSelect(e) { | ||
| 1478 | 1506 | const groupExists = groups.some(x => String(x.id) === String(key)); |
| 1479 | 1507 | |
| 1480 | 1508 | if (!characterExists && !groupExists) { |
| 1481 | 1509 | warnings.push(`Tag map key ${key} does not exist as character or group.`); |
| 1482 | 1510 | continue; |
| 1483 | 1511 | } |
| 1484 | 1512 | |
| 1485 | 1513 | // Get existing tag ids for this key or empty array. |
| 1486 | 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 | 1521 | // Verify that all tags exist. Remove tags that don't exist. |
| 1490 | 1522 | tag_map[key] = tag_map[key]combinedTags.filter(xtagId => tags.some(y => String(y.id) === String(xtagId))); |
| 1491 | 1523 | } |
| 1492 | 1524 | |
| 1493 | 1525 | if (warnings.length) { |
| 1494 | 1526 | 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 | + }); | |
| 1495 | 1530 | console.warn(`TAG RESTORE REPORT\n====================\n${warnings.join('\n')}`); |
| 1496 | 1531 | } else { |
| 1497 | 1532 | toastr.success('Tags restored successfully.', 'Tag Restore'); |
| 1498 | 1533 | } |
| 1499 | 1534 | |
| 1500 | 1535 | $('#tag_view_restore_input').val(''); |
| 1501 | 1536 | printCharactersDebounced(); |
| 1502 | 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 | 1544 | function onBackupRestoreClick() { |
| @@ -470,6 +470,13 @@ kbd { | ||
| 470 | 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 | 481 | hr { |
| 475 | 482 | background-image: linear-gradient(90deg, var(--transparent), var(--SmartThemeBodyColor), var(--transparent)); |