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
Signed| @@ -1,9 +1,9 @@ | ||
| 1 | 1 | import { DOMPurify, Popper } from '../lib.js'; |
| 2 | 2 | |
| 3 | 3 | import { eventSource, event_types, saveSettings, saveSettingsDebounced, getRequestHeaders, animation_duration, CLIENT_VERSION } from '../script.js'; |
| 4 | 4 | import { POPUP_RESULT, POPUP_TYPE, Popup } from './popup.js'; |
| 5 | 5 | import { renderTemplate, renderTemplateAsync } from './templates.js'; |
| 6 | 6 | import { delay, deleteValueByPath, equalsIgnoreCaseAndAccents, escapeHtml, isSubsetOf, sanitizeSelector, setValueByPath, versionCompare } from './utils.js'; |
| 7 | 7 | import { getContext } from './st-context.js'; |
| 8 | 8 | import { isAdmin } from './user.js'; |
| 9 | 9 | import { addLocaleData, getCurrentLocale, t } from './i18n.js'; |
| @@ -280,6 +280,18 @@ export async function doExtrasFetch(endpoint, args = {}) { | ||
| 280 | 280 | } |
| 281 | 281 | |
| 282 | 282 | /** |
| 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 | +/** | |
| 283 | 295 | * Discovers extensions from the API. |
| 284 | 296 | * @returns {Promise<{name: string, type: string}[]>} |
| 285 | 297 | */ |
| @@ -356,7 +368,7 @@ function onToggleAllExtensions(extensionsToToggle, toggleContainer) { | ||
| 356 | 368 | } |
| 357 | 369 | |
| 358 | 370 | toggleContainer |
| 359 | 371 | .find(`.extension_block[data-name="${name.replacegetNameSelector('third-party', ''name)}"] .extension_toggle input`) |
| 360 | 372 | .prop('checked', enable) |
| 361 | 373 | .toggleClass('toggle_enable', !enable) |
| 362 | 374 | .toggleClass('toggle_disable', enable) |
| @@ -865,7 +877,7 @@ function addExtensionLocale(name, manifest) { | ||
| 865 | 877 | } |
| 866 | 878 | |
| 867 | 879 | /** |
| 868 | 880 | * Generates HTMLan stringelement for displaying an extension in the UI. |
| 869 | 881 | * |
| 870 | 882 | * @param {string} name - The name of the extension. |
| 871 | 883 | * @param {object} manifest - The manifest of the extension. |
| @@ -873,98 +885,180 @@ function addExtensionLocale(name, manifest) { | ||
| 873 | 885 | * @param {boolean} isDisabled - Whether the extension is disabled or not. |
| 874 | 886 | * @param {boolean} isExternal - Whether the extension is external or not. |
| 875 | 887 | * @param {string} checkboxClass - The class for the checkbox HTML element. |
| 876 | 888 | * @return {stringHTMLElement} - The HTML stringelement that represents the extension. |
| 877 | 889 | */ |
| 878 | 890 | function generateExtensionHtmlgenerateExtensionElement(name, manifest, isActive, isDisabled, isExternal, checkboxClass) { |
| 879 | 891 | function getExtensionIcon() { |
| 880 | 892 | const type = getExtensionType(name); |
| 893 | + const icon = document.createElement('i'); | |
| 894 | + icon.classList.add('fa-sm', 'fa-fw', 'fa-solid'); | |
| 881 | 895 | switch (type) { |
| 882 | 896 | 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; | |
| 884 | 900 | 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; | |
| 886 | 904 | 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; | |
| 888 | 908 | 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; | |
| 890 | 912 | } |
| 913 | + return icon; | |
| 891 | 914 | } |
| 892 | 915 | |
| 893 | 916 | const isUserAdmin = isAdmin(); |
| 894 | - const extensionIcon = getExtensionIcon(); | |
| 895 | 917 | const displayName = manifest.display_name; |
| 896 | 918 | const displayVersion = manifest.version || ''; |
| 897 | 919 | 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; | |
| 901 | 942 | } |
| 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'); | |
| 902 | 955 | |
| 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>`; | |
| 906 | 958 | |
| 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); | |
| 913 | 971 | |
| 914 | 972 | if (isActive && Array.isArray(manifest.optional)) { |
| 915 | 973 | const optional = new Set(manifest.optional); |
| 916 | 974 | modules.forEach(x => optional.delete(x)); |
| 917 | 975 | if (optional.size > 0) { |
| 918 | 976 | 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 | |
| 922 | 986 | const requirements = new Set(manifest.requires); |
| 923 | 987 | modules.forEach(x => requirements.delete(x)); |
| 924 | 988 | if (requirements.size > 0) { |
| 925 | 989 | 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); | |
| 927 | 996 | } |
| 928 | 997 | } |
| 929 | 998 | |
| 930 | 999 | // 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 | + } | |
| 931 | 1007 | |
| 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; | |
| 961 | 1055 | } |
| 962 | 1056 | |
| 963 | 1057 | /** |
| 964 | 1058 | * Gets extension data and generates the corresponding HTMLelement for displaying the extension. |
| 965 | 1059 | * |
| 966 | 1060 | * @param {Array} extension - An array where the first element is the extension name and the second element is the extension manifest. |
| 967 | 1061 | * @return {object{isExternal: boolean, extensionElement: HTMLElement}} - An object with 'isExternal' indicating whether the extension is external, and 'extensionHtmlextensionElement' for the extension's HTML stringelement. |
| 968 | 1062 | */ |
| 969 | 1063 | function getExtensionData(extension) { |
| 970 | 1064 | const name = extension[0]; |
| @@ -974,33 +1068,43 @@ function getExtensionData(extension) { | ||
| 974 | 1068 | const isExternal = name.startsWith('third-party'); |
| 975 | 1069 | |
| 976 | 1070 | const checkboxClass = isDisabled ? 'checkbox_disabled' : ''; |
| 1071 | + const extensionElement = generateExtensionElement(name, manifest, isActive, isDisabled, isExternal, checkboxClass); | |
| 977 | 1072 | |
| 978 | - const extensionHtml = generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal, checkboxClass); | |
| 1073 | + return { isExternal, extensionElement }; | |
| 979 | - | |
| 980 | - return { isExternal, extensionHtml }; | |
| 981 | 1074 | } |
| 982 | 1075 | |
| 983 | 1076 | |
| 984 | 1077 | /** |
| 985 | 1078 | * Gets the module information to be displayed. |
| 986 | 1079 | * |
| 987 | 1080 | * @return {stringHTMLElement} - The HTML stringelement forcontaining the module information. |
| 988 | 1081 | */ |
| 989 | 1082 | function 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; | |
| 995 | 1099 | } |
| 996 | 1100 | |
| 997 | 1101 | /** |
| 998 | 1102 | * Generates HTMLHTMLElement for the extension load errors. |
| 999 | 1103 | * @returns {stringHTMLElement} HTML- stringThe element containing the errors that occurred whileextension loadingload extensionserrors. |
| 1000 | 1104 | */ |
| 1001 | 1105 | function getExtensionLoadErrorsHtmlgetExtensionLoadErrors() { |
| 1002 | 1106 | if (extensionLoadErrors.size === 0) { |
| 1003 | 1107 | return document.createElement('div'); |
| 1004 | 1108 | } |
| 1005 | 1109 | |
| 1006 | 1110 | const container = document.createElement('div'); |
| @@ -1012,7 +1116,7 @@ function getExtensionLoadErrorsHtml() { | ||
| 1012 | 1116 | container.appendChild(errorElement); |
| 1013 | 1117 | } |
| 1014 | 1118 | |
| 1015 | 1119 | return container.outerHTML; |
| 1016 | 1120 | } |
| 1017 | 1121 | |
| 1018 | 1122 | /** |
| @@ -1029,22 +1133,35 @@ async function showExtensionsDetails() { | ||
| 1029 | 1133 | initialScrollTop = oldPopup.content.scrollTop; |
| 1030 | 1134 | await oldPopup.completeCancelled(); |
| 1031 | 1135 | } |
| 1032 | 1136 | 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); | |
| 1048 | 1165 | |
| 1049 | 1166 | const sortOrderKey = 'extensions_sortByName'; |
| 1050 | 1167 | const sortByName = accountStorage.getItem(sortOrderKey) === 'true'; |
| @@ -1053,16 +1170,16 @@ async function showExtensionsDetails() { | ||
| 1053 | 1170 | let extensionsToToggle = []; |
| 1054 | 1171 | |
| 1055 | 1172 | extensions.forEach(value => { |
| 1056 | 1173 | const { isExternal, extensionHtmlextensionElement } = value; |
| 1057 | 1174 | const container = isExternal ? htmlExternalexternalContainer : htmlDefaultdefaultContainer; |
| 1058 | 1175 | container.appendappendChild(extensionHtmlextensionElement); |
| 1059 | 1176 | }); |
| 1060 | 1177 | |
| 1061 | 1178 | const htmlextensionsMenu = $('<div></div>') |
| 1062 | 1179 | .addClass('extensions_info') |
| 1063 | 1180 | .append(htmlErrorserrors) |
| 1064 | 1181 | .append(htmlDefaultdefaultContainer) |
| 1065 | 1182 | .append(htmlExternalexternalContainer) |
| 1066 | 1183 | .append(getModuleInformation()); |
| 1067 | 1184 | |
| 1068 | 1185 | { |
| @@ -1088,23 +1205,24 @@ async function showExtensionsDetails() { | ||
| 1088 | 1205 | const toggleAllExtensionsButton = document.createElement('div'); |
| 1089 | 1206 | toggleAllExtensionsButton.classList.add('menu_button', 'menu_button_icon'); |
| 1090 | 1207 | 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); | |
| 1095 | 1213 | |
| 1096 | 1214 | const restoreBulkToggledExtensionsButton = document.createElement('div'); |
| 1097 | 1215 | restoreBulkToggledExtensionsButton.classList.add('menu_button', 'menu_button_icon', 'fa-solid', 'fa-arrow-right-rotate', 'displayNone'); |
| 1098 | 1216 | restoreBulkToggledExtensionsButton.title = t`Restore toggled extensions.\n\nIt does not restore extensions toggled individually.`; |
| 1099 | 1217 | |
| 1100 | 1218 | toggleAllExtensionsButton.addEventListener('click', () => { |
| 1101 | 1219 | extensionsToToggle = onToggleAllExtensions(extensionsToToggle, htmlExternal$(externalContainer)); |
| 1102 | 1220 | |
| 1103 | 1221 | for (const extension of extensionsToToggle) { |
| 1104 | 1222 | const { name } = extension; |
| 1105 | 1223 | |
| 1106 | - htmlExternal | |
| 1224 | + $(externalContainer) | |
| 1107 | 1225 | .find(`.extension_block[data-name="${name.replacegetNameSelector('third-party', ''name)}"] .extension_toggle input`) |
| 1108 | 1226 | .off('click') |
| 1109 | 1227 | .one('click', () => { |
| 1110 | 1228 | extensionsToToggle = extensionsToToggle.filter(ext => ext.name !== name); |
| @@ -1121,8 +1239,8 @@ async function showExtensionsDetails() { | ||
| 1121 | 1239 | const { name } = extension; |
| 1122 | 1240 | const isDisabled = extension_settings.disabledExtensions.includes(name); |
| 1123 | 1241 | |
| 1124 | - htmlExternal | |
| 1242 | + $(externalContainer) | |
| 1125 | 1243 | .find(`.extension_block[data-name="${name.replacegetNameSelector('third-party', ''name)}"] .extension_toggle input`) |
| 1126 | 1244 | .prop('checked', !isDisabled) |
| 1127 | 1245 | .toggleClass('toggle_enable', isDisabled) |
| 1128 | 1246 | .toggleClass('toggle_disable', !isDisabled) |
| @@ -1146,13 +1264,13 @@ async function showExtensionsDetails() { | ||
| 1146 | 1264 | }); |
| 1147 | 1265 | |
| 1148 | 1266 | toolbar.append(updateAllButton, updateEnabledOnlyButton, flexExpander, sortOrderButton); |
| 1149 | 1267 | htmlExternal.find('.third_party_toolbar')thirdPartyToolbar.append(restoreBulkToggledExtensionsButton, toggleAllExtensionsButton); |
| 1150 | 1268 | htmlextensionsMenu.prepend(toolbar); |
| 1151 | 1269 | } |
| 1152 | 1270 | |
| 1153 | 1271 | let waitingForSave = false; |
| 1154 | 1272 | |
| 1155 | 1273 | const popup = new Popup(htmlextensionsMenu, POPUP_TYPE.TEXT, '', { |
| 1156 | 1274 | okButton: t`Close`, |
| 1157 | 1275 | wide: true, |
| 1158 | 1276 | large: true, |
| @@ -1194,7 +1312,7 @@ async function showExtensionsDetails() { | ||
| 1194 | 1312 | }); |
| 1195 | 1313 | popupPromise = popup.show(); |
| 1196 | 1314 | popup.content.scrollTop = initialScrollTop; |
| 1197 | 1315 | checkForUpdatesManual(sortFn, abortController.signal).finally(() => htmlLoadingloadingEl.remove()); |
| 1198 | 1316 | } catch (error) { |
| 1199 | 1317 | toastr.error(t`Error loading extensions. See browser console for details.`); |
| 1200 | 1318 | console.error(error); |
| @@ -1297,7 +1415,7 @@ async function onDeleteClick() { | ||
| 1297 | 1415 | /** @type {import('./popup.js').CustomPopupInput[]} */ |
| 1298 | 1416 | const customInputs = hasCleanHook ? [{ id: 'extension_delete_cleanup', label: t`Also clean up extension data`, defaultState: false }] : null; |
| 1299 | 1417 | |
| 1300 | 1418 | const popup = new Popup(t`Are you sure you want to delete ${escapeHtml(extensionName)}?`, POPUP_TYPE.CONFIRM, '', { customInputs }); |
| 1301 | 1419 | const confirmation = await popup.show(); |
| 1302 | 1420 | if (confirmation === POPUP_RESULT.AFFIRMATIVE) { |
| 1303 | 1421 | const shouldClean = hasCleanHook && Boolean(popup.inputResults?.get('extension_delete_cleanup')); |
| @@ -1312,7 +1430,7 @@ async function onDeleteClick() { | ||
| 1312 | 1430 | async function onCleanClick() { |
| 1313 | 1431 | const extensionName = $(this).data('name'); |
| 1314 | 1432 | |
| 1315 | 1433 | 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.`); |
| 1316 | 1434 | if (!confirmation) { |
| 1317 | 1435 | return; |
| 1318 | 1436 | } |
| @@ -1388,8 +1506,8 @@ async function onMoveClick() { | ||
| 1388 | 1506 | |
| 1389 | 1507 | const confirmationHeader = t`Move extension`; |
| 1390 | 1508 | const confirmationText = source == 'global' |
| 1391 | 1509 | ? t`Are you sure you want to move ${escapeHtml(extensionName)} to your local extensions? This will make it available only for you.` |
| 1392 | 1510 | : t`Are you sure you want to move ${escapeHtml(extensionName)} to the global extensions? This will make it available for all users.`; |
| 1393 | 1511 | |
| 1394 | 1512 | const confirmation = await Popup.show.confirm(confirmationHeader, confirmationText); |
| 1395 | 1513 | |
| @@ -1493,6 +1611,9 @@ async function getExtensionVersion(extensionName, abortSignal) { | ||
| 1493 | 1611 | const data = await response.json(); |
| 1494 | 1612 | return data; |
| 1495 | 1613 | } catch (error) { |
| 1614 | + if (error instanceof Error && error.name === 'AbortError') { | |
| 1615 | + return; | |
| 1616 | + } | |
| 1496 | 1617 | console.error('Error:', error); |
| 1497 | 1618 | } |
| 1498 | 1619 | } |
| @@ -1730,7 +1851,11 @@ async function checkForUpdatesManual(sortFn, abortSignal) { | ||
| 1730 | 1851 | const promise = enqueueVersionCheck(async () => { |
| 1731 | 1852 | try { |
| 1732 | 1853 | 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}"]`); | |
| 1734 | 1859 | if (extensionBlock && data) { |
| 1735 | 1860 | if (data.isUpToDate === false) { |
| 1736 | 1861 | const buttonElement = extensionBlock.querySelector('.btn_update'); |
| @@ -1825,6 +1950,9 @@ async function checkForExtensionUpdates(force) { | ||
| 1825 | 1950 | const promise = enqueueVersionCheck(async () => { |
| 1826 | 1951 | try { |
| 1827 | 1952 | const data = await getExtensionVersion(id.replace('third-party', '')); |
| 1953 | + if (!data) { | |
| 1954 | + return; | |
| 1955 | + } | |
| 1828 | 1956 | if (!data.isUpToDate) { |
| 1829 | 1957 | updatesAvailable.push(manifest.display_name); |
| 1830 | 1958 | } |
| @@ -7,10 +7,10 @@ import { DOMPurify } from '../../../lib.js'; | ||
| 7 | 7 | import { getRequestHeaders, processDroppedFiles, eventSource, event_types } from '../../../script.js'; |
| 8 | 8 | import { deleteExtension, EMPTY_AUTHOR, extensionNames, getAuthorFromUrl, getContext, installExtension, renderExtensionTemplateAsync, isOfficialExtension } from '../../extensions.js'; |
| 9 | 9 | import { POPUP_TYPE, Popup, callGenericPopup } from '../../popup.js'; |
| 10 | -import { executeSlashCommandsWithOptions } from '../../slash-commands.js'; | |
| 11 | 10 | import { accountStorage } from '../../util/AccountStorage.js'; |
| 12 | 11 | import { escapeHtml, flashHighlight, getStringHash, isValidUrl } from '../../utils.js'; |
| 13 | 12 | import { t, translate } from '../../i18n.js'; |
| 13 | +import { SlashCommandParser } from '/scripts/slash-commands/SlashCommandParser.js'; | |
| 14 | 14 | export { MODULE_NAME }; |
| 15 | 15 | |
| 16 | 16 | const MODULE_NAME = 'assets'; |
| @@ -60,64 +60,19 @@ const KNOWN_TYPES = { | ||
| 60 | 60 | 'blip': t`Blip sounds`, |
| 61 | 61 | }; |
| 62 | 62 | |
| 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' }); | |
| 115 | 73 | const label = $('<i class="fa-fw fa-solid fa-download fa-lg"></i>'); |
| 116 | 74 | element.append(label); |
| 117 | 75 | |
| 118 | - //if (DEBUG_TONY_SAMA_FORK_MODE) | |
| 119 | - // asset["url"] = asset["url"].replace("https://github.com/SillyTavern/","https://github.com/Tony-sama/"); // DBG | |
| 120 | - | |
| 121 | 76 | console.debug(DEBUG_PREFIX, 'Checking asset', asset.id, asset.url); |
| 122 | 77 | |
| 123 | 78 | const assetInstall = async function () { |
| @@ -150,7 +105,7 @@ async function downloadAssetsList(url) { | ||
| 150 | 105 | const assetDelete = async function () { |
| 151 | 106 | if (assetType === 'character') { |
| 152 | 107 | 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); | |
| 154 | 109 | return; |
| 155 | 110 | } |
| 156 | 111 | element.off('click'); |
| @@ -183,6 +138,17 @@ async function downloadAssetsList(url) { | ||
| 183 | 138 | element.on('click', assetInstall); |
| 184 | 139 | } |
| 185 | 140 | |
| 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) { | |
| 186 | 152 | console.debug(DEBUG_PREFIX, 'Created element for ', asset.id); |
| 187 | 153 | |
| 188 | 154 | const displayName = DOMPurify.sanitize(asset.name || asset.id); |
| @@ -193,23 +159,31 @@ async function downloadAssetsList(url) { | ||
| 193 | 159 | const toolTag = assetType === 'extension' && asset.tool; |
| 194 | 160 | const author = url && assetType === 'extension' ? getAuthorFromUrl(url) : EMPTY_AUTHOR; |
| 195 | 161 | |
| 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); | |
| 213 | 187 | |
| 214 | 188 | assetBlock.find('.tag').on('click', function (e) { |
| 215 | 189 | const a = document.createElement('a'); |
| @@ -220,12 +194,33 @@ async function downloadAssetsList(url) { | ||
| 220 | 194 | |
| 221 | 195 | if (assetType === 'character') { |
| 222 | 196 | if (asset.highlight) { |
| 223 | 197 | assetBlock.find('.asset-name') nameSpan.append($('<i>', { class=": 'fa-solid fa-sm fa-trophy"></i>' })); |
| 224 | 198 | } |
| 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 }))); | |
| 226 | 200 | } |
| 227 | 201 | |
| 228 | 202 | 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); | |
| 229 | 224 | |
| 230 | 225 | if (assetType === 'extension') { |
| 231 | 226 | const extensionBlockList = isOfficialExtension(asset.url) |
| @@ -236,29 +231,91 @@ async function downloadAssetsList(url) { | ||
| 236 | 231 | assetTypeMenu.append(assetBlock); |
| 237 | 232 | } |
| 238 | 233 | } |
| 234 | + | |
| 239 | 235 | assetTypeMenu.appendTo('#assets_menu'); |
| 240 | 236 | assetTypeMenu.on('click', 'a.asset_preview', previewAsset); |
| 241 | 237 | } |
| 242 | 238 | |
| 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 | + | |
| 243 | 280 | filterAssets(); |
| 244 | 281 | $('#assets_filters').show(); |
| 245 | 282 | $('#assets_menu').show(); |
| 246 | 283 | }) |
| 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 | |
| 249 | 303 | const installButton = $('#third_party_extension_button'); |
| 250 | 304 | flashHighlight(installButton, 10_000); |
| 251 | 305 | toastr.info('Click the flashing button at the top right corner of the menu.', 'Trying to install a custom extension?', { timeOut: 10_000 }); |
| 252 | 306 | |
| 253 | 307 | // Error logged after, to appear on top |
| 254 | 308 | console.error(error); |
| 255 | 309 | toastr.error('Problem with assets URL', DEBUG_PREFIX + 'Cannot get assets list'); |
| 256 | 310 | $('#assets-connect-button').addClass('fa-plug-circle-exclamation'); |
| 257 | 311 | $('#assets-connect-button').addClass('redOverlayGlow'); |
| 258 | - }); | |
| 312 | + } | |
| 259 | - }); | |
| 260 | 313 | } |
| 261 | 314 | |
| 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 | + */ | |
| 262 | 319 | function previewAsset(e) { |
| 263 | 320 | const href = $(this).attr('href'); |
| 264 | 321 | const audioExtensions = ['.mp3', '.ogg', '.wav']; |
| @@ -281,6 +338,15 @@ function previewAsset(e) { | ||
| 281 | 338 | } |
| 282 | 339 | } |
| 283 | 340 | |
| 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 | + */ | |
| 284 | 350 | function isAssetInstalled(assetType, filename) { |
| 285 | 351 | let assetList = currentAssets[assetType]; |
| 286 | 352 | |
| @@ -302,6 +368,13 @@ function isAssetInstalled(assetType, filename) { | ||
| 302 | 368 | return false; |
| 303 | 369 | } |
| 304 | 370 | |
| 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 | + */ | |
| 305 | 378 | async function installAsset(url, assetType, filename) { |
| 306 | 379 | console.debug(DEBUG_PREFIX, 'Downloading ', url); |
| 307 | 380 | const category = assetType; |
| @@ -326,7 +399,8 @@ async function installAsset(url, assetType, filename) { | ||
| 326 | 399 | console.debug(DEBUG_PREFIX, 'Importing character ', filename); |
| 327 | 400 | const blob = await result.blob(); |
| 328 | 401 | const file = new File([blob], filename, { type: blob.type }); |
| 329 | 402 | awaitconst processDroppedFilesfileNameMap = new Map([[file, filename]]); |
| 403 | + await processDroppedFiles([file], fileNameMap); | |
| 330 | 404 | console.debug(DEBUG_PREFIX, 'Character downloaded.'); |
| 331 | 405 | } |
| 332 | 406 | return true; |
| @@ -338,6 +412,12 @@ async function installAsset(url, assetType, filename) { | ||
| 338 | 412 | } |
| 339 | 413 | } |
| 340 | 414 | |
| 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 | + */ | |
| 341 | 421 | async function deleteAsset(assetType, filename) { |
| 342 | 422 | console.debug(DEBUG_PREFIX, 'Deleting ', assetType, filename); |
| 343 | 423 | const category = assetType; |
| @@ -346,6 +426,7 @@ async function deleteAsset(assetType, filename) { | ||
| 346 | 426 | console.debug(DEBUG_PREFIX, 'Deleting extension ', filename); |
| 347 | 427 | await deleteExtension(filename); |
| 348 | 428 | console.debug(DEBUG_PREFIX, 'Extension deleted.'); |
| 429 | + return true; | |
| 349 | 430 | } |
| 350 | 431 | |
| 351 | 432 | const body = { category, filename }; |
| @@ -357,19 +438,37 @@ async function deleteAsset(assetType, filename) { | ||
| 357 | 438 | }); |
| 358 | 439 | if (result.ok) { |
| 359 | 440 | console.debug(DEBUG_PREFIX, 'Deletion success.'); |
| 441 | + return true; | |
| 360 | 442 | } |
| 443 | + return false; | |
| 361 | 444 | } catch (err) { |
| 362 | 445 | console.log(err); |
| 363 | 446 | return []false; |
| 364 | 447 | } |
| 365 | 448 | } |
| 366 | 449 | |
| 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 | + */ | |
| 367 | 455 | async function openCharacterBrowser(forceDefault) { |
| 368 | 456 | 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 | + } | |
| 369 | 461 | const fetchResult = await fetch(url, { cache: 'no-cache' }); |
| 462 | + if (!fetchResult.ok) { | |
| 463 | + toastr.error('Cannot download the assets list.'); | |
| 464 | + return; | |
| 465 | + } | |
| 370 | 466 | 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'); | |
| 373 | 472 | if (!characters.length) { |
| 374 | 473 | toastr.error('No characters found in the assets list', 'Character browser'); |
| 375 | 474 | return; |
| @@ -395,7 +494,10 @@ async function openCharacterBrowser(forceDefault) { | ||
| 395 | 494 | } |
| 396 | 495 | }); |
| 397 | 496 | |
| 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 | + }); | |
| 399 | 501 | |
| 400 | 502 | listElement.append(characterElement); |
| 401 | 503 | } |
| @@ -449,11 +551,16 @@ export async function init() { | ||
| 449 | 551 | |
| 450 | 552 | const connectButton = windowHtml.find('#assets-connect-button'); |
| 451 | 553 | connectButton.on('click', async function () { |
| 452 | 554 | 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)}`; | |
| 454 | 561 | const skipConfirm = accountStorage.getItem(rememberKey) === 'true'; |
| 455 | 562 | |
| 456 | 563 | 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>`, { |
| 457 | 564 | customInputs: [{ id: 'assets-remember', label: 'Don\'t ask again for this URL' }], |
| 458 | 565 | onClose: popup => { |
| 459 | 566 | if (popup.result) { |
| @@ -472,7 +579,7 @@ export async function init() { | ||
| 472 | 579 | connectButton.addClass('fa-plug-circle-check'); |
| 473 | 580 | } catch (error) { |
| 474 | 581 | console.error('Error:', error); |
| 475 | 582 | toastr.error(`Cannot get assets list from ${url.href}`); |
| 476 | 583 | connectButton.removeClass('fa-plug-circle-check'); |
| 477 | 584 | connectButton.addClass('fa-plug-circle-exclamation'); |
| 478 | 585 | connectButton.removeClass('redOverlayGlow'); |