', { class: 'avatar' }).append($('
![]()
', { 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
}
+ */
+async function buildAssetTypeSection(assetType) {
+ const assetTypeMenu = $('', { id: `assets_${assetType}_div`, class: 'assets-list-div' });
+ assetTypeMenu.attr('data-type', assetType);
+ assetTypeMenu.append($('').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)
+ ? assetTypeMenu.find('.assets-list-extensions-official .assets-list-extensions')
+ : assetTypeMenu.find('.assets-list-extensions-community .assets-list-extensions');
+ extensionBlockList.append(assetBlock);
+ } else {
+ 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($('', { value: '', text: t`All` }));
+
+ for (const type of assetTypes) {
+ const text = translate(KNOWN_TYPES[type] || type);
+ const 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();
+}
+
+/**
+ * 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', '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} 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} 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}
+ */
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`, '' + t`Are you sure you want to connect to the following url?` + `${url}`, {
+ const confirmation = skipConfirm || await Popup.show.confirm(t`Loading Asset List`, '' + t`Are you sure you want to connect to the following url?` + `${escapeHtml(url.href)}`, {
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');