From 97392a4ca0357f089c9e6e7250d76fc73d5cc3ea Mon Sep 17 00:00:00 2001 From: Cohee <18619528+Cohee1207@users.noreply.github.com> Date: Sat, 2 May 2026 20:01:44 +0300 Subject: [PATCH] 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 --- public/scripts/extensions.js | 346 ++++++++++----- public/scripts/extensions/assets/index.js | 513 +++++++++++++--------- 2 files changed, 547 insertions(+), 312 deletions(-) diff --git a/public/scripts/extensions.js b/public/scripts/extensions.js index 3b8369b48..5d708e2af 100644 --- a/public/scripts/extensions.js +++ b/public/scripts/extensions.js @@ -1,9 +1,9 @@ -import { DOMPurify, Popper } from '../lib.js'; +import { Popper } from '../lib.js'; import { eventSource, event_types, saveSettings, saveSettingsDebounced, getRequestHeaders, animation_duration, CLIENT_VERSION } from '../script.js'; import { POPUP_RESULT, POPUP_TYPE, Popup } from './popup.js'; import { renderTemplate, renderTemplateAsync } from './templates.js'; -import { delay, deleteValueByPath, equalsIgnoreCaseAndAccents, isSubsetOf, sanitizeSelector, setValueByPath, versionCompare } from './utils.js'; +import { delay, deleteValueByPath, equalsIgnoreCaseAndAccents, escapeHtml, isSubsetOf, sanitizeSelector, setValueByPath, versionCompare } from './utils.js'; import { getContext } from './st-context.js'; import { isAdmin } from './user.js'; import { addLocaleData, getCurrentLocale, t } from './i18n.js'; @@ -279,6 +279,18 @@ export async function doExtrasFetch(endpoint, args = {}) { return await fetch(endpoint, args); } +/** + * Generates a CSS selector for an extension based on its name, allowing omission of a common prefix. + * @param {string} name Name of the extension, with or without the "third-party" prefix + * @param {object} [options] Optional parameters + * @param {string} [options.prefix] Optional prefix to ignore when generating the selector (e.g. "third-party") + * @returns {string} CSS selector for the extension, with the prefix removed if it was present and specified in options + */ +function getNameSelector(name, { prefix = 'third-party' } = {}) { + const nameWithoutPrefix = prefix && name.startsWith(prefix) ? name.slice(prefix.length) : name; + return CSS.escape(nameWithoutPrefix); +} + /** * Discovers extensions from the API. * @returns {Promise<{name: string, type: string}[]>} @@ -356,7 +368,7 @@ function onToggleAllExtensions(extensionsToToggle, toggleContainer) { } toggleContainer - .find(`.extension_block[data-name="${name.replace('third-party', '')}"] .extension_toggle input`) + .find(`.extension_block[data-name="${getNameSelector(name)}"] .extension_toggle input`) .prop('checked', enable) .toggleClass('toggle_enable', !enable) .toggleClass('toggle_disable', enable) @@ -865,7 +877,7 @@ function addExtensionLocale(name, manifest) { } /** - * Generates HTML string for displaying an extension in the UI. + * Generates an element for displaying an extension in the UI. * * @param {string} name - The name of the extension. * @param {object} manifest - The manifest of the extension. @@ -873,98 +885,180 @@ function addExtensionLocale(name, manifest) { * @param {boolean} isDisabled - Whether the extension is disabled or not. * @param {boolean} isExternal - Whether the extension is external or not. * @param {string} checkboxClass - The class for the checkbox HTML element. - * @return {string} - The HTML string that represents the extension. + * @return {HTMLElement} - The element that represents the extension. */ -function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal, checkboxClass) { +function generateExtensionElement(name, manifest, isActive, isDisabled, isExternal, checkboxClass) { function getExtensionIcon() { const type = getExtensionType(name); + const icon = document.createElement('i'); + icon.classList.add('fa-sm', 'fa-fw', 'fa-solid'); switch (type) { case 'global': - return ''; + icon.classList.add('fa-server'); + icon.title = t`This is a global extension, available for all users.`; + break; case 'local': - return ''; + icon.classList.add('fa-user'); + icon.title = t`This is a local extension, available only for you.`; + break; case 'system': - return ''; + icon.classList.add('fa-cog'); + icon.title = t`This is a built-in extension. It cannot be deleted and updates with the app.`; + break; default: - return ''; + icon.classList.add('fa-question'); + icon.title = t`Unknown extension type.`; + break; } + return icon; } const isUserAdmin = isAdmin(); - const extensionIcon = getExtensionIcon(); const displayName = manifest.display_name; const displayVersion = manifest.version || ''; const externalId = name.replace('third-party', ''); - let originHtml = ''; - if (isExternal) { - originHtml = ''; + + // Root block + const block = document.createElement('div'); + block.classList.add('extension_block'); + block.dataset.name = externalId; + + // Toggle + const toggleDiv = document.createElement('div'); + toggleDiv.classList.add('extension_toggle'); + const toggle = document.createElement('input'); + toggle.type = 'checkbox'; + toggle.dataset.name = name; + if (isActive || isDisabled) { + toggle.title = t`Click to toggle`; + toggle.classList.add(isActive ? 'toggle_disable' : 'toggle_enable'); + if (checkboxClass) toggle.classList.add(checkboxClass); + toggle.checked = isActive; + } else { + toggle.title = t`Cannot enable extension`; + toggle.classList.add('extension_missing'); + if (checkboxClass) toggle.classList.add(checkboxClass); + toggle.disabled = true; } + toggleDiv.appendChild(toggle); + block.appendChild(toggleDiv); - let toggleElement = isActive || isDisabled ? - '` : - ``; + // Icon + const iconDiv = document.createElement('div'); + iconDiv.classList.add('extension_icon'); + iconDiv.appendChild(getExtensionIcon()); + block.appendChild(iconDiv); - let deleteButton = isExternal ? `` : ''; - let updateButton = isExternal ? `` : ''; - let moveButton = isExternal && isUserAdmin ? `` : ''; - let branchButton = isExternal && isUserAdmin ? `` : ''; - let cleanButton = isExternal && hasExtensionHook(externalId, 'clean') ? `` : ''; - let modulesInfo = ''; + // Text block + const textBlock = document.createElement('div'); + textBlock.classList.add('flexGrow', 'extension_text_block'); + + const statusSpan = document.createElement('span'); + statusSpan.className = isActive ? 'extension_enabled' : isDisabled ? 'extension_disabled' : 'extension_missing'; + + const nameSpan = document.createElement('span'); + nameSpan.classList.add('extension_name'); + nameSpan.textContent = displayName; + + const authorSpan = document.createElement('span'); + authorSpan.classList.add('extension_author'); + + const versionSpan = document.createElement('span'); + versionSpan.classList.add('extension_version'); + versionSpan.textContent = displayVersion; + + statusSpan.append(nameSpan, authorSpan, versionSpan); if (isActive && Array.isArray(manifest.optional)) { const optional = new Set(manifest.optional); modules.forEach(x => optional.delete(x)); if (optional.size > 0) { - const optionalString = DOMPurify.sanitize([...optional].join(', ')); - modulesInfo = '
' + t`Optional modules:` + ` ${optionalString}
`; + const modulesDiv = document.createElement('div'); + modulesDiv.classList.add('extension_modules'); + const optionalSpan = document.createElement('span'); + optionalSpan.classList.add('optional'); + optionalSpan.textContent = [...optional].join(', '); + modulesDiv.append(t`Optional modules:`, ' ', optionalSpan); + statusSpan.appendChild(modulesDiv); } - } else if (!isDisabled) { // Neither active nor disabled + } else if (!isDisabled) { + // Neither active nor disabled const requirements = new Set(manifest.requires); modules.forEach(x => requirements.delete(x)); if (requirements.size > 0) { - const requirementsString = DOMPurify.sanitize([...requirements].join(', ')); - modulesInfo = `
Missing modules: ${requirementsString}
`; + const modulesDiv = document.createElement('div'); + modulesDiv.classList.add('extension_modules'); + const failureSpan = document.createElement('span'); + failureSpan.classList.add('failure'); + failureSpan.textContent = [...requirements].join(', '); + modulesDiv.append(t`Missing modules:`, ' ', failureSpan); + statusSpan.appendChild(modulesDiv); } } // if external, wrap the name in a link to the repo + if (isExternal) { + const originLink = document.createElement('a'); + originLink.appendChild(statusSpan); + textBlock.appendChild(originLink); + } else { + textBlock.appendChild(statusSpan); + } - let extensionHtml = ` -
-
- ${toggleElement} -
-
- ${extensionIcon} -
-
- ${originHtml} - - ${DOMPurify.sanitize(displayName)} - - ${DOMPurify.sanitize(displayVersion)} - ${modulesInfo} - - ${isExternal ? '' : ''} -
+ block.appendChild(textBlock); -
- ${updateButton} - ${cleanButton} - ${branchButton} - ${moveButton} - ${deleteButton} -
-
`; + // Actions + const actionsDiv = document.createElement('div'); + actionsDiv.classList.add('extension_actions', 'flex-container', 'alignItemsCenter'); - return extensionHtml; + /** + * Helper function to create an action button for an extension. + * @param {string} cls Class name + * @param {string} dataName Name of the extension + * @param {string} title Title of the button + * @param {string} iconClasses Classes for the icon + * @returns {HTMLButtonElement} The created button element + */ + function makeActionButton(cls, dataName, title, iconClasses) { + const btn = document.createElement('button'); + btn.classList.add(cls, 'menu_button'); + btn.dataset.name = dataName; + btn.title = title; + const icon = document.createElement('i'); + icon.classList.add(...iconClasses.split(' ')); + btn.appendChild(icon); + return btn; + } + + if (isExternal) { + const updateBtn = makeActionButton('btn_update', externalId, t`Update available`, 'fa-solid fa-download fa-fw'); + updateBtn.classList.add('displayNone'); + actionsDiv.appendChild(updateBtn); + } + + if (isExternal && hasExtensionHook(externalId, 'clean')) { + actionsDiv.appendChild(makeActionButton('btn_clean', externalId, t`Clean extension data`, 'fa-fw fa-solid fa-broom')); + } + + if (isExternal && isUserAdmin) { + actionsDiv.appendChild(makeActionButton('btn_branch', externalId, t`Switch branch`, 'fa-solid fa-code-branch fa-fw')); + actionsDiv.appendChild(makeActionButton('btn_move', externalId, t`Move`, 'fa-solid fa-folder-tree fa-fw')); + } + + if (isExternal) { + actionsDiv.appendChild(makeActionButton('btn_delete', externalId, t`Delete`, 'fa-fw fa-solid fa-trash-can')); + } + + block.appendChild(actionsDiv); + + return block; } /** - * Gets extension data and generates the corresponding HTML for displaying the extension. + * Gets extension data and generates the corresponding element for displaying the extension. * * @param {Array} extension - An array where the first element is the extension name and the second element is the extension manifest. - * @return {object} - An object with 'isExternal' indicating whether the extension is external, and 'extensionHtml' for the extension's HTML string. + * @return {{isExternal: boolean, extensionElement: HTMLElement}} - An object with 'isExternal' indicating whether the extension is external, and 'extensionElement' for the extension's HTML element. */ function getExtensionData(extension) { const name = extension[0]; @@ -974,33 +1068,43 @@ function getExtensionData(extension) { const isExternal = name.startsWith('third-party'); const checkboxClass = isDisabled ? 'checkbox_disabled' : ''; + const extensionElement = generateExtensionElement(name, manifest, isActive, isDisabled, isExternal, checkboxClass); - const extensionHtml = generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal, checkboxClass); - - return { isExternal, extensionHtml }; + return { isExternal, extensionElement }; } /** * Gets the module information to be displayed. * - * @return {string} - The HTML string for the module information. + * @return {HTMLElement} - The element containing the module information. */ function getModuleInformation() { - let moduleInfo = modules.length ? `

${DOMPurify.sanitize(modules.join(', '))}

` : '

' + t`Not connected to the API!` + '

'; - return ` -

` + t`Modules provided by your Extras API:` + `

- ${moduleInfo} - `; + const container = document.createElement('div'); + + const heading = document.createElement('h3'); + heading.textContent = t`Modules provided by your Extras API:`; + container.appendChild(heading); + + const moduleInfo = document.createElement('p'); + if (modules.length) { + moduleInfo.textContent = modules.join(', '); + } else { + moduleInfo.classList.add('failure'); + moduleInfo.textContent = t`Not connected to the API!`; + } + container.appendChild(moduleInfo); + + return container; } /** - * Generates HTML for the extension load errors. - * @returns {string} HTML string containing the errors that occurred while loading extensions. + * Generates HTMLElement for the extension load errors. + * @returns {HTMLElement} - The element containing the extension load errors. */ -function getExtensionLoadErrorsHtml() { +function getExtensionLoadErrors() { if (extensionLoadErrors.size === 0) { - return ''; + return document.createElement('div'); } const container = document.createElement('div'); @@ -1012,7 +1116,7 @@ function getExtensionLoadErrorsHtml() { container.appendChild(errorElement); } - return container.outerHTML; + return container; } /** @@ -1029,22 +1133,35 @@ async function showExtensionsDetails() { initialScrollTop = oldPopup.content.scrollTop; await oldPopup.completeCancelled(); } - const htmlErrors = getExtensionLoadErrorsHtml(); - const htmlDefault = $('

' + t`Built-in Extensions:` + '

'); + const errors = getExtensionLoadErrors(); - const htmlExternal = $(`
-
-

${t`Installed Extensions:`}

-
-
-
`); + const defaultContainer = document.createElement('div'); + defaultContainer.classList.add('marginBot10'); + const defaultHeading = document.createElement('h3'); + defaultHeading.textContent = t`Built-in Extensions:`; + defaultContainer.appendChild(defaultHeading); - const htmlLoading = $(`
- - ` + t`Loading third-party extensions... Please wait...` + ` -
`); + const externalContainer = document.createElement('div'); + externalContainer.classList.add('marginBot10'); + const externalHeader = document.createElement('div'); + externalHeader.classList.add('flex-container', 'alignitemscenter', 'spaceBetween', 'flexnowrap', 'marginBot10'); + const externalHeading = document.createElement('h3'); + externalHeading.classList.add('margin0'); + externalHeading.textContent = t`Installed Extensions:`; + const thirdPartyToolbar = document.createElement('div'); + thirdPartyToolbar.classList.add('flex-container', 'third_party_toolbar'); + externalHeader.append(externalHeading, thirdPartyToolbar); + externalContainer.appendChild(externalHeader); - htmlExternal.append(htmlLoading); + const loadingEl = document.createElement('div'); + loadingEl.classList.add('flex-container', 'alignItemsCenter', 'justifyCenter', 'marginTop10', 'marginBot5'); + const loadingIcon = document.createElement('i'); + loadingIcon.classList.add('fa-solid', 'fa-spinner', 'fa-spin'); + const loadingSpan = document.createElement('span'); + loadingSpan.textContent = t`Loading third-party extensions... Please wait...`; + loadingEl.append(loadingIcon, loadingSpan); + + externalContainer.appendChild(loadingEl); const sortOrderKey = 'extensions_sortByName'; const sortByName = accountStorage.getItem(sortOrderKey) === 'true'; @@ -1053,16 +1170,16 @@ async function showExtensionsDetails() { let extensionsToToggle = []; extensions.forEach(value => { - const { isExternal, extensionHtml } = value; - const container = isExternal ? htmlExternal : htmlDefault; - container.append(extensionHtml); + const { isExternal, extensionElement } = value; + const container = isExternal ? externalContainer : defaultContainer; + container.appendChild(extensionElement); }); - const html = $('
') + const extensionsMenu = $('
') .addClass('extensions_info') - .append(htmlErrors) - .append(htmlDefault) - .append(htmlExternal) + .append(errors) + .append(defaultContainer) + .append(externalContainer) .append(getModuleInformation()); { @@ -1088,23 +1205,24 @@ async function showExtensionsDetails() { const toggleAllExtensionsButton = document.createElement('div'); toggleAllExtensionsButton.classList.add('menu_button', 'menu_button_icon'); toggleAllExtensionsButton.title = t`Bulk toggle third-party extensions.`; - toggleAllExtensionsButton.innerHTML = ` - ${t`Toggle extensions`} -
- `; + const toggleAllLabel = document.createElement('span'); + toggleAllLabel.textContent = t`Toggle extensions`; + const toggleAllIcon = document.createElement('div'); + toggleAllIcon.classList.add('fa-solid', 'fa-circle-info', 'opacity50p'); + toggleAllExtensionsButton.append(toggleAllLabel, toggleAllIcon); const restoreBulkToggledExtensionsButton = document.createElement('div'); restoreBulkToggledExtensionsButton.classList.add('menu_button', 'menu_button_icon', 'fa-solid', 'fa-arrow-right-rotate', 'displayNone'); restoreBulkToggledExtensionsButton.title = t`Restore toggled extensions.\n\nIt does not restore extensions toggled individually.`; toggleAllExtensionsButton.addEventListener('click', () => { - extensionsToToggle = onToggleAllExtensions(extensionsToToggle, htmlExternal); + extensionsToToggle = onToggleAllExtensions(extensionsToToggle, $(externalContainer)); for (const extension of extensionsToToggle) { const { name } = extension; - htmlExternal - .find(`.extension_block[data-name="${name.replace('third-party', '')}"] .extension_toggle input`) + $(externalContainer) + .find(`.extension_block[data-name="${getNameSelector(name)}"] .extension_toggle input`) .off('click') .one('click', () => { extensionsToToggle = extensionsToToggle.filter(ext => ext.name !== name); @@ -1121,8 +1239,8 @@ async function showExtensionsDetails() { const { name } = extension; const isDisabled = extension_settings.disabledExtensions.includes(name); - htmlExternal - .find(`.extension_block[data-name="${name.replace('third-party', '')}"] .extension_toggle input`) + $(externalContainer) + .find(`.extension_block[data-name="${getNameSelector(name)}"] .extension_toggle input`) .prop('checked', !isDisabled) .toggleClass('toggle_enable', isDisabled) .toggleClass('toggle_disable', !isDisabled) @@ -1146,13 +1264,13 @@ async function showExtensionsDetails() { }); toolbar.append(updateAllButton, updateEnabledOnlyButton, flexExpander, sortOrderButton); - htmlExternal.find('.third_party_toolbar').append(restoreBulkToggledExtensionsButton, toggleAllExtensionsButton); - html.prepend(toolbar); + thirdPartyToolbar.append(restoreBulkToggledExtensionsButton, toggleAllExtensionsButton); + extensionsMenu.prepend(toolbar); } let waitingForSave = false; - const popup = new Popup(html, POPUP_TYPE.TEXT, '', { + const popup = new Popup(extensionsMenu, POPUP_TYPE.TEXT, '', { okButton: t`Close`, wide: true, large: true, @@ -1194,7 +1312,7 @@ async function showExtensionsDetails() { }); popupPromise = popup.show(); popup.content.scrollTop = initialScrollTop; - checkForUpdatesManual(sortFn, abortController.signal).finally(() => htmlLoading.remove()); + checkForUpdatesManual(sortFn, abortController.signal).finally(() => loadingEl.remove()); } catch (error) { toastr.error(t`Error loading extensions. See browser console for details.`); console.error(error); @@ -1297,7 +1415,7 @@ async function onDeleteClick() { /** @type {import('./popup.js').CustomPopupInput[]} */ const customInputs = hasCleanHook ? [{ id: 'extension_delete_cleanup', label: t`Also clean up extension data`, defaultState: false }] : null; - const popup = new Popup(t`Are you sure you want to delete ${extensionName}?`, POPUP_TYPE.CONFIRM, '', { customInputs }); + const popup = new Popup(t`Are you sure you want to delete ${escapeHtml(extensionName)}?`, POPUP_TYPE.CONFIRM, '', { customInputs }); const confirmation = await popup.show(); if (confirmation === POPUP_RESULT.AFFIRMATIVE) { const shouldClean = hasCleanHook && Boolean(popup.inputResults?.get('extension_delete_cleanup')); @@ -1312,7 +1430,7 @@ async function onDeleteClick() { async function onCleanClick() { const extensionName = $(this).data('name'); - const confirmation = await Popup.show.confirm(t`Clean extension data`, t`Are you sure you want to clean up data for ${extensionName}? This action cannot be undone.`); + 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.`); if (!confirmation) { return; } @@ -1388,8 +1506,8 @@ async function onMoveClick() { const confirmationHeader = t`Move extension`; const confirmationText = source == 'global' - ? t`Are you sure you want to move ${extensionName} to your local extensions? This will make it available only for you.` - : t`Are you sure you want to move ${extensionName} to the global extensions? This will make it available for all users.`; + ? t`Are you sure you want to move ${escapeHtml(extensionName)} to your local extensions? This will make it available only for you.` + : t`Are you sure you want to move ${escapeHtml(extensionName)} to the global extensions? This will make it available for all users.`; const confirmation = await Popup.show.confirm(confirmationHeader, confirmationText); @@ -1493,6 +1611,9 @@ async function getExtensionVersion(extensionName, abortSignal) { const data = await response.json(); return data; } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + return; + } console.error('Error:', error); } } @@ -1730,7 +1851,11 @@ async function checkForUpdatesManual(sortFn, abortSignal) { const promise = enqueueVersionCheck(async () => { try { const data = await getExtensionVersion(externalId, abortSignal); - const extensionBlock = document.querySelector(`.extension_block[data-name="${externalId}"]`); + if (!data) { + return; + } + const selector = getNameSelector(externalId, { prefix: '' }); + const extensionBlock = document.querySelector(`.extension_block[data-name="${selector}"]`); if (extensionBlock && data) { if (data.isUpToDate === false) { const buttonElement = extensionBlock.querySelector('.btn_update'); @@ -1825,6 +1950,9 @@ async function checkForExtensionUpdates(force) { const promise = enqueueVersionCheck(async () => { try { const data = await getExtensionVersion(id.replace('third-party', '')); + if (!data) { + return; + } if (!data.isUpToDate) { updatesAvailable.push(manifest.display_name); } diff --git a/public/scripts/extensions/assets/index.js b/public/scripts/extensions/assets/index.js index 09d791e6e..7067d3497 100644 --- a/public/scripts/extensions/assets/index.js +++ b/public/scripts/extensions/assets/index.js @@ -7,10 +7,10 @@ import { DOMPurify } from '../../../lib.js'; import { getRequestHeaders, processDroppedFiles, eventSource, event_types } from '../../../script.js'; import { deleteExtension, EMPTY_AUTHOR, extensionNames, getAuthorFromUrl, getContext, installExtension, renderExtensionTemplateAsync, isOfficialExtension } from '../../extensions.js'; import { POPUP_TYPE, Popup, callGenericPopup } from '../../popup.js'; -import { executeSlashCommandsWithOptions } from '../../slash-commands.js'; import { accountStorage } from '../../util/AccountStorage.js'; -import { flashHighlight, getStringHash, isValidUrl } from '../../utils.js'; +import { escapeHtml, flashHighlight, getStringHash, isValidUrl } from '../../utils.js'; import { t, translate } from '../../i18n.js'; +import { SlashCommandParser } from '/scripts/slash-commands/SlashCommandParser.js'; export { MODULE_NAME }; const MODULE_NAME = 'assets'; @@ -60,205 +60,262 @@ const KNOWN_TYPES = { 'blip': t`Blip sounds`, }; -async function downloadAssetsList(url) { - updateCurrentAssets().then(async function () { - fetch(url, { cache: 'no-cache' }) - .then(response => response.json()) - .then(async function (json) { - availableAssets = {}; - $('#assets_menu').empty(); +/** + * Creates the download/delete button element for a single asset, with all interaction handlers attached. + * @param {object} asset The asset data object, containing at least id, name, description and url fields + * @param {string} assetType Asset type, e.g. 'extension', 'character', 'ambient', 'bgm', 'blip' + * @param {number} index Index of the asset in the list of available assets of the same type, used to create a unique element ID + * @returns {JQuery} The button element + */ +function createAssetButton(asset, assetType, index) { + const elemId = `assets_install_${assetType}_${index}`; + const element = $('
', { id: elemId, class: 'asset-download-button right_menu_button' }); + const label = $(''); + element.append(label); - console.debug(DEBUG_PREFIX, 'Received assets dictionary', json); + console.debug(DEBUG_PREFIX, 'Checking asset', asset.id, asset.url); - for (const i of json) { - //console.log(DEBUG_PREFIX,i) - if (availableAssets[i.type] === undefined) - availableAssets[i.type] = []; + const assetInstall = async function () { + element.off('click'); + label.removeClass('fa-download'); + this.classList.add('asset-download-button-loading'); + const result = await installAsset(asset.url, assetType, asset.id); + if (!result) { + this.classList.remove('asset-download-button-loading'); + label.addClass('fa-download'); + label.removeClass('fa-spinner'); + label.removeClass('fa-spin'); + element.on('click', assetInstall); + return; + } + label.addClass('fa-check'); + this.classList.remove('asset-download-button-loading'); + element.on('click', assetDelete); + element.on('mouseenter', function () { + label.removeClass('fa-check'); + label.addClass('fa-trash'); + label.addClass('redOverlayGlow'); + }).on('mouseleave', function () { + label.addClass('fa-check'); + label.removeClass('fa-trash'); + label.removeClass('redOverlayGlow'); + }); + }; - availableAssets[i.type].push(i); - } + const assetDelete = async function () { + if (assetType === 'character') { + toastr.error('Go to the characters menu to delete a character.', 'Character deletion not supported'); + await SlashCommandParser.commands.go.callback(null, asset.id); + return; + } + element.off('click'); + await deleteAsset(assetType, asset.id); + label.removeClass('fa-check'); + label.removeClass('redOverlayGlow'); + label.removeClass('fa-trash'); + label.addClass('fa-download'); + element.off('mouseenter').off('mouseleave'); + element.on('click', assetInstall); + }; - console.debug(DEBUG_PREFIX, 'Updated available assets to', availableAssets); - // First extensions, then everything else - const assetTypes = Object.keys(availableAssets).sort((a, b) => (a === 'extension') ? -1 : (b === 'extension') ? 1 : 0); + if (isAssetInstalled(assetType, asset.id)) { + console.debug(DEBUG_PREFIX, 'installed, checked'); + label.toggleClass('fa-download'); + label.toggleClass('fa-check'); + element.on('click', assetDelete); + element.on('mouseenter', function () { + label.removeClass('fa-check'); + label.addClass('fa-trash'); + label.addClass('redOverlayGlow'); + }).on('mouseleave', function () { + label.addClass('fa-check'); + label.removeClass('fa-trash'); + label.removeClass('redOverlayGlow'); + }); + } else { + console.debug(DEBUG_PREFIX, 'not installed, unchecked'); + element.prop('checked', false); + element.on('click', assetInstall); + } - $('#assets_type_select').empty(); - $('#assets_search').val(''); - $('#assets_type_select').append($('