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
This commit is contained in:
Cohee
2026-05-02 20:01:44 +03:00
committed by GitHub
parent e5ae782705
commit 97392a4ca0
2 changed files with 547 additions and 312 deletions
+237 -109
View File
@@ -1,9 +1,9 @@
import { DOMPurify, Popper } from '../lib.js';
import { Popper } from '../lib.js';
import { eventSource, event_types, saveSettings, saveSettingsDebounced, getRequestHeaders, animation_duration, CLIENT_VERSION } from '../script.js';
import { POPUP_RESULT, POPUP_TYPE, Popup } from './popup.js';
import { renderTemplate, renderTemplateAsync } from './templates.js';
import { delay, deleteValueByPath, equalsIgnoreCaseAndAccents, isSubsetOf, sanitizeSelector, setValueByPath, versionCompare } from './utils.js';
import { delay, deleteValueByPath, equalsIgnoreCaseAndAccents, escapeHtml, isSubsetOf, sanitizeSelector, setValueByPath, versionCompare } from './utils.js';
import { getContext } from './st-context.js';
import { isAdmin } from './user.js';
import { addLocaleData, getCurrentLocale, t } from './i18n.js';
@@ -279,6 +279,18 @@ export async function doExtrasFetch(endpoint, args = {}) {
return await fetch(endpoint, args);
}
/**
* Generates a CSS selector for an extension based on its name, allowing omission of a common prefix.
* @param {string} name Name of the extension, with or without the "third-party" prefix
* @param {object} [options] Optional parameters
* @param {string} [options.prefix] Optional prefix to ignore when generating the selector (e.g. "third-party")
* @returns {string} CSS selector for the extension, with the prefix removed if it was present and specified in options
*/
function getNameSelector(name, { prefix = 'third-party' } = {}) {
const nameWithoutPrefix = prefix && name.startsWith(prefix) ? name.slice(prefix.length) : name;
return CSS.escape(nameWithoutPrefix);
}
/**
* Discovers extensions from the API.
* @returns {Promise<{name: string, type: string}[]>}
@@ -356,7 +368,7 @@ function onToggleAllExtensions(extensionsToToggle, toggleContainer) {
}
toggleContainer
.find(`.extension_block[data-name="${name.replace('third-party', '')}"] .extension_toggle input`)
.find(`.extension_block[data-name="${getNameSelector(name)}"] .extension_toggle input`)
.prop('checked', enable)
.toggleClass('toggle_enable', !enable)
.toggleClass('toggle_disable', enable)
@@ -865,7 +877,7 @@ function addExtensionLocale(name, manifest) {
}
/**
* Generates HTML string for displaying an extension in the UI.
* Generates an element for displaying an extension in the UI.
*
* @param {string} name - The name of the extension.
* @param {object} manifest - The manifest of the extension.
@@ -873,98 +885,180 @@ function addExtensionLocale(name, manifest) {
* @param {boolean} isDisabled - Whether the extension is disabled or not.
* @param {boolean} isExternal - Whether the extension is external or not.
* @param {string} checkboxClass - The class for the checkbox HTML element.
* @return {string} - The HTML string that represents the extension.
* @return {HTMLElement} - The element that represents the extension.
*/
function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal, checkboxClass) {
function generateExtensionElement(name, manifest, isActive, isDisabled, isExternal, checkboxClass) {
function getExtensionIcon() {
const type = getExtensionType(name);
const icon = document.createElement('i');
icon.classList.add('fa-sm', 'fa-fw', 'fa-solid');
switch (type) {
case 'global':
return '<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>';
icon.classList.add('fa-server');
icon.title = t`This is a global extension, available for all users.`;
break;
case 'local':
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>';
icon.classList.add('fa-user');
icon.title = t`This is a local extension, available only for you.`;
break;
case 'system':
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>';
icon.classList.add('fa-cog');
icon.title = t`This is a built-in extension. It cannot be deleted and updates with the app.`;
break;
default:
return '<i class="fa-sm fa-fw fa-solid fa-question" title="Unknown extension type."></i>';
icon.classList.add('fa-question');
icon.title = t`Unknown extension type.`;
break;
}
return icon;
}
const isUserAdmin = isAdmin();
const extensionIcon = getExtensionIcon();
const displayName = manifest.display_name;
const displayVersion = manifest.version || '';
const externalId = name.replace('third-party', '');
let originHtml = '';
if (isExternal) {
originHtml = '<a>';
// Root block
const block = document.createElement('div');
block.classList.add('extension_block');
block.dataset.name = externalId;
// Toggle
const toggleDiv = document.createElement('div');
toggleDiv.classList.add('extension_toggle');
const toggle = document.createElement('input');
toggle.type = 'checkbox';
toggle.dataset.name = name;
if (isActive || isDisabled) {
toggle.title = t`Click to toggle`;
toggle.classList.add(isActive ? 'toggle_disable' : 'toggle_enable');
if (checkboxClass) toggle.classList.add(checkboxClass);
toggle.checked = isActive;
} else {
toggle.title = t`Cannot enable extension`;
toggle.classList.add('extension_missing');
if (checkboxClass) toggle.classList.add(checkboxClass);
toggle.disabled = true;
}
toggleDiv.appendChild(toggle);
block.appendChild(toggleDiv);
let toggleElement = isActive || isDisabled ?
'<input type="checkbox" title="' + t`Click to toggle` + `" data-name="${name}" class="${isActive ? 'toggle_disable' : 'toggle_enable'} ${checkboxClass}" ${isActive ? 'checked' : ''}>` :
`<input type="checkbox" title="Cannot enable extension" data-name="${name}" class="extension_missing ${checkboxClass}" disabled>`;
// Icon
const iconDiv = document.createElement('div');
iconDiv.classList.add('extension_icon');
iconDiv.appendChild(getExtensionIcon());
block.appendChild(iconDiv);
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>` : '';
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>` : '';
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>` : '';
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>` : '';
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>` : '';
let modulesInfo = '';
// Text block
const textBlock = document.createElement('div');
textBlock.classList.add('flexGrow', 'extension_text_block');
const statusSpan = document.createElement('span');
statusSpan.className = isActive ? 'extension_enabled' : isDisabled ? 'extension_disabled' : 'extension_missing';
const nameSpan = document.createElement('span');
nameSpan.classList.add('extension_name');
nameSpan.textContent = displayName;
const authorSpan = document.createElement('span');
authorSpan.classList.add('extension_author');
const versionSpan = document.createElement('span');
versionSpan.classList.add('extension_version');
versionSpan.textContent = displayVersion;
statusSpan.append(nameSpan, authorSpan, versionSpan);
if (isActive && Array.isArray(manifest.optional)) {
const optional = new Set(manifest.optional);
modules.forEach(x => optional.delete(x));
if (optional.size > 0) {
const optionalString = DOMPurify.sanitize([...optional].join(', '));
modulesInfo = '<div class="extension_modules">' + t`Optional modules:` + ` <span class="optional">${optionalString}</span></div>`;
const modulesDiv = document.createElement('div');
modulesDiv.classList.add('extension_modules');
const optionalSpan = document.createElement('span');
optionalSpan.classList.add('optional');
optionalSpan.textContent = [...optional].join(', ');
modulesDiv.append(t`Optional modules:`, ' ', optionalSpan);
statusSpan.appendChild(modulesDiv);
}
} else if (!isDisabled) { // Neither active nor disabled
} else if (!isDisabled) {
// Neither active nor disabled
const requirements = new Set(manifest.requires);
modules.forEach(x => requirements.delete(x));
if (requirements.size > 0) {
const requirementsString = DOMPurify.sanitize([...requirements].join(', '));
modulesInfo = `<div class="extension_modules">Missing modules: <span class="failure">${requirementsString}</span></div>`;
const modulesDiv = document.createElement('div');
modulesDiv.classList.add('extension_modules');
const failureSpan = document.createElement('span');
failureSpan.classList.add('failure');
failureSpan.textContent = [...requirements].join(', ');
modulesDiv.append(t`Missing modules:`, ' ', failureSpan);
statusSpan.appendChild(modulesDiv);
}
}
// if external, wrap the name in a link to the repo
if (isExternal) {
const originLink = document.createElement('a');
originLink.appendChild(statusSpan);
textBlock.appendChild(originLink);
} else {
textBlock.appendChild(statusSpan);
}
let extensionHtml = `
<div class="extension_block" data-name="${externalId}">
<div class="extension_toggle">
${toggleElement}
</div>
<div class="extension_icon">
${extensionIcon}
</div>
<div class="flexGrow extension_text_block">
${originHtml}
<span class="${isActive ? 'extension_enabled' : isDisabled ? 'extension_disabled' : 'extension_missing'}">
<span class="extension_name">${DOMPurify.sanitize(displayName)}</span>
<span class="extension_author"></span>
<span class="extension_version">${DOMPurify.sanitize(displayVersion)}</span>
${modulesInfo}
</span>
${isExternal ? '</a>' : ''}
</div>
block.appendChild(textBlock);
<div class="extension_actions flex-container alignItemsCenter">
${updateButton}
${cleanButton}
${branchButton}
${moveButton}
${deleteButton}
</div>
</div>`;
// Actions
const actionsDiv = document.createElement('div');
actionsDiv.classList.add('extension_actions', 'flex-container', 'alignItemsCenter');
return extensionHtml;
/**
* Helper function to create an action button for an extension.
* @param {string} cls Class name
* @param {string} dataName Name of the extension
* @param {string} title Title of the button
* @param {string} iconClasses Classes for the icon
* @returns {HTMLButtonElement} The created button element
*/
function makeActionButton(cls, dataName, title, iconClasses) {
const btn = document.createElement('button');
btn.classList.add(cls, 'menu_button');
btn.dataset.name = dataName;
btn.title = title;
const icon = document.createElement('i');
icon.classList.add(...iconClasses.split(' '));
btn.appendChild(icon);
return btn;
}
if (isExternal) {
const updateBtn = makeActionButton('btn_update', externalId, t`Update available`, 'fa-solid fa-download fa-fw');
updateBtn.classList.add('displayNone');
actionsDiv.appendChild(updateBtn);
}
if (isExternal && hasExtensionHook(externalId, 'clean')) {
actionsDiv.appendChild(makeActionButton('btn_clean', externalId, t`Clean extension data`, 'fa-fw fa-solid fa-broom'));
}
if (isExternal && isUserAdmin) {
actionsDiv.appendChild(makeActionButton('btn_branch', externalId, t`Switch branch`, 'fa-solid fa-code-branch fa-fw'));
actionsDiv.appendChild(makeActionButton('btn_move', externalId, t`Move`, 'fa-solid fa-folder-tree fa-fw'));
}
if (isExternal) {
actionsDiv.appendChild(makeActionButton('btn_delete', externalId, t`Delete`, 'fa-fw fa-solid fa-trash-can'));
}
block.appendChild(actionsDiv);
return block;
}
/**
* Gets extension data and generates the corresponding HTML for displaying the extension.
* Gets extension data and generates the corresponding element for displaying the extension.
*
* @param {Array} extension - An array where the first element is the extension name and the second element is the extension manifest.
* @return {object} - An object with 'isExternal' indicating whether the extension is external, and 'extensionHtml' for the extension's HTML string.
* @return {{isExternal: boolean, extensionElement: HTMLElement}} - An object with 'isExternal' indicating whether the extension is external, and 'extensionElement' for the extension's HTML element.
*/
function getExtensionData(extension) {
const name = extension[0];
@@ -974,33 +1068,43 @@ function getExtensionData(extension) {
const isExternal = name.startsWith('third-party');
const checkboxClass = isDisabled ? 'checkbox_disabled' : '';
const extensionElement = generateExtensionElement(name, manifest, isActive, isDisabled, isExternal, checkboxClass);
const extensionHtml = generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal, checkboxClass);
return { isExternal, extensionHtml };
return { isExternal, extensionElement };
}
/**
* Gets the module information to be displayed.
*
* @return {string} - The HTML string for the module information.
* @return {HTMLElement} - The element containing the module information.
*/
function getModuleInformation() {
let moduleInfo = modules.length ? `<p>${DOMPurify.sanitize(modules.join(', '))}</p>` : '<p class="failure">' + t`Not connected to the API!` + '</p>';
return `
<h3>` + t`Modules provided by your Extras API:` + `</h3>
${moduleInfo}
`;
const container = document.createElement('div');
const heading = document.createElement('h3');
heading.textContent = t`Modules provided by your Extras API:`;
container.appendChild(heading);
const moduleInfo = document.createElement('p');
if (modules.length) {
moduleInfo.textContent = modules.join(', ');
} else {
moduleInfo.classList.add('failure');
moduleInfo.textContent = t`Not connected to the API!`;
}
container.appendChild(moduleInfo);
return container;
}
/**
* Generates HTML for the extension load errors.
* @returns {string} HTML string containing the errors that occurred while loading extensions.
* Generates HTMLElement for the extension load errors.
* @returns {HTMLElement} - The element containing the extension load errors.
*/
function getExtensionLoadErrorsHtml() {
function getExtensionLoadErrors() {
if (extensionLoadErrors.size === 0) {
return '';
return document.createElement('div');
}
const container = document.createElement('div');
@@ -1012,7 +1116,7 @@ function getExtensionLoadErrorsHtml() {
container.appendChild(errorElement);
}
return container.outerHTML;
return container;
}
/**
@@ -1029,22 +1133,35 @@ async function showExtensionsDetails() {
initialScrollTop = oldPopup.content.scrollTop;
await oldPopup.completeCancelled();
}
const htmlErrors = getExtensionLoadErrorsHtml();
const htmlDefault = $('<div class="marginBot10"><h3>' + t`Built-in Extensions:` + '</h3></div>');
const errors = getExtensionLoadErrors();
const htmlExternal = $(`<div class="marginBot10">
<div class="flex-container alignitemscenter spaceBetween flexnowrap marginBot10">
<h3 class="margin0">${t`Installed Extensions:`}</h3>
<div class="flex-container third_party_toolbar"></div>
</div>
</div>`);
const defaultContainer = document.createElement('div');
defaultContainer.classList.add('marginBot10');
const defaultHeading = document.createElement('h3');
defaultHeading.textContent = t`Built-in Extensions:`;
defaultContainer.appendChild(defaultHeading);
const htmlLoading = $(`<div class="flex-container alignItemsCenter justifyCenter marginTop10 marginBot5">
<i class="fa-solid fa-spinner fa-spin"></i>
<span>` + t`Loading third-party extensions... Please wait...` + `</span>
</div>`);
const externalContainer = document.createElement('div');
externalContainer.classList.add('marginBot10');
const externalHeader = document.createElement('div');
externalHeader.classList.add('flex-container', 'alignitemscenter', 'spaceBetween', 'flexnowrap', 'marginBot10');
const externalHeading = document.createElement('h3');
externalHeading.classList.add('margin0');
externalHeading.textContent = t`Installed Extensions:`;
const thirdPartyToolbar = document.createElement('div');
thirdPartyToolbar.classList.add('flex-container', 'third_party_toolbar');
externalHeader.append(externalHeading, thirdPartyToolbar);
externalContainer.appendChild(externalHeader);
htmlExternal.append(htmlLoading);
const loadingEl = document.createElement('div');
loadingEl.classList.add('flex-container', 'alignItemsCenter', 'justifyCenter', 'marginTop10', 'marginBot5');
const loadingIcon = document.createElement('i');
loadingIcon.classList.add('fa-solid', 'fa-spinner', 'fa-spin');
const loadingSpan = document.createElement('span');
loadingSpan.textContent = t`Loading third-party extensions... Please wait...`;
loadingEl.append(loadingIcon, loadingSpan);
externalContainer.appendChild(loadingEl);
const sortOrderKey = 'extensions_sortByName';
const sortByName = accountStorage.getItem(sortOrderKey) === 'true';
@@ -1053,16 +1170,16 @@ async function showExtensionsDetails() {
let extensionsToToggle = [];
extensions.forEach(value => {
const { isExternal, extensionHtml } = value;
const container = isExternal ? htmlExternal : htmlDefault;
container.append(extensionHtml);
const { isExternal, extensionElement } = value;
const container = isExternal ? externalContainer : defaultContainer;
container.appendChild(extensionElement);
});
const html = $('<div></div>')
const extensionsMenu = $('<div></div>')
.addClass('extensions_info')
.append(htmlErrors)
.append(htmlDefault)
.append(htmlExternal)
.append(errors)
.append(defaultContainer)
.append(externalContainer)
.append(getModuleInformation());
{
@@ -1088,23 +1205,24 @@ async function showExtensionsDetails() {
const toggleAllExtensionsButton = document.createElement('div');
toggleAllExtensionsButton.classList.add('menu_button', 'menu_button_icon');
toggleAllExtensionsButton.title = t`Bulk toggle third-party extensions.`;
toggleAllExtensionsButton.innerHTML = `
<span>${t`Toggle extensions`}</span>
<div class="fa-solid fa-circle-info opacity50p"></div>
`;
const toggleAllLabel = document.createElement('span');
toggleAllLabel.textContent = t`Toggle extensions`;
const toggleAllIcon = document.createElement('div');
toggleAllIcon.classList.add('fa-solid', 'fa-circle-info', 'opacity50p');
toggleAllExtensionsButton.append(toggleAllLabel, toggleAllIcon);
const restoreBulkToggledExtensionsButton = document.createElement('div');
restoreBulkToggledExtensionsButton.classList.add('menu_button', 'menu_button_icon', 'fa-solid', 'fa-arrow-right-rotate', 'displayNone');
restoreBulkToggledExtensionsButton.title = t`Restore toggled extensions.\n\nIt does not restore extensions toggled individually.`;
toggleAllExtensionsButton.addEventListener('click', () => {
extensionsToToggle = onToggleAllExtensions(extensionsToToggle, htmlExternal);
extensionsToToggle = onToggleAllExtensions(extensionsToToggle, $(externalContainer));
for (const extension of extensionsToToggle) {
const { name } = extension;
htmlExternal
.find(`.extension_block[data-name="${name.replace('third-party', '')}"] .extension_toggle input`)
$(externalContainer)
.find(`.extension_block[data-name="${getNameSelector(name)}"] .extension_toggle input`)
.off('click')
.one('click', () => {
extensionsToToggle = extensionsToToggle.filter(ext => ext.name !== name);
@@ -1121,8 +1239,8 @@ async function showExtensionsDetails() {
const { name } = extension;
const isDisabled = extension_settings.disabledExtensions.includes(name);
htmlExternal
.find(`.extension_block[data-name="${name.replace('third-party', '')}"] .extension_toggle input`)
$(externalContainer)
.find(`.extension_block[data-name="${getNameSelector(name)}"] .extension_toggle input`)
.prop('checked', !isDisabled)
.toggleClass('toggle_enable', isDisabled)
.toggleClass('toggle_disable', !isDisabled)
@@ -1146,13 +1264,13 @@ async function showExtensionsDetails() {
});
toolbar.append(updateAllButton, updateEnabledOnlyButton, flexExpander, sortOrderButton);
htmlExternal.find('.third_party_toolbar').append(restoreBulkToggledExtensionsButton, toggleAllExtensionsButton);
html.prepend(toolbar);
thirdPartyToolbar.append(restoreBulkToggledExtensionsButton, toggleAllExtensionsButton);
extensionsMenu.prepend(toolbar);
}
let waitingForSave = false;
const popup = new Popup(html, POPUP_TYPE.TEXT, '', {
const popup = new Popup(extensionsMenu, POPUP_TYPE.TEXT, '', {
okButton: t`Close`,
wide: true,
large: true,
@@ -1194,7 +1312,7 @@ async function showExtensionsDetails() {
});
popupPromise = popup.show();
popup.content.scrollTop = initialScrollTop;
checkForUpdatesManual(sortFn, abortController.signal).finally(() => htmlLoading.remove());
checkForUpdatesManual(sortFn, abortController.signal).finally(() => loadingEl.remove());
} catch (error) {
toastr.error(t`Error loading extensions. See browser console for details.`);
console.error(error);
@@ -1297,7 +1415,7 @@ async function onDeleteClick() {
/** @type {import('./popup.js').CustomPopupInput[]} */
const customInputs = hasCleanHook ? [{ id: 'extension_delete_cleanup', label: t`Also clean up extension data`, defaultState: false }] : null;
const popup = new Popup(t`Are you sure you want to delete ${extensionName}?`, POPUP_TYPE.CONFIRM, '', { customInputs });
const popup = new Popup(t`Are you sure you want to delete ${escapeHtml(extensionName)}?`, POPUP_TYPE.CONFIRM, '', { customInputs });
const confirmation = await popup.show();
if (confirmation === POPUP_RESULT.AFFIRMATIVE) {
const shouldClean = hasCleanHook && Boolean(popup.inputResults?.get('extension_delete_cleanup'));
@@ -1312,7 +1430,7 @@ async function onDeleteClick() {
async function onCleanClick() {
const extensionName = $(this).data('name');
const confirmation = await Popup.show.confirm(t`Clean extension data`, t`Are you sure you want to clean up data for ${extensionName}? This action cannot be undone.`);
const confirmation = await Popup.show.confirm(t`Clean extension data`, t`Are you sure you want to clean up data for ${escapeHtml(extensionName)}? This action cannot be undone.`);
if (!confirmation) {
return;
}
@@ -1388,8 +1506,8 @@ async function onMoveClick() {
const confirmationHeader = t`Move extension`;
const confirmationText = source == 'global'
? t`Are you sure you want to move ${extensionName} to your local extensions? This will make it available only for you.`
: t`Are you sure you want to move ${extensionName} to the global extensions? This will make it available for all users.`;
? t`Are you sure you want to move ${escapeHtml(extensionName)} to your local extensions? This will make it available only for you.`
: t`Are you sure you want to move ${escapeHtml(extensionName)} to the global extensions? This will make it available for all users.`;
const confirmation = await Popup.show.confirm(confirmationHeader, confirmationText);
@@ -1493,6 +1611,9 @@ async function getExtensionVersion(extensionName, abortSignal) {
const data = await response.json();
return data;
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
return;
}
console.error('Error:', error);
}
}
@@ -1730,7 +1851,11 @@ async function checkForUpdatesManual(sortFn, abortSignal) {
const promise = enqueueVersionCheck(async () => {
try {
const data = await getExtensionVersion(externalId, abortSignal);
const extensionBlock = document.querySelector(`.extension_block[data-name="${externalId}"]`);
if (!data) {
return;
}
const selector = getNameSelector(externalId, { prefix: '' });
const extensionBlock = document.querySelector(`.extension_block[data-name="${selector}"]`);
if (extensionBlock && data) {
if (data.isUpToDate === false) {
const buttonElement = extensionBlock.querySelector('.btn_update');
@@ -1825,6 +1950,9 @@ async function checkForExtensionUpdates(force) {
const promise = enqueueVersionCheck(async () => {
try {
const data = await getExtensionVersion(id.replace('third-party', ''));
if (!data) {
return;
}
if (!data.isUpToDate) {
updatesAvailable.push(manifest.display_name);
}
+199 -92
View File
@@ -7,10 +7,10 @@ import { DOMPurify } from '../../../lib.js';
import { getRequestHeaders, processDroppedFiles, eventSource, event_types } from '../../../script.js';
import { deleteExtension, EMPTY_AUTHOR, extensionNames, getAuthorFromUrl, getContext, installExtension, renderExtensionTemplateAsync, isOfficialExtension } from '../../extensions.js';
import { POPUP_TYPE, Popup, callGenericPopup } from '../../popup.js';
import { executeSlashCommandsWithOptions } from '../../slash-commands.js';
import { accountStorage } from '../../util/AccountStorage.js';
import { flashHighlight, getStringHash, isValidUrl } from '../../utils.js';
import { escapeHtml, flashHighlight, getStringHash, isValidUrl } from '../../utils.js';
import { t, translate } from '../../i18n.js';
import { SlashCommandParser } from '/scripts/slash-commands/SlashCommandParser.js';
export { MODULE_NAME };
const MODULE_NAME = 'assets';
@@ -60,64 +60,19 @@ const KNOWN_TYPES = {
'blip': t`Blip sounds`,
};
async function downloadAssetsList(url) {
updateCurrentAssets().then(async function () {
fetch(url, { cache: 'no-cache' })
.then(response => response.json())
.then(async function (json) {
availableAssets = {};
$('#assets_menu').empty();
console.debug(DEBUG_PREFIX, 'Received assets dictionary', json);
for (const i of json) {
//console.log(DEBUG_PREFIX,i)
if (availableAssets[i.type] === undefined)
availableAssets[i.type] = [];
availableAssets[i.type].push(i);
}
console.debug(DEBUG_PREFIX, 'Updated available assets to', availableAssets);
// First extensions, then everything else
const assetTypes = Object.keys(availableAssets).sort((a, b) => (a === 'extension') ? -1 : (b === 'extension') ? 1 : 0);
$('#assets_type_select').empty();
$('#assets_search').val('');
$('#assets_type_select').append($('<option />', { value: '', text: t`All` }));
for (const type of assetTypes) {
const text = translate(KNOWN_TYPES[type] || type);
const option = $('<option />', { value: type, text: text });
$('#assets_type_select').append(option);
}
if (assetTypes.includes('extension')) {
$('#assets_type_select').val('extension');
}
$('#assets_type_select').off('change').on('change', filterAssets);
$('#assets_search').off('input').on('input', filterAssets);
for (const assetType of assetTypes) {
let assetTypeMenu = $('<div />', { id: 'assets_audio_ambient_div', class: 'assets-list-div' });
assetTypeMenu.attr('data-type', assetType);
assetTypeMenu.append(`<h3>${KNOWN_TYPES[assetType] || assetType}</h3>`).hide();
if (assetType == 'extension') {
assetTypeMenu.append(await renderExtensionTemplateAsync('assets', 'installation'));
}
for (const asset of availableAssets[assetType].sort((a, b) => a?.name && b?.name && a.name.localeCompare(b.name))) {
const i = availableAssets[assetType].indexOf(asset);
const elemId = `assets_install_${assetType}_${i}`;
let element = $('<div />', { id: elemId, class: 'asset-download-button right_menu_button' });
/**
* Creates the download/delete button element for a single asset, with all interaction handlers attached.
* @param {object} asset The asset data object, containing at least id, name, description and url fields
* @param {string} assetType Asset type, e.g. 'extension', 'character', 'ambient', 'bgm', 'blip'
* @param {number} index Index of the asset in the list of available assets of the same type, used to create a unique element ID
* @returns {JQuery} The button element
*/
function createAssetButton(asset, assetType, index) {
const elemId = `assets_install_${assetType}_${index}`;
const element = $('<div />', { id: elemId, class: 'asset-download-button right_menu_button' });
const label = $('<i class="fa-fw fa-solid fa-download fa-lg"></i>');
element.append(label);
//if (DEBUG_TONY_SAMA_FORK_MODE)
// asset["url"] = asset["url"].replace("https://github.com/SillyTavern/","https://github.com/Tony-sama/"); // DBG
console.debug(DEBUG_PREFIX, 'Checking asset', asset.id, asset.url);
const assetInstall = async function () {
@@ -150,7 +105,7 @@ async function downloadAssetsList(url) {
const assetDelete = async function () {
if (assetType === 'character') {
toastr.error('Go to the characters menu to delete a character.', 'Character deletion not supported');
await executeSlashCommandsWithOptions(`/go ${asset.id}`);
await SlashCommandParser.commands.go.callback(null, asset.id);
return;
}
element.off('click');
@@ -183,6 +138,17 @@ async function downloadAssetsList(url) {
element.on('click', assetInstall);
}
return element;
}
/**
* Creates the full visual block element for a single asset.
* @param {object} asset The asset data object, containing at least id, name, description and url fields
* @param {string} assetType Asset type, e.g. 'extension', 'character', 'ambient', 'bgm', 'blip'
* @param {JQuery} element The button element from createAssetButton
* @returns {JQuery} The asset block element
*/
function createAssetBlock(asset, assetType, element) {
console.debug(DEBUG_PREFIX, 'Created element for ', asset.id);
const displayName = DOMPurify.sanitize(asset.name || asset.id);
@@ -193,23 +159,31 @@ async function downloadAssetsList(url) {
const toolTag = assetType === 'extension' && asset.tool;
const author = url && assetType === 'extension' ? getAuthorFromUrl(url) : EMPTY_AUTHOR;
const assetBlock = $('<i></i>')
.append(element)
.append(`<div class="flex-container flexFlowColumn flexNoGap wide100p overflowHidden">
<span class="asset-name flex-container alignitemscenter">
<b>${displayName}</b>
<a class="asset_preview" href="${url}" target="_blank" title="${title}">
<i class="fa-solid fa-sm ${previewIcon}"></i>
</a>` +
(toolTag ? '<span class="tag" title="' + t`Adds a function tool` + '"><i class="fa-solid fa-sm fa-wrench"></i> ' +
t`Tool` + '</span>' : '') +
'<span class="expander"></span>' +
(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>` : '') +
`</span>
<small class="asset-description">
${description}
</small>
</div>`);
const nameSpan = $('<span>', { class: 'asset-name flex-container alignitemscenter' })
.append($('<b>').text(displayName))
.append($('<a>', { class: 'asset_preview', href: url, target: '_blank', title: title })
.append($('<i>', { class: `fa-solid fa-sm ${previewIcon}` })));
if (toolTag) {
const tagSpan = $('<span>', { class: 'tag', title: t`Adds a function tool` })
.append($('<i>', { class: 'fa-solid fa-sm fa-wrench' }))
.append(document.createTextNode(` ${t`Tool`}`));
nameSpan.append(tagSpan);
}
nameSpan.append($('<span>', { class: 'expander' }));
if (author.name) {
nameSpan.append($('<a>', { href: author.url, target: '_blank', class: 'asset-author-info' })
.append($('<i>', { class: 'fa-solid fa-at fa-xs' }))
.append($('<span>').text(author.name)));
}
const infoDiv = $('<div>', { class: 'flex-container flexFlowColumn flexNoGap wide100p overflowHidden' })
.append(nameSpan)
.append($('<small>', { class: 'asset-description' }).text(description));
const assetBlock = $('<i></i>').append(element).append(infoDiv);
assetBlock.find('.tag').on('click', function (e) {
const a = document.createElement('a');
@@ -220,12 +194,33 @@ async function downloadAssetsList(url) {
if (assetType === 'character') {
if (asset.highlight) {
assetBlock.find('.asset-name').append('<i class="fa-solid fa-sm fa-trophy"></i>');
nameSpan.append($('<i>', { class: 'fa-solid fa-sm fa-trophy' }));
}
assetBlock.find('.asset-name').prepend(`<div class="avatar"><img src="${asset.url}" alt="${displayName}"></div>`);
nameSpan.prepend($('<div>', { class: 'avatar' }).append($('<img>', { src: asset.url, alt: displayName })));
}
assetBlock.addClass('asset-block');
return assetBlock;
}
/**
* Builds and appends the menu section for a single asset type.
* @param {string} assetType Asset type, e.g. 'extension', 'character', 'ambient', 'bgm', 'blip'
* @returns {Promise<void>}
*/
async function buildAssetTypeSection(assetType) {
const assetTypeMenu = $('<div />', { id: `assets_${assetType}_div`, class: 'assets-list-div' });
assetTypeMenu.attr('data-type', assetType);
assetTypeMenu.append($('<h3>').text(KNOWN_TYPES[assetType] || assetType)).hide();
if (assetType == 'extension') {
assetTypeMenu.append(await renderExtensionTemplateAsync('assets', 'installation'));
}
for (const asset of availableAssets[assetType].sort((a, b) => a?.name && b?.name && a.name.localeCompare(b.name))) {
const i = availableAssets[assetType].indexOf(asset);
const element = createAssetButton(asset, assetType, i);
const assetBlock = createAssetBlock(asset, assetType, element);
if (assetType === 'extension') {
const extensionBlockList = isOfficialExtension(asset.url)
@@ -236,29 +231,91 @@ async function downloadAssetsList(url) {
assetTypeMenu.append(assetBlock);
}
}
assetTypeMenu.appendTo('#assets_menu');
assetTypeMenu.on('click', 'a.asset_preview', previewAsset);
}
/**
* Parses the fetched assets JSON and renders the full assets menu.
* @param {object[]} json Array of asset objects, each containing at least id, name, description, url and type fields
*/
async function populateAssetsMenu(json) {
availableAssets = {};
$('#assets_menu').empty();
console.debug(DEBUG_PREFIX, 'Received assets dictionary', json);
for (const i of json) {
if (availableAssets[i.type] === undefined)
availableAssets[i.type] = [];
availableAssets[i.type].push(i);
}
console.debug(DEBUG_PREFIX, 'Updated available assets to', availableAssets);
// First extensions, then everything else
const assetTypes = Object.keys(availableAssets).sort((a, b) => (a === 'extension') ? -1 : (b === 'extension') ? 1 : 0);
$('#assets_type_select').empty();
$('#assets_search').val('');
$('#assets_type_select').append($('<option />', { value: '', text: t`All` }));
for (const type of assetTypes) {
const text = translate(KNOWN_TYPES[type] || type);
const option = $('<option />', { value: type, text: text });
$('#assets_type_select').append(option);
}
if (assetTypes.includes('extension')) {
$('#assets_type_select').val('extension');
}
$('#assets_type_select').off('change').on('change', filterAssets);
$('#assets_search').off('input').on('input', filterAssets);
for (const assetType of assetTypes) {
await buildAssetTypeSection(assetType);
}
filterAssets();
$('#assets_filters').show();
$('#assets_menu').show();
})
.catch((error) => {
// Info hint if the user maybe... likely accidently was trying to install an extension and we wanna help guide them? uwu :3
}
/**
* Downloads the assets list from the given URL and populates the menu. Shows error message if something goes wrong.
* @param {URL} url URL to fetch from
*/
async function downloadAssetsList(url) {
await updateCurrentAssets();
try {
const response = await fetch(url, { cache: 'no-cache' });
if (!response.ok) {
throw new Error('Cannot download the assets list.');
}
const json = await response.json();
if (!Array.isArray(json)) {
throw new Error('Assets list is not an array');
}
await populateAssetsMenu(json);
} catch (error) {
// Info hint if the user maybe... likely accidentally was trying to install an extension and we wanna help guide them? uwu :3
const installButton = $('#third_party_extension_button');
flashHighlight(installButton, 10_000);
toastr.info('Click the flashing button at the top right corner of the menu.', 'Trying to install a custom extension?', { timeOut: 10_000 });
// Error logged after, to appear on top
console.error(error);
toastr.error('Problem with assets URL', DEBUG_PREFIX + 'Cannot get assets list');
toastr.error('Problem with assets URL', 'Cannot get assets list');
$('#assets-connect-button').addClass('fa-plug-circle-exclamation');
$('#assets-connect-button').addClass('redOverlayGlow');
});
});
}
}
/**
* 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.
* @param {JQuery.Event} e Click event
*/
function previewAsset(e) {
const href = $(this).attr('href');
const audioExtensions = ['.mp3', '.ogg', '.wav'];
@@ -281,6 +338,15 @@ function previewAsset(e) {
}
}
/**
* Checks if the asset is already installed.
* For extensions, it checks if the extension name is in the list of installed extensions.
* For characters, it checks if any character has the same avatar URL.
* For other asset types, it checks if any installed asset of the same type has a URL that includes the filename.
* @param {string} assetType Type of the asset, e.g. 'extension', 'character', 'ambient', 'bgm', 'blip'
* @param {string} filename Name or ID of the asset
* @returns {boolean} True if the asset is installed, false otherwise
*/
function isAssetInstalled(assetType, filename) {
let assetList = currentAssets[assetType];
@@ -302,6 +368,13 @@ function isAssetInstalled(assetType, filename) {
return false;
}
/**
* Installs the asset by sending a request to the server to download it. If it's an extension, it uses the existing installExtension function.
* @param {string} url URL of the asset to download
* @param {string} assetType Type of the asset, e.g. 'extension', 'character', 'ambient', 'bgm', 'blip'
* @param {string} filename Name or ID of the asset
* @returns {Promise<boolean>} True if the asset was successfully installed, false otherwise
*/
async function installAsset(url, assetType, filename) {
console.debug(DEBUG_PREFIX, 'Downloading ', url);
const category = assetType;
@@ -326,7 +399,8 @@ async function installAsset(url, assetType, filename) {
console.debug(DEBUG_PREFIX, 'Importing character ', filename);
const blob = await result.blob();
const file = new File([blob], filename, { type: blob.type });
await processDroppedFiles([file]);
const fileNameMap = new Map([[file, filename]]);
await processDroppedFiles([file], fileNameMap);
console.debug(DEBUG_PREFIX, 'Character downloaded.');
}
return true;
@@ -338,6 +412,12 @@ async function installAsset(url, assetType, filename) {
}
}
/**
* Deletes the asset by sending a request to the server to delete it. If it's an extension, it uses the existing deleteExtension function.
* @param {string} assetType Type of the asset, e.g. 'extension', 'character', 'ambient', 'bgm', 'blip'
* @param {string} filename Name or ID of the asset
* @returns {Promise<boolean>} True if the asset was successfully deleted, false otherwise
*/
async function deleteAsset(assetType, filename) {
console.debug(DEBUG_PREFIX, 'Deleting ', assetType, filename);
const category = assetType;
@@ -346,6 +426,7 @@ async function deleteAsset(assetType, filename) {
console.debug(DEBUG_PREFIX, 'Deleting extension ', filename);
await deleteExtension(filename);
console.debug(DEBUG_PREFIX, 'Extension deleted.');
return true;
}
const body = { category, filename };
@@ -357,19 +438,37 @@ async function deleteAsset(assetType, filename) {
});
if (result.ok) {
console.debug(DEBUG_PREFIX, 'Deletion success.');
return true;
}
return false;
} catch (err) {
console.log(err);
return [];
return false;
}
}
/**
* Opens the character browser popup, which shows all available characters and allows downloading them.
* @param {boolean} forceDefault If true, it uses the default ASSETS_JSON_URL instead of the one from the input field.
* @returns {Promise<void>}
*/
async function openCharacterBrowser(forceDefault) {
const url = forceDefault ? ASSETS_JSON_URL : String($('#assets-json-url-field').val());
if (!isValidUrl(url)) {
toastr.error('Please enter a valid URL');
return;
}
const fetchResult = await fetch(url, { cache: 'no-cache' });
if (!fetchResult.ok) {
toastr.error('Cannot download the assets list.');
return;
}
const json = await fetchResult.json();
const characters = json.filter(x => x.type === 'character');
if (!Array.isArray(json)) {
toastr.error('Assets list is not an array');
return;
}
const characters = json.filter(x => x && x.type === 'character');
if (!characters.length) {
toastr.error('No characters found in the assets list', 'Character browser');
return;
@@ -395,7 +494,10 @@ async function openCharacterBrowser(forceDefault) {
}
});
checkMark.toggle(isInstalled);
checkMark.toggle(isInstalled).on('click', async () => {
toastr.error('Go to the characters menu to delete a character.', 'Character deletion not supported');
await SlashCommandParser.commands.go.callback(null, character.id);
});
listElement.append(characterElement);
}
@@ -449,11 +551,16 @@ export async function init() {
const connectButton = windowHtml.find('#assets-connect-button');
connectButton.on('click', async function () {
const url = DOMPurify.sanitize(String(assetsJsonUrl.val()));
const rememberKey = `Assets_SkipConfirm_${getStringHash(url)}`;
const urlString = String(assetsJsonUrl.val()).trim();
if (!isValidUrl(urlString)) {
toastr.error('Please enter a valid URL');
return;
}
const url = new URL(urlString);
const rememberKey = `Assets_SkipConfirm_${getStringHash(url.href)}`;
const skipConfirm = accountStorage.getItem(rememberKey) === 'true';
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>`, {
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>`, {
customInputs: [{ id: 'assets-remember', label: 'Don\'t ask again for this URL' }],
onClose: popup => {
if (popup.result) {
@@ -472,7 +579,7 @@ export async function init() {
connectButton.addClass('fa-plug-circle-check');
} catch (error) {
console.error('Error:', error);
toastr.error(`Cannot get assets list from ${url}`);
toastr.error(`Cannot get assets list from ${url.href}`);
connectButton.removeClass('fa-plug-circle-check');
connectButton.addClass('fa-plug-circle-exclamation');
connectButton.removeClass('redOverlayGlow');