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>

7bedaed92cea0687aff8f6e405baaad750c0b657

Wolfsblvt <wolfsblvt@gmail.com>

Signed
3 files changed, +137 -54Showing whitespace changes
public/script.js+60 -49
@@ -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';
179import { debounce_timeout, GENERATION_TYPE_TRIGGERS, IGNORE_SYMBOL, inject_ids } from './scripts/constants.js';179import { debounce_timeout, GENERATION_TYPE_TRIGGERS, IGNORE_SYMBOL, inject_ids } from './scripts/constants.js';
180180
@@ -8928,6 +8928,8 @@ async function importCharacter(file, { preserveFileName = '', importTags = false
8928 return;8928 return;
8929 }8929 }
89308930
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 }
89558957
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');
89588967
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 }
1090310941
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);
1102411075
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 });
1106911080
public/scripts/popup.js+18 -4
@@ -37,8 +37,8 @@ export const POPUP_RESULT = {
3737
38/**38/**
39 * @typedef {object} PopupOptions39 * @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 field42 * @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 {
326326
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: {
public/scripts/utils.js+59 -1
@@ -7,7 +7,7 @@ import {
7} from '../lib.js';7} from '../lib.js';
88
9import { getContext } from './extensions.js';9import { getContext } from './extensions.js';
10import { characters, getRequestHeaders, this_chid, user_avatar } from '../script.js';10import { characters, getRequestHeaders, processDroppedFiles, this_chid, user_avatar } from '../script.js';
11import { isMobile } from './RossAscends-mods.js';11import { isMobile } from './RossAscends-mods.js';
12import { collapseNewlines, power_user } from './power-user.js';12import { collapseNewlines, power_user } from './power-user.js';
13import { debounce_timeout } from './constants.js';13import { debounce_timeout } from './constants.js';
@@ -16,6 +16,7 @@ import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js';
16import { getTagsList } from './tags.js';16import { getTagsList } from './tags.js';
17import { groups, selected_group } from './group-chats.js';17import { groups, selected_group } from './group-chats.js';
18import { getCurrentLocale, t } from './i18n.js';18import { getCurrentLocale, t } from './i18n.js';
19import { importWorldInfo } from './world-info.js';
1920
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 */
2625export 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}