| | import { DOMPurify, Popper } from '../lib.js'; |
| |
|
| | import { eventSource, event_types, saveSettings, saveSettingsDebounced, getRequestHeaders, animation_duration, CLIENT_VERSION } 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, versionCompare } 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'; |
| | import { SimpleMutex } from './util/SimpleMutex.js'; |
| |
|
| | export { |
| | getContext, |
| | getApiUrl, |
| | SimpleMutex as ModuleWorkerWrapper, |
| | }; |
| |
|
| | |
| | export let extensionNames = []; |
| |
|
| | |
| | |
| | |
| | |
| | |
| | export let extensionTypes = {}; |
| |
|
| | |
| | |
| | |
| | |
| | export let modules = []; |
| |
|
| | |
| | |
| | |
| | |
| | const activeExtensions = new Set(); |
| |
|
| | |
| | |
| | |
| | |
| | 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; |
| |
|
| | |
| | |
| | |
| | |
| | let manifests = {}; |
| |
|
| | |
| | |
| | |
| | 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); |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | export function renderExtensionTemplate(extensionName, templateId, templateData = {}, sanitize = true, localize = true) { |
| | return renderTemplate(`scripts/extensions/${extensionName}/${templateId}.html`, templateData, sanitize, localize, true); |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | export function renderExtensionTemplateAsync(extensionName, templateId, templateData = {}, sanitize = true, localize = true) { |
| | return renderTemplateAsync(`scripts/extensions/${extensionName}/${templateId}.html`, templateData, sanitize, localize, true); |
| | } |
| |
|
| | export const extension_settings = { |
| | apiUrl: defaultUrl, |
| | apiKey: '', |
| | autoConnect: false, |
| | notifyUpdates: false, |
| | disabledExtensions: [], |
| | expressionOverrides: [], |
| | memory: {}, |
| | note: { |
| | default: '', |
| | chara: [], |
| | wiAddition: [], |
| | }, |
| | caption: { |
| | refine_mode: false, |
| | }, |
| | expressions: { |
| | |
| | api: undefined, |
| | |
| | custom: [], |
| | showDefault: false, |
| | translate: false, |
| | |
| | fallback_expression: undefined, |
| | |
| | llmPrompt: undefined, |
| | allowMultiple: true, |
| | rerollIfSame: false, |
| | promptType: 'raw', |
| | }, |
| | connectionManager: { |
| | selectedProfile: '', |
| | |
| | profiles: [], |
| | }, |
| | dice: {}, |
| | |
| | regex: [], |
| | |
| | regex_presets: [], |
| | |
| | character_allowed_regex: [], |
| | |
| | preset_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: {}, |
| | }, |
| | |
| | |
| | |
| | attachments: [], |
| | |
| | |
| | |
| | character_attachments: {}, |
| | |
| | |
| | |
| | disabled_attachments: [], |
| | gallery: { |
| | |
| | folders: {}, |
| | |
| | sort: 'dateAsc', |
| | }, |
| | }; |
| |
|
| | function showHideExtensionsMenu() { |
| | |
| | const hasMenuItems = $('#extensionsMenu').children().filter((_, child) => $(child).css('display') !== 'none').length > 0; |
| |
|
| | |
| | if (hasMenuItems) { |
| | clearInterval(menuInterval); |
| | } |
| |
|
| | |
| | $('#extensionsMenuButton').toggle(hasMenuItems); |
| | } |
| |
|
| | |
| | const menuInterval = setInterval(showHideExtensionsMenu, 1000); |
| |
|
| | |
| | |
| | |
| | |
| | |
| | function getExtensionType(externalId) { |
| | const id = Object.keys(extensionTypes).find(id => id === externalId || (id.startsWith('third-party') && id.endsWith(externalId))); |
| | return id ? extensionTypes[id] : ''; |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | 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); |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | 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); |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | 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; |
| | } |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | export async function disableExtension(name, reload = true) { |
| | extension_settings.disabledExtensions.push(name); |
| | stateChanged = true; |
| | await saveSettings(); |
| | if (reload) { |
| | location.reload(); |
| | } else { |
| | requiresReload = true; |
| | } |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | 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; |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | async function activateExtensions() { |
| | extensionLoadErrors.clear(); |
| | const clientVersion = CLIENT_VERSION.split(':')[1]; |
| | 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 minClientVersion = manifest.minimum_client_version; |
| | const displayName = manifest.display_name || name; |
| |
|
| | if (activeExtensions.has(name)) { |
| | continue; |
| | } |
| | |
| | let meetsClientMinimumVersion = true; |
| | if (minClientVersion !== undefined) { |
| | meetsClientMinimumVersion = versionCompare(clientVersion, minClientVersion); |
| | } |
| |
|
| | |
| | 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.`); |
| | } |
| | } |
| |
|
| | |
| | let meetsExtensionDeps = true; |
| | let missingDependencies = []; |
| | let disabledDependencies = []; |
| | if (extensionDependencies !== undefined) { |
| | if (Array.isArray(extensionDependencies)) { |
| | |
| | meetsExtensionDeps = isSubsetOf(extensionNames, extensionDependencies); |
| | missingDependencies = extensionDependencies.filter(dep => !extensionNames.includes(dep)); |
| | |
| | if (meetsExtensionDeps) { |
| | disabledDependencies = extensionDependencies.filter(dep => extension_settings.disabledExtensions.includes(dep)); |
| | if (disabledDependencies.length > 0) { |
| | |
| | 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 && meetsClientMinimumVersion && !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(', ')}"`); |
| | } |
| | } else if (!meetsClientMinimumVersion && !isDisabled) { |
| | console.warn(t`Extension "${name}" did not load. Requires ST client version ${minClientVersion}, but current version is ${clientVersion}.`); |
| | extensionLoadErrors.add(t`Extension "${displayName}" did not load. Requires ST client version ${minClientVersion}, but current version is ${clientVersion}.`); |
| | } |
| | } |
| |
|
| | 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); |
| | } |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | 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); |
| | } |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | 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); |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | 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); |
| | } |
| | }); |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | 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); |
| | } |
| | }); |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | function addExtensionLocale(name, manifest) { |
| | |
| | if (!manifest.i18n || typeof manifest.i18n !== 'object') { |
| | return Promise.resolve(); |
| | } |
| |
|
| | const currentLocale = getCurrentLocale(); |
| | const localeFile = manifest.i18n[currentLocale]; |
| |
|
| | |
| | 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); |
| | }); |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | 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) { |
| | 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>`; |
| | } |
| | } |
| |
|
| | |
| |
|
| | 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; |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | 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 }; |
| | } |
| |
|
| |
|
| | |
| | |
| | |
| | |
| | |
| | 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} |
| | `; |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | 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; |
| | } |
| |
|
| | |
| | |
| | |
| | async function showExtensionsDetails() { |
| | const abortController = new AbortController(); |
| | let popupPromise; |
| | try { |
| | |
| | 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(); |
| | } |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | 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); |
| | |
| | icon.removeClass('fa-spin'); |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | 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); |
| | } |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | 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; |
| | } |
| |
|
| | |
| | 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); |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | 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); |
| | } |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | 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()); |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | 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); |
| | } |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | 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 []; |
| | } |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | 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); |
| | } |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | 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); |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | 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); |
| |
|
| | |
| | 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(); |
| | }); |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | 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); |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | async function checkForExtensionUpdates(force) { |
| | if (!force) { |
| | const STORAGE_NAG_KEY = 'extension_update_nag'; |
| | const currentDate = new Date().toDateString(); |
| |
|
| | |
| | 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`); |
| | } |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | 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); |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | export async function runGenerationInterceptors(chat, contextSize, type) { |
| | let aborted = false; |
| | let exitImmediately = false; |
| |
|
| | const abort = ( 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; |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | 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); |
| |
|
| | |
| | if (character.json_data) { |
| | const jsonData = JSON.parse(character.json_data); |
| | setValueByPath(jsonData, path, value); |
| | character.json_data = JSON.stringify(jsonData); |
| |
|
| | |
| | if (Number(characterId) === Number(context.characterId)) { |
| | $('#character_json_data').val(character.json_data); |
| | } |
| | } |
| |
|
| | |
| | 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); |
| | } |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | 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); |
| | }, |
| | }; |
| | |
| | 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); |
| |
|
| | |
| | |
| | |
| | |
| | |
| | $('#third_party_extension_button').on('click', () => openThirdPartyExtensionMenu()); |
| | } |
| |
|