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 | importEmbeddedWorldInfo, | 43 | importEmbeddedWorldInfo, |
| 44 | checkEmbeddedWorld, | 44 | checkEmbeddedWorld, |
| 45 | setWorldInfoButtonClass, | 45 | setWorldInfoButtonClass, |
| 46 | importWorldInfo, | ||
| 47 | wi_anchor_position, | 46 | wi_anchor_position, |
| 48 | world_info_include_names, | 47 | world_info_include_names, |
| 49 | initWorldInfo, | 48 | initWorldInfo, |
| @@ -175,6 +174,7 @@ import { | |||
| 175 | localizePagination, | 174 | localizePagination, |
| 176 | renderPaginationDropdown, | 175 | renderPaginationDropdown, |
| 177 | paginationDropdownChangeHandler, | 176 | paginationDropdownChangeHandler, |
| 177 | importFromExternalUrl, | ||
| 178 | } from './scripts/utils.js'; | 178 | } from './scripts/utils.js'; |
| 179 | import { debounce_timeout, GENERATION_TYPE_TRIGGERS, IGNORE_SYMBOL, inject_ids } from './scripts/constants.js'; | 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 | return; | 8928 | return; |
| 8929 | } | 8929 | } |
| 8930 | 8930 | ||
| 8931 | const exists = preserveFileName ? characters.find(character => character.avatar === preserveFileName) : undefined; | ||
| 8932 | |||
| 8931 | const format = ext[1].toLowerCase(); | 8933 | const format = ext[1].toLowerCase(); |
| 8932 | $('#character_import_file_type').val(format); | 8934 | $('#character_import_file_type').val(format); |
| 8933 | const formData = new FormData(); | 8935 | const formData = new FormData(); |
| @@ -8954,10 +8956,20 @@ async function importCharacter(file, { preserveFileName = '', importTags = false | |||
| 8954 | } | 8956 | } |
| 8955 | 8957 | ||
| 8956 | if (data.file_name !== undefined) { | 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 | $('#character_search_bar').val('').trigger('input'); | 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 | toastr.success(t`Character Created: ${String(data.file_name).replace('.png', '')}`); | 8971 | toastr.success(t`Character Created: ${String(data.file_name).replace('.png', '')}`); |
| 8960 | let avatarFileName = `${data.file_name}.png`; | 8972 | } |
| 8961 | if (importTags) { | 8973 | if (importTags) { |
| 8962 | await importCharactersTags([avatarFileName]); | 8974 | await importCharactersTags([avatarFileName]); |
| 8963 | selectImportedChar(data.file_name); | 8975 | selectImportedChar(data.file_name); |
| @@ -10892,27 +10904,66 @@ jQuery(async function () { | |||
| 10892 | } | 10904 | } |
| 10893 | } break; | 10905 | } break; |
| 10894 | case 'replace_update': { | 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 | async function uploadReplacementCard(e) { | 10936 | async function uploadReplacementCard(e) { |
| 10898 | const file = e.target.files[0]; | 10937 | const file = e.target.files[0]; |
| 10899 | |||
| 10900 | if (!file) { | 10938 | if (!file) { |
| 10901 | return; | 10939 | return; |
| 10902 | } | 10940 | } |
| 10903 | 10941 | ||
| 10904 | try { | 10942 | try { |
| 10905 | const chatFile = characters[this_chid]['chat']; | ||
| 10906 | const data = new Map(); | 10943 | const data = new Map(); |
| 10907 | data.set(file, characters[this_chid].avatar); | 10944 | data.set(file, characters[this_chid].avatar); |
| 10908 | await processDroppedFiles([file], data); | 10945 | await processDroppedFiles([file], data); |
| 10909 | await openCharacterChat(chatFile); | 10946 | await postReplace(); |
| 10910 | await fetch(getThumbnailUrl('avatar', characters[this_chid].avatar), { cache: 'reload' }); | ||
| 10911 | } catch { | 10947 | } catch { |
| 10912 | toastr.error('Failed to replace the character card.', 'Something went wrong'); | 10948 | toastr.error('Failed to replace the character card.', 'Something went wrong'); |
| 10913 | } | 10949 | } |
| 10914 | } | 10950 | } |
| 10915 | $('#character_replace_file').off('change').on('change', uploadReplacementCard).trigger('click'); | 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 | } break; | 10968 | } break; |
| 10918 | case 'import_tags': { | 10969 | case 'import_tags': { |
| @@ -11023,47 +11074,7 @@ jQuery(async function () { | |||
| 11023 | const inputs = String(input).split('\n').map(x => x.trim()).filter(x => x.length > 0); | 11074 | const inputs = String(input).split('\n').map(x => x.trim()).filter(x => x.length > 0); |
| 11024 | 11075 | ||
| 11025 | for (const url of inputs) { | 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 | * @typedef {object} PopupOptions | 39 | * @typedef {object} PopupOptions |
| 40 | * @property {string|boolean?} [okButton=null] - Custom text for the OK button, or `true` to use the default (If set, the button will always be displayed, no matter the type of popup) | 40 | * @property {string|boolean?} [okButton=null] - Custom text for the OK button. A set text will always show the button. `true` or `false` to explicitly show or hide the button. `null` will leave the behavior and display of the button unchanged, based on the popup type. |
| 41 | * @property {string|boolean?} [cancelButton=null] - Custom text for the Cancel button, or `true` to use the default (If set, the button will always be displayed, no matter the type of popup) | 41 | * @property {string|boolean?} [cancelButton=null] - Custom text for the Cancel button. A set text will always show the button. `true` or `false` to explicitly show or hide the button. `null` will leave the behavior and display of the button unchanged, based on the popup type. |
| 42 | * @property {number?} [rows=1] - The number of rows for the input field | 42 | * @property {number?} [rows=1] - The number of rows for the input field |
| 43 | * @property {boolean?} [wide=false] - Whether to display the popup in wide mode (wide screen, 1/1 aspect ratio) | 43 | * @property {boolean?} [wide=false] - Whether to display the popup in wide mode (wide screen, 1/1 aspect ratio) |
| 44 | * @property {boolean?} [wider=false] - Whether to display the popup in wider mode (just wider, no height scaling) | 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 | switch (type) { | 327 | switch (type) { |
| 328 | case POPUP_TYPE.TEXT: { | 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 | if (!cancelButton) this.cancelButton.style.display = 'none'; | 331 | if (!cancelButton) this.cancelButton.style.display = 'none'; |
| 330 | break; | 332 | break; |
| 331 | } | 333 | } |
| 332 | case POPUP_TYPE.CONFIRM: { | 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 | if (!okButton) this.okButton.textContent = template.getAttribute('popup-button-yes'); | 339 | if (!okButton) this.okButton.textContent = template.getAttribute('popup-button-yes'); |
| 334 | if (!cancelButton) this.cancelButton.textContent = template.getAttribute('popup-button-no'); | 340 | if (!cancelButton) this.cancelButton.textContent = template.getAttribute('popup-button-no'); |
| 335 | break; | 341 | break; |
| 336 | } | 342 | } |
| 337 | case POPUP_TYPE.INPUT: { | 343 | case POPUP_TYPE.INPUT: { |
| 338 | this.mainInput.style.display = 'block'; | 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 | if (cancelButton === false) this.cancelButton.style.display = 'none'; | 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 | break; | 350 | break; |
| 342 | } | 351 | } |
| 343 | case POPUP_TYPE.DISPLAY: { | 352 | case POPUP_TYPE.DISPLAY: { |
| 353 | // Display hides OK and CANCEL and all main button controls | ||
| 344 | this.buttonControls.style.display = 'none'; | 354 | this.buttonControls.style.display = 'none'; |
| 345 | this.closeButton.style.display = 'block'; | 355 | this.closeButton.style.display = 'block'; |
| 346 | break; | 356 | break; |
| @@ -348,7 +358,6 @@ export class Popup { | |||
| 348 | case POPUP_TYPE.CROP: { | 358 | case POPUP_TYPE.CROP: { |
| 349 | this.cropWrap.style.display = 'block'; | 359 | this.cropWrap.style.display = 'block'; |
| 350 | this.cropImage.src = cropImage; | 360 | this.cropImage.src = cropImage; |
| 351 | if (!okButton) this.okButton.textContent = template.getAttribute('popup-button-crop'); | ||
| 352 | $(this.cropImage).cropper({ | 361 | $(this.cropImage).cropper({ |
| 353 | aspectRatio: cropAspect ?? 2 / 3, | 362 | aspectRatio: cropAspect ?? 2 / 3, |
| 354 | autoCropArea: 1, | 363 | autoCropArea: 1, |
| @@ -359,6 +368,11 @@ export class Popup { | |||
| 359 | this.cropData.want_resize = !power_user.never_resize_avatars; | 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 | break; | 376 | break; |
| 363 | } | 377 | } |
| 364 | default: { | 378 | default: { |
| @@ -7,7 +7,7 @@ import { | |||
| 7 | } from '../lib.js'; | 7 | } from '../lib.js'; |
| 8 | 8 | ||
| 9 | import { getContext } from './extensions.js'; | 9 | import { getContext } from './extensions.js'; |
| 10 | import { characters, getRequestHeaders, this_chid, user_avatar } from '../script.js'; | 10 | import { characters, getRequestHeaders, processDroppedFiles, this_chid, user_avatar } from '../script.js'; |
| 11 | import { isMobile } from './RossAscends-mods.js'; | 11 | import { isMobile } from './RossAscends-mods.js'; |
| 12 | import { collapseNewlines, power_user } from './power-user.js'; | 12 | import { collapseNewlines, power_user } from './power-user.js'; |
| 13 | import { debounce_timeout } from './constants.js'; | 13 | import { debounce_timeout } from './constants.js'; |
| @@ -16,6 +16,7 @@ import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js'; | |||
| 16 | import { getTagsList } from './tags.js'; | 16 | import { getTagsList } from './tags.js'; |
| 17 | import { groups, selected_group } from './group-chats.js'; | 17 | import { groups, selected_group } from './group-chats.js'; |
| 18 | import { getCurrentLocale, t } from './i18n.js'; | 18 | import { getCurrentLocale, t } from './i18n.js'; |
| 19 | import { importWorldInfo } from './world-info.js'; | ||
| 19 | 20 | ||
| 20 | /** | 21 | /** |
| 21 | * Pagination status string template. | 22 | * Pagination status string template. |
| @@ -2613,3 +2614,60 @@ export function setupScrollToTop({ scrollContainerId, buttonId, drawerId, visibi | |||
| 2613 | btn.removeEventListener('click', onActivate); | 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 | } | ||