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 | import { DOMPurify, Popper } from '../lib.js'; | 1 | import { Popper } from '../lib.js'; |
| 2 | 2 | ||
| 3 | import { eventSource, event_types, saveSettings, saveSettingsDebounced, getRequestHeaders, animation_duration, CLIENT_VERSION } from '../script.js'; | 3 | import { eventSource, event_types, saveSettings, saveSettingsDebounced, getRequestHeaders, animation_duration, CLIENT_VERSION } from '../script.js'; |
| 4 | import { POPUP_RESULT, POPUP_TYPE, Popup } from './popup.js'; | 4 | import { POPUP_RESULT, POPUP_TYPE, Popup } from './popup.js'; |
| 5 | import { renderTemplate, renderTemplateAsync } from './templates.js'; | 5 | import { renderTemplate, renderTemplateAsync } from './templates.js'; |
| 6 | import { delay, deleteValueByPath, equalsIgnoreCaseAndAccents, isSubsetOf, sanitizeSelector, setValueByPath, versionCompare } from './utils.js'; | 6 | import { delay, deleteValueByPath, equalsIgnoreCaseAndAccents, escapeHtml, isSubsetOf, sanitizeSelector, setValueByPath, versionCompare } from './utils.js'; |
| 7 | import { getContext } from './st-context.js'; | 7 | import { getContext } from './st-context.js'; |
| 8 | import { isAdmin } from './user.js'; | 8 | import { isAdmin } from './user.js'; |
| 9 | import { addLocaleData, getCurrentLocale, t } from './i18n.js'; | 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 | * Discovers extensions from the API. | 295 | * Discovers extensions from the API. |
| 284 | * @returns {Promise<{name: string, type: string}[]>} | 296 | * @returns {Promise<{name: string, type: string}[]>} |
| 285 | */ | 297 | */ |
| @@ -356,7 +368,7 @@ function onToggleAllExtensions(extensionsToToggle, toggleContainer) { | |||
| 356 | } | 368 | } |
| 357 | 369 | ||
| 358 | toggleContainer | 370 | toggleContainer |
| 359 | .find(`.extension_block[data-name="${name.replace('third-party', '')}"] .extension_toggle input`) | 371 | .find(`.extension_block[data-name="${getNameSelector(name)}"] .extension_toggle input`) |
| 360 | .prop('checked', enable) | 372 | .prop('checked', enable) |
| 361 | .toggleClass('toggle_enable', !enable) | 373 | .toggleClass('toggle_enable', !enable) |
| 362 | .toggleClass('toggle_disable', enable) | 374 | .toggleClass('toggle_disable', enable) |
| @@ -865,7 +877,7 @@ function addExtensionLocale(name, manifest) { | |||
| 865 | } | 877 | } |
| 866 | 878 | ||
| 867 | /** | 879 | /** |
| 868 | * Generates HTML string for displaying an extension in the UI. | 880 | * Generates an element for displaying an extension in the UI. |
| 869 | * | 881 | * |
| 870 | * @param {string} name - The name of the extension. | 882 | * @param {string} name - The name of the extension. |
| 871 | * @param {object} manifest - The manifest of the extension. | 883 | * @param {object} manifest - The manifest of the extension. |
| @@ -873,98 +885,180 @@ function addExtensionLocale(name, manifest) { | |||
| 873 | * @param {boolean} isDisabled - Whether the extension is disabled or not. | 885 | * @param {boolean} isDisabled - Whether the extension is disabled or not. |
| 874 | * @param {boolean} isExternal - Whether the extension is external or not. | 886 | * @param {boolean} isExternal - Whether the extension is external or not. |
| 875 | * @param {string} checkboxClass - The class for the checkbox HTML element. | 887 | * @param {string} checkboxClass - The class for the checkbox HTML element. |
| 876 | * @return {string} - The HTML string that represents the extension. | 888 | * @return {HTMLElement} - The element that represents the extension. |
| 877 | */ | 889 | */ |
| 878 | function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal, checkboxClass) { | 890 | function generateExtensionElement(name, manifest, isActive, isDisabled, isExternal, checkboxClass) { |
| 879 | function getExtensionIcon() { | 891 | function getExtensionIcon() { |
| 880 | const type = getExtensionType(name); | 892 | const type = getExtensionType(name); |
| 893 | const icon = document.createElement('i'); | ||
| 894 | icon.classList.add('fa-sm', 'fa-fw', 'fa-solid'); | ||
| 881 | switch (type) { | 895 | switch (type) { |
| 882 | case 'global': | 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 | case 'local': | 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 | case 'system': | 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 | default: | 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 | const isUserAdmin = isAdmin(); | 916 | const isUserAdmin = isAdmin(); |
| 894 | const extensionIcon = getExtensionIcon(); | ||
| 895 | const displayName = manifest.display_name; | 917 | const displayName = manifest.display_name; |
| 896 | const displayVersion = manifest.version || ''; | 918 | const displayVersion = manifest.version || ''; |
| 897 | const externalId = name.replace('third-party', ''); | 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'); | ||
| 955 | |||
| 956 | const statusSpan = document.createElement('span'); | ||
| 957 | statusSpan.className = isActive ? 'extension_enabled' : isDisabled ? 'extension_disabled' : 'extension_missing'; | ||
| 958 | |||
| 959 | const nameSpan = document.createElement('span'); | ||
| 960 | nameSpan.classList.add('extension_name'); | ||
| 961 | nameSpan.textContent = displayName; | ||
| 962 | |||
| 963 | const authorSpan = document.createElement('span'); | ||
| 964 | authorSpan.classList.add('extension_author'); | ||
| 902 | 965 | ||
| 903 | let toggleElement = isActive || isDisabled ? | 966 | const versionSpan = document.createElement('span'); |
| 904 | '<input type="checkbox" title="' + t`Click to toggle` + `" data-name="${name}" class="${isActive ? 'toggle_disable' : 'toggle_enable'} ${checkboxClass}" ${isActive ? 'checked' : ''}>` : | 967 | versionSpan.classList.add('extension_version'); |
| 905 | `<input type="checkbox" title="Cannot enable extension" data-name="${name}" class="extension_missing ${checkboxClass}" disabled>`; | 968 | versionSpan.textContent = displayVersion; |
| 906 | 969 | ||
| 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>` : ''; | 970 | statusSpan.append(nameSpan, authorSpan, versionSpan); |
| 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>` : ''; | ||
| 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>` : ''; | ||
| 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>` : ''; | ||
| 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>` : ''; | ||
| 912 | let modulesInfo = ''; | ||
| 913 | 971 | ||
| 914 | if (isActive && Array.isArray(manifest.optional)) { | 972 | if (isActive && Array.isArray(manifest.optional)) { |
| 915 | const optional = new Set(manifest.optional); | 973 | const optional = new Set(manifest.optional); |
| 916 | modules.forEach(x => optional.delete(x)); | 974 | modules.forEach(x => optional.delete(x)); |
| 917 | if (optional.size > 0) { | 975 | if (optional.size > 0) { |
| 918 | const optionalString = DOMPurify.sanitize([...optional].join(', ')); | 976 | const modulesDiv = document.createElement('div'); |
| 919 | modulesInfo = '<div class="extension_modules">' + t`Optional modules:` + ` <span class="optional">${optionalString}</span></div>`; | 977 | modulesDiv.classList.add('extension_modules'); |
| 978 | const optionalSpan = document.createElement('span'); | ||
| 979 | optionalSpan.classList.add('optional'); | ||
| 980 | optionalSpan.textContent = [...optional].join(', '); | ||
| 981 | modulesDiv.append(t`Optional modules:`, ' ', optionalSpan); | ||
| 982 | statusSpan.appendChild(modulesDiv); | ||
| 920 | } | 983 | } |
| 921 | } else if (!isDisabled) { // Neither active nor disabled | 984 | } else if (!isDisabled) { |
| 985 | // Neither active nor disabled | ||
| 922 | const requirements = new Set(manifest.requires); | 986 | const requirements = new Set(manifest.requires); |
| 923 | modules.forEach(x => requirements.delete(x)); | 987 | modules.forEach(x => requirements.delete(x)); |
| 924 | if (requirements.size > 0) { | 988 | if (requirements.size > 0) { |
| 925 | const requirementsString = DOMPurify.sanitize([...requirements].join(', ')); | 989 | const modulesDiv = document.createElement('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 | // if external, wrap the name in a link to the repo | 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 | } | ||
| 1007 | |||
| 1008 | block.appendChild(textBlock); | ||
| 1009 | |||
| 1010 | // Actions | ||
| 1011 | const actionsDiv = document.createElement('div'); | ||
| 1012 | actionsDiv.classList.add('extension_actions', 'flex-container', 'alignItemsCenter'); | ||
| 1013 | |||
| 1014 | /** | ||
| 1015 | * Helper function to create an action button for an extension. | ||
| 1016 | * @param {string} cls Class name | ||
| 1017 | * @param {string} dataName Name of the extension | ||
| 1018 | * @param {string} title Title of the button | ||
| 1019 | * @param {string} iconClasses Classes for the icon | ||
| 1020 | * @returns {HTMLButtonElement} The created button element | ||
| 1021 | */ | ||
| 1022 | function makeActionButton(cls, dataName, title, iconClasses) { | ||
| 1023 | const btn = document.createElement('button'); | ||
| 1024 | btn.classList.add(cls, 'menu_button'); | ||
| 1025 | btn.dataset.name = dataName; | ||
| 1026 | btn.title = title; | ||
| 1027 | const icon = document.createElement('i'); | ||
| 1028 | icon.classList.add(...iconClasses.split(' ')); | ||
| 1029 | btn.appendChild(icon); | ||
| 1030 | return btn; | ||
| 1031 | } | ||
| 1032 | |||
| 1033 | if (isExternal) { | ||
| 1034 | const updateBtn = makeActionButton('btn_update', externalId, t`Update available`, 'fa-solid fa-download fa-fw'); | ||
| 1035 | updateBtn.classList.add('displayNone'); | ||
| 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); | ||
| 931 | 1053 | ||
| 932 | let extensionHtml = ` | 1054 | return block; |
| 933 | <div class="extension_block" data-name="${externalId}"> | ||
| 934 | <div class="extension_toggle"> | ||
| 935 | ${toggleElement} | ||
| 936 | </div> | ||
| 937 | <div class="extension_icon"> | ||
| 938 | ${extensionIcon} | ||
| 939 | </div> | ||
| 940 | <div class="flexGrow extension_text_block"> | ||
| 941 | ${originHtml} | ||
| 942 | <span class="${isActive ? 'extension_enabled' : isDisabled ? 'extension_disabled' : 'extension_missing'}"> | ||
| 943 | <span class="extension_name">${DOMPurify.sanitize(displayName)}</span> | ||
| 944 | <span class="extension_author"></span> | ||
| 945 | <span class="extension_version">${DOMPurify.sanitize(displayVersion)}</span> | ||
| 946 | ${modulesInfo} | ||
| 947 | </span> | ||
| 948 | ${isExternal ? '</a>' : ''} | ||
| 949 | </div> | ||
| 950 | |||
| 951 | <div class="extension_actions flex-container alignItemsCenter"> | ||
| 952 | ${updateButton} | ||
| 953 | ${cleanButton} | ||
| 954 | ${branchButton} | ||
| 955 | ${moveButton} | ||
| 956 | ${deleteButton} | ||
| 957 | </div> | ||
| 958 | </div>`; | ||
| 959 | |||
| 960 | return extensionHtml; | ||
| 961 | } | 1055 | } |
| 962 | 1056 | ||
| 963 | /** | 1057 | /** |
| 964 | * Gets extension data and generates the corresponding HTML for displaying the extension. | 1058 | * Gets extension data and generates the corresponding element for displaying the extension. |
| 965 | * | 1059 | * |
| 966 | * @param {Array} extension - An array where the first element is the extension name and the second element is the extension manifest. | 1060 | * @param {Array} extension - An array where the first element is the extension name and the second element is the extension manifest. |
| 967 | * @return {object} - An object with 'isExternal' indicating whether the extension is external, and 'extensionHtml' for the extension's HTML string. | 1061 | * @return {{isExternal: boolean, extensionElement: HTMLElement}} - An object with 'isExternal' indicating whether the extension is external, and 'extensionElement' for the extension's HTML element. |
| 968 | */ | 1062 | */ |
| 969 | function getExtensionData(extension) { | 1063 | function getExtensionData(extension) { |
| 970 | const name = extension[0]; | 1064 | const name = extension[0]; |
| @@ -974,33 +1068,43 @@ function getExtensionData(extension) { | |||
| 974 | const isExternal = name.startsWith('third-party'); | 1068 | const isExternal = name.startsWith('third-party'); |
| 975 | 1069 | ||
| 976 | const checkboxClass = isDisabled ? 'checkbox_disabled' : ''; | 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 | * Gets the module information to be displayed. | 1078 | * Gets the module information to be displayed. |
| 986 | * | 1079 | * |
| 987 | * @return {string} - The HTML string for the module information. | 1080 | * @return {HTMLElement} - The element containing the module information. |
| 988 | */ | 1081 | */ |
| 989 | function getModuleInformation() { | 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 | * Generates HTML for the extension load errors. | 1102 | * Generates HTMLElement for the extension load errors. |
| 999 | * @returns {string} HTML string containing the errors that occurred while loading extensions. | 1103 | * @returns {HTMLElement} - The element containing the extension load errors. |
| 1000 | */ | 1104 | */ |
| 1001 | function getExtensionLoadErrorsHtml() { | 1105 | function getExtensionLoadErrors() { |
| 1002 | if (extensionLoadErrors.size === 0) { | 1106 | if (extensionLoadErrors.size === 0) { |
| 1003 | return ''; | 1107 | return document.createElement('div'); |
| 1004 | } | 1108 | } |
| 1005 | 1109 | ||
| 1006 | const container = document.createElement('div'); | 1110 | const container = document.createElement('div'); |
| @@ -1012,7 +1116,7 @@ function getExtensionLoadErrorsHtml() { | |||
| 1012 | container.appendChild(errorElement); | 1116 | container.appendChild(errorElement); |
| 1013 | } | 1117 | } |
| 1014 | 1118 | ||
| 1015 | return container.outerHTML; | 1119 | return container; |
| 1016 | } | 1120 | } |
| 1017 | 1121 | ||
| 1018 | /** | 1122 | /** |
| @@ -1029,22 +1133,35 @@ async function showExtensionsDetails() { | |||
| 1029 | initialScrollTop = oldPopup.content.scrollTop; | 1133 | initialScrollTop = oldPopup.content.scrollTop; |
| 1030 | await oldPopup.completeCancelled(); | 1134 | await oldPopup.completeCancelled(); |
| 1031 | } | 1135 | } |
| 1032 | const htmlErrors = getExtensionLoadErrorsHtml(); | 1136 | const errors = getExtensionLoadErrors(); |
| 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 | const sortOrderKey = 'extensions_sortByName'; | 1166 | const sortOrderKey = 'extensions_sortByName'; |
| 1050 | const sortByName = accountStorage.getItem(sortOrderKey) === 'true'; | 1167 | const sortByName = accountStorage.getItem(sortOrderKey) === 'true'; |
| @@ -1053,16 +1170,16 @@ async function showExtensionsDetails() { | |||
| 1053 | let extensionsToToggle = []; | 1170 | let extensionsToToggle = []; |
| 1054 | 1171 | ||
| 1055 | extensions.forEach(value => { | 1172 | extensions.forEach(value => { |
| 1056 | const { isExternal, extensionHtml } = value; | 1173 | const { isExternal, extensionElement } = value; |
| 1057 | const container = isExternal ? htmlExternal : htmlDefault; | 1174 | const container = isExternal ? externalContainer : defaultContainer; |
| 1058 | container.append(extensionHtml); | 1175 | container.appendChild(extensionElement); |
| 1059 | }); | 1176 | }); |
| 1060 | 1177 | ||
| 1061 | const html = $('<div></div>') | 1178 | const extensionsMenu = $('<div></div>') |
| 1062 | .addClass('extensions_info') | 1179 | .addClass('extensions_info') |
| 1063 | .append(htmlErrors) | 1180 | .append(errors) |
| 1064 | .append(htmlDefault) | 1181 | .append(defaultContainer) |
| 1065 | .append(htmlExternal) | 1182 | .append(externalContainer) |
| 1066 | .append(getModuleInformation()); | 1183 | .append(getModuleInformation()); |
| 1067 | 1184 | ||
| 1068 | { | 1185 | { |
| @@ -1088,23 +1205,24 @@ async function showExtensionsDetails() { | |||
| 1088 | const toggleAllExtensionsButton = document.createElement('div'); | 1205 | const toggleAllExtensionsButton = document.createElement('div'); |
| 1089 | toggleAllExtensionsButton.classList.add('menu_button', 'menu_button_icon'); | 1206 | toggleAllExtensionsButton.classList.add('menu_button', 'menu_button_icon'); |
| 1090 | toggleAllExtensionsButton.title = t`Bulk toggle third-party extensions.`; | 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 | const restoreBulkToggledExtensionsButton = document.createElement('div'); | 1214 | const restoreBulkToggledExtensionsButton = document.createElement('div'); |
| 1097 | restoreBulkToggledExtensionsButton.classList.add('menu_button', 'menu_button_icon', 'fa-solid', 'fa-arrow-right-rotate', 'displayNone'); | 1215 | restoreBulkToggledExtensionsButton.classList.add('menu_button', 'menu_button_icon', 'fa-solid', 'fa-arrow-right-rotate', 'displayNone'); |
| 1098 | restoreBulkToggledExtensionsButton.title = t`Restore toggled extensions.\n\nIt does not restore extensions toggled individually.`; | 1216 | restoreBulkToggledExtensionsButton.title = t`Restore toggled extensions.\n\nIt does not restore extensions toggled individually.`; |
| 1099 | 1217 | ||
| 1100 | toggleAllExtensionsButton.addEventListener('click', () => { | 1218 | toggleAllExtensionsButton.addEventListener('click', () => { |
| 1101 | extensionsToToggle = onToggleAllExtensions(extensionsToToggle, htmlExternal); | 1219 | extensionsToToggle = onToggleAllExtensions(extensionsToToggle, $(externalContainer)); |
| 1102 | 1220 | ||
| 1103 | for (const extension of extensionsToToggle) { | 1221 | for (const extension of extensionsToToggle) { |
| 1104 | const { name } = extension; | 1222 | const { name } = extension; |
| 1105 | 1223 | ||
| 1106 | htmlExternal | 1224 | $(externalContainer) |
| 1107 | .find(`.extension_block[data-name="${name.replace('third-party', '')}"] .extension_toggle input`) | 1225 | .find(`.extension_block[data-name="${getNameSelector(name)}"] .extension_toggle input`) |
| 1108 | .off('click') | 1226 | .off('click') |
| 1109 | .one('click', () => { | 1227 | .one('click', () => { |
| 1110 | extensionsToToggle = extensionsToToggle.filter(ext => ext.name !== name); | 1228 | extensionsToToggle = extensionsToToggle.filter(ext => ext.name !== name); |
| @@ -1121,8 +1239,8 @@ async function showExtensionsDetails() { | |||
| 1121 | const { name } = extension; | 1239 | const { name } = extension; |
| 1122 | const isDisabled = extension_settings.disabledExtensions.includes(name); | 1240 | const isDisabled = extension_settings.disabledExtensions.includes(name); |
| 1123 | 1241 | ||
| 1124 | htmlExternal | 1242 | $(externalContainer) |
| 1125 | .find(`.extension_block[data-name="${name.replace('third-party', '')}"] .extension_toggle input`) | 1243 | .find(`.extension_block[data-name="${getNameSelector(name)}"] .extension_toggle input`) |
| 1126 | .prop('checked', !isDisabled) | 1244 | .prop('checked', !isDisabled) |
| 1127 | .toggleClass('toggle_enable', isDisabled) | 1245 | .toggleClass('toggle_enable', isDisabled) |
| 1128 | .toggleClass('toggle_disable', !isDisabled) | 1246 | .toggleClass('toggle_disable', !isDisabled) |
| @@ -1146,13 +1264,13 @@ async function showExtensionsDetails() { | |||
| 1146 | }); | 1264 | }); |
| 1147 | 1265 | ||
| 1148 | toolbar.append(updateAllButton, updateEnabledOnlyButton, flexExpander, sortOrderButton); | 1266 | toolbar.append(updateAllButton, updateEnabledOnlyButton, flexExpander, sortOrderButton); |
| 1149 | htmlExternal.find('.third_party_toolbar').append(restoreBulkToggledExtensionsButton, toggleAllExtensionsButton); | 1267 | thirdPartyToolbar.append(restoreBulkToggledExtensionsButton, toggleAllExtensionsButton); |
| 1150 | html.prepend(toolbar); | 1268 | extensionsMenu.prepend(toolbar); |
| 1151 | } | 1269 | } |
| 1152 | 1270 | ||
| 1153 | let waitingForSave = false; | 1271 | let waitingForSave = false; |
| 1154 | 1272 | ||
| 1155 | const popup = new Popup(html, POPUP_TYPE.TEXT, '', { | 1273 | const popup = new Popup(extensionsMenu, POPUP_TYPE.TEXT, '', { |
| 1156 | okButton: t`Close`, | 1274 | okButton: t`Close`, |
| 1157 | wide: true, | 1275 | wide: true, |
| 1158 | large: true, | 1276 | large: true, |
| @@ -1194,7 +1312,7 @@ async function showExtensionsDetails() { | |||
| 1194 | }); | 1312 | }); |
| 1195 | popupPromise = popup.show(); | 1313 | popupPromise = popup.show(); |
| 1196 | popup.content.scrollTop = initialScrollTop; | 1314 | popup.content.scrollTop = initialScrollTop; |
| 1197 | checkForUpdatesManual(sortFn, abortController.signal).finally(() => htmlLoading.remove()); | 1315 | checkForUpdatesManual(sortFn, abortController.signal).finally(() => loadingEl.remove()); |
| 1198 | } catch (error) { | 1316 | } catch (error) { |
| 1199 | toastr.error(t`Error loading extensions. See browser console for details.`); | 1317 | toastr.error(t`Error loading extensions. See browser console for details.`); |
| 1200 | console.error(error); | 1318 | console.error(error); |
| @@ -1297,7 +1415,7 @@ async function onDeleteClick() { | |||
| 1297 | /** @type {import('./popup.js').CustomPopupInput[]} */ | 1415 | /** @type {import('./popup.js').CustomPopupInput[]} */ |
| 1298 | const customInputs = hasCleanHook ? [{ id: 'extension_delete_cleanup', label: t`Also clean up extension data`, defaultState: false }] : null; | 1416 | const customInputs = hasCleanHook ? [{ id: 'extension_delete_cleanup', label: t`Also clean up extension data`, defaultState: false }] : null; |
| 1299 | 1417 | ||
| 1300 | const popup = new Popup(t`Are you sure you want to delete ${extensionName}?`, POPUP_TYPE.CONFIRM, '', { customInputs }); | 1418 | const popup = new Popup(t`Are you sure you want to delete ${escapeHtml(extensionName)}?`, POPUP_TYPE.CONFIRM, '', { customInputs }); |
| 1301 | const confirmation = await popup.show(); | 1419 | const confirmation = await popup.show(); |
| 1302 | if (confirmation === POPUP_RESULT.AFFIRMATIVE) { | 1420 | if (confirmation === POPUP_RESULT.AFFIRMATIVE) { |
| 1303 | const shouldClean = hasCleanHook && Boolean(popup.inputResults?.get('extension_delete_cleanup')); | 1421 | const shouldClean = hasCleanHook && Boolean(popup.inputResults?.get('extension_delete_cleanup')); |
| @@ -1312,7 +1430,7 @@ async function onDeleteClick() { | |||
| 1312 | async function onCleanClick() { | 1430 | async function onCleanClick() { |
| 1313 | const extensionName = $(this).data('name'); | 1431 | const extensionName = $(this).data('name'); |
| 1314 | 1432 | ||
| 1315 | 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.`); | 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 | if (!confirmation) { | 1434 | if (!confirmation) { |
| 1317 | return; | 1435 | return; |
| 1318 | } | 1436 | } |
| @@ -1388,8 +1506,8 @@ async function onMoveClick() { | |||
| 1388 | 1506 | ||
| 1389 | const confirmationHeader = t`Move extension`; | 1507 | const confirmationHeader = t`Move extension`; |
| 1390 | const confirmationText = source == 'global' | 1508 | const confirmationText = source == 'global' |
| 1391 | ? t`Are you sure you want to move ${extensionName} to your local extensions? This will make it available only for you.` | 1509 | ? t`Are you sure you want to move ${escapeHtml(extensionName)} to your local extensions? This will make it available only for you.` |
| 1392 | : t`Are you sure you want to move ${extensionName} to the global extensions? This will make it available for all users.`; | 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 | const confirmation = await Popup.show.confirm(confirmationHeader, confirmationText); | 1512 | const confirmation = await Popup.show.confirm(confirmationHeader, confirmationText); |
| 1395 | 1513 | ||
| @@ -1493,6 +1611,9 @@ async function getExtensionVersion(extensionName, abortSignal) { | |||
| 1493 | const data = await response.json(); | 1611 | const data = await response.json(); |
| 1494 | return data; | 1612 | return data; |
| 1495 | } catch (error) { | 1613 | } catch (error) { |
| 1614 | if (error instanceof Error && error.name === 'AbortError') { | ||
| 1615 | return; | ||
| 1616 | } | ||
| 1496 | console.error('Error:', error); | 1617 | console.error('Error:', error); |
| 1497 | } | 1618 | } |
| 1498 | } | 1619 | } |
| @@ -1730,7 +1851,11 @@ async function checkForUpdatesManual(sortFn, abortSignal) { | |||
| 1730 | const promise = enqueueVersionCheck(async () => { | 1851 | const promise = enqueueVersionCheck(async () => { |
| 1731 | try { | 1852 | try { |
| 1732 | const data = await getExtensionVersion(externalId, abortSignal); | 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 | if (extensionBlock && data) { | 1859 | if (extensionBlock && data) { |
| 1735 | if (data.isUpToDate === false) { | 1860 | if (data.isUpToDate === false) { |
| 1736 | const buttonElement = extensionBlock.querySelector('.btn_update'); | 1861 | const buttonElement = extensionBlock.querySelector('.btn_update'); |
| @@ -1825,6 +1950,9 @@ async function checkForExtensionUpdates(force) { | |||
| 1825 | const promise = enqueueVersionCheck(async () => { | 1950 | const promise = enqueueVersionCheck(async () => { |
| 1826 | try { | 1951 | try { |
| 1827 | const data = await getExtensionVersion(id.replace('third-party', '')); | 1952 | const data = await getExtensionVersion(id.replace('third-party', '')); |
| 1953 | if (!data) { | ||
| 1954 | return; | ||
| 1955 | } | ||
| 1828 | if (!data.isUpToDate) { | 1956 | if (!data.isUpToDate) { |
| 1829 | updatesAvailable.push(manifest.display_name); | 1957 | updatesAvailable.push(manifest.display_name); |
| 1830 | } | 1958 | } |
| @@ -7,10 +7,10 @@ import { DOMPurify } from '../../../lib.js'; | |||
| 7 | import { getRequestHeaders, processDroppedFiles, eventSource, event_types } from '../../../script.js'; | 7 | import { getRequestHeaders, processDroppedFiles, eventSource, event_types } from '../../../script.js'; |
| 8 | import { deleteExtension, EMPTY_AUTHOR, extensionNames, getAuthorFromUrl, getContext, installExtension, renderExtensionTemplateAsync, isOfficialExtension } from '../../extensions.js'; | 8 | import { deleteExtension, EMPTY_AUTHOR, extensionNames, getAuthorFromUrl, getContext, installExtension, renderExtensionTemplateAsync, isOfficialExtension } from '../../extensions.js'; |
| 9 | import { POPUP_TYPE, Popup, callGenericPopup } from '../../popup.js'; | 9 | import { POPUP_TYPE, Popup, callGenericPopup } from '../../popup.js'; |
| 10 | import { executeSlashCommandsWithOptions } from '../../slash-commands.js'; | ||
| 11 | import { accountStorage } from '../../util/AccountStorage.js'; | 10 | import { accountStorage } from '../../util/AccountStorage.js'; |
| 12 | import { flashHighlight, getStringHash, isValidUrl } from '../../utils.js'; | 11 | import { escapeHtml, flashHighlight, getStringHash, isValidUrl } from '../../utils.js'; |
| 13 | import { t, translate } from '../../i18n.js'; | 12 | import { t, translate } from '../../i18n.js'; |
| 13 | import { SlashCommandParser } from '/scripts/slash-commands/SlashCommandParser.js'; | ||
| 14 | export { MODULE_NAME }; | 14 | export { MODULE_NAME }; |
| 15 | 15 | ||
| 16 | const MODULE_NAME = 'assets'; | 16 | const MODULE_NAME = 'assets'; |
| @@ -60,205 +60,262 @@ const KNOWN_TYPES = { | |||
| 60 | 'blip': t`Blip sounds`, | 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 | const element = $('<div />', { id: elemId, class: 'asset-download-button right_menu_button' }); | ||
| 73 | const label = $('<i class="fa-fw fa-solid fa-download fa-lg"></i>'); | ||
| 74 | element.append(label); | ||
| 75 | |||
| 76 | console.debug(DEBUG_PREFIX, 'Checking asset', asset.id, asset.url); | ||
| 77 | |||
| 78 | const assetInstall = async function () { | ||
| 79 | element.off('click'); | ||
| 80 | label.removeClass('fa-download'); | ||
| 81 | this.classList.add('asset-download-button-loading'); | ||
| 82 | const result = await installAsset(asset.url, assetType, asset.id); | ||
| 83 | if (!result) { | ||
| 84 | this.classList.remove('asset-download-button-loading'); | ||
| 85 | label.addClass('fa-download'); | ||
| 86 | label.removeClass('fa-spinner'); | ||
| 87 | label.removeClass('fa-spin'); | ||
| 88 | element.on('click', assetInstall); | ||
| 89 | return; | ||
| 90 | } | ||
| 91 | label.addClass('fa-check'); | ||
| 92 | this.classList.remove('asset-download-button-loading'); | ||
| 93 | element.on('click', assetDelete); | ||
| 94 | element.on('mouseenter', function () { | ||
| 95 | label.removeClass('fa-check'); | ||
| 96 | label.addClass('fa-trash'); | ||
| 97 | label.addClass('redOverlayGlow'); | ||
| 98 | }).on('mouseleave', function () { | ||
| 99 | label.addClass('fa-check'); | ||
| 100 | label.removeClass('fa-trash'); | ||
| 101 | label.removeClass('redOverlayGlow'); | ||
| 102 | }); | ||
| 103 | }; | ||
| 72 | 104 | ||
| 73 | for (const i of json) { | 105 | const assetDelete = async function () { |
| 74 | //console.log(DEBUG_PREFIX,i) | 106 | if (assetType === 'character') { |
| 75 | if (availableAssets[i.type] === undefined) | 107 | toastr.error('Go to the characters menu to delete a character.', 'Character deletion not supported'); |
| 76 | availableAssets[i.type] = []; | 108 | await SlashCommandParser.commands.go.callback(null, asset.id); |
| 109 | return; | ||
| 110 | } | ||
| 111 | element.off('click'); | ||
| 112 | await deleteAsset(assetType, asset.id); | ||
| 113 | label.removeClass('fa-check'); | ||
| 114 | label.removeClass('redOverlayGlow'); | ||
| 115 | label.removeClass('fa-trash'); | ||
| 116 | label.addClass('fa-download'); | ||
| 117 | element.off('mouseenter').off('mouseleave'); | ||
| 118 | element.on('click', assetInstall); | ||
| 119 | }; | ||
| 120 | |||
| 121 | if (isAssetInstalled(assetType, asset.id)) { | ||
| 122 | console.debug(DEBUG_PREFIX, 'installed, checked'); | ||
| 123 | label.toggleClass('fa-download'); | ||
| 124 | label.toggleClass('fa-check'); | ||
| 125 | element.on('click', assetDelete); | ||
| 126 | element.on('mouseenter', function () { | ||
| 127 | label.removeClass('fa-check'); | ||
| 128 | label.addClass('fa-trash'); | ||
| 129 | label.addClass('redOverlayGlow'); | ||
| 130 | }).on('mouseleave', function () { | ||
| 131 | label.addClass('fa-check'); | ||
| 132 | label.removeClass('fa-trash'); | ||
| 133 | label.removeClass('redOverlayGlow'); | ||
| 134 | }); | ||
| 135 | } else { | ||
| 136 | console.debug(DEBUG_PREFIX, 'not installed, unchecked'); | ||
| 137 | element.prop('checked', false); | ||
| 138 | element.on('click', assetInstall); | ||
| 139 | } | ||
| 77 | 140 | ||
| 78 | availableAssets[i.type].push(i); | 141 | return element; |
| 79 | } | 142 | } |
| 80 | 143 | ||
| 81 | console.debug(DEBUG_PREFIX, 'Updated available assets to', availableAssets); | 144 | /** |
| 82 | // First extensions, then everything else | 145 | * Creates the full visual block element for a single asset. |
| 83 | const assetTypes = Object.keys(availableAssets).sort((a, b) => (a === 'extension') ? -1 : (b === 'extension') ? 1 : 0); | 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) { | ||
| 152 | console.debug(DEBUG_PREFIX, 'Created element for ', asset.id); | ||
| 153 | |||
| 154 | const displayName = DOMPurify.sanitize(asset.name || asset.id); | ||
| 155 | const description = DOMPurify.sanitize(asset.description || ''); | ||
| 156 | const url = isValidUrl(asset.url) ? asset.url : ''; | ||
| 157 | const title = assetType === 'extension' ? t`Extension repo/guide:` + ` ${url}` : t`Preview in browser`; | ||
| 158 | const previewIcon = (assetType === 'extension' || assetType === 'character') ? 'fa-arrow-up-right-from-square' : 'fa-headphones-simple'; | ||
| 159 | const toolTag = assetType === 'extension' && asset.tool; | ||
| 160 | const author = url && assetType === 'extension' ? getAuthorFromUrl(url) : EMPTY_AUTHOR; | ||
| 161 | |||
| 162 | const nameSpan = $('<span>', { class: 'asset-name flex-container alignitemscenter' }) | ||
| 163 | .append($('<b>').text(displayName)) | ||
| 164 | .append($('<a>', { class: 'asset_preview', href: url, target: '_blank', title: title }) | ||
| 165 | .append($('<i>', { class: `fa-solid fa-sm ${previewIcon}` }))); | ||
| 166 | |||
| 167 | if (toolTag) { | ||
| 168 | const tagSpan = $('<span>', { class: 'tag', title: t`Adds a function tool` }) | ||
| 169 | .append($('<i>', { class: 'fa-solid fa-sm fa-wrench' })) | ||
| 170 | .append(document.createTextNode(` ${t`Tool`}`)); | ||
| 171 | nameSpan.append(tagSpan); | ||
| 172 | } | ||
| 84 | 173 | ||
| 85 | $('#assets_type_select').empty(); | 174 | nameSpan.append($('<span>', { class: 'expander' })); |
| 86 | $('#assets_search').val(''); | ||
| 87 | $('#assets_type_select').append($('<option />', { value: '', text: t`All` })); | ||
| 88 | 175 | ||
| 89 | for (const type of assetTypes) { | 176 | if (author.name) { |
| 90 | const text = translate(KNOWN_TYPES[type] || type); | 177 | nameSpan.append($('<a>', { href: author.url, target: '_blank', class: 'asset-author-info' }) |
| 91 | const option = $('<option />', { value: type, text: text }); | 178 | .append($('<i>', { class: 'fa-solid fa-at fa-xs' })) |
| 92 | $('#assets_type_select').append(option); | 179 | .append($('<span>').text(author.name))); |
| 93 | } | 180 | } |
| 94 | 181 | ||
| 95 | if (assetTypes.includes('extension')) { | 182 | const infoDiv = $('<div>', { class: 'flex-container flexFlowColumn flexNoGap wide100p overflowHidden' }) |
| 96 | $('#assets_type_select').val('extension'); | 183 | .append(nameSpan) |
| 97 | } | 184 | .append($('<small>', { class: 'asset-description' }).text(description)); |
| 98 | 185 | ||
| 99 | $('#assets_type_select').off('change').on('change', filterAssets); | 186 | const assetBlock = $('<i></i>').append(element).append(infoDiv); |
| 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 | const label = $('<i class="fa-fw fa-solid fa-download fa-lg"></i>'); | ||
| 116 | element.append(label); | ||
| 117 | |||
| 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 | console.debug(DEBUG_PREFIX, 'Checking asset', asset.id, asset.url); | ||
| 122 | |||
| 123 | const assetInstall = async function () { | ||
| 124 | element.off('click'); | ||
| 125 | label.removeClass('fa-download'); | ||
| 126 | this.classList.add('asset-download-button-loading'); | ||
| 127 | const result = await installAsset(asset.url, assetType, asset.id); | ||
| 128 | if (!result) { | ||
| 129 | this.classList.remove('asset-download-button-loading'); | ||
| 130 | label.addClass('fa-download'); | ||
| 131 | label.removeClass('fa-spinner'); | ||
| 132 | label.removeClass('fa-spin'); | ||
| 133 | element.on('click', assetInstall); | ||
| 134 | return; | ||
| 135 | } | ||
| 136 | label.addClass('fa-check'); | ||
| 137 | this.classList.remove('asset-download-button-loading'); | ||
| 138 | element.on('click', assetDelete); | ||
| 139 | element.on('mouseenter', function () { | ||
| 140 | label.removeClass('fa-check'); | ||
| 141 | label.addClass('fa-trash'); | ||
| 142 | label.addClass('redOverlayGlow'); | ||
| 143 | }).on('mouseleave', function () { | ||
| 144 | label.addClass('fa-check'); | ||
| 145 | label.removeClass('fa-trash'); | ||
| 146 | label.removeClass('redOverlayGlow'); | ||
| 147 | }); | ||
| 148 | }; | ||
| 149 | |||
| 150 | const assetDelete = async function () { | ||
| 151 | if (assetType === 'character') { | ||
| 152 | toastr.error('Go to the characters menu to delete a character.', 'Character deletion not supported'); | ||
| 153 | await executeSlashCommandsWithOptions(`/go ${asset.id}`); | ||
| 154 | return; | ||
| 155 | } | ||
| 156 | element.off('click'); | ||
| 157 | await deleteAsset(assetType, asset.id); | ||
| 158 | label.removeClass('fa-check'); | ||
| 159 | label.removeClass('redOverlayGlow'); | ||
| 160 | label.removeClass('fa-trash'); | ||
| 161 | label.addClass('fa-download'); | ||
| 162 | element.off('mouseenter').off('mouseleave'); | ||
| 163 | element.on('click', assetInstall); | ||
| 164 | }; | ||
| 165 | |||
| 166 | if (isAssetInstalled(assetType, asset.id)) { | ||
| 167 | console.debug(DEBUG_PREFIX, 'installed, checked'); | ||
| 168 | label.toggleClass('fa-download'); | ||
| 169 | label.toggleClass('fa-check'); | ||
| 170 | element.on('click', assetDelete); | ||
| 171 | element.on('mouseenter', function () { | ||
| 172 | label.removeClass('fa-check'); | ||
| 173 | label.addClass('fa-trash'); | ||
| 174 | label.addClass('redOverlayGlow'); | ||
| 175 | }).on('mouseleave', function () { | ||
| 176 | label.addClass('fa-check'); | ||
| 177 | label.removeClass('fa-trash'); | ||
| 178 | label.removeClass('redOverlayGlow'); | ||
| 179 | }); | ||
| 180 | } else { | ||
| 181 | console.debug(DEBUG_PREFIX, 'not installed, unchecked'); | ||
| 182 | element.prop('checked', false); | ||
| 183 | element.on('click', assetInstall); | ||
| 184 | } | ||
| 185 | |||
| 186 | console.debug(DEBUG_PREFIX, 'Created element for ', asset.id); | ||
| 187 | |||
| 188 | const displayName = DOMPurify.sanitize(asset.name || asset.id); | ||
| 189 | const description = DOMPurify.sanitize(asset.description || ''); | ||
| 190 | const url = isValidUrl(asset.url) ? asset.url : ''; | ||
| 191 | const title = assetType === 'extension' ? t`Extension repo/guide:` + ` ${url}` : t`Preview in browser`; | ||
| 192 | const previewIcon = (assetType === 'extension' || assetType === 'character') ? 'fa-arrow-up-right-from-square' : 'fa-headphones-simple'; | ||
| 193 | const toolTag = assetType === 'extension' && asset.tool; | ||
| 194 | const author = url && assetType === 'extension' ? getAuthorFromUrl(url) : EMPTY_AUTHOR; | ||
| 195 | |||
| 196 | const assetBlock = $('<i></i>') | ||
| 197 | .append(element) | ||
| 198 | .append(`<div class="flex-container flexFlowColumn flexNoGap wide100p overflowHidden"> | ||
| 199 | <span class="asset-name flex-container alignitemscenter"> | ||
| 200 | <b>${displayName}</b> | ||
| 201 | <a class="asset_preview" href="${url}" target="_blank" title="${title}"> | ||
| 202 | <i class="fa-solid fa-sm ${previewIcon}"></i> | ||
| 203 | </a>` + | ||
| 204 | (toolTag ? '<span class="tag" title="' + t`Adds a function tool` + '"><i class="fa-solid fa-sm fa-wrench"></i> ' + | ||
| 205 | t`Tool` + '</span>' : '') + | ||
| 206 | '<span class="expander"></span>' + | ||
| 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>` : '') + | ||
| 208 | `</span> | ||
| 209 | <small class="asset-description"> | ||
| 210 | ${description} | ||
| 211 | </small> | ||
| 212 | </div>`); | ||
| 213 | |||
| 214 | assetBlock.find('.tag').on('click', function (e) { | ||
| 215 | const a = document.createElement('a'); | ||
| 216 | a.href = 'https://docs.sillytavern.app/for-contributors/function-calling/'; | ||
| 217 | a.target = '_blank'; | ||
| 218 | a.click(); | ||
| 219 | }); | ||
| 220 | |||
| 221 | if (assetType === 'character') { | ||
| 222 | if (asset.highlight) { | ||
| 223 | assetBlock.find('.asset-name').append('<i class="fa-solid fa-sm fa-trophy"></i>'); | ||
| 224 | } | ||
| 225 | assetBlock.find('.asset-name').prepend(`<div class="avatar"><img src="${asset.url}" alt="${displayName}"></div>`); | ||
| 226 | } | ||
| 227 | |||
| 228 | assetBlock.addClass('asset-block'); | ||
| 229 | |||
| 230 | if (assetType === 'extension') { | ||
| 231 | const extensionBlockList = isOfficialExtension(asset.url) | ||
| 232 | ? assetTypeMenu.find('.assets-list-extensions-official .assets-list-extensions') | ||
| 233 | : assetTypeMenu.find('.assets-list-extensions-community .assets-list-extensions'); | ||
| 234 | extensionBlockList.append(assetBlock); | ||
| 235 | } else { | ||
| 236 | assetTypeMenu.append(assetBlock); | ||
| 237 | } | ||
| 238 | } | ||
| 239 | assetTypeMenu.appendTo('#assets_menu'); | ||
| 240 | assetTypeMenu.on('click', 'a.asset_preview', previewAsset); | ||
| 241 | } | ||
| 242 | 187 | ||
| 243 | filterAssets(); | 188 | assetBlock.find('.tag').on('click', function (e) { |
| 244 | $('#assets_filters').show(); | 189 | const a = document.createElement('a'); |
| 245 | $('#assets_menu').show(); | 190 | a.href = 'https://docs.sillytavern.app/for-contributors/function-calling/'; |
| 246 | }) | 191 | a.target = '_blank'; |
| 247 | .catch((error) => { | 192 | a.click(); |
| 248 | // Info hint if the user maybe... likely accidently was trying to install an extension and we wanna help guide them? uwu :3 | ||
| 249 | const installButton = $('#third_party_extension_button'); | ||
| 250 | flashHighlight(installButton, 10_000); | ||
| 251 | toastr.info('Click the flashing button at the top right corner of the menu.', 'Trying to install a custom extension?', { timeOut: 10_000 }); | ||
| 252 | |||
| 253 | // Error logged after, to appear on top | ||
| 254 | console.error(error); | ||
| 255 | toastr.error('Problem with assets URL', DEBUG_PREFIX + 'Cannot get assets list'); | ||
| 256 | $('#assets-connect-button').addClass('fa-plug-circle-exclamation'); | ||
| 257 | $('#assets-connect-button').addClass('redOverlayGlow'); | ||
| 258 | }); | ||
| 259 | }); | 193 | }); |
| 194 | |||
| 195 | if (assetType === 'character') { | ||
| 196 | if (asset.highlight) { | ||
| 197 | nameSpan.append($('<i>', { class: 'fa-solid fa-sm fa-trophy' })); | ||
| 198 | } | ||
| 199 | nameSpan.prepend($('<div>', { class: 'avatar' }).append($('<img>', { src: asset.url, alt: displayName }))); | ||
| 200 | } | ||
| 201 | |||
| 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); | ||
| 224 | |||
| 225 | if (assetType === 'extension') { | ||
| 226 | const extensionBlockList = isOfficialExtension(asset.url) | ||
| 227 | ? assetTypeMenu.find('.assets-list-extensions-official .assets-list-extensions') | ||
| 228 | : assetTypeMenu.find('.assets-list-extensions-community .assets-list-extensions'); | ||
| 229 | extensionBlockList.append(assetBlock); | ||
| 230 | } else { | ||
| 231 | assetTypeMenu.append(assetBlock); | ||
| 232 | } | ||
| 233 | } | ||
| 234 | |||
| 235 | assetTypeMenu.appendTo('#assets_menu'); | ||
| 236 | assetTypeMenu.on('click', 'a.asset_preview', previewAsset); | ||
| 237 | } | ||
| 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 | |||
| 280 | filterAssets(); | ||
| 281 | $('#assets_filters').show(); | ||
| 282 | $('#assets_menu').show(); | ||
| 260 | } | 283 | } |
| 261 | 284 | ||
| 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 | ||
| 303 | const installButton = $('#third_party_extension_button'); | ||
| 304 | flashHighlight(installButton, 10_000); | ||
| 305 | toastr.info('Click the flashing button at the top right corner of the menu.', 'Trying to install a custom extension?', { timeOut: 10_000 }); | ||
| 306 | |||
| 307 | // Error logged after, to appear on top | ||
| 308 | console.error(error); | ||
| 309 | toastr.error('Problem with assets URL', 'Cannot get assets list'); | ||
| 310 | $('#assets-connect-button').addClass('fa-plug-circle-exclamation'); | ||
| 311 | $('#assets-connect-button').addClass('redOverlayGlow'); | ||
| 312 | } | ||
| 313 | } | ||
| 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 | function previewAsset(e) { | 319 | function previewAsset(e) { |
| 263 | const href = $(this).attr('href'); | 320 | const href = $(this).attr('href'); |
| 264 | const audioExtensions = ['.mp3', '.ogg', '.wav']; | 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 | function isAssetInstalled(assetType, filename) { | 350 | function isAssetInstalled(assetType, filename) { |
| 285 | let assetList = currentAssets[assetType]; | 351 | let assetList = currentAssets[assetType]; |
| 286 | 352 | ||
| @@ -302,6 +368,13 @@ function isAssetInstalled(assetType, filename) { | |||
| 302 | return false; | 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 | async function installAsset(url, assetType, filename) { | 378 | async function installAsset(url, assetType, filename) { |
| 306 | console.debug(DEBUG_PREFIX, 'Downloading ', url); | 379 | console.debug(DEBUG_PREFIX, 'Downloading ', url); |
| 307 | const category = assetType; | 380 | const category = assetType; |
| @@ -326,7 +399,8 @@ async function installAsset(url, assetType, filename) { | |||
| 326 | console.debug(DEBUG_PREFIX, 'Importing character ', filename); | 399 | console.debug(DEBUG_PREFIX, 'Importing character ', filename); |
| 327 | const blob = await result.blob(); | 400 | const blob = await result.blob(); |
| 328 | const file = new File([blob], filename, { type: blob.type }); | 401 | const file = new File([blob], filename, { type: blob.type }); |
| 329 | await processDroppedFiles([file]); | 402 | const fileNameMap = new Map([[file, filename]]); |
| 403 | await processDroppedFiles([file], fileNameMap); | ||
| 330 | console.debug(DEBUG_PREFIX, 'Character downloaded.'); | 404 | console.debug(DEBUG_PREFIX, 'Character downloaded.'); |
| 331 | } | 405 | } |
| 332 | return true; | 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 | async function deleteAsset(assetType, filename) { | 421 | async function deleteAsset(assetType, filename) { |
| 342 | console.debug(DEBUG_PREFIX, 'Deleting ', assetType, filename); | 422 | console.debug(DEBUG_PREFIX, 'Deleting ', assetType, filename); |
| 343 | const category = assetType; | 423 | const category = assetType; |
| @@ -346,6 +426,7 @@ async function deleteAsset(assetType, filename) { | |||
| 346 | console.debug(DEBUG_PREFIX, 'Deleting extension ', filename); | 426 | console.debug(DEBUG_PREFIX, 'Deleting extension ', filename); |
| 347 | await deleteExtension(filename); | 427 | await deleteExtension(filename); |
| 348 | console.debug(DEBUG_PREFIX, 'Extension deleted.'); | 428 | console.debug(DEBUG_PREFIX, 'Extension deleted.'); |
| 429 | return true; | ||
| 349 | } | 430 | } |
| 350 | 431 | ||
| 351 | const body = { category, filename }; | 432 | const body = { category, filename }; |
| @@ -357,19 +438,37 @@ async function deleteAsset(assetType, filename) { | |||
| 357 | }); | 438 | }); |
| 358 | if (result.ok) { | 439 | if (result.ok) { |
| 359 | console.debug(DEBUG_PREFIX, 'Deletion success.'); | 440 | console.debug(DEBUG_PREFIX, 'Deletion success.'); |
| 441 | return true; | ||
| 360 | } | 442 | } |
| 443 | return false; | ||
| 361 | } catch (err) { | 444 | } catch (err) { |
| 362 | console.log(err); | 445 | console.log(err); |
| 363 | return []; | 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 | async function openCharacterBrowser(forceDefault) { | 455 | async function openCharacterBrowser(forceDefault) { |
| 368 | const url = forceDefault ? ASSETS_JSON_URL : String($('#assets-json-url-field').val()); | 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 | const fetchResult = await fetch(url, { cache: 'no-cache' }); | 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 | const json = await fetchResult.json(); | 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 | if (!characters.length) { | 472 | if (!characters.length) { |
| 374 | toastr.error('No characters found in the assets list', 'Character browser'); | 473 | toastr.error('No characters found in the assets list', 'Character browser'); |
| 375 | return; | 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 | listElement.append(characterElement); | 502 | listElement.append(characterElement); |
| 401 | } | 503 | } |
| @@ -449,11 +551,16 @@ export async function init() { | |||
| 449 | 551 | ||
| 450 | const connectButton = windowHtml.find('#assets-connect-button'); | 552 | const connectButton = windowHtml.find('#assets-connect-button'); |
| 451 | connectButton.on('click', async function () { | 553 | connectButton.on('click', async function () { |
| 452 | const url = DOMPurify.sanitize(String(assetsJsonUrl.val())); | 554 | const urlString = 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 | const skipConfirm = accountStorage.getItem(rememberKey) === 'true'; | 561 | const skipConfirm = accountStorage.getItem(rememberKey) === 'true'; |
| 455 | 562 | ||
| 456 | 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>${url}</var>`, { | 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 | customInputs: [{ id: 'assets-remember', label: 'Don\'t ask again for this URL' }], | 564 | customInputs: [{ id: 'assets-remember', label: 'Don\'t ask again for this URL' }], |
| 458 | onClose: popup => { | 565 | onClose: popup => { |
| 459 | if (popup.result) { | 566 | if (popup.result) { |
| @@ -472,7 +579,7 @@ export async function init() { | |||
| 472 | connectButton.addClass('fa-plug-circle-check'); | 579 | connectButton.addClass('fa-plug-circle-check'); |
| 473 | } catch (error) { | 580 | } catch (error) { |
| 474 | console.error('Error:', error); | 581 | console.error('Error:', error); |
| 475 | toastr.error(`Cannot get assets list from ${url}`); | 582 | toastr.error(`Cannot get assets list from ${url.href}`); |
| 476 | connectButton.removeClass('fa-plug-circle-check'); | 583 | connectButton.removeClass('fa-plug-circle-check'); |
| 477 | connectButton.addClass('fa-plug-circle-exclamation'); | 584 | connectButton.addClass('fa-plug-circle-exclamation'); |
| 478 | connectButton.removeClass('redOverlayGlow'); | 585 | connectButton.removeClass('redOverlayGlow'); |