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

97392a4ca0357f089c9e6e7250d76fc73d5cc3ea

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

Signed
2 files changed, +548 -313Ignore whitespace
public/scripts/extensions.js+242 -114
@@ -1,9 +1,9 @@
1import { DOMPurify, Popper } from '../lib.js';1import { Popper } from '../lib.js';
22
3import { eventSource, event_types, saveSettings, saveSettingsDebounced, getRequestHeaders, animation_duration, CLIENT_VERSION } from '../script.js';3import { eventSource, event_types, saveSettings, saveSettingsDebounced, getRequestHeaders, animation_duration, CLIENT_VERSION } from '../script.js';
4import { POPUP_RESULT, POPUP_TYPE, Popup } from './popup.js';4import { POPUP_RESULT, POPUP_TYPE, Popup } from './popup.js';
5import { renderTemplate, renderTemplateAsync } from './templates.js';5import { renderTemplate, renderTemplateAsync } from './templates.js';
6import { delay, deleteValueByPath, equalsIgnoreCaseAndAccents, isSubsetOf, sanitizeSelector, setValueByPath, versionCompare } from './utils.js';6import { delay, deleteValueByPath, equalsIgnoreCaseAndAccents, escapeHtml, isSubsetOf, sanitizeSelector, setValueByPath, versionCompare } from './utils.js';
7import { getContext } from './st-context.js';7import { getContext } from './st-context.js';
8import { isAdmin } from './user.js';8import { isAdmin } from './user.js';
9import { addLocaleData, getCurrentLocale, t } from './i18n.js';9import { addLocaleData, getCurrentLocale, t } from './i18n.js';
@@ -280,6 +280,18 @@ export async function doExtrasFetch(endpoint, args = {}) {
280}280}
281281
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 */
289function 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 }
357369
358 toggleContainer370 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}
866878
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 */
878function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal, checkboxClass) {890function 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 }
892915
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');
902965
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;
906969
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 = '';
913971
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 disabled984 } 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 }
929998
930 // if external, wrap the name in a link to the repo999 // 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);
9311053
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}
9621056
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 */
969function getExtensionData(extension) {1063function 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');
9751069
976 const checkboxClass = isDisabled ? 'checkbox_disabled' : '';1070 const checkboxClass = isDisabled ? 'checkbox_disabled' : '';
1071 const extensionElement = generateExtensionElement(name, manifest, isActive, isDisabled, isExternal, checkboxClass);
9771072
978 const extensionHtml = generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal, checkboxClass);1073 return { isExternal, extensionElement };
979
980 return { isExternal, extensionHtml };
981}1074}
9821075
9831076
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 */
989function getModuleInformation() {1082function 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}
9961100
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 */
1001function getExtensionLoadErrorsHtml() {1105function getExtensionLoadErrors() {
1002 if (extensionLoadErrors.size === 0) {1106 if (extensionLoadErrors.size === 0) {
1003 return '';1107 return document.createElement('div');
1004 }1108 }
10051109
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 }
10141118
1015 return container.outerHTML;1119 return container;
1016}1120}
10171121
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
10341138 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');
10411145 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');
10461150 externalHeading.textContent = t`Installed Extensions:`;
1047 htmlExternal.append(htmlLoading);1151 const thirdPartyToolbar = document.createElement('div');
1152 thirdPartyToolbar.classList.add('flex-container', 'third_party_toolbar');
1153 externalHeader.append(externalHeading, thirdPartyToolbar);
1154 externalContainer.appendChild(externalHeader);
1155
1156 const loadingEl = document.createElement('div');
1157 loadingEl.classList.add('flex-container', 'alignItemsCenter', 'justifyCenter', 'marginTop10', 'marginBot5');
1158 const loadingIcon = document.createElement('i');
1159 loadingIcon.classList.add('fa-solid', 'fa-spinner', 'fa-spin');
1160 const loadingSpan = document.createElement('span');
1161 loadingSpan.textContent = t`Loading third-party extensions... Please wait...`;
1162 loadingEl.append(loadingIcon, loadingSpan);
1163
1164 externalContainer.appendChild(loadingEl);
10481165
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 = [];
10541171
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 });
10601177
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());
10671184
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);
10951213
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.`;
10991217
1100 toggleAllExtensionsButton.addEventListener('click', () => {1218 toggleAllExtensionsButton.addEventListener('click', () => {
1101 extensionsToToggle = onToggleAllExtensions(extensionsToToggle, htmlExternal);1219 extensionsToToggle = onToggleAllExtensions(extensionsToToggle, $(externalContainer));
11021220
1103 for (const extension of extensionsToToggle) {1221 for (const extension of extensionsToToggle) {
1104 const { name } = extension;1222 const { name } = extension;
11051223
1106 htmlExternal1224 $(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);
11231241
1124 htmlExternal1242 $(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 });
11471265
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 }
11521270
1153 let waitingForSave = false;1271 let waitingForSave = false;
11541272
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;
12991417
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() {
1312async function onCleanClick() {1430async function onCleanClick() {
1313 const extensionName = $(this).data('name');1431 const extensionName = $(this).data('name');
13141432
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() {
13881506
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.`;
13931511
1394 const confirmation = await Popup.show.confirm(confirmationHeader, confirmationText);1512 const confirmation = await Popup.show.confirm(confirmationHeader, confirmationText);
13951513
@@ -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 }
public/scripts/extensions/assets/index.js+306 -199
@@ -7,10 +7,10 @@ import { DOMPurify } from '../../../lib.js';
7import { getRequestHeaders, processDroppedFiles, eventSource, event_types } from '../../../script.js';7import { getRequestHeaders, processDroppedFiles, eventSource, event_types } from '../../../script.js';
8import { deleteExtension, EMPTY_AUTHOR, extensionNames, getAuthorFromUrl, getContext, installExtension, renderExtensionTemplateAsync, isOfficialExtension } from '../../extensions.js';8import { deleteExtension, EMPTY_AUTHOR, extensionNames, getAuthorFromUrl, getContext, installExtension, renderExtensionTemplateAsync, isOfficialExtension } from '../../extensions.js';
9import { POPUP_TYPE, Popup, callGenericPopup } from '../../popup.js';9import { POPUP_TYPE, Popup, callGenericPopup } from '../../popup.js';
10import { executeSlashCommandsWithOptions } from '../../slash-commands.js';
11import { accountStorage } from '../../util/AccountStorage.js';10import { accountStorage } from '../../util/AccountStorage.js';
12import { flashHighlight, getStringHash, isValidUrl } from '../../utils.js';11import { escapeHtml, flashHighlight, getStringHash, isValidUrl } from '../../utils.js';
13import { t, translate } from '../../i18n.js';12import { t, translate } from '../../i18n.js';
13import { SlashCommandParser } from '/scripts/slash-commands/SlashCommandParser.js';
14export { MODULE_NAME };14export { MODULE_NAME };
1515
16const MODULE_NAME = 'assets';16const MODULE_NAME = 'assets';
@@ -60,205 +60,262 @@ const KNOWN_TYPES = {
60 'blip': t`Blip sounds`,60 'blip': t`Blip sounds`,
61};61};
6262
63async 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 */
7070function 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 };
72104
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 }
77140
78 availableAssets[i.type].push(i);141 return element;
79 }142}
80143
81 console.debug(DEBUG_PREFIX, 'Updated available assets to', availableAssets);144/**
82 // First extensions, then everything else145 * 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 */
151function 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 }
84173
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` }));
88175
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 }
94181
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));
98185
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 }
242187
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 */
211async 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 */
243async 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}
261284
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 */
289async 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 */
262function previewAsset(e) {319function 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}
283340
341/**
342 * Checks if the asset is already installed.
343 * For extensions, it checks if the extension name is in the list of installed extensions.
344 * For characters, it checks if any character has the same avatar URL.
345 * For other asset types, it checks if any installed asset of the same type has a URL that includes the filename.
346 * @param {string} assetType Type of the asset, e.g. 'extension', 'character', 'ambient', 'bgm', 'blip'
347 * @param {string} filename Name or ID of the asset
348 * @returns {boolean} True if the asset is installed, false otherwise
349 */
284function isAssetInstalled(assetType, filename) {350function isAssetInstalled(assetType, filename) {
285 let assetList = currentAssets[assetType];351 let assetList = currentAssets[assetType];
286352
@@ -302,6 +368,13 @@ function isAssetInstalled(assetType, filename) {
302 return false;368 return false;
303}369}
304370
371/**
372 * Installs the asset by sending a request to the server to download it. If it's an extension, it uses the existing installExtension function.
373 * @param {string} url URL of the asset to download
374 * @param {string} assetType Type of the asset, e.g. 'extension', 'character', 'ambient', 'bgm', 'blip'
375 * @param {string} filename Name or ID of the asset
376 * @returns {Promise<boolean>} True if the asset was successfully installed, false otherwise
377 */
305async function installAsset(url, assetType, filename) {378async 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}
340414
415/**
416 * Deletes the asset by sending a request to the server to delete it. If it's an extension, it uses the existing deleteExtension function.
417 * @param {string} assetType Type of the asset, e.g. 'extension', 'character', 'ambient', 'bgm', 'blip'
418 * @param {string} filename Name or ID of the asset
419 * @returns {Promise<boolean>} True if the asset was successfully deleted, false otherwise
420 */
341async function deleteAsset(assetType, filename) {421async 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 }
350431
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}
366449
450/**
451 * Opens the character browser popup, which shows all available characters and allows downloading them.
452 * @param {boolean} forceDefault If true, it uses the default ASSETS_JSON_URL instead of the one from the input field.
453 * @returns {Promise<void>}
454 */
367async function openCharacterBrowser(forceDefault) {455async 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)) {
372468 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 });
397496
398 checkMark.toggle(isInstalled);497 checkMark.toggle(isInstalled).on('click', async () => {
498 toastr.error('Go to the characters menu to delete a character.', 'Character deletion not supported');
499 await SlashCommandParser.commands.go.callback(null, character.id);
500 });
399501
400 listElement.append(characterElement);502 listElement.append(characterElement);
401 }503 }
@@ -449,11 +551,16 @@ export async function init() {
449551
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';
455562
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');