Refactor extension management and assets download menu (#5583) * feat: refactor extension and asset management * feat: refactor name selector * fix: make text localizable * fix: handle abort errors in extension version checks * fix: replace returns with throws * fix: remove debug prefix from toast * fix: preserve file names of imported characters

97392a4ca0357f089c9e6e7250d76fc73d5cc3ea

Cohee <18619528+Cohee1207@users.noreply.github.com>

Signed
2 files changed, +442 -207Showing whitespace changes
public/scripts/extensions.js+243 -115
@@ -1,9 +1,9 @@
11import { DOMPurify, Popper } from '../lib.js';
22
33import { eventSource, event_types, saveSettings, saveSettingsDebounced, getRequestHeaders, animation_duration, CLIENT_VERSION } from '../script.js';
44import { POPUP_RESULT, POPUP_TYPE, Popup } from './popup.js';
55import { renderTemplate, renderTemplateAsync } from './templates.js';
66import { delay, deleteValueByPath, equalsIgnoreCaseAndAccents, escapeHtml, isSubsetOf, sanitizeSelector, setValueByPath, versionCompare } from './utils.js';
77import { getContext } from './st-context.js';
88import { isAdmin } from './user.js';
99import { addLocaleData, getCurrentLocale, t } from './i18n.js';
@@ -280,6 +280,18 @@ export async function doExtrasFetch(endpoint, args = {}) {
280280}
281281
282282/**
283+ * Generates a CSS selector for an extension based on its name, allowing omission of a common prefix.
284+ * @param {string} name Name of the extension, with or without the "third-party" prefix
285+ * @param {object} [options] Optional parameters
286+ * @param {string} [options.prefix] Optional prefix to ignore when generating the selector (e.g. "third-party")
287+ * @returns {string} CSS selector for the extension, with the prefix removed if it was present and specified in options
288+ */
289+function getNameSelector(name, { prefix = 'third-party' } = {}) {
290+ const nameWithoutPrefix = prefix && name.startsWith(prefix) ? name.slice(prefix.length) : name;
291+ return CSS.escape(nameWithoutPrefix);
292+}
293+
294+/**
283295 * Discovers extensions from the API.
284296 * @returns {Promise<{name: string, type: string}[]>}
285297 */
@@ -356,7 +368,7 @@ function onToggleAllExtensions(extensionsToToggle, toggleContainer) {
356368 }
357369
358370 toggleContainer
359371 .find(`.extension_block[data-name="${name.replacegetNameSelector('third-party', ''name)}"] .extension_toggle input`)
360372 .prop('checked', enable)
361373 .toggleClass('toggle_enable', !enable)
362374 .toggleClass('toggle_disable', enable)
@@ -865,7 +877,7 @@ function addExtensionLocale(name, manifest) {
865877}
866878
867879/**
868880 * Generates HTMLan stringelement for displaying an extension in the UI.
869881 *
870882 * @param {string} name - The name of the extension.
871883 * @param {object} manifest - The manifest of the extension.
@@ -873,98 +885,180 @@ function addExtensionLocale(name, manifest) {
873885 * @param {boolean} isDisabled - Whether the extension is disabled or not.
874886 * @param {boolean} isExternal - Whether the extension is external or not.
875887 * @param {string} checkboxClass - The class for the checkbox HTML element.
876888 * @return {stringHTMLElement} - The HTML stringelement that represents the extension.
877889 */
878890function generateExtensionHtmlgenerateExtensionElement(name, manifest, isActive, isDisabled, isExternal, checkboxClass) {
879891 function getExtensionIcon() {
880892 const type = getExtensionType(name);
893+ const icon = document.createElement('i');
894+ icon.classList.add('fa-sm', 'fa-fw', 'fa-solid');
881895 switch (type) {
882896 case 'global':
883- return '<i class="fa-sm fa-fw fa-solid fa-server" data-i18n="[title]ext_type_global" title="This is a global extension, available for all users."></i>';
897+ icon.classList.add('fa-server');
898+ icon.title = t`This is a global extension, available for all users.`;
899+ break;
884900 case 'local':
885- return '<i class="fa-sm fa-fw fa-solid fa-user" data-i18n="[title]ext_type_local" title="This is a local extension, available only for you."></i>';
901+ icon.classList.add('fa-user');
902+ icon.title = t`This is a local extension, available only for you.`;
903+ break;
886904 case 'system':
887- return '<i class="fa-sm fa-fw fa-solid fa-cog" data-i18n="[title]ext_type_system" title="This is a built-in extension. It cannot be deleted and updates with the app."></i>';
905+ icon.classList.add('fa-cog');
906+ icon.title = t`This is a built-in extension. It cannot be deleted and updates with the app.`;
907+ break;
888908 default:
889- return '<i class="fa-sm fa-fw fa-solid fa-question" title="Unknown extension type."></i>';
909+ icon.classList.add('fa-question');
910+ icon.title = t`Unknown extension type.`;
911+ break;
890912 }
913+ return icon;
891914 }
892915
893916 const isUserAdmin = isAdmin();
894- const extensionIcon = getExtensionIcon();
895917 const displayName = manifest.display_name;
896918 const displayVersion = manifest.version || '';
897919 const externalId = name.replace('third-party', '');
898- let originHtml = '';
920+
899- if (isExternal) {
921+ // Root block
900- originHtml = '<a>';
922+ const block = document.createElement('div');
923+ block.classList.add('extension_block');
924+ block.dataset.name = externalId;
925+
926+ // Toggle
927+ const toggleDiv = document.createElement('div');
928+ toggleDiv.classList.add('extension_toggle');
929+ const toggle = document.createElement('input');
930+ toggle.type = 'checkbox';
931+ toggle.dataset.name = name;
932+ if (isActive || isDisabled) {
933+ toggle.title = t`Click to toggle`;
934+ toggle.classList.add(isActive ? 'toggle_disable' : 'toggle_enable');
935+ if (checkboxClass) toggle.classList.add(checkboxClass);
936+ toggle.checked = isActive;
937+ } else {
938+ toggle.title = t`Cannot enable extension`;
939+ toggle.classList.add('extension_missing');
940+ if (checkboxClass) toggle.classList.add(checkboxClass);
941+ toggle.disabled = true;
901942 }
943+ toggleDiv.appendChild(toggle);
944+ block.appendChild(toggleDiv);
945+
946+ // Icon
947+ const iconDiv = document.createElement('div');
948+ iconDiv.classList.add('extension_icon');
949+ iconDiv.appendChild(getExtensionIcon());
950+ block.appendChild(iconDiv);
951+
952+ // Text block
953+ const textBlock = document.createElement('div');
954+ textBlock.classList.add('flexGrow', 'extension_text_block');
902955
903- let toggleElement = isActive || isDisabled ?
956+ const statusSpan = document.createElement('span');
904- '<input type="checkbox" title="' + t`Click to toggle` + `" data-name="${name}" class="${isActive ? 'toggle_disable' : 'toggle_enable'} ${checkboxClass}" ${isActive ? 'checked' : ''}>` :
957+ statusSpan.className = isActive ? 'extension_enabled' : isDisabled ? 'extension_disabled' : 'extension_missing';
905- `<input type="checkbox" title="Cannot enable extension" data-name="${name}" class="extension_missing ${checkboxClass}" disabled>`;
906958
907- let deleteButton = isExternal ? `<button class="btn_delete menu_button" data-name="${externalId}" data-i18n="[title]Delete" title="Delete"><i class="fa-fw fa-solid fa-trash-can"></i></button>` : '';
959+ const nameSpan = document.createElement('span');
908- let updateButton = isExternal ? `<button class="btn_update menu_button displayNone" data-name="${externalId}" title="Update available"><i class="fa-solid fa-download fa-fw"></i></button>` : '';
960+ nameSpan.classList.add('extension_name');
909- let moveButton = isExternal && isUserAdmin ? `<button class="btn_move menu_button" data-name="${externalId}" data-i18n="[title]Move" title="Move"><i class="fa-solid fa-folder-tree fa-fw"></i></button>` : '';
961+ nameSpan.textContent = displayName;
910- let branchButton = isExternal && isUserAdmin ? `<button class="btn_branch menu_button" data-name="${externalId}" data-i18n="[title]Switch branch" title="Switch branch"><i class="fa-solid fa-code-branch fa-fw"></i></button>` : '';
962+
911- let cleanButton = isExternal && hasExtensionHook(externalId, 'clean') ? `<button class="btn_clean menu_button" data-name="${externalId}" data-i18n="[title]Clean extension data" title="Clean extension data"><i class="fa-fw fa-solid fa-broom"></i></button>` : '';
963+ const authorSpan = document.createElement('span');
912- let modulesInfo = '';
964+ authorSpan.classList.add('extension_author');
965+
966+ const versionSpan = document.createElement('span');
967+ versionSpan.classList.add('extension_version');
968+ versionSpan.textContent = displayVersion;
969+
970+ statusSpan.append(nameSpan, authorSpan, versionSpan);
913971
914972 if (isActive && Array.isArray(manifest.optional)) {
915973 const optional = new Set(manifest.optional);
916974 modules.forEach(x => optional.delete(x));
917975 if (optional.size > 0) {
918976 const optionalStringmodulesDiv = DOMPurify.sanitize([...optional]document.joincreateElement(', div'));
919- modulesInfo = '<div class="extension_modules">' + t`Optional modules:` + ` <span class="optional">${optionalString}</span></div>`;
977+ modulesDiv.classList.add('extension_modules');
920- }
978+ const optionalSpan = document.createElement('span');
921- } else if (!isDisabled) { // Neither active nor disabled
979+ optionalSpan.classList.add('optional');
980+ optionalSpan.textContent = [...optional].join(', ');
981+ modulesDiv.append(t`Optional modules:`, ' ', optionalSpan);
982+ statusSpan.appendChild(modulesDiv);
983+ }
984+ } else if (!isDisabled) {
985+ // Neither active nor disabled
922986 const requirements = new Set(manifest.requires);
923987 modules.forEach(x => requirements.delete(x));
924988 if (requirements.size > 0) {
925989 const requirementsStringmodulesDiv = DOMPurify.sanitize([...requirements]document.joincreateElement(', div'));
926- modulesInfo = `<div class="extension_modules">Missing modules: <span class="failure">${requirementsString}</span></div>`;
990+ modulesDiv.classList.add('extension_modules');
991+ const failureSpan = document.createElement('span');
992+ failureSpan.classList.add('failure');
993+ failureSpan.textContent = [...requirements].join(', ');
994+ modulesDiv.append(t`Missing modules:`, ' ', failureSpan);
995+ statusSpan.appendChild(modulesDiv);
927996 }
928997 }
929998
930999 // if external, wrap the name in a link to the repo
1000+ if (isExternal) {
1001+ const originLink = document.createElement('a');
1002+ originLink.appendChild(statusSpan);
1003+ textBlock.appendChild(originLink);
1004+ } else {
1005+ textBlock.appendChild(statusSpan);
1006+ }
9311007
932- let extensionHtml = `
1008+ block.appendChild(textBlock);
933- <div class="extension_block" data-name="${externalId}">
1009+
934- <div class="extension_toggle">
1010+ // Actions
935- ${toggleElement}
1011+ const actionsDiv = document.createElement('div');
936- </div>
1012+ actionsDiv.classList.add('extension_actions', 'flex-container', 'alignItemsCenter');
937- <div class="extension_icon">
1013+
938- ${extensionIcon}
1014+ /**
939- </div>
1015+ * Helper function to create an action button for an extension.
940- <div class="flexGrow extension_text_block">
1016+ * @param {string} cls Class name
941- ${originHtml}
1017+ * @param {string} dataName Name of the extension
942- <span class="${isActive ? 'extension_enabled' : isDisabled ? 'extension_disabled' : 'extension_missing'}">
1018+ * @param {string} title Title of the button
943- <span class="extension_name">${DOMPurify.sanitize(displayName)}</span>
1019+ * @param {string} iconClasses Classes for the icon
944- <span class="extension_author"></span>
1020+ * @returns {HTMLButtonElement} The created button element
945- <span class="extension_version">${DOMPurify.sanitize(displayVersion)}</span>
1021+ */
946- ${modulesInfo}
1022+ function makeActionButton(cls, dataName, title, iconClasses) {
947- </span>
1023+ const btn = document.createElement('button');
948- ${isExternal ? '</a>' : ''}
1024+ btn.classList.add(cls, 'menu_button');
949- </div>
1025+ btn.dataset.name = dataName;
950-
1026+ btn.title = title;
951- <div class="extension_actions flex-container alignItemsCenter">
1027+ const icon = document.createElement('i');
952- ${updateButton}
1028+ icon.classList.add(...iconClasses.split(' '));
953- ${cleanButton}
1029+ btn.appendChild(icon);
954- ${branchButton}
1030+ return btn;
955- ${moveButton}
1031+ }
956- ${deleteButton}
1032+
957- </div>
1033+ if (isExternal) {
958- </div>`;
1034+ const updateBtn = makeActionButton('btn_update', externalId, t`Update available`, 'fa-solid fa-download fa-fw');
959-
1035+ updateBtn.classList.add('displayNone');
960- return extensionHtml;
1036+ actionsDiv.appendChild(updateBtn);
1037+ }
1038+
1039+ if (isExternal && hasExtensionHook(externalId, 'clean')) {
1040+ actionsDiv.appendChild(makeActionButton('btn_clean', externalId, t`Clean extension data`, 'fa-fw fa-solid fa-broom'));
1041+ }
1042+
1043+ if (isExternal && isUserAdmin) {
1044+ actionsDiv.appendChild(makeActionButton('btn_branch', externalId, t`Switch branch`, 'fa-solid fa-code-branch fa-fw'));
1045+ actionsDiv.appendChild(makeActionButton('btn_move', externalId, t`Move`, 'fa-solid fa-folder-tree fa-fw'));
1046+ }
1047+
1048+ if (isExternal) {
1049+ actionsDiv.appendChild(makeActionButton('btn_delete', externalId, t`Delete`, 'fa-fw fa-solid fa-trash-can'));
1050+ }
1051+
1052+ block.appendChild(actionsDiv);
1053+
1054+ return block;
9611055}
9621056
9631057/**
9641058 * Gets extension data and generates the corresponding HTMLelement for displaying the extension.
9651059 *
9661060 * @param {Array} extension - An array where the first element is the extension name and the second element is the extension manifest.
9671061 * @return {object{isExternal: boolean, extensionElement: HTMLElement}} - An object with 'isExternal' indicating whether the extension is external, and 'extensionHtmlextensionElement' for the extension's HTML stringelement.
9681062 */
9691063function getExtensionData(extension) {
9701064 const name = extension[0];
@@ -974,33 +1068,43 @@ function getExtensionData(extension) {
9741068 const isExternal = name.startsWith('third-party');
9751069
9761070 const checkboxClass = isDisabled ? 'checkbox_disabled' : '';
1071+ const extensionElement = generateExtensionElement(name, manifest, isActive, isDisabled, isExternal, checkboxClass);
9771072
978- const extensionHtml = generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal, checkboxClass);
1073+ return { isExternal, extensionElement };
979-
980- return { isExternal, extensionHtml };
9811074}
9821075
9831076
9841077/**
9851078 * Gets the module information to be displayed.
9861079 *
9871080 * @return {stringHTMLElement} - The HTML stringelement forcontaining the module information.
9881081 */
9891082function getModuleInformation() {
990- let moduleInfo = modules.length ? `<p>${DOMPurify.sanitize(modules.join(', '))}</p>` : '<p class="failure">' + t`Not connected to the API!` + '</p>';
1083+ const container = document.createElement('div');
991- return `
1084+
992- <h3>` + t`Modules provided by your Extras API:` + `</h3>
1085+ const heading = document.createElement('h3');
993- ${moduleInfo}
1086+ heading.textContent = t`Modules provided by your Extras API:`;
994- `;
1087+ container.appendChild(heading);
1088+
1089+ const moduleInfo = document.createElement('p');
1090+ if (modules.length) {
1091+ moduleInfo.textContent = modules.join(', ');
1092+ } else {
1093+ moduleInfo.classList.add('failure');
1094+ moduleInfo.textContent = t`Not connected to the API!`;
1095+ }
1096+ container.appendChild(moduleInfo);
1097+
1098+ return container;
9951099}
9961100
9971101/**
9981102 * Generates HTMLHTMLElement for the extension load errors.
9991103 * @returns {stringHTMLElement} HTML- stringThe element containing the errors that occurred whileextension loadingload extensionserrors.
10001104 */
10011105function getExtensionLoadErrorsHtmlgetExtensionLoadErrors() {
10021106 if (extensionLoadErrors.size === 0) {
10031107 return document.createElement('div');
10041108 }
10051109
10061110 const container = document.createElement('div');
@@ -1012,7 +1116,7 @@ function getExtensionLoadErrorsHtml() {
10121116 container.appendChild(errorElement);
10131117 }
10141118
10151119 return container.outerHTML;
10161120}
10171121
10181122/**
@@ -1029,22 +1133,35 @@ async function showExtensionsDetails() {
10291133 initialScrollTop = oldPopup.content.scrollTop;
10301134 await oldPopup.completeCancelled();
10311135 }
10321136 const htmlErrorserrors = getExtensionLoadErrorsHtmlgetExtensionLoadErrors();
1033- const htmlDefault = $('<div class="marginBot10"><h3>' + t`Built-in Extensions:` + '</h3></div>');
1137+
1034-
1138+ const defaultContainer = document.createElement('div');
1035- const htmlExternal = $(`<div class="marginBot10">
1139+ defaultContainer.classList.add('marginBot10');
1036- <div class="flex-container alignitemscenter spaceBetween flexnowrap marginBot10">
1140+ const defaultHeading = document.createElement('h3');
1037- <h3 class="margin0">${t`Installed Extensions:`}</h3>
1141+ defaultHeading.textContent = t`Built-in Extensions:`;
1038- <div class="flex-container third_party_toolbar"></div>
1142+ defaultContainer.appendChild(defaultHeading);
1039- </div>
1143+
1040- </div>`);
1144+ const externalContainer = document.createElement('div');
1041-
1145+ externalContainer.classList.add('marginBot10');
1042- const htmlLoading = $(`<div class="flex-container alignItemsCenter justifyCenter marginTop10 marginBot5">
1146+ const externalHeader = document.createElement('div');
1043- <i class="fa-solid fa-spinner fa-spin"></i>
1147+ externalHeader.classList.add('flex-container', 'alignitemscenter', 'spaceBetween', 'flexnowrap', 'marginBot10');
1044- <span>` + t`Loading third-party extensions... Please wait...` + `</span>
1148+ const externalHeading = document.createElement('h3');
1045- </div>`);
1149+ externalHeading.classList.add('margin0');
1046-
1150+ externalHeading.textContent = t`Installed Extensions:`;
1047- htmlExternal.append(htmlLoading);
1151+ const thirdPartyToolbar = document.createElement('div');
1152+ thirdPartyToolbar.classList.add('flex-container', 'third_party_toolbar');
1153+ externalHeader.append(externalHeading, thirdPartyToolbar);
1154+ externalContainer.appendChild(externalHeader);
1155+
1156+ const loadingEl = document.createElement('div');
1157+ loadingEl.classList.add('flex-container', 'alignItemsCenter', 'justifyCenter', 'marginTop10', 'marginBot5');
1158+ const loadingIcon = document.createElement('i');
1159+ loadingIcon.classList.add('fa-solid', 'fa-spinner', 'fa-spin');
1160+ const loadingSpan = document.createElement('span');
1161+ loadingSpan.textContent = t`Loading third-party extensions... Please wait...`;
1162+ loadingEl.append(loadingIcon, loadingSpan);
1163+
1164+ externalContainer.appendChild(loadingEl);
10481165
10491166 const sortOrderKey = 'extensions_sortByName';
10501167 const sortByName = accountStorage.getItem(sortOrderKey) === 'true';
@@ -1053,16 +1170,16 @@ async function showExtensionsDetails() {
10531170 let extensionsToToggle = [];
10541171
10551172 extensions.forEach(value => {
10561173 const { isExternal, extensionHtmlextensionElement } = value;
10571174 const container = isExternal ? htmlExternalexternalContainer : htmlDefaultdefaultContainer;
10581175 container.appendappendChild(extensionHtmlextensionElement);
10591176 });
10601177
10611178 const htmlextensionsMenu = $('<div></div>')
10621179 .addClass('extensions_info')
10631180 .append(htmlErrorserrors)
10641181 .append(htmlDefaultdefaultContainer)
10651182 .append(htmlExternalexternalContainer)
10661183 .append(getModuleInformation());
10671184
10681185 {
@@ -1088,23 +1205,24 @@ async function showExtensionsDetails() {
10881205 const toggleAllExtensionsButton = document.createElement('div');
10891206 toggleAllExtensionsButton.classList.add('menu_button', 'menu_button_icon');
10901207 toggleAllExtensionsButton.title = t`Bulk toggle third-party extensions.`;
1091- toggleAllExtensionsButton.innerHTML = `
1208+ const toggleAllLabel = document.createElement('span');
1092- <span>${t`Toggle extensions`}</span>
1209+ toggleAllLabel.textContent = t`Toggle extensions`;
1093- <div class="fa-solid fa-circle-info opacity50p"></div>
1210+ const toggleAllIcon = document.createElement('div');
1094- `;
1211+ toggleAllIcon.classList.add('fa-solid', 'fa-circle-info', 'opacity50p');
1212+ toggleAllExtensionsButton.append(toggleAllLabel, toggleAllIcon);
10951213
10961214 const restoreBulkToggledExtensionsButton = document.createElement('div');
10971215 restoreBulkToggledExtensionsButton.classList.add('menu_button', 'menu_button_icon', 'fa-solid', 'fa-arrow-right-rotate', 'displayNone');
10981216 restoreBulkToggledExtensionsButton.title = t`Restore toggled extensions.\n\nIt does not restore extensions toggled individually.`;
10991217
11001218 toggleAllExtensionsButton.addEventListener('click', () => {
11011219 extensionsToToggle = onToggleAllExtensions(extensionsToToggle, htmlExternal$(externalContainer));
11021220
11031221 for (const extension of extensionsToToggle) {
11041222 const { name } = extension;
11051223
1106- htmlExternal
1224+ $(externalContainer)
11071225 .find(`.extension_block[data-name="${name.replacegetNameSelector('third-party', ''name)}"] .extension_toggle input`)
11081226 .off('click')
11091227 .one('click', () => {
11101228 extensionsToToggle = extensionsToToggle.filter(ext => ext.name !== name);
@@ -1121,8 +1239,8 @@ async function showExtensionsDetails() {
11211239 const { name } = extension;
11221240 const isDisabled = extension_settings.disabledExtensions.includes(name);
11231241
1124- htmlExternal
1242+ $(externalContainer)
11251243 .find(`.extension_block[data-name="${name.replacegetNameSelector('third-party', ''name)}"] .extension_toggle input`)
11261244 .prop('checked', !isDisabled)
11271245 .toggleClass('toggle_enable', isDisabled)
11281246 .toggleClass('toggle_disable', !isDisabled)
@@ -1146,13 +1264,13 @@ async function showExtensionsDetails() {
11461264 });
11471265
11481266 toolbar.append(updateAllButton, updateEnabledOnlyButton, flexExpander, sortOrderButton);
11491267 htmlExternal.find('.third_party_toolbar')thirdPartyToolbar.append(restoreBulkToggledExtensionsButton, toggleAllExtensionsButton);
11501268 htmlextensionsMenu.prepend(toolbar);
11511269 }
11521270
11531271 let waitingForSave = false;
11541272
11551273 const popup = new Popup(htmlextensionsMenu, POPUP_TYPE.TEXT, '', {
11561274 okButton: t`Close`,
11571275 wide: true,
11581276 large: true,
@@ -1194,7 +1312,7 @@ async function showExtensionsDetails() {
11941312 });
11951313 popupPromise = popup.show();
11961314 popup.content.scrollTop = initialScrollTop;
11971315 checkForUpdatesManual(sortFn, abortController.signal).finally(() => htmlLoadingloadingEl.remove());
11981316 } catch (error) {
11991317 toastr.error(t`Error loading extensions. See browser console for details.`);
12001318 console.error(error);
@@ -1297,7 +1415,7 @@ async function onDeleteClick() {
12971415 /** @type {import('./popup.js').CustomPopupInput[]} */
12981416 const customInputs = hasCleanHook ? [{ id: 'extension_delete_cleanup', label: t`Also clean up extension data`, defaultState: false }] : null;
12991417
13001418 const popup = new Popup(t`Are you sure you want to delete ${escapeHtml(extensionName)}?`, POPUP_TYPE.CONFIRM, '', { customInputs });
13011419 const confirmation = await popup.show();
13021420 if (confirmation === POPUP_RESULT.AFFIRMATIVE) {
13031421 const shouldClean = hasCleanHook && Boolean(popup.inputResults?.get('extension_delete_cleanup'));
@@ -1312,7 +1430,7 @@ async function onDeleteClick() {
13121430async function onCleanClick() {
13131431 const extensionName = $(this).data('name');
13141432
13151433 const confirmation = await Popup.show.confirm(t`Clean extension data`, t`Are you sure you want to clean up data for ${escapeHtml(extensionName)}? This action cannot be undone.`);
13161434 if (!confirmation) {
13171435 return;
13181436 }
@@ -1388,8 +1506,8 @@ async function onMoveClick() {
13881506
13891507 const confirmationHeader = t`Move extension`;
13901508 const confirmationText = source == 'global'
13911509 ? t`Are you sure you want to move ${escapeHtml(extensionName)} to your local extensions? This will make it available only for you.`
13921510 : t`Are you sure you want to move ${escapeHtml(extensionName)} to the global extensions? This will make it available for all users.`;
13931511
13941512 const confirmation = await Popup.show.confirm(confirmationHeader, confirmationText);
13951513
@@ -1493,6 +1611,9 @@ async function getExtensionVersion(extensionName, abortSignal) {
14931611 const data = await response.json();
14941612 return data;
14951613 } catch (error) {
1614+ if (error instanceof Error && error.name === 'AbortError') {
1615+ return;
1616+ }
14961617 console.error('Error:', error);
14971618 }
14981619}
@@ -1730,7 +1851,11 @@ async function checkForUpdatesManual(sortFn, abortSignal) {
17301851 const promise = enqueueVersionCheck(async () => {
17311852 try {
17321853 const data = await getExtensionVersion(externalId, abortSignal);
1733- const extensionBlock = document.querySelector(`.extension_block[data-name="${externalId}"]`);
1854+ if (!data) {
1855+ return;
1856+ }
1857+ const selector = getNameSelector(externalId, { prefix: '' });
1858+ const extensionBlock = document.querySelector(`.extension_block[data-name="${selector}"]`);
17341859 if (extensionBlock && data) {
17351860 if (data.isUpToDate === false) {
17361861 const buttonElement = extensionBlock.querySelector('.btn_update');
@@ -1825,6 +1950,9 @@ async function checkForExtensionUpdates(force) {
18251950 const promise = enqueueVersionCheck(async () => {
18261951 try {
18271952 const data = await getExtensionVersion(id.replace('third-party', ''));
1953+ if (!data) {
1954+ return;
1955+ }
18281956 if (!data.isUpToDate) {
18291957 updatesAvailable.push(manifest.display_name);
18301958 }
public/scripts/extensions/assets/index.js+199 -92
@@ -7,10 +7,10 @@ import { DOMPurify } from '../../../lib.js';
77import { getRequestHeaders, processDroppedFiles, eventSource, event_types } from '../../../script.js';
88import { deleteExtension, EMPTY_AUTHOR, extensionNames, getAuthorFromUrl, getContext, installExtension, renderExtensionTemplateAsync, isOfficialExtension } from '../../extensions.js';
99import { POPUP_TYPE, Popup, callGenericPopup } from '../../popup.js';
10-import { executeSlashCommandsWithOptions } from '../../slash-commands.js';
1110import { accountStorage } from '../../util/AccountStorage.js';
1211import { escapeHtml, flashHighlight, getStringHash, isValidUrl } from '../../utils.js';
1312import { t, translate } from '../../i18n.js';
13+import { SlashCommandParser } from '/scripts/slash-commands/SlashCommandParser.js';
1414export { MODULE_NAME };
1515
1616const MODULE_NAME = 'assets';
@@ -60,64 +60,19 @@ const KNOWN_TYPES = {
6060 'blip': t`Blip sounds`,
6161};
6262
63-async function downloadAssetsList(url) {
63+/**
64- updateCurrentAssets().then(async function () {
64+ * Creates the download/delete button element for a single asset, with all interaction handlers attached.
65- fetch(url, { cache: 'no-cache' })
65+ * @param {object} asset The asset data object, containing at least id, name, description and url fields
66- .then(response => response.json())
66+ * @param {string} assetType Asset type, e.g. 'extension', 'character', 'ambient', 'bgm', 'blip'
67- .then(async function (json) {
67+ * @param {number} index Index of the asset in the list of available assets of the same type, used to create a unique element ID
68- availableAssets = {};
68+ * @returns {JQuery} The button element
69- $('#assets_menu').empty();
69+ */
70-
70+function createAssetButton(asset, assetType, index) {
71- console.debug(DEBUG_PREFIX, 'Received assets dictionary', json);
71+ const elemId = `assets_install_${assetType}_${index}`;
72-
72+ const element = $('<div />', { id: elemId, class: 'asset-download-button right_menu_button' });
73- for (const i of json) {
74- //console.log(DEBUG_PREFIX,i)
75- if (availableAssets[i.type] === undefined)
76- availableAssets[i.type] = [];
77-
78- availableAssets[i.type].push(i);
79- }
80-
81- console.debug(DEBUG_PREFIX, 'Updated available assets to', availableAssets);
82- // First extensions, then everything else
83- const assetTypes = Object.keys(availableAssets).sort((a, b) => (a === 'extension') ? -1 : (b === 'extension') ? 1 : 0);
84-
85- $('#assets_type_select').empty();
86- $('#assets_search').val('');
87- $('#assets_type_select').append($('<option />', { value: '', text: t`All` }));
88-
89- for (const type of assetTypes) {
90- const text = translate(KNOWN_TYPES[type] || type);
91- const option = $('<option />', { value: type, text: text });
92- $('#assets_type_select').append(option);
93- }
94-
95- if (assetTypes.includes('extension')) {
96- $('#assets_type_select').val('extension');
97- }
98-
99- $('#assets_type_select').off('change').on('change', filterAssets);
100- $('#assets_search').off('input').on('input', filterAssets);
101-
102- for (const assetType of assetTypes) {
103- let assetTypeMenu = $('<div />', { id: 'assets_audio_ambient_div', class: 'assets-list-div' });
104- assetTypeMenu.attr('data-type', assetType);
105- assetTypeMenu.append(`<h3>${KNOWN_TYPES[assetType] || assetType}</h3>`).hide();
106-
107- if (assetType == 'extension') {
108- assetTypeMenu.append(await renderExtensionTemplateAsync('assets', 'installation'));
109- }
110-
111- for (const asset of availableAssets[assetType].sort((a, b) => a?.name && b?.name && a.name.localeCompare(b.name))) {
112- const i = availableAssets[assetType].indexOf(asset);
113- const elemId = `assets_install_${assetType}_${i}`;
114- let element = $('<div />', { id: elemId, class: 'asset-download-button right_menu_button' });
11573 const label = $('<i class="fa-fw fa-solid fa-download fa-lg"></i>');
11674 element.append(label);
11775
118- //if (DEBUG_TONY_SAMA_FORK_MODE)
119- // asset["url"] = asset["url"].replace("https://github.com/SillyTavern/","https://github.com/Tony-sama/"); // DBG
120-
12176 console.debug(DEBUG_PREFIX, 'Checking asset', asset.id, asset.url);
12277
12378 const assetInstall = async function () {
@@ -150,7 +105,7 @@ async function downloadAssetsList(url) {
150105 const assetDelete = async function () {
151106 if (assetType === 'character') {
152107 toastr.error('Go to the characters menu to delete a character.', 'Character deletion not supported');
153- await executeSlashCommandsWithOptions(`/go ${asset.id}`);
108+ await SlashCommandParser.commands.go.callback(null, asset.id);
154109 return;
155110 }
156111 element.off('click');
@@ -183,6 +138,17 @@ async function downloadAssetsList(url) {
183138 element.on('click', assetInstall);
184139 }
185140
141+ return element;
142+}
143+
144+/**
145+ * Creates the full visual block element for a single asset.
146+ * @param {object} asset The asset data object, containing at least id, name, description and url fields
147+ * @param {string} assetType Asset type, e.g. 'extension', 'character', 'ambient', 'bgm', 'blip'
148+ * @param {JQuery} element The button element from createAssetButton
149+ * @returns {JQuery} The asset block element
150+ */
151+function createAssetBlock(asset, assetType, element) {
186152 console.debug(DEBUG_PREFIX, 'Created element for ', asset.id);
187153
188154 const displayName = DOMPurify.sanitize(asset.name || asset.id);
@@ -193,23 +159,31 @@ async function downloadAssetsList(url) {
193159 const toolTag = assetType === 'extension' && asset.tool;
194160 const author = url && assetType === 'extension' ? getAuthorFromUrl(url) : EMPTY_AUTHOR;
195161
196- const assetBlock = $('<i></i>')
162+ const nameSpan = $('<span>', { class: 'asset-name flex-container alignitemscenter' })
197- .append(element)
163+ .append($('<b>').text(displayName))
198- .append(`<div class="flex-container flexFlowColumn flexNoGap wide100p overflowHidden">
164+ .append($('<a>', { class: 'asset_preview', href: url, target: '_blank', title: title })
199- <span class="asset-name flex-container alignitemscenter">
165+ .append($('<i>', { class: `fa-solid fa-sm ${previewIcon}` })));
200- <b>${displayName}</b>
166+
201- <a class="asset_preview" href="${url}" target="_blank" title="${title}">
167+ if (toolTag) {
202- <i class="fa-solid fa-sm ${previewIcon}"></i>
168+ const tagSpan = $('<span>', { class: 'tag', title: t`Adds a function tool` })
203- </a>` +
169+ .append($('<i>', { class: 'fa-solid fa-sm fa-wrench' }))
204- (toolTag ? '<span class="tag" title="' + t`Adds a function tool` + '"><i class="fa-solid fa-sm fa-wrench"></i> ' +
170+ .append(document.createTextNode(` ${t`Tool`}`));
205- t`Tool` + '</span>' : '') +
171+ nameSpan.append(tagSpan);
206- '<span class="expander"></span>' +
172+ }
207- (author.name ? `<a href="${author.url}" target="_blank" class="asset-author-info"><i class="fa-solid fa-at fa-xs"></i><span>${author.name}</span></a>` : '') +
173+
208- `</span>
174+ nameSpan.append($('<span>', { class: 'expander' }));
209- <small class="asset-description">
175+
210- ${description}
176+ if (author.name) {
211- </small>
177+ nameSpan.append($('<a>', { href: author.url, target: '_blank', class: 'asset-author-info' })
212- </div>`);
178+ .append($('<i>', { class: 'fa-solid fa-at fa-xs' }))
179+ .append($('<span>').text(author.name)));
180+ }
181+
182+ const infoDiv = $('<div>', { class: 'flex-container flexFlowColumn flexNoGap wide100p overflowHidden' })
183+ .append(nameSpan)
184+ .append($('<small>', { class: 'asset-description' }).text(description));
185+
186+ const assetBlock = $('<i></i>').append(element).append(infoDiv);
213187
214188 assetBlock.find('.tag').on('click', function (e) {
215189 const a = document.createElement('a');
@@ -220,12 +194,33 @@ async function downloadAssetsList(url) {
220194
221195 if (assetType === 'character') {
222196 if (asset.highlight) {
223197 assetBlock.find('.asset-name') nameSpan.append($('<i>', { class=": 'fa-solid fa-sm fa-trophy"></i>' }));
224198 }
225- assetBlock.find('.asset-name').prepend(`<div class="avatar"><img src="${asset.url}" alt="${displayName}"></div>`);
199+ nameSpan.prepend($('<div>', { class: 'avatar' }).append($('<img>', { src: asset.url, alt: displayName })));
226200 }
227201
228202 assetBlock.addClass('asset-block');
203+ return assetBlock;
204+}
205+
206+/**
207+ * Builds and appends the menu section for a single asset type.
208+ * @param {string} assetType Asset type, e.g. 'extension', 'character', 'ambient', 'bgm', 'blip'
209+ * @returns {Promise<void>}
210+ */
211+async function buildAssetTypeSection(assetType) {
212+ const assetTypeMenu = $('<div />', { id: `assets_${assetType}_div`, class: 'assets-list-div' });
213+ assetTypeMenu.attr('data-type', assetType);
214+ assetTypeMenu.append($('<h3>').text(KNOWN_TYPES[assetType] || assetType)).hide();
215+
216+ if (assetType == 'extension') {
217+ assetTypeMenu.append(await renderExtensionTemplateAsync('assets', 'installation'));
218+ }
219+
220+ for (const asset of availableAssets[assetType].sort((a, b) => a?.name && b?.name && a.name.localeCompare(b.name))) {
221+ const i = availableAssets[assetType].indexOf(asset);
222+ const element = createAssetButton(asset, assetType, i);
223+ const assetBlock = createAssetBlock(asset, assetType, element);
229224
230225 if (assetType === 'extension') {
231226 const extensionBlockList = isOfficialExtension(asset.url)
@@ -236,29 +231,91 @@ async function downloadAssetsList(url) {
236231 assetTypeMenu.append(assetBlock);
237232 }
238233 }
234+
239235 assetTypeMenu.appendTo('#assets_menu');
240236 assetTypeMenu.on('click', 'a.asset_preview', previewAsset);
241237}
242238
239+/**
240+ * Parses the fetched assets JSON and renders the full assets menu.
241+ * @param {object[]} json Array of asset objects, each containing at least id, name, description, url and type fields
242+ */
243+async function populateAssetsMenu(json) {
244+ availableAssets = {};
245+ $('#assets_menu').empty();
246+
247+ console.debug(DEBUG_PREFIX, 'Received assets dictionary', json);
248+
249+ for (const i of json) {
250+ if (availableAssets[i.type] === undefined)
251+ availableAssets[i.type] = [];
252+ availableAssets[i.type].push(i);
253+ }
254+
255+ console.debug(DEBUG_PREFIX, 'Updated available assets to', availableAssets);
256+ // First extensions, then everything else
257+ const assetTypes = Object.keys(availableAssets).sort((a, b) => (a === 'extension') ? -1 : (b === 'extension') ? 1 : 0);
258+
259+ $('#assets_type_select').empty();
260+ $('#assets_search').val('');
261+ $('#assets_type_select').append($('<option />', { value: '', text: t`All` }));
262+
263+ for (const type of assetTypes) {
264+ const text = translate(KNOWN_TYPES[type] || type);
265+ const option = $('<option />', { value: type, text: text });
266+ $('#assets_type_select').append(option);
267+ }
268+
269+ if (assetTypes.includes('extension')) {
270+ $('#assets_type_select').val('extension');
271+ }
272+
273+ $('#assets_type_select').off('change').on('change', filterAssets);
274+ $('#assets_search').off('input').on('input', filterAssets);
275+
276+ for (const assetType of assetTypes) {
277+ await buildAssetTypeSection(assetType);
278+ }
279+
243280 filterAssets();
244281 $('#assets_filters').show();
245282 $('#assets_menu').show();
246283 })
247- .catch((error) => {
284+
248- // Info hint if the user maybe... likely accidently was trying to install an extension and we wanna help guide them? uwu :3
285+/**
286+ * Downloads the assets list from the given URL and populates the menu. Shows error message if something goes wrong.
287+ * @param {URL} url URL to fetch from
288+ */
289+async function downloadAssetsList(url) {
290+ await updateCurrentAssets();
291+ try {
292+ const response = await fetch(url, { cache: 'no-cache' });
293+ if (!response.ok) {
294+ throw new Error('Cannot download the assets list.');
295+ }
296+ const json = await response.json();
297+ if (!Array.isArray(json)) {
298+ throw new Error('Assets list is not an array');
299+ }
300+ await populateAssetsMenu(json);
301+ } catch (error) {
302+ // Info hint if the user maybe... likely accidentally was trying to install an extension and we wanna help guide them? uwu :3
249303 const installButton = $('#third_party_extension_button');
250304 flashHighlight(installButton, 10_000);
251305 toastr.info('Click the flashing button at the top right corner of the menu.', 'Trying to install a custom extension?', { timeOut: 10_000 });
252306
253307 // Error logged after, to appear on top
254308 console.error(error);
255309 toastr.error('Problem with assets URL', DEBUG_PREFIX + 'Cannot get assets list');
256310 $('#assets-connect-button').addClass('fa-plug-circle-exclamation');
257311 $('#assets-connect-button').addClass('redOverlayGlow');
258- });
312+ }
259- });
260313}
261314
315+/**
316+ * Previews the asset by opening its URL. If it's an audio asset, it plays a preview sound. Otherwise, it opens the URL in a new tab.
317+ * @param {JQuery.Event} e Click event
318+ */
262319function previewAsset(e) {
263320 const href = $(this).attr('href');
264321 const audioExtensions = ['.mp3', '.ogg', '.wav'];
@@ -281,6 +338,15 @@ function previewAsset(e) {
281338 }
282339}
283340
341+/**
342+ * Checks if the asset is already installed.
343+ * For extensions, it checks if the extension name is in the list of installed extensions.
344+ * For characters, it checks if any character has the same avatar URL.
345+ * For other asset types, it checks if any installed asset of the same type has a URL that includes the filename.
346+ * @param {string} assetType Type of the asset, e.g. 'extension', 'character', 'ambient', 'bgm', 'blip'
347+ * @param {string} filename Name or ID of the asset
348+ * @returns {boolean} True if the asset is installed, false otherwise
349+ */
284350function isAssetInstalled(assetType, filename) {
285351 let assetList = currentAssets[assetType];
286352
@@ -302,6 +368,13 @@ function isAssetInstalled(assetType, filename) {
302368 return false;
303369}
304370
371+/**
372+ * Installs the asset by sending a request to the server to download it. If it's an extension, it uses the existing installExtension function.
373+ * @param {string} url URL of the asset to download
374+ * @param {string} assetType Type of the asset, e.g. 'extension', 'character', 'ambient', 'bgm', 'blip'
375+ * @param {string} filename Name or ID of the asset
376+ * @returns {Promise<boolean>} True if the asset was successfully installed, false otherwise
377+ */
305378async function installAsset(url, assetType, filename) {
306379 console.debug(DEBUG_PREFIX, 'Downloading ', url);
307380 const category = assetType;
@@ -326,7 +399,8 @@ async function installAsset(url, assetType, filename) {
326399 console.debug(DEBUG_PREFIX, 'Importing character ', filename);
327400 const blob = await result.blob();
328401 const file = new File([blob], filename, { type: blob.type });
329402 awaitconst processDroppedFilesfileNameMap = new Map([[file, filename]]);
403+ await processDroppedFiles([file], fileNameMap);
330404 console.debug(DEBUG_PREFIX, 'Character downloaded.');
331405 }
332406 return true;
@@ -338,6 +412,12 @@ async function installAsset(url, assetType, filename) {
338412 }
339413}
340414
415+/**
416+ * Deletes the asset by sending a request to the server to delete it. If it's an extension, it uses the existing deleteExtension function.
417+ * @param {string} assetType Type of the asset, e.g. 'extension', 'character', 'ambient', 'bgm', 'blip'
418+ * @param {string} filename Name or ID of the asset
419+ * @returns {Promise<boolean>} True if the asset was successfully deleted, false otherwise
420+ */
341421async function deleteAsset(assetType, filename) {
342422 console.debug(DEBUG_PREFIX, 'Deleting ', assetType, filename);
343423 const category = assetType;
@@ -346,6 +426,7 @@ async function deleteAsset(assetType, filename) {
346426 console.debug(DEBUG_PREFIX, 'Deleting extension ', filename);
347427 await deleteExtension(filename);
348428 console.debug(DEBUG_PREFIX, 'Extension deleted.');
429+ return true;
349430 }
350431
351432 const body = { category, filename };
@@ -357,19 +438,37 @@ async function deleteAsset(assetType, filename) {
357438 });
358439 if (result.ok) {
359440 console.debug(DEBUG_PREFIX, 'Deletion success.');
441+ return true;
360442 }
443+ return false;
361444 } catch (err) {
362445 console.log(err);
363446 return []false;
364447 }
365448}
366449
450+/**
451+ * Opens the character browser popup, which shows all available characters and allows downloading them.
452+ * @param {boolean} forceDefault If true, it uses the default ASSETS_JSON_URL instead of the one from the input field.
453+ * @returns {Promise<void>}
454+ */
367455async function openCharacterBrowser(forceDefault) {
368456 const url = forceDefault ? ASSETS_JSON_URL : String($('#assets-json-url-field').val());
457+ if (!isValidUrl(url)) {
458+ toastr.error('Please enter a valid URL');
459+ return;
460+ }
369461 const fetchResult = await fetch(url, { cache: 'no-cache' });
462+ if (!fetchResult.ok) {
463+ toastr.error('Cannot download the assets list.');
464+ return;
465+ }
370466 const json = await fetchResult.json();
371- const characters = json.filter(x => x.type === 'character');
467+ if (!Array.isArray(json)) {
372-
468+ toastr.error('Assets list is not an array');
469+ return;
470+ }
471+ const characters = json.filter(x => x && x.type === 'character');
373472 if (!characters.length) {
374473 toastr.error('No characters found in the assets list', 'Character browser');
375474 return;
@@ -395,7 +494,10 @@ async function openCharacterBrowser(forceDefault) {
395494 }
396495 });
397496
398- checkMark.toggle(isInstalled);
497+ checkMark.toggle(isInstalled).on('click', async () => {
498+ toastr.error('Go to the characters menu to delete a character.', 'Character deletion not supported');
499+ await SlashCommandParser.commands.go.callback(null, character.id);
500+ });
399501
400502 listElement.append(characterElement);
401503 }
@@ -449,11 +551,16 @@ export async function init() {
449551
450552 const connectButton = windowHtml.find('#assets-connect-button');
451553 connectButton.on('click', async function () {
452554 const urlurlString = DOMPurify.sanitize(String(assetsJsonUrl.val()).trim();
453- const rememberKey = `Assets_SkipConfirm_${getStringHash(url)}`;
555+ if (!isValidUrl(urlString)) {
556+ toastr.error('Please enter a valid URL');
557+ return;
558+ }
559+ const url = new URL(urlString);
560+ const rememberKey = `Assets_SkipConfirm_${getStringHash(url.href)}`;
454561 const skipConfirm = accountStorage.getItem(rememberKey) === 'true';
455562
456563 const confirmation = skipConfirm || await Popup.show.confirm(t`Loading Asset List`, '<span>' + t`Are you sure you want to connect to the following url?` + `</span><var>${escapeHtml(url.href)}</var>`, {
457564 customInputs: [{ id: 'assets-remember', label: 'Don\'t ask again for this URL' }],
458565 onClose: popup => {
459566 if (popup.result) {
@@ -472,7 +579,7 @@ export async function init() {
472579 connectButton.addClass('fa-plug-circle-check');
473580 } catch (error) {
474581 console.error('Error:', error);
475582 toastr.error(`Cannot get assets list from ${url.href}`);
476583 connectButton.removeClass('fa-plug-circle-check');
477584 connectButton.addClass('fa-plug-circle-exclamation');
478585 connectButton.removeClass('redOverlayGlow');