Extend Update/Replace character function to replace directly from URL + Fixes on cached thumbnail (#4581) * refactor: allow popup okButton and cancelButton to be set to false to hide them, even if visible by default * feat: add character replacement from online source URL - add utility function to import anything from external URL (refactored from existing function) - fix character replace toast showing "Created" - fix thumbnails not refreshing for replaced char - fix accidentally creating new chat on replace, instead of reloading the open chat * fix: refresh avatar thumbnail only after successful character import and when character exists * chore: fix lint, unused import --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com>
Signed| @@ -43,7 +43,6 @@ import { | ||
| 43 | 43 | importEmbeddedWorldInfo, |
| 44 | 44 | checkEmbeddedWorld, |
| 45 | 45 | setWorldInfoButtonClass, |
| 46 | - importWorldInfo, | |
| 47 | 46 | wi_anchor_position, |
| 48 | 47 | world_info_include_names, |
| 49 | 48 | initWorldInfo, |
| @@ -175,6 +174,7 @@ import { | ||
| 175 | 174 | localizePagination, |
| 176 | 175 | renderPaginationDropdown, |
| 177 | 176 | paginationDropdownChangeHandler, |
| 177 | + importFromExternalUrl, | |
| 178 | 178 | } from './scripts/utils.js'; |
| 179 | 179 | import { debounce_timeout, GENERATION_TYPE_TRIGGERS, IGNORE_SYMBOL, inject_ids } from './scripts/constants.js'; |
| 180 | 180 | |
| @@ -8928,6 +8928,8 @@ async function importCharacter(file, { preserveFileName = '', importTags = false | ||
| 8928 | 8928 | return; |
| 8929 | 8929 | } |
| 8930 | 8930 | |
| 8931 | + const exists = preserveFileName ? characters.find(character => character.avatar === preserveFileName) : undefined; | |
| 8932 | + | |
| 8931 | 8933 | const format = ext[1].toLowerCase(); |
| 8932 | 8934 | $('#character_import_file_type').val(format); |
| 8933 | 8935 | const formData = new FormData(); |
| @@ -8954,10 +8956,20 @@ async function importCharacter(file, { preserveFileName = '', importTags = false | ||
| 8954 | 8956 | } |
| 8955 | 8957 | |
| 8956 | 8958 | if (data.file_name !== undefined) { |
| 8959 | + let avatarFileName = `${data.file_name}.png`; | |
| 8960 | + | |
| 8961 | + // Refresh existing thumbnail | |
| 8962 | + if (exists && this_chid !== undefined) { | |
| 8963 | + await fetch(getThumbnailUrl('avatar', avatarFileName), { cache: 'reload' }); | |
| 8964 | + } | |
| 8965 | + | |
| 8957 | 8966 | $('#character_search_bar').val('').trigger('input'); |
| 8958 | 8967 | |
| 8968 | + if (exists) { | |
| 8969 | + toastr.success(t`Character Replaced: ${String(data.file_name).replace('.png', '')}`); | |
| 8970 | + } else { | |
| 8959 | 8971 | toastr.success(t`Character Created: ${String(data.file_name).replace('.png', '')}`); |
| 8960 | - let avatarFileName = `${data.file_name}.png`; | |
| 8972 | + } | |
| 8961 | 8973 | if (importTags) { |
| 8962 | 8974 | await importCharactersTags([avatarFileName]); |
| 8963 | 8975 | selectImportedChar(data.file_name); |
| @@ -10892,27 +10904,66 @@ jQuery(async function () { | ||
| 10892 | 10904 | } |
| 10893 | 10905 | } break; |
| 10894 | 10906 | case 'replace_update': { |
| 10895 | - const confirm = await Popup.show.confirm(t`Replace Character`, '<p>' + t`Choose a new character card to replace this character with.` + '</p>' + t`All chats, assets and group memberships will be preserved, but local changes to the character data will be lost.` + '<br />' + t`Proceed?`); | |
| 10907 | + let onlineUrl = getCharacterSource(this_chid); | |
| 10896 | - if (confirm) { | |
| 10908 | + | |
| 10909 | + const POPUP_RESULT_URL = POPUP_RESULT.CUSTOM1, POPUP_RESULT_FILE = POPUP_RESULT.CUSTOM2; | |
| 10910 | + const result = await Popup.show.confirm(t`Replace Character`, | |
| 10911 | + `<p>${t`Choose a new character card to replace this character with.`}</p>` + | |
| 10912 | + `<p>${t`You can also replace this character with the one from the online source.`}${onlineUrl ? `<br />This character was downloaded from: <var>${onlineUrl}</var>` : ''}</p>` + | |
| 10913 | + `<p>${t`All chats, assets and group memberships will be preserved, but local changes to the character data will be lost.`}<br />${t`Proceed?`}</p>`, | |
| 10914 | + { | |
| 10915 | + okButton: false, | |
| 10916 | + customButtons: [{ | |
| 10917 | + text: t`Replace with URL`, | |
| 10918 | + result: POPUP_RESULT_URL, | |
| 10919 | + classes: ['popup-button-ok'], | |
| 10920 | + }, { | |
| 10921 | + text: t`Replace with File`, | |
| 10922 | + result: POPUP_RESULT_FILE, | |
| 10923 | + classes: ['popup-button-ok'], | |
| 10924 | + }], | |
| 10925 | + defaultResult: onlineUrl ? POPUP_RESULT_URL : POPUP_RESULT_FILE, | |
| 10926 | + }); | |
| 10927 | + | |
| 10928 | + // Remember the chat currently selected, so we can reload it after the replacement | |
| 10929 | + const currentChatFile = characters[this_chid]['chat']; | |
| 10930 | + async function postReplace() { | |
| 10931 | + await openCharacterChat(currentChatFile); | |
| 10932 | + } | |
| 10933 | + | |
| 10934 | + switch (result) { | |
| 10935 | + case POPUP_RESULT_FILE: { | |
| 10897 | 10936 | async function uploadReplacementCard(e) { |
| 10898 | 10937 | const file = e.target.files[0]; |
| 10899 | - | |
| 10900 | 10938 | if (!file) { |
| 10901 | 10939 | return; |
| 10902 | 10940 | } |
| 10903 | 10941 | |
| 10904 | 10942 | try { |
| 10905 | - const chatFile = characters[this_chid]['chat']; | |
| 10906 | 10943 | const data = new Map(); |
| 10907 | 10944 | data.set(file, characters[this_chid].avatar); |
| 10908 | 10945 | await processDroppedFiles([file], data); |
| 10909 | 10946 | await openCharacterChatpostReplace(chatFile); |
| 10910 | - await fetch(getThumbnailUrl('avatar', characters[this_chid].avatar), { cache: 'reload' }); | |
| 10911 | 10947 | } catch { |
| 10912 | 10948 | toastr.error('Failed to replace the character card.', 'Something went wrong'); |
| 10913 | 10949 | } |
| 10914 | 10950 | } |
| 10915 | 10951 | $('#character_replace_file').off('change').on('change', uploadReplacementCard).trigger('click'); |
| 10952 | + break; | |
| 10953 | + } | |
| 10954 | + case POPUP_RESULT_URL: { | |
| 10955 | + const inputUrl = await Popup.show.input(t`Replace Character from URL`, | |
| 10956 | + `<p>${t`Enter the URL of the character card to replace this character with.`}</p>` + | |
| 10957 | + (onlineUrl ? `<p>${t`This character was downloaded from: <var>${onlineUrl}</var>`}</p>` : ''), | |
| 10958 | + onlineUrl); | |
| 10959 | + if (!inputUrl) { | |
| 10960 | + break; | |
| 10961 | + } | |
| 10962 | + onlineUrl = inputUrl; | |
| 10963 | + await importFromExternalUrl(onlineUrl, { preserveFileName: characters[this_chid].avatar }); | |
| 10964 | + await postReplace(); | |
| 10965 | + break; | |
| 10966 | + } | |
| 10916 | 10967 | } |
| 10917 | 10968 | } break; |
| 10918 | 10969 | case 'import_tags': { |
| @@ -11023,47 +11074,7 @@ jQuery(async function () { | ||
| 11023 | 11074 | const inputs = String(input).split('\n').map(x => x.trim()).filter(x => x.length > 0); |
| 11024 | 11075 | |
| 11025 | 11076 | for (const url of inputs) { |
| 11026 | - let request; | |
| 11077 | + await importFromExternalUrl(url); | |
| 11027 | - | |
| 11028 | - if (isValidUrl(url)) { | |
| 11029 | - console.debug('Custom content import started for URL: ', url); | |
| 11030 | - request = await fetch('/api/content/importURL', { | |
| 11031 | - method: 'POST', | |
| 11032 | - headers: getRequestHeaders(), | |
| 11033 | - body: JSON.stringify({ url }), | |
| 11034 | - }); | |
| 11035 | - } else { | |
| 11036 | - console.debug('Custom content import started for Char UUID: ', url); | |
| 11037 | - request = await fetch('/api/content/importUUID', { | |
| 11038 | - method: 'POST', | |
| 11039 | - headers: getRequestHeaders(), | |
| 11040 | - body: JSON.stringify({ url }), | |
| 11041 | - }); | |
| 11042 | - } | |
| 11043 | - | |
| 11044 | - if (!request.ok) { | |
| 11045 | - toastr.info(request.statusText, 'Custom content import failed'); | |
| 11046 | - console.error('Custom content import failed', request.status, request.statusText); | |
| 11047 | - return; | |
| 11048 | - } | |
| 11049 | - | |
| 11050 | - const data = await request.blob(); | |
| 11051 | - const customContentType = request.headers.get('X-Custom-Content-Type'); | |
| 11052 | - const fileName = request.headers.get('Content-Disposition').split('filename=')[1].replace(/"/g, ''); | |
| 11053 | - const file = new File([data], fileName, { type: data.type }); | |
| 11054 | - | |
| 11055 | - switch (customContentType) { | |
| 11056 | - case 'character': | |
| 11057 | - await processDroppedFiles([file]); | |
| 11058 | - break; | |
| 11059 | - case 'lorebook': | |
| 11060 | - await importWorldInfo(file); | |
| 11061 | - break; | |
| 11062 | - default: | |
| 11063 | - toastr.warning('Unknown content type'); | |
| 11064 | - console.error('Unknown content type', customContentType); | |
| 11065 | - break; | |
| 11066 | - } | |
| 11067 | 11078 | } |
| 11068 | 11079 | }); |
| 11069 | 11080 | |
| @@ -37,8 +37,8 @@ export const POPUP_RESULT = { | ||
| 37 | 37 | |
| 38 | 38 | /** |
| 39 | 39 | * @typedef {object} PopupOptions |
| 40 | 40 | * @property {string|boolean?} [okButton=null] - Custom text for the OK button,. orA set text will always show the button. `true` toor use`false` theto defaultexplicitly (Ifshow set,or hide the button. `null` will alwaysleave bethe displayed,behavior noand matterdisplay of the typebutton ofunchanged, based on the popup) type. |
| 41 | 41 | * @property {string|boolean?} [cancelButton=null] - Custom text for the Cancel button,. orA set text will always show the button. `true` toor use`false` theto defaultexplicitly (Ifshow set,or hide the button. `null` will alwaysleave bethe displayed,behavior noand matterdisplay of the typebutton ofunchanged, based on the popup) type. |
| 42 | 42 | * @property {number?} [rows=1] - The number of rows for the input field |
| 43 | 43 | * @property {boolean?} [wide=false] - Whether to display the popup in wide mode (wide screen, 1/1 aspect ratio) |
| 44 | 44 | * @property {boolean?} [wider=false] - Whether to display the popup in wider mode (just wider, no height scaling) |
| @@ -326,21 +326,31 @@ export class Popup { | ||
| 326 | 326 | |
| 327 | 327 | switch (type) { |
| 328 | 328 | case POPUP_TYPE.TEXT: { |
| 329 | + //Text shows OK if not explicitly set to false, and CANCEL only if defined as true or with a caption | |
| 330 | + if (okButton === false) this.okButton.style.display = 'none'; | |
| 329 | 331 | if (!cancelButton) this.cancelButton.style.display = 'none'; |
| 330 | 332 | break; |
| 331 | 333 | } |
| 332 | 334 | case POPUP_TYPE.CONFIRM: { |
| 335 | + // Confirm shows OK if not explicitly set to false, and CANCEL if not explicitly set to false | |
| 336 | + if (okButton === false) this.okButton.style.display = 'none'; | |
| 337 | + if (cancelButton === false) this.cancelButton.style.display = 'none'; | |
| 338 | + // Override default captions for confirm on OK->Yes, CANCEL->No | |
| 333 | 339 | if (!okButton) this.okButton.textContent = template.getAttribute('popup-button-yes'); |
| 334 | 340 | if (!cancelButton) this.cancelButton.textContent = template.getAttribute('popup-button-no'); |
| 335 | 341 | break; |
| 336 | 342 | } |
| 337 | 343 | case POPUP_TYPE.INPUT: { |
| 338 | 344 | this.mainInput.style.display = 'block'; |
| 339 | - if (!okButton) this.okButton.textContent = template.getAttribute('popup-button-save'); | |
| 345 | + // Input shows OK if not explicitly set to false, and CANCEL if not explicitly set to false | |
| 346 | + if (okButton === false) this.okButton.style.display = 'none'; | |
| 340 | 347 | if (cancelButton === false) this.cancelButton.style.display = 'none'; |
| 348 | + // Override default captions for input on OK->Save | |
| 349 | + if (!okButton) this.okButton.textContent = template.getAttribute('popup-button-save'); | |
| 341 | 350 | break; |
| 342 | 351 | } |
| 343 | 352 | case POPUP_TYPE.DISPLAY: { |
| 353 | + // Display hides OK and CANCEL and all main button controls | |
| 344 | 354 | this.buttonControls.style.display = 'none'; |
| 345 | 355 | this.closeButton.style.display = 'block'; |
| 346 | 356 | break; |
| @@ -348,7 +358,6 @@ export class Popup { | ||
| 348 | 358 | case POPUP_TYPE.CROP: { |
| 349 | 359 | this.cropWrap.style.display = 'block'; |
| 350 | 360 | this.cropImage.src = cropImage; |
| 351 | - if (!okButton) this.okButton.textContent = template.getAttribute('popup-button-crop'); | |
| 352 | 361 | $(this.cropImage).cropper({ |
| 353 | 362 | aspectRatio: cropAspect ?? 2 / 3, |
| 354 | 363 | autoCropArea: 1, |
| @@ -359,6 +368,11 @@ export class Popup { | ||
| 359 | 368 | this.cropData.want_resize = !power_user.never_resize_avatars; |
| 360 | 369 | }, |
| 361 | 370 | }); |
| 371 | + // Crop shows OK if not explicitly set to false, and CANCEL if not explicitly set to false | |
| 372 | + if (okButton === false) this.okButton.style.display = 'none'; | |
| 373 | + if (cancelButton === false) this.cancelButton.style.display = 'none'; | |
| 374 | + // Override default captions for crop on OK->Crop | |
| 375 | + if (!okButton) this.okButton.textContent = template.getAttribute('popup-button-crop'); | |
| 362 | 376 | break; |
| 363 | 377 | } |
| 364 | 378 | default: { |
| @@ -7,7 +7,7 @@ import { | ||
| 7 | 7 | } from '../lib.js'; |
| 8 | 8 | |
| 9 | 9 | import { getContext } from './extensions.js'; |
| 10 | 10 | import { characters, getRequestHeaders, processDroppedFiles, this_chid, user_avatar } from '../script.js'; |
| 11 | 11 | import { isMobile } from './RossAscends-mods.js'; |
| 12 | 12 | import { collapseNewlines, power_user } from './power-user.js'; |
| 13 | 13 | import { debounce_timeout } from './constants.js'; |
| @@ -16,6 +16,7 @@ import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js'; | ||
| 16 | 16 | import { getTagsList } from './tags.js'; |
| 17 | 17 | import { groups, selected_group } from './group-chats.js'; |
| 18 | 18 | import { getCurrentLocale, t } from './i18n.js'; |
| 19 | +import { importWorldInfo } from './world-info.js'; | |
| 19 | 20 | |
| 20 | 21 | /** |
| 21 | 22 | * Pagination status string template. |
| @@ -2613,3 +2614,60 @@ export function setupScrollToTop({ scrollContainerId, buttonId, drawerId, visibi | ||
| 2613 | 2614 | btn.removeEventListener('click', onActivate); |
| 2614 | 2615 | }; |
| 2615 | 2616 | } |
| 2617 | + | |
| 2618 | +/** | |
| 2619 | + * Imports content from an external URL. | |
| 2620 | + * @param {string} url URL or UUID of the content to import. | |
| 2621 | + * @param {Object} [options={}] Options object. | |
| 2622 | + * @param {string|null} [options.preserveFileName=null] Optional file name to use for the imported content. | |
| 2623 | + * @returns {Promise<void>} A promise that resolves when the import is complete. | |
| 2624 | + */ | |
| 2625 | +export async function importFromExternalUrl(url, { preserveFileName = null } = {}) { | |
| 2626 | + let request; | |
| 2627 | + | |
| 2628 | + if (isValidUrl(url)) { | |
| 2629 | + console.debug('Custom content import started for URL: ', url); | |
| 2630 | + request = await fetch('/api/content/importURL', { | |
| 2631 | + method: 'POST', | |
| 2632 | + headers: getRequestHeaders(), | |
| 2633 | + body: JSON.stringify({ url }), | |
| 2634 | + }); | |
| 2635 | + } else { | |
| 2636 | + console.debug('Custom content import started for Char UUID: ', url); | |
| 2637 | + request = await fetch('/api/content/importUUID', { | |
| 2638 | + method: 'POST', | |
| 2639 | + headers: getRequestHeaders(), | |
| 2640 | + body: JSON.stringify({ url }), | |
| 2641 | + }); | |
| 2642 | + } | |
| 2643 | + | |
| 2644 | + if (!request.ok) { | |
| 2645 | + toastr.info(request.statusText, 'Custom content import failed'); | |
| 2646 | + console.error('Custom content import failed', request.status, request.statusText); | |
| 2647 | + return; | |
| 2648 | + } | |
| 2649 | + | |
| 2650 | + const data = await request.blob(); | |
| 2651 | + const customContentType = request.headers.get('X-Custom-Content-Type'); | |
| 2652 | + let fileName = request.headers.get('Content-Disposition').split('filename=')[1].replace(/"/g, ''); | |
| 2653 | + const file = new File([data], fileName, { type: data.type }); | |
| 2654 | + | |
| 2655 | + const extraData = new Map(); | |
| 2656 | + if (preserveFileName) { | |
| 2657 | + fileName = preserveFileName; | |
| 2658 | + extraData.set(file, preserveFileName); | |
| 2659 | + } | |
| 2660 | + | |
| 2661 | + switch (customContentType) { | |
| 2662 | + case 'character': | |
| 2663 | + await processDroppedFiles([file], extraData); | |
| 2664 | + break; | |
| 2665 | + case 'lorebook': | |
| 2666 | + await importWorldInfo(file); | |
| 2667 | + break; | |
| 2668 | + default: | |
| 2669 | + toastr.warning('Unknown content type'); | |
| 2670 | + console.error('Unknown content type', customContentType); | |
| 2671 | + break; | |
| 2672 | + } | |
| 2673 | +} | |