Spaces:
Paused
Paused
| import { DOMPurify, Popper } from '../lib.js'; | |
| import { eventSource, event_types, saveSettings, saveSettingsDebounced, getRequestHeaders, animation_duration } from '../script.js'; | |
| import { showLoader } from './loader.js'; | |
| import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js'; | |
| import { renderTemplate, renderTemplateAsync } from './templates.js'; | |
| import { delay, isSubsetOf, sanitizeSelector, setValueByPath } from './utils.js'; | |
| import { getContext } from './st-context.js'; | |
| import { isAdmin } from './user.js'; | |
| import { addLocaleData, getCurrentLocale, t } from './i18n.js'; | |
| import { debounce_timeout } from './constants.js'; | |
| import { accountStorage } from './util/AccountStorage.js'; | |
| export { | |
| getContext, | |
| getApiUrl, | |
| }; | |
| /** @type {string[]} */ | |
| export let extensionNames = []; | |
| /** | |
| * Holds the type of each extension. | |
| * Don't use this directly, use getExtensionType instead! | |
| * @type {Record<string, string>} | |
| */ | |
| export let extensionTypes = {}; | |
| /** | |
| * A list of active modules provided by the Extras API. | |
| * @type {string[]} | |
| */ | |
| export let modules = []; | |
| /** | |
| * A set of active extensions. | |
| * @type {Set<string>} | |
| */ | |
| const activeExtensions = new Set(); | |
| /** | |
| * Errors that occurred while loading extensions. | |
| * @type {Set<string>} | |
| */ | |
| const extensionLoadErrors = new Set(); | |
| const getApiUrl = () => extension_settings.apiUrl; | |
| const sortManifestsByOrder = (a, b) => parseInt(a.loading_order) - parseInt(b.loading_order) || String(a.display_name).localeCompare(String(b.display_name)); | |
| const sortManifestsByName = (a, b) => String(a.display_name).localeCompare(String(b.display_name)) || parseInt(a.loading_order) - parseInt(b.loading_order); | |
| let connectedToApi = false; | |
| /** | |
| * Holds manifest data for each extension. | |
| * @type {Record<string, object>} | |
| */ | |
| let manifests = {}; | |
| /** | |
| * Default URL for the Extras API. | |
| */ | |
| const defaultUrl = 'http://localhost:5100'; | |
| let requiresReload = false; | |
| let stateChanged = false; | |
| let saveMetadataTimeout = null; | |
| export function cancelDebouncedMetadataSave() { | |
| if (saveMetadataTimeout) { | |
| console.debug('Debounced metadata save cancelled'); | |
| clearTimeout(saveMetadataTimeout); | |
| saveMetadataTimeout = null; | |
| } | |
| } | |
| export function saveMetadataDebounced() { | |
| const context = getContext(); | |
| const groupId = context.groupId; | |
| const characterId = context.characterId; | |
| cancelDebouncedMetadataSave(); | |
| saveMetadataTimeout = setTimeout(async () => { | |
| const newContext = getContext(); | |
| if (groupId !== newContext.groupId) { | |
| console.warn('Group changed, not saving metadata'); | |
| return; | |
| } | |
| if (characterId !== newContext.characterId) { | |
| console.warn('Character changed, not saving metadata'); | |
| return; | |
| } | |
| console.debug('Saving metadata...'); | |
| await newContext.saveMetadata(); | |
| console.debug('Saved metadata...'); | |
| }, debounce_timeout.relaxed); | |
| } | |
| /** | |
| * Provides an ability for extensions to render HTML templates synchronously. | |
| * Templates sanitation and localization is forced. | |
| * @param {string} extensionName Extension name | |
| * @param {string} templateId Template ID | |
| * @param {object} templateData Additional data to pass to the template | |
| * @returns {string} Rendered HTML | |
| * | |
| * @deprecated Use renderExtensionTemplateAsync instead. | |
| */ | |
| export function renderExtensionTemplate(extensionName, templateId, templateData = {}, sanitize = true, localize = true) { | |
| return renderTemplate(`scripts/extensions/${extensionName}/${templateId}.html`, templateData, sanitize, localize, true); | |
| } | |
| /** | |
| * Provides an ability for extensions to render HTML templates asynchronously. | |
| * Templates sanitation and localization is forced. | |
| * @param {string} extensionName Extension name | |
| * @param {string} templateId Template ID | |
| * @param {object} templateData Additional data to pass to the template | |
| * @returns {Promise<string>} Rendered HTML | |
| */ | |
| export function renderExtensionTemplateAsync(extensionName, templateId, templateData = {}, sanitize = true, localize = true) { | |
| return renderTemplateAsync(`scripts/extensions/${extensionName}/${templateId}.html`, templateData, sanitize, localize, true); | |
| } | |
| // Disables parallel updates | |
| export class ModuleWorkerWrapper { | |
| constructor(callback) { | |
| this.isBusy = false; | |
| this.callback = callback; | |
| } | |
| // Called by the extension | |
| async update(...args) { | |
| // Don't touch me I'm busy... | |
| if (this.isBusy) { | |
| return; | |
| } | |
| // I'm free. Let's update! | |
| try { | |
| this.isBusy = true; | |
| await this.callback(...args); | |
| } | |
| finally { | |
| this.isBusy = false; | |
| } | |
| } | |
| } | |
| export const extension_settings = { | |
| apiUrl: defaultUrl, | |
| apiKey: '', | |
| autoConnect: false, | |
| notifyUpdates: false, | |
| disabledExtensions: [], | |
| expressionOverrides: [], | |
| memory: {}, | |
| note: { | |
| default: '', | |
| chara: [], | |
| wiAddition: [], | |
| }, | |
| caption: { | |
| refine_mode: false, | |
| }, | |
| expressions: { | |
| /** @type {number} see `EXPRESSION_API` */ | |
| api: undefined, | |
| /** @type {string[]} */ | |
| custom: [], | |
| showDefault: false, | |
| translate: false, | |
| /** @type {string} */ | |
| fallback_expression: undefined, | |
| /** @type {string} */ | |
| llmPrompt: undefined, | |
| allowMultiple: true, | |
| rerollIfSame: false, | |
| promptType: 'raw', | |
| }, | |
| connectionManager: { | |
| selectedProfile: '', | |
| /** @type {import('./extensions/connection-manager/index.js').ConnectionProfile[]} */ | |
| profiles: [], | |
| }, | |
| dice: {}, | |
| /** @type {import('./char-data.js').RegexScriptData[]} */ | |
| regex: [], | |
| character_allowed_regex: [], | |
| tts: {}, | |
| sd: { | |
| prompts: {}, | |
| character_prompts: {}, | |
| character_negative_prompts: {}, | |
| }, | |
| chromadb: {}, | |
| translate: {}, | |
| objective: {}, | |
| quickReply: {}, | |
| randomizer: { | |
| controls: [], | |
| fluctuation: 0.1, | |
| enabled: false, | |
| }, | |
| speech_recognition: {}, | |
| rvc: {}, | |
| hypebot: {}, | |
| vectors: {}, | |
| variables: { | |
| global: {}, | |
| }, | |
| /** | |
| * @type {import('./chats.js').FileAttachment[]} | |
| */ | |
| attachments: [], | |
| /** | |
| * @type {Record<string, import('./chats.js').FileAttachment[]>} | |
| */ | |
| character_attachments: {}, | |
| /** | |
| * @type {string[]} | |
| */ | |
| disabled_attachments: [], | |
| gallery: { | |
| /** @type {{[characterKey: string]: string}} */ | |
| folders: {}, | |
| /** @type {string} */ | |
| sort: 'dateAsc', | |
| }, | |
| }; | |
| function showHideExtensionsMenu() { | |
| // Get the number of menu items that are not hidden | |
| const hasMenuItems = $('#extensionsMenu').children().filter((_, child) => $(child).css('display') !== 'none').length > 0; | |
| // We have menu items, so we can stop checking | |
| if (hasMenuItems) { | |
| clearInterval(menuInterval); | |
| } | |
| // Show or hide the menu button | |
| $('#extensionsMenuButton').toggle(hasMenuItems); | |
| } | |
| // Periodically check for new extensions | |
| const menuInterval = setInterval(showHideExtensionsMenu, 1000); | |
| /** | |
| * Gets the type of an extension based on its external ID. | |
| * @param {string} externalId External ID of the extension (excluding or including the leading 'third-party/') | |
| * @returns {string} Type of the extension (global, local, system, or empty string if not found) | |
| */ | |
| function getExtensionType(externalId) { | |
| const id = Object.keys(extensionTypes).find(id => id === externalId || (id.startsWith('third-party') && id.endsWith(externalId))); | |
| return id ? extensionTypes[id] : ''; | |
| } | |
| /** | |
| * Performs a fetch of the Extras API. | |
| * @param {string|URL} endpoint Extras API endpoint | |
| * @param {RequestInit} args Request arguments | |
| * @returns {Promise<Response>} Response from the fetch | |
| */ | |
| export async function doExtrasFetch(endpoint, args = {}) { | |
| if (!args) { | |
| args = {}; | |
| } | |
| if (!args.method) { | |
| Object.assign(args, { method: 'GET' }); | |
| } | |
| if (!args.headers) { | |
| args.headers = {}; | |
| } | |
| if (extension_settings.apiKey) { | |
| Object.assign(args.headers, { | |
| 'Authorization': `Bearer ${extension_settings.apiKey}`, | |
| }); | |
| } | |
| return await fetch(endpoint, args); | |
| } | |
| /** | |
| * Discovers extensions from the API. | |
| * @returns {Promise<{name: string, type: string}[]>} | |
| */ | |
| async function discoverExtensions() { | |
| try { | |
| const response = await fetch('/api/extensions/discover'); | |
| if (response.ok) { | |
| const extensions = await response.json(); | |
| return extensions; | |
| } | |
| else { | |
| return []; | |
| } | |
| } | |
| catch (err) { | |
| console.error(err); | |
| return []; | |
| } | |
| } | |
| function onDisableExtensionClick() { | |
| const name = $(this).data('name'); | |
| disableExtension(name, false); | |
| } | |
| function onEnableExtensionClick() { | |
| const name = $(this).data('name'); | |
| enableExtension(name, false); | |
| } | |
| /** | |
| * Enables an extension by name. | |
| * @param {string} name Extension name | |
| * @param {boolean} [reload=true] If true, reload the page after enabling the extension | |
| */ | |
| export async function enableExtension(name, reload = true) { | |
| extension_settings.disabledExtensions = extension_settings.disabledExtensions.filter(x => x !== name); | |
| stateChanged = true; | |
| await saveSettings(); | |
| if (reload) { | |
| location.reload(); | |
| } else { | |
| requiresReload = true; | |
| } | |
| } | |
| /** | |
| * Disables an extension by name. | |
| * @param {string} name Extension name | |
| * @param {boolean} [reload=true] If true, reload the page after disabling the extension | |
| */ | |
| export async function disableExtension(name, reload = true) { | |
| extension_settings.disabledExtensions.push(name); | |
| stateChanged = true; | |
| await saveSettings(); | |
| if (reload) { | |
| location.reload(); | |
| } else { | |
| requiresReload = true; | |
| } | |
| } | |
| /** | |
| * Loads manifest.json files for extensions. | |
| * @param {string[]} names Array of extension names | |
| * @returns {Promise<Record<string, object>>} Object with extension names as keys and their manifests as values | |
| */ | |
| async function getManifests(names) { | |
| const obj = {}; | |
| const promises = []; | |
| for (const name of names) { | |
| const promise = new Promise((resolve, reject) => { | |
| fetch(`/scripts/extensions/${name}/manifest.json`).then(async response => { | |
| if (response.ok) { | |
| const json = await response.json(); | |
| obj[name] = json; | |
| resolve(); | |
| } else { | |
| reject(); | |
| } | |
| }).catch(err => { | |
| reject(); | |
| console.log('Could not load manifest.json for ' + name, err); | |
| }); | |
| }); | |
| promises.push(promise); | |
| } | |
| await Promise.allSettled(promises); | |
| return obj; | |
| } | |
| /** | |
| * Tries to activate all available extensions that are not already active. | |
| * @returns {Promise<void>} | |
| */ | |
| async function activateExtensions() { | |
| extensionLoadErrors.clear(); | |
| const extensions = Object.entries(manifests).sort((a, b) => sortManifestsByOrder(a[1], b[1])); | |
| const extensionNames = extensions.map(x => x[0]); | |
| const promises = []; | |
| for (let entry of extensions) { | |
| const name = entry[0]; | |
| const manifest = entry[1]; | |
| const extrasRequirements = manifest.requires; | |
| const extensionDependencies = manifest.dependencies; | |
| const displayName = manifest.display_name || name; | |
| if (activeExtensions.has(name)) { | |
| continue; | |
| } | |
| // Module requirements: pass if 'requires' is undefined, null, or not an array; check subset if it's an array | |
| let meetsModuleRequirements = true; | |
| let missingModules = []; | |
| if (extrasRequirements !== undefined) { | |
| if (Array.isArray(extrasRequirements)) { | |
| meetsModuleRequirements = isSubsetOf(modules, extrasRequirements); | |
| missingModules = extrasRequirements.filter(req => !modules.includes(req)); | |
| } else { | |
| console.warn(`Extension ${name}: manifest.json 'requires' field is not an array. Loading allowed, but any intended requirements were not verified to exist.`); | |
| } | |
| } | |
| // Extension dependencies: pass if 'dependencies' is undefined or not an array; check subset and disabled status if it's an array | |
| let meetsExtensionDeps = true; | |
| let missingDependencies = []; | |
| let disabledDependencies = []; | |
| if (extensionDependencies !== undefined) { | |
| if (Array.isArray(extensionDependencies)) { | |
| // Check if all dependencies exist | |
| meetsExtensionDeps = isSubsetOf(extensionNames, extensionDependencies); | |
| missingDependencies = extensionDependencies.filter(dep => !extensionNames.includes(dep)); | |
| // Check for disabled dependencies | |
| if (meetsExtensionDeps) { | |
| disabledDependencies = extensionDependencies.filter(dep => extension_settings.disabledExtensions.includes(dep)); | |
| if (disabledDependencies.length > 0) { | |
| // Fail if any dependencies are disabled | |
| meetsExtensionDeps = false; | |
| } | |
| } | |
| } else { | |
| console.warn(`Extension ${name}: manifest.json 'dependencies' field is not an array. Loading allowed, but any intended requirements were not verified to exist.`); | |
| } | |
| } | |
| const isDisabled = extension_settings.disabledExtensions.includes(name); | |
| if (meetsModuleRequirements && meetsExtensionDeps && !isDisabled) { | |
| try { | |
| console.debug('Activating extension', name); | |
| const promise = addExtensionLocale(name, manifest).finally(() => | |
| Promise.all([addExtensionScript(name, manifest), addExtensionStyle(name, manifest)]), | |
| ); | |
| await promise | |
| .then(() => activeExtensions.add(name)) | |
| .catch(err => { | |
| console.log('Could not activate extension', name, err); | |
| extensionLoadErrors.add(t`Extension "${displayName}" failed to load: ${err}`); | |
| }); | |
| promises.push(promise); | |
| } catch (error) { | |
| console.error('Could not activate extension', name, error); | |
| } | |
| } else if (!meetsModuleRequirements && !isDisabled) { | |
| console.warn(t`Extension "${name}" did not load. Missing required Extras module(s): "${missingModules.join(', ')}"`); | |
| extensionLoadErrors.add(t`Extension "${displayName}" did not load. Missing required Extras module(s): "${missingModules.join(', ')}"`); | |
| } else if (!meetsExtensionDeps && !isDisabled) { | |
| if (disabledDependencies.length > 0) { | |
| console.warn(t`Extension "${name}" did not load. Required extensions exist but are disabled: "${disabledDependencies.join(', ')}". Enable them first, then reload.`); | |
| extensionLoadErrors.add(t`Extension "${displayName}" did not load. Required extensions exist but are disabled: "${disabledDependencies.join(', ')}". Enable them first, then reload.`); | |
| } else { | |
| console.warn(t`Extension "${name}" did not load. Missing required extensions: "${missingDependencies.join(', ')}"`); | |
| extensionLoadErrors.add(t`Extension "${displayName}" did not load. Missing required extensions: "${missingDependencies.join(', ')}"`); | |
| } | |
| } | |
| } | |
| await Promise.allSettled(promises); | |
| $('#extensions_details').toggleClass('warning', extensionLoadErrors.size > 0); | |
| } | |
| async function connectClickHandler() { | |
| const baseUrl = String($('#extensions_url').val()); | |
| extension_settings.apiUrl = baseUrl; | |
| const testApiKey = $('#extensions_api_key').val(); | |
| extension_settings.apiKey = String(testApiKey); | |
| saveSettingsDebounced(); | |
| await connectToApi(baseUrl); | |
| } | |
| function autoConnectInputHandler() { | |
| const value = $(this).prop('checked'); | |
| extension_settings.autoConnect = !!value; | |
| if (value && !connectedToApi) { | |
| $('#extensions_connect').trigger('click'); | |
| } | |
| saveSettingsDebounced(); | |
| } | |
| async function addExtensionsButtonAndMenu() { | |
| const buttonHTML = await renderTemplateAsync('wandButton'); | |
| const extensionsMenuHTML = await renderTemplateAsync('wandMenu'); | |
| $(document.body).append(extensionsMenuHTML); | |
| $('#leftSendForm').append(buttonHTML); | |
| const button = $('#extensionsMenuButton'); | |
| const dropdown = $('#extensionsMenu'); | |
| let isDropdownVisible = false; | |
| let popper = Popper.createPopper(button.get(0), dropdown.get(0), { | |
| placement: 'top-start', | |
| }); | |
| $(button).on('click', function () { | |
| if (isDropdownVisible) { | |
| dropdown.fadeOut(animation_duration); | |
| isDropdownVisible = false; | |
| } else { | |
| dropdown.fadeIn(animation_duration); | |
| isDropdownVisible = true; | |
| } | |
| popper.update(); | |
| }); | |
| $('html').on('click', function (e) { | |
| if (!isDropdownVisible) return; | |
| const clickTarget = $(e.target); | |
| const noCloseTargets = ['#sd_gen', '#extensionsMenuButton', '#roll_dice']; | |
| if (!noCloseTargets.some(id => clickTarget.closest(id).length > 0)) { | |
| dropdown.fadeOut(animation_duration); | |
| isDropdownVisible = false; | |
| } | |
| }); | |
| } | |
| function notifyUpdatesInputHandler() { | |
| extension_settings.notifyUpdates = !!$('#extensions_notify_updates').prop('checked'); | |
| saveSettingsDebounced(); | |
| if (extension_settings.notifyUpdates) { | |
| checkForExtensionUpdates(true); | |
| } | |
| } | |
| /** | |
| * Connects to the Extras API. | |
| * @param {string} baseUrl Extras API base URL | |
| * @returns {Promise<void>} | |
| */ | |
| async function connectToApi(baseUrl) { | |
| if (!baseUrl) { | |
| return; | |
| } | |
| const url = new URL(baseUrl); | |
| url.pathname = '/api/modules'; | |
| try { | |
| const getExtensionsResult = await doExtrasFetch(url); | |
| if (getExtensionsResult.ok) { | |
| const data = await getExtensionsResult.json(); | |
| modules = data.modules; | |
| await activateExtensions(); | |
| await eventSource.emit(event_types.EXTRAS_CONNECTED, modules); | |
| } | |
| updateStatus(getExtensionsResult.ok); | |
| } | |
| catch { | |
| updateStatus(false); | |
| } | |
| } | |
| /** | |
| * Updates the status of Extras API connection. | |
| * @param {boolean} success Whether the connection was successful | |
| */ | |
| function updateStatus(success) { | |
| connectedToApi = success; | |
| const _text = success ? t`Connected to API` : t`Could not connect to API`; | |
| const _class = success ? 'success' : 'failure'; | |
| $('#extensions_status').text(_text); | |
| $('#extensions_status').attr('class', _class); | |
| } | |
| /** | |
| * Adds a CSS file for an extension. | |
| * @param {string} name Extension name | |
| * @param {object} manifest Extension manifest | |
| * @returns {Promise<void>} When the CSS is loaded | |
| */ | |
| function addExtensionStyle(name, manifest) { | |
| if (!manifest.css) { | |
| return Promise.resolve(); | |
| } | |
| return new Promise((resolve, reject) => { | |
| const url = `/scripts/extensions/${name}/${manifest.css}`; | |
| const id = sanitizeSelector(`${name}-css`); | |
| if ($(`link[id="${id}"]`).length === 0) { | |
| const link = document.createElement('link'); | |
| link.id = id; | |
| link.rel = 'stylesheet'; | |
| link.type = 'text/css'; | |
| link.href = url; | |
| link.onload = function () { | |
| resolve(); | |
| }; | |
| link.onerror = function (e) { | |
| reject(e); | |
| }; | |
| document.head.appendChild(link); | |
| } | |
| }); | |
| } | |
| /** | |
| * Loads a JS file for an extension. | |
| * @param {string} name Extension name | |
| * @param {object} manifest Extension manifest | |
| * @returns {Promise<void>} When the script is loaded | |
| */ | |
| function addExtensionScript(name, manifest) { | |
| if (!manifest.js) { | |
| return Promise.resolve(); | |
| } | |
| return new Promise((resolve, reject) => { | |
| const url = `/scripts/extensions/${name}/${manifest.js}`; | |
| const id = sanitizeSelector(`${name}-js`); | |
| let ready = false; | |
| if ($(`script[id="${id}"]`).length === 0) { | |
| const script = document.createElement('script'); | |
| script.id = id; | |
| script.type = 'module'; | |
| script.src = url; | |
| script.async = true; | |
| script.onerror = function (err) { | |
| reject(err); | |
| }; | |
| script.onload = function () { | |
| if (!ready) { | |
| ready = true; | |
| resolve(); | |
| } | |
| }; | |
| document.body.appendChild(script); | |
| } | |
| }); | |
| } | |
| /** | |
| * Adds a localization data for an extension. | |
| * @param {string} name Extension name | |
| * @param {object} manifest Manifest object | |
| */ | |
| function addExtensionLocale(name, manifest) { | |
| // No i18n data in the manifest | |
| if (!manifest.i18n || typeof manifest.i18n !== 'object') { | |
| return Promise.resolve(); | |
| } | |
| const currentLocale = getCurrentLocale(); | |
| const localeFile = manifest.i18n[currentLocale]; | |
| // Manifest doesn't provide a locale file for the current locale | |
| if (!localeFile) { | |
| return Promise.resolve(); | |
| } | |
| return fetch(`/scripts/extensions/${name}/${localeFile}`) | |
| .then(async response => { | |
| if (!response.ok) { | |
| throw new Error(`HTTP ${response.status}: ${response.statusText}`); | |
| } | |
| const data = await response.json(); | |
| if (data && typeof data === 'object') { | |
| addLocaleData(currentLocale, data); | |
| } | |
| }) | |
| .catch(err => { | |
| console.log('Could not load extension locale data for ' + name, err); | |
| }); | |
| } | |
| /** | |
| * Generates HTML string for displaying an extension in the UI. | |
| * | |
| * @param {string} name - The name of the extension. | |
| * @param {object} manifest - The manifest of the extension. | |
| * @param {boolean} isActive - Whether the extension is active or not. | |
| * @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. | |
| */ | |
| function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal, checkboxClass) { | |
| function getExtensionIcon() { | |
| const type = getExtensionType(name); | |
| 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>'; | |
| 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>'; | |
| 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>'; | |
| default: | |
| return '<i class="fa-sm fa-fw fa-solid fa-question" title="Unknown extension type."></i>'; | |
| } | |
| } | |
| 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>'; | |
| } | |
| 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>`; | |
| 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 modulesInfo = ''; | |
| 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>`; | |
| } | |
| } 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>`; | |
| } | |
| } | |
| // if external, wrap the name in a link to the repo | |
| 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_version">${DOMPurify.sanitize(displayVersion)}</span> | |
| ${modulesInfo} | |
| </span> | |
| ${isExternal ? '</a>' : ''} | |
| </div> | |
| <div class="extension_actions flex-container alignItemsCenter"> | |
| ${updateButton} | |
| ${branchButton} | |
| ${moveButton} | |
| ${deleteButton} | |
| </div> | |
| </div>`; | |
| return extensionHtml; | |
| } | |
| /** | |
| * Gets extension data and generates the corresponding HTML 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. | |
| */ | |
| function getExtensionData(extension) { | |
| const name = extension[0]; | |
| const manifest = extension[1]; | |
| const isActive = activeExtensions.has(name); | |
| const isDisabled = extension_settings.disabledExtensions.includes(name); | |
| const isExternal = name.startsWith('third-party'); | |
| const checkboxClass = isDisabled ? 'checkbox_disabled' : ''; | |
| const extensionHtml = generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal, checkboxClass); | |
| return { isExternal, extensionHtml }; | |
| } | |
| /** | |
| * Gets the module information to be displayed. | |
| * | |
| * @return {string} - The HTML string for 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} | |
| `; | |
| } | |
| /** | |
| * Generates HTML for the extension load errors. | |
| * @returns {string} HTML string containing the errors that occurred while loading extensions. | |
| */ | |
| function getExtensionLoadErrorsHtml() { | |
| if (extensionLoadErrors.size === 0) { | |
| return ''; | |
| } | |
| const container = document.createElement('div'); | |
| container.classList.add('info-block', 'error'); | |
| for (const error of extensionLoadErrors) { | |
| const errorElement = document.createElement('div'); | |
| errorElement.textContent = error; | |
| container.appendChild(errorElement); | |
| } | |
| return container.outerHTML; | |
| } | |
| /** | |
| * Generates the HTML strings for all extensions and displays them in a popup. | |
| */ | |
| async function showExtensionsDetails() { | |
| const abortController = new AbortController(); | |
| let popupPromise; | |
| try { | |
| // If we are updating an extension, the "old" popup is still active. We should close that. | |
| let initialScrollTop = 0; | |
| const oldPopup = Popup.util.popups.find(popup => popup.content.querySelector('.extensions_info')); | |
| if (oldPopup) { | |
| initialScrollTop = oldPopup.content.scrollTop; | |
| await oldPopup.completeCancelled(); | |
| } | |
| const htmlErrors = getExtensionLoadErrorsHtml(); | |
| const htmlDefault = $('<div class="marginBot10"><h3 class="textAlignCenter">' + t`Built-in Extensions:` + '</h3></div>'); | |
| const htmlExternal = $('<div class="marginBot10"><h3 class="textAlignCenter">' + t`Installed Extensions:` + '</h3></div>'); | |
| 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>`); | |
| htmlExternal.append(htmlLoading); | |
| const sortOrderKey = 'extensions_sortByName'; | |
| const sortByName = accountStorage.getItem(sortOrderKey) === 'true'; | |
| const sortFn = sortByName ? sortManifestsByName : sortManifestsByOrder; | |
| const extensions = Object.entries(manifests).sort((a, b) => sortFn(a[1], b[1])).map(getExtensionData); | |
| extensions.forEach(value => { | |
| const { isExternal, extensionHtml } = value; | |
| const container = isExternal ? htmlExternal : htmlDefault; | |
| container.append(extensionHtml); | |
| }); | |
| const html = $('<div></div>') | |
| .addClass('extensions_info') | |
| .append(htmlErrors) | |
| .append(htmlDefault) | |
| .append(htmlExternal) | |
| .append(getModuleInformation()); | |
| { | |
| const updateAction = async (force) => { | |
| requiresReload = true; | |
| await autoUpdateExtensions(force); | |
| await popup.complete(POPUP_RESULT.AFFIRMATIVE); | |
| }; | |
| const toolbar = document.createElement('div'); | |
| toolbar.classList.add('extensions_toolbar'); | |
| const updateAllButton = document.createElement('button'); | |
| updateAllButton.classList.add('menu_button', 'menu_button_icon'); | |
| updateAllButton.textContent = t`Update all`; | |
| updateAllButton.addEventListener('click', () => updateAction(true)); | |
| const updateEnabledOnlyButton = document.createElement('button'); | |
| updateEnabledOnlyButton.classList.add('menu_button', 'menu_button_icon'); | |
| updateEnabledOnlyButton.textContent = t`Update enabled`; | |
| updateEnabledOnlyButton.addEventListener('click', () => updateAction(false)); | |
| const flexExpander = document.createElement('div'); | |
| flexExpander.classList.add('expander'); | |
| const sortOrderButton = document.createElement('button'); | |
| sortOrderButton.classList.add('menu_button', 'menu_button_icon'); | |
| sortOrderButton.textContent = sortByName ? t`Sort: Display Name` : t`Sort: Loading Order`; | |
| sortOrderButton.addEventListener('click', async () => { | |
| abortController.abort(); | |
| accountStorage.setItem(sortOrderKey, sortByName ? 'false' : 'true'); | |
| await showExtensionsDetails(); | |
| }); | |
| toolbar.append(updateAllButton, updateEnabledOnlyButton, flexExpander, sortOrderButton); | |
| html.prepend(toolbar); | |
| } | |
| let waitingForSave = false; | |
| const popup = new Popup(html, POPUP_TYPE.TEXT, '', { | |
| okButton: t`Close`, | |
| wide: true, | |
| large: true, | |
| customButtons: [], | |
| allowVerticalScrolling: true, | |
| onClosing: async () => { | |
| if (waitingForSave) { | |
| return false; | |
| } | |
| if (stateChanged) { | |
| waitingForSave = true; | |
| const toast = toastr.info(t`The page will be reloaded shortly...`, t`Extensions state changed`); | |
| await saveSettings(); | |
| toastr.clear(toast); | |
| waitingForSave = false; | |
| requiresReload = true; | |
| } | |
| return true; | |
| }, | |
| }); | |
| popupPromise = popup.show(); | |
| popup.content.scrollTop = initialScrollTop; | |
| checkForUpdatesManual(sortFn, abortController.signal).finally(() => htmlLoading.remove()); | |
| } catch (error) { | |
| toastr.error(t`Error loading extensions. See browser console for details.`); | |
| console.error(error); | |
| } | |
| if (popupPromise) { | |
| await popupPromise; | |
| abortController.abort(); | |
| } | |
| if (requiresReload) { | |
| showLoader(); | |
| location.reload(); | |
| } | |
| } | |
| /** | |
| * Handles the click event for the update button of an extension. | |
| * This function makes a POST request to '/api/extensions/update' with the extension's name. | |
| * If the extension is already up to date, it displays a success message. | |
| * If the extension is not up to date, it updates the extension and displays a success message with the new commit hash. | |
| */ | |
| async function onUpdateClick() { | |
| const isCurrentUserAdmin = isAdmin(); | |
| const extensionName = $(this).data('name'); | |
| const isGlobal = getExtensionType(extensionName) === 'global'; | |
| if (isGlobal && !isCurrentUserAdmin) { | |
| toastr.error(t`You don't have permission to update global extensions.`); | |
| return; | |
| } | |
| const icon = $(this).find('i'); | |
| icon.addClass('fa-spin'); | |
| await updateExtension(extensionName, false); | |
| // updateExtension eats the error, but we can at least stop the spinner | |
| icon.removeClass('fa-spin'); | |
| } | |
| /** | |
| * Updates a third-party extension via the API. | |
| * @param {string} extensionName Extension folder name | |
| * @param {boolean} quiet If true, don't show a success message | |
| * @param {number?} timeout Timeout in milliseconds to wait for the update to complete. If null, no timeout is set. | |
| */ | |
| async function updateExtension(extensionName, quiet, timeout = null) { | |
| try { | |
| const signal = timeout ? AbortSignal.timeout(timeout) : undefined; | |
| const response = await fetch('/api/extensions/update', { | |
| method: 'POST', | |
| signal: signal, | |
| headers: getRequestHeaders(), | |
| body: JSON.stringify({ | |
| extensionName, | |
| global: getExtensionType(extensionName) === 'global', | |
| }), | |
| }); | |
| if (!response.ok) { | |
| const text = await response.text(); | |
| toastr.error(text || response.statusText, t`Extension update failed`, { timeOut: 5000 }); | |
| console.error('Extension update failed', response.status, response.statusText, text); | |
| return; | |
| } | |
| const data = await response.json(); | |
| if (!quiet) { | |
| void showExtensionsDetails(); | |
| } | |
| if (data.isUpToDate) { | |
| if (!quiet) { | |
| toastr.success('Extension is already up to date'); | |
| } | |
| } else { | |
| toastr.success(t`Extension ${extensionName} updated to ${data.shortCommitHash}`, t`Reload the page to apply updates`); | |
| } | |
| } catch (error) { | |
| console.error('Extension update error:', error); | |
| } | |
| } | |
| /** | |
| * Handles the click event for the delete button of an extension. | |
| * This function makes a POST request to '/api/extensions/delete' with the extension's name. | |
| * If the extension is deleted, it displays a success message. | |
| * Creates a popup for the user to confirm before delete. | |
| */ | |
| async function onDeleteClick() { | |
| const extensionName = $(this).data('name'); | |
| const isCurrentUserAdmin = isAdmin(); | |
| const isGlobal = getExtensionType(extensionName) === 'global'; | |
| if (isGlobal && !isCurrentUserAdmin) { | |
| toastr.error(t`You don't have permission to delete global extensions.`); | |
| return; | |
| } | |
| // use callPopup to create a popup for the user to confirm before delete | |
| const confirmation = await callGenericPopup(t`Are you sure you want to delete ${extensionName}?`, POPUP_TYPE.CONFIRM, '', {}); | |
| if (confirmation === POPUP_RESULT.AFFIRMATIVE) { | |
| await deleteExtension(extensionName); | |
| } | |
| } | |
| async function onBranchClick() { | |
| const extensionName = $(this).data('name'); | |
| const isCurrentUserAdmin = isAdmin(); | |
| const isGlobal = getExtensionType(extensionName) === 'global'; | |
| if (isGlobal && !isCurrentUserAdmin) { | |
| toastr.error(t`You don't have permission to switch branch.`); | |
| return; | |
| } | |
| let newBranch = ''; | |
| const branches = await getExtensionBranches(extensionName, isGlobal); | |
| const selectElement = document.createElement('select'); | |
| selectElement.classList.add('text_pole', 'wide100p'); | |
| selectElement.addEventListener('change', function () { | |
| newBranch = this.value; | |
| }); | |
| for (const branch of branches) { | |
| const option = document.createElement('option'); | |
| option.value = branch.name; | |
| option.textContent = `${branch.name} (${branch.commit}) [${branch.label}]`; | |
| option.selected = branch.current; | |
| selectElement.appendChild(option); | |
| } | |
| const popup = new Popup(selectElement, POPUP_TYPE.CONFIRM, '', { | |
| okButton: t`Switch`, | |
| cancelButton: t`Cancel`, | |
| }); | |
| const popupResult = await popup.show(); | |
| if (!popupResult || !newBranch) { | |
| return; | |
| } | |
| await switchExtensionBranch(extensionName, isGlobal, newBranch); | |
| } | |
| async function onMoveClick() { | |
| const extensionName = $(this).data('name'); | |
| const isCurrentUserAdmin = isAdmin(); | |
| const isGlobal = getExtensionType(extensionName) === 'global'; | |
| if (isGlobal && !isCurrentUserAdmin) { | |
| toastr.error(t`You don't have permission to move extensions.`); | |
| return; | |
| } | |
| const source = getExtensionType(extensionName); | |
| const destination = source === 'global' ? 'local' : 'global'; | |
| 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.`; | |
| const confirmation = await Popup.show.confirm(confirmationHeader, confirmationText); | |
| if (!confirmation) { | |
| return; | |
| } | |
| $(this).find('i').addClass('fa-spin'); | |
| await moveExtension(extensionName, source, destination); | |
| } | |
| /** | |
| * Moves an extension via the API. | |
| * @param {string} extensionName Extension name | |
| * @param {string} source Source type | |
| * @param {string} destination Destination type | |
| * @returns {Promise<void>} | |
| */ | |
| async function moveExtension(extensionName, source, destination) { | |
| try { | |
| const result = await fetch('/api/extensions/move', { | |
| method: 'POST', | |
| headers: getRequestHeaders(), | |
| body: JSON.stringify({ | |
| extensionName, | |
| source, | |
| destination, | |
| }), | |
| }); | |
| if (!result.ok) { | |
| const text = await result.text(); | |
| toastr.error(text || result.statusText, t`Extension move failed`, { timeOut: 5000 }); | |
| console.error('Extension move failed', result.status, result.statusText, text); | |
| return; | |
| } | |
| toastr.success(t`Extension ${extensionName} moved.`); | |
| await loadExtensionSettings({}, false, false); | |
| void showExtensionsDetails(); | |
| } catch (error) { | |
| console.error('Error:', error); | |
| } | |
| } | |
| /** | |
| * Deletes an extension via the API. | |
| * @param {string} extensionName Extension name to delete | |
| */ | |
| export async function deleteExtension(extensionName) { | |
| try { | |
| await fetch('/api/extensions/delete', { | |
| method: 'POST', | |
| headers: getRequestHeaders(), | |
| body: JSON.stringify({ | |
| extensionName, | |
| global: getExtensionType(extensionName) === 'global', | |
| }), | |
| }); | |
| } catch (error) { | |
| console.error('Error:', error); | |
| } | |
| toastr.success(t`Extension ${extensionName} deleted`); | |
| delay(1000).then(() => location.reload()); | |
| } | |
| /** | |
| * Fetches the version details of a specific extension. | |
| * | |
| * @param {string} extensionName - The name of the extension. | |
| * @param {AbortSignal} [abortSignal] - The signal to abort the operation. | |
| * @return {Promise<object>} - An object containing the extension's version details. | |
| * This object includes the currentBranchName, currentCommitHash, isUpToDate, and remoteUrl. | |
| * @throws {error} - If there is an error during the fetch operation, it logs the error to the console. | |
| */ | |
| async function getExtensionVersion(extensionName, abortSignal) { | |
| try { | |
| const response = await fetch('/api/extensions/version', { | |
| method: 'POST', | |
| headers: getRequestHeaders(), | |
| body: JSON.stringify({ | |
| extensionName, | |
| global: getExtensionType(extensionName) === 'global', | |
| }), | |
| signal: abortSignal, | |
| }); | |
| const data = await response.json(); | |
| return data; | |
| } catch (error) { | |
| console.error('Error:', error); | |
| } | |
| } | |
| /** | |
| * Gets the list of branches for a specific extension. | |
| * @param {string} extensionName The name of the extension | |
| * @param {boolean} isGlobal Whether the extension is global or not | |
| * @returns {Promise<ExtensionBranch[]>} List of branches for the extension | |
| * @typedef {object} ExtensionBranch | |
| * @property {string} name The name of the branch | |
| * @property {string} commit The commit hash of the branch | |
| * @property {boolean} current Whether this branch is the current one | |
| * @property {string} label The commit label of the branch | |
| */ | |
| async function getExtensionBranches(extensionName, isGlobal) { | |
| try { | |
| const response = await fetch('/api/extensions/branches', { | |
| method: 'POST', | |
| headers: getRequestHeaders(), | |
| body: JSON.stringify({ | |
| extensionName, | |
| global: isGlobal, | |
| }), | |
| }); | |
| if (!response.ok) { | |
| const text = await response.text(); | |
| toastr.error(text || response.statusText, t`Extension branches fetch failed`); | |
| console.error('Extension branches fetch failed', response.status, response.statusText, text); | |
| return []; | |
| } | |
| return await response.json(); | |
| } catch (error) { | |
| console.error('Error:', error); | |
| return []; | |
| } | |
| } | |
| /** | |
| * Switches the branch of an extension. | |
| * @param {string} extensionName The name of the extension | |
| * @param {boolean} isGlobal If the extension is global | |
| * @param {string} branch Branch name to switch to | |
| * @returns {Promise<void>} | |
| */ | |
| async function switchExtensionBranch(extensionName, isGlobal, branch) { | |
| try { | |
| const response = await fetch('/api/extensions/switch', { | |
| method: 'POST', | |
| headers: getRequestHeaders(), | |
| body: JSON.stringify({ | |
| extensionName, | |
| branch, | |
| global: isGlobal, | |
| }), | |
| }); | |
| if (!response.ok) { | |
| const text = await response.text(); | |
| toastr.error(text || response.statusText, t`Extension branch switch failed`); | |
| console.error('Extension branch switch failed', response.status, response.statusText, text); | |
| return; | |
| } | |
| toastr.success(t`Extension ${extensionName} switched to ${branch}`); | |
| await loadExtensionSettings({}, false, false); | |
| void showExtensionsDetails(); | |
| } catch (error) { | |
| console.error('Error:', error); | |
| } | |
| } | |
| /** | |
| * Installs a third-party extension via the API. | |
| * @param {string} url Extension repository URL | |
| * @param {boolean} global Is the extension global? | |
| * @returns {Promise<void>} | |
| */ | |
| export async function installExtension(url, global, branch = '') { | |
| console.debug('Extension installation started', url); | |
| toastr.info(t`Please wait...`, t`Installing extension`); | |
| const request = await fetch('/api/extensions/install', { | |
| method: 'POST', | |
| headers: getRequestHeaders(), | |
| body: JSON.stringify({ | |
| url, | |
| global, | |
| branch, | |
| }), | |
| }); | |
| if (!request.ok) { | |
| const text = await request.text(); | |
| toastr.warning(text || request.statusText, t`Extension installation failed`, { timeOut: 5000 }); | |
| console.error('Extension installation failed', request.status, request.statusText, text); | |
| return; | |
| } | |
| const response = await request.json(); | |
| toastr.success(t`Extension '${response.display_name}' by ${response.author} (version ${response.version}) has been installed successfully!`, t`Extension installation successful`); | |
| console.debug(`Extension "${response.display_name}" has been installed successfully at ${response.extensionPath}`); | |
| await loadExtensionSettings({}, false, false); | |
| await eventSource.emit(event_types.EXTENSION_SETTINGS_LOADED, response); | |
| } | |
| /** | |
| * Loads extension settings from the app settings. | |
| * @param {object} settings App Settings | |
| * @param {boolean} versionChanged Is this a version change? | |
| * @param {boolean} enableAutoUpdate Enable auto-update | |
| */ | |
| export async function loadExtensionSettings(settings, versionChanged, enableAutoUpdate) { | |
| if (settings.extension_settings) { | |
| Object.assign(extension_settings, settings.extension_settings); | |
| } | |
| $('#extensions_url').val(extension_settings.apiUrl); | |
| $('#extensions_api_key').val(extension_settings.apiKey); | |
| $('#extensions_autoconnect').prop('checked', extension_settings.autoConnect); | |
| $('#extensions_notify_updates').prop('checked', extension_settings.notifyUpdates); | |
| // Activate offline extensions | |
| await eventSource.emit(event_types.EXTENSIONS_FIRST_LOAD); | |
| const extensions = await discoverExtensions(); | |
| extensionNames = extensions.map(x => x.name); | |
| extensionTypes = Object.fromEntries(extensions.map(x => [x.name, x.type])); | |
| manifests = await getManifests(extensionNames); | |
| if (versionChanged && enableAutoUpdate) { | |
| await autoUpdateExtensions(false); | |
| } | |
| await activateExtensions(); | |
| if (extension_settings.autoConnect && extension_settings.apiUrl) { | |
| connectToApi(extension_settings.apiUrl); | |
| } | |
| } | |
| export function doDailyExtensionUpdatesCheck() { | |
| setTimeout(() => { | |
| if (extension_settings.notifyUpdates) { | |
| checkForExtensionUpdates(false); | |
| } | |
| }, 1); | |
| } | |
| const concurrencyLimit = 5; | |
| let activeRequestsCount = 0; | |
| const versionCheckQueue = []; | |
| function enqueueVersionCheck(fn) { | |
| return new Promise((resolve, reject) => { | |
| versionCheckQueue.push(() => fn().then(resolve).catch(reject)); | |
| processVersionCheckQueue(); | |
| }); | |
| } | |
| function processVersionCheckQueue() { | |
| if (activeRequestsCount >= concurrencyLimit || versionCheckQueue.length === 0) { | |
| return; | |
| } | |
| activeRequestsCount++; | |
| const fn = versionCheckQueue.shift(); | |
| fn().finally(() => { | |
| activeRequestsCount--; | |
| processVersionCheckQueue(); | |
| }); | |
| } | |
| /** | |
| * Performs a manual check for updates on all 3rd-party extensions. | |
| * @param {function} sortFn Sort function | |
| * @param {AbortSignal} abortSignal Signal to abort the operation | |
| * @returns {Promise<any[]>} | |
| */ | |
| async function checkForUpdatesManual(sortFn, abortSignal) { | |
| const promises = []; | |
| for (const id of Object.keys(manifests).filter(x => x.startsWith('third-party')).sort((a, b) => sortFn(manifests[a], manifests[b]))) { | |
| const externalId = id.replace('third-party', ''); | |
| const promise = enqueueVersionCheck(async () => { | |
| try { | |
| const data = await getExtensionVersion(externalId, abortSignal); | |
| const extensionBlock = document.querySelector(`.extension_block[data-name="${externalId}"]`); | |
| if (extensionBlock && data) { | |
| if (data.isUpToDate === false) { | |
| const buttonElement = extensionBlock.querySelector('.btn_update'); | |
| if (buttonElement) { | |
| buttonElement.classList.remove('displayNone'); | |
| } | |
| const nameElement = extensionBlock.querySelector('.extension_name'); | |
| if (nameElement) { | |
| nameElement.classList.add('update_available'); | |
| } | |
| } | |
| let branch = data.currentBranchName; | |
| let commitHash = data.currentCommitHash; | |
| let origin = data.remoteUrl; | |
| const originLink = extensionBlock.querySelector('a'); | |
| if (originLink) { | |
| try { | |
| const url = new URL(origin); | |
| if (!['https:', 'http:'].includes(url.protocol)) { | |
| throw new Error('Invalid protocol'); | |
| } | |
| originLink.href = url.href; | |
| originLink.target = '_blank'; | |
| originLink.rel = 'noopener noreferrer'; | |
| } catch (error) { | |
| console.log('Error setting origin link', originLink, error); | |
| } | |
| } | |
| const versionElement = extensionBlock.querySelector('.extension_version'); | |
| if (versionElement) { | |
| versionElement.textContent += ` (${branch}-${commitHash.substring(0, 7)})`; | |
| } | |
| } | |
| } catch (error) { | |
| console.error('Error checking for extension updates', error); | |
| } | |
| }); | |
| promises.push(promise); | |
| } | |
| return Promise.allSettled(promises); | |
| } | |
| /** | |
| * Checks if there are updates available for enabled 3rd-party extensions. | |
| * @param {boolean} force Skip nag check | |
| * @returns {Promise<any>} | |
| */ | |
| async function checkForExtensionUpdates(force) { | |
| if (!force) { | |
| const STORAGE_NAG_KEY = 'extension_update_nag'; | |
| const currentDate = new Date().toDateString(); | |
| // Don't nag more than once a day | |
| if (accountStorage.getItem(STORAGE_NAG_KEY) === currentDate) { | |
| return; | |
| } | |
| accountStorage.setItem(STORAGE_NAG_KEY, currentDate); | |
| } | |
| const isCurrentUserAdmin = isAdmin(); | |
| const updatesAvailable = []; | |
| const promises = []; | |
| for (const [id, manifest] of Object.entries(manifests)) { | |
| const isDisabled = extension_settings.disabledExtensions.includes(id); | |
| if (isDisabled) { | |
| console.debug(`Skipping extension: ${manifest.display_name} (${id}) for non-admin user`); | |
| continue; | |
| } | |
| const isGlobal = getExtensionType(id) === 'global'; | |
| if (isGlobal && !isCurrentUserAdmin) { | |
| console.debug(`Skipping global extension: ${manifest.display_name} (${id}) for non-admin user`); | |
| continue; | |
| } | |
| if (manifest.auto_update && id.startsWith('third-party')) { | |
| const promise = enqueueVersionCheck(async () => { | |
| try { | |
| const data = await getExtensionVersion(id.replace('third-party', '')); | |
| if (!data.isUpToDate) { | |
| updatesAvailable.push(manifest.display_name); | |
| } | |
| } catch (error) { | |
| console.error('Error checking for extension updates', error); | |
| } | |
| }); | |
| promises.push(promise); | |
| } | |
| } | |
| await Promise.allSettled(promises); | |
| if (updatesAvailable.length > 0) { | |
| toastr.info(`${updatesAvailable.map(x => `• ${x}`).join('\n')}`, t`Extension updates available`); | |
| } | |
| } | |
| /** | |
| * Updates all enabled 3rd-party extensions that have auto-update enabled. | |
| * @param {boolean} forceAll Include disabled and not auto-updating | |
| * @returns {Promise<void>} | |
| */ | |
| async function autoUpdateExtensions(forceAll) { | |
| if (!Object.values(manifests).some(x => x.auto_update)) { | |
| return; | |
| } | |
| const banner = toastr.info(t`Auto-updating extensions. This may take several minutes.`, t`Please wait...`, { timeOut: 10000, extendedTimeOut: 10000 }); | |
| const isCurrentUserAdmin = isAdmin(); | |
| const promises = []; | |
| const autoUpdateTimeout = 60 * 1000; | |
| for (const [id, manifest] of Object.entries(manifests)) { | |
| const isDisabled = extension_settings.disabledExtensions.includes(id); | |
| if (!forceAll && isDisabled) { | |
| console.debug(`Skipping extension: ${manifest.display_name} (${id}) for non-admin user`); | |
| continue; | |
| } | |
| const isGlobal = getExtensionType(id) === 'global'; | |
| if (isGlobal && !isCurrentUserAdmin) { | |
| console.debug(`Skipping global extension: ${manifest.display_name} (${id}) for non-admin user`); | |
| continue; | |
| } | |
| if ((forceAll || manifest.auto_update) && id.startsWith('third-party')) { | |
| console.debug(`Auto-updating 3rd-party extension: ${manifest.display_name} (${id})`); | |
| promises.push(updateExtension(id.replace('third-party', ''), true, autoUpdateTimeout)); | |
| } | |
| } | |
| await Promise.allSettled(promises); | |
| toastr.clear(banner); | |
| } | |
| /** | |
| * Runs the generate interceptors for all extensions. | |
| * @param {any[]} chat Chat array | |
| * @param {number} contextSize Context size | |
| * @param {string} type Generation type | |
| * @returns {Promise<boolean>} True if generation should be aborted | |
| */ | |
| export async function runGenerationInterceptors(chat, contextSize, type) { | |
| let aborted = false; | |
| let exitImmediately = false; | |
| const abort = (/** @type {boolean} */ immediately) => { | |
| aborted = true; | |
| exitImmediately = immediately; | |
| }; | |
| for (const manifest of Object.values(manifests).filter(x => x.generate_interceptor).sort((a, b) => sortManifestsByOrder(a, b))) { | |
| const interceptorKey = manifest.generate_interceptor; | |
| if (typeof globalThis[interceptorKey] === 'function') { | |
| try { | |
| await globalThis[interceptorKey](chat, contextSize, abort, type); | |
| } catch (e) { | |
| console.error(`Failed running interceptor for ${manifest.display_name}`, e); | |
| } | |
| } | |
| if (exitImmediately) { | |
| break; | |
| } | |
| } | |
| return aborted; | |
| } | |
| /** | |
| * Writes a field to the character's data extensions object. | |
| * @param {number|string} characterId Index in the character array | |
| * @param {string} key Field name | |
| * @param {any} value Field value | |
| * @returns {Promise<void>} When the field is written | |
| */ | |
| export async function writeExtensionField(characterId, key, value) { | |
| const context = getContext(); | |
| const character = context.characters[characterId]; | |
| if (!character) { | |
| console.warn('Character not found', characterId); | |
| return; | |
| } | |
| const path = `data.extensions.${key}`; | |
| setValueByPath(character, path, value); | |
| // Process JSON data | |
| if (character.json_data) { | |
| const jsonData = JSON.parse(character.json_data); | |
| setValueByPath(jsonData, path, value); | |
| character.json_data = JSON.stringify(jsonData); | |
| // Make sure the data doesn't get lost when saving the current character | |
| if (Number(characterId) === Number(context.characterId)) { | |
| $('#character_json_data').val(character.json_data); | |
| } | |
| } | |
| // Save data to the server | |
| const saveDataRequest = { | |
| avatar: character.avatar, | |
| data: { | |
| extensions: { | |
| [key]: value, | |
| }, | |
| }, | |
| }; | |
| const mergeResponse = await fetch('/api/characters/merge-attributes', { | |
| method: 'POST', | |
| headers: getRequestHeaders(), | |
| body: JSON.stringify(saveDataRequest), | |
| }); | |
| if (!mergeResponse.ok) { | |
| console.error('Failed to save extension field', mergeResponse.statusText); | |
| } | |
| } | |
| /** | |
| * Prompts the user to enter the Git URL of the extension to import. | |
| * After obtaining the Git URL, makes a POST request to '/api/extensions/install' to import the extension. | |
| * If the extension is imported successfully, a success message is displayed. | |
| * If the extension import fails, an error message is displayed and the error is logged to the console. | |
| * After successfully importing the extension, the extension settings are reloaded and a 'EXTENSION_SETTINGS_LOADED' event is emitted. | |
| * @param {string} [suggestUrl] Suggested URL to install | |
| * @returns {Promise<void>} | |
| */ | |
| export async function openThirdPartyExtensionMenu(suggestUrl = '') { | |
| const isCurrentUserAdmin = isAdmin(); | |
| const html = await renderTemplateAsync('installExtension', { isCurrentUserAdmin }); | |
| const okButton = isCurrentUserAdmin ? t`Install just for me` : t`Install`; | |
| let global = false; | |
| const installForAllButton = { | |
| text: t`Install for all users`, | |
| appendAtEnd: false, | |
| action: async () => { | |
| global = true; | |
| await popup.complete(POPUP_RESULT.AFFIRMATIVE); | |
| }, | |
| }; | |
| /** @type {import('./popup.js').CustomPopupInput} */ | |
| const branchNameInput = { | |
| id: 'extension_branch_name', | |
| label: t`Branch or tag name (optional)`, | |
| type: 'text', | |
| tooltip: 'e.g. main, dev, v1.0.0', | |
| }; | |
| const customButtons = isCurrentUserAdmin ? [installForAllButton] : []; | |
| const customInputs = [branchNameInput]; | |
| const popup = new Popup(html, POPUP_TYPE.INPUT, suggestUrl ?? '', { okButton, customButtons, customInputs }); | |
| const input = await popup.show(); | |
| if (!input) { | |
| console.debug('Extension install cancelled'); | |
| return; | |
| } | |
| const url = String(input).trim(); | |
| const branchName = String(popup.inputResults.get('extension_branch_name') ?? '').trim(); | |
| await installExtension(url, global, branchName); | |
| } | |
| export async function initExtensions() { | |
| await addExtensionsButtonAndMenu(); | |
| $('#extensionsMenuButton').css('display', 'flex'); | |
| $('#extensions_connect').on('click', connectClickHandler); | |
| $('#extensions_autoconnect').on('input', autoConnectInputHandler); | |
| $('#extensions_details').on('click', showExtensionsDetails); | |
| $('#extensions_notify_updates').on('input', notifyUpdatesInputHandler); | |
| $(document).on('click', '.extensions_info .extension_block .toggle_disable', onDisableExtensionClick); | |
| $(document).on('click', '.extensions_info .extension_block .toggle_enable', onEnableExtensionClick); | |
| $(document).on('click', '.extensions_info .extension_block .btn_update', onUpdateClick); | |
| $(document).on('click', '.extensions_info .extension_block .btn_delete', onDeleteClick); | |
| $(document).on('click', '.extensions_info .extension_block .btn_move', onMoveClick); | |
| $(document).on('click', '.extensions_info .extension_block .btn_branch', onBranchClick); | |
| /** | |
| * Handles the click event for the third-party extension import button. | |
| * | |
| * @listens #third_party_extension_button#click - The click event of the '#third_party_extension_button' element. | |
| */ | |
| $('#third_party_extension_button').on('click', () => openThirdPartyExtensionMenu()); | |
| } | |