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, +149 -66Ignore whitespace
public/script.js+72 -61
@@ -43,7 +43,6 @@ import {
4343 importEmbeddedWorldInfo,
4444 checkEmbeddedWorld,
4545 setWorldInfoButtonClass,
46- importWorldInfo,
4746 wi_anchor_position,
4847 world_info_include_names,
4948 initWorldInfo,
@@ -175,6 +174,7 @@ import {
175174 localizePagination,
176175 renderPaginationDropdown,
177176 paginationDropdownChangeHandler,
177+ importFromExternalUrl,
178178} from './scripts/utils.js';
179179import { 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
89288928 return;
89298929 }
89308930
8931+ const exists = preserveFileName ? characters.find(character => character.avatar === preserveFileName) : undefined;
8932+
89318933 const format = ext[1].toLowerCase();
89328934 $('#character_import_file_type').val(format);
89338935 const formData = new FormData();
@@ -8954,10 +8956,20 @@ async function importCharacter(file, { preserveFileName = '', importTags = false
89548956 }
89558957
89568958 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+
89578966 $('#character_search_bar').val('').trigger('input');
89588967
8959- toastr.success(t`Character Created: ${String(data.file_name).replace('.png', '')}`);
8968+ if (exists) {
8960- let avatarFileName = `${data.file_name}.png`;
8969+ toastr.success(t`Character Replaced: ${String(data.file_name).replace('.png', '')}`);
8970+ } else {
8971+ toastr.success(t`Character Created: ${String(data.file_name).replace('.png', '')}`);
8972+ }
89618973 if (importTags) {
89628974 await importCharactersTags([avatarFileName]);
89638975 selectImportedChar(data.file_name);
@@ -10892,27 +10904,66 @@ jQuery(async function () {
1089210904 }
1089310905 } break;
1089410906 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+
10897- async function uploadReplacementCard(e) {
10909+ const POPUP_RESULT_URL = POPUP_RESULT.CUSTOM1, POPUP_RESULT_FILE = POPUP_RESULT.CUSTOM2;
10898- const file = e.target.files[0];
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+ });
1089910927
10900- if (!file) {
10928+ // Remember the chat currently selected, so we can reload it after the replacement
10901- return;
10929+ const currentChatFile = characters[this_chid]['chat'];
10902- }
10930+ async function postReplace() {
10931+ await openCharacterChat(currentChatFile);
10932+ }
1090310933
10904- try {
10934+ switch (result) {
10905- const chatFile = characters[this_chid]['chat'];
10935+ case POPUP_RESULT_FILE: {
10906- const data = new Map();
10936+ async function uploadReplacementCard(e) {
10907- data.set(file, characters[this_chid].avatar);
10937+ const file = e.target.files[0];
10908- await processDroppedFiles([file], data);
10938+ if (!file) {
10909- await openCharacterChat(chatFile);
10939+ return;
10910- await fetch(getThumbnailUrl('avatar', characters[this_chid].avatar), { cache: 'reload' });
10940+ }
10911- } catch {
10941+
10912- toastr.error('Failed to replace the character card.', 'Something went wrong');
10942+ try {
10943+ const data = new Map();
10944+ data.set(file, characters[this_chid].avatar);
10945+ await processDroppedFiles([file], data);
10946+ await postReplace();
10947+ } catch {
10948+ toastr.error('Failed to replace the character card.', 'Something went wrong');
10949+ }
10950+ }
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;
1091310961 }
10962+ onlineUrl = inputUrl;
10963+ await importFromExternalUrl(onlineUrl, { preserveFileName: characters[this_chid].avatar });
10964+ await postReplace();
10965+ break;
1091410966 }
10915- $('#character_replace_file').off('change').on('change', uploadReplacementCard).trigger('click');
1091610967 }
1091710968 } break;
1091810969 case 'import_tags': {
@@ -11023,47 +11074,7 @@ jQuery(async function () {
1102311074 const inputs = String(input).split('\n').map(x => x.trim()).filter(x => x.length > 0);
1102411075
1102511076 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- }
1106711078 }
1106811079 });
1106911080
public/scripts/popup.js+18 -4
@@ -37,8 +37,8 @@ export const POPUP_RESULT = {
3737
3838/**
3939 * @typedef {object} PopupOptions
4040 * @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.
4141 * @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.
4242 * @property {number?} [rows=1] - The number of rows for the input field
4343 * @property {boolean?} [wide=false] - Whether to display the popup in wide mode (wide screen, 1/1 aspect ratio)
4444 * @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
327327 switch (type) {
328328 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';
329331 if (!cancelButton) this.cancelButton.style.display = 'none';
330332 break;
331333 }
332334 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
333339 if (!okButton) this.okButton.textContent = template.getAttribute('popup-button-yes');
334340 if (!cancelButton) this.cancelButton.textContent = template.getAttribute('popup-button-no');
335341 break;
336342 }
337343 case POPUP_TYPE.INPUT: {
338344 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';
340347 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');
341350 break;
342351 }
343352 case POPUP_TYPE.DISPLAY: {
353+ // Display hides OK and CANCEL and all main button controls
344354 this.buttonControls.style.display = 'none';
345355 this.closeButton.style.display = 'block';
346356 break;
@@ -348,7 +358,6 @@ export class Popup {
348358 case POPUP_TYPE.CROP: {
349359 this.cropWrap.style.display = 'block';
350360 this.cropImage.src = cropImage;
351- if (!okButton) this.okButton.textContent = template.getAttribute('popup-button-crop');
352361 $(this.cropImage).cropper({
353362 aspectRatio: cropAspect ?? 2 / 3,
354363 autoCropArea: 1,
@@ -359,6 +368,11 @@ export class Popup {
359368 this.cropData.want_resize = !power_user.never_resize_avatars;
360369 },
361370 });
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');
362376 break;
363377 }
364378 default: {
public/scripts/utils.js+59 -1
@@ -7,7 +7,7 @@ import {
77} from '../lib.js';
88
99import { getContext } from './extensions.js';
1010import { characters, getRequestHeaders, processDroppedFiles, this_chid, user_avatar } from '../script.js';
1111import { isMobile } from './RossAscends-mods.js';
1212import { collapseNewlines, power_user } from './power-user.js';
1313import { debounce_timeout } from './constants.js';
@@ -16,6 +16,7 @@ import { SlashCommandClosure } from './slash-commands/SlashCommandClosure.js';
1616import { getTagsList } from './tags.js';
1717import { groups, selected_group } from './group-chats.js';
1818import { getCurrentLocale, t } from './i18n.js';
19+import { importWorldInfo } from './world-info.js';
1920
2021/**
2122 * Pagination status string template.
@@ -2613,3 +2614,60 @@ export function setupScrollToTop({ scrollContainerId, buttonId, drawerId, visibi
26132614 btn.removeEventListener('click', onActivate);
26142615 };
26152616}
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+}