Blame Raw
Cohee · 51ad27fb · · 2315 lines (85.6 KB)
3 contributors
1import { Popper } from '../lib.js';
2
3import { eventSource, event_types, saveSettings, saveSettingsDebounced, getRequestHeaders, animation_duration, CLIENT_VERSION } from '../script.js';
4import { POPUP_RESULT, POPUP_TYPE, Popup } from './popup.js';
5import { renderTemplate, renderTemplateAsync } from './templates.js';
6import { delay, deleteValueByPath, equalsIgnoreCaseAndAccents, escapeHtml, isSubsetOf, sanitizeSelector, setValueByPath, versionCompare } from './utils.js';
7import { getContext } from './st-context.js';
8import { isAdmin } from './user.js';
9import { addLocaleData, getCurrentLocale, t } from './i18n.js';
10import { debounce_timeout } from './constants.js';
11import { accountStorage } from './util/AccountStorage.js';
12import { SimpleMutex } from './util/SimpleMutex.js';
13
14export {
15 getContext,
16 getApiUrl,
17 SimpleMutex as ModuleWorkerWrapper,
18};
19
20/** @type {string[]} */
21export let extensionNames = [];
22
23/**
24 * Holds the type of each extension.
25 * Don't use this directly, use getExtensionType instead!
26 * @type {Record<string, string>}
27 */
28export let extensionTypes = {};
29
30/**
31 * A list of active modules provided by the Extras API.
32 * @type {string[]}
33 */
34export let modules = [];
35
36/**
37 * A set of active extensions.
38 * @type {Set<string>}
39 */
40const activeExtensions = new Set();
41
42/**
43 * Errors that occurred while loading extensions.
44 * @type {Set<string>}
45 */
46const extensionLoadErrors = new Set();
47
48const getApiUrl = () => extension_settings.apiUrl;
49const sortManifestsByOrder = (a, b) => parseInt(a.loading_order) - parseInt(b.loading_order) || String(a.display_name).localeCompare(String(b.display_name));
50const sortManifestsByName = (a, b) => String(a.display_name).localeCompare(String(b.display_name)) || parseInt(a.loading_order) - parseInt(b.loading_order);
51let connectedToApi = false;
52
53/**
54 * Holds manifest data for each extension.
55 * @type {Record<string, object>}
56 */
57let manifests = {};
58
59/**
60 * Default URL for the Extras API.
61 */
62const defaultUrl = 'http://localhost:5100';
63
64/**
65 * Checks if the extension is officially supported by its URL pattern.
66 * @param {string} url URL to check
67 * @returns {boolean} True if the URL matches the pattern, false otherwise (or not a valid URL)
68 */
69export const isOfficialExtension = (url) => {
70 try {
71 return /^https:\/\/github\.com\/SillyTavern\/(.+)$/i.test(new URL(url).href);
72 } catch (e) {
73 return false;
74 }
75};
76
77let requiresReload = false;
78let stateChanged = false;
79let saveMetadataTimeout = null;
80
81export function cancelDebouncedMetadataSave() {
82 if (saveMetadataTimeout) {
83 console.debug('Debounced metadata save cancelled');
84 clearTimeout(saveMetadataTimeout);
85 saveMetadataTimeout = null;
86 }
87}
88
89export function saveMetadataDebounced() {
90 const context = getContext();
91 const groupId = context.groupId;
92 const characterId = context.characterId;
93
94 cancelDebouncedMetadataSave();
95
96 saveMetadataTimeout = setTimeout(async () => {
97 const newContext = getContext();
98
99 if (groupId !== newContext.groupId) {
100 console.warn('Group changed, not saving metadata');
101 return;
102 }
103
104 if (characterId !== newContext.characterId) {
105 console.warn('Character changed, not saving metadata');
106 return;
107 }
108
109 console.debug('Saving metadata...');
110 await newContext.saveMetadata();
111 console.debug('Saved metadata...');
112 }, debounce_timeout.relaxed);
113}
114
115/**
116 * Provides an ability for extensions to render HTML templates synchronously.
117 * Templates sanitation and localization is forced.
118 * @param {string} extensionName Extension name
119 * @param {string} templateId Template ID
120 * @param {object} templateData Additional data to pass to the template
121 * @returns {string} Rendered HTML
122 *
123 * @deprecated Use renderExtensionTemplateAsync instead.
124 */
125export function renderExtensionTemplate(extensionName, templateId, templateData = {}, sanitize = true, localize = true) {
126 return renderTemplate(`scripts/extensions/${extensionName}/${templateId}.html`, templateData, sanitize, localize, true);
127}
128
129/**
130 * Provides an ability for extensions to render HTML templates asynchronously.
131 * Templates sanitation and localization is forced.
132 * @param {string} extensionName Extension name
133 * @param {string} templateId Template ID
134 * @param {object} templateData Additional data to pass to the template
135 * @returns {Promise<string>} Rendered HTML
136 */
137export function renderExtensionTemplateAsync(extensionName, templateId, templateData = {}, sanitize = true, localize = true) {
138 return renderTemplateAsync(`scripts/extensions/${extensionName}/${templateId}.html`, templateData, sanitize, localize, true);
139}
140
141export const extension_settings = {
142 apiUrl: defaultUrl,
143 apiKey: '',
144 autoConnect: false,
145 notifyUpdates: false,
146 disabledExtensions: [],
147 expressionOverrides: [],
148 memory: {},
149 note: {
150 default: '',
151 chara: [],
152 wiAddition: [],
153 },
154 caption: {
155 refine_mode: false,
156 },
157 expressions: {
158 /** @type {number} see `EXPRESSION_API` */
159 api: undefined,
160 /** @type {string[]} */
161 custom: [],
162 showDefault: false,
163 translate: false,
164 /** @type {string} */
165 fallback_expression: undefined,
166 /** @type {string} */
167 llmPrompt: undefined,
168 allowMultiple: true,
169 rerollIfSame: false,
170 promptType: 'raw',
171 },
172 connectionManager: {
173 selectedProfile: '',
174 /** @type {import('./extensions/connection-manager/index.js').ConnectionProfile[]} */
175 profiles: [],
176 },
177 dice: {},
178 /** @type {import('./char-data.js').RegexScriptData[]} */
179 regex: [],
180 /** @type {import('./extensions/regex/index.js').RegexPreset[]} */
181 regex_presets: [],
182 /** @type {string[]} */
183 character_allowed_regex: [],
184 /** @type {Record<string, string[]>} */
185 preset_allowed_regex: {},
186 tts: {},
187 sd: {
188 prompts: {},
189 character_prompts: {},
190 character_negative_prompts: {},
191 },
192 chromadb: {},
193 translate: {},
194 objective: {},
195 quickReply: {},
196 randomizer: {
197 controls: [],
198 fluctuation: 0.1,
199 enabled: false,
200 },
201 speech_recognition: {},
202 rvc: {},
203 hypebot: {},
204 vectors: {},
205 variables: {
206 global: {},
207 },
208 /**
209 * @type {import('./chats.js').FileAttachment[]}
210 */
211 attachments: [],
212 /**
213 * @type {Record<string, import('./chats.js').FileAttachment[]>}
214 */
215 character_attachments: {},
216 /**
217 * @type {string[]}
218 */
219 disabled_attachments: [],
220 gallery: {
221 /** @type {{[characterKey: string]: string}} */
222 folders: {},
223 /** @type {string} */
224 sort: 'dateAsc',
225 },
226};
227
228function showHideExtensionsMenu() {
229 // Get the number of menu items that are not hidden
230 const hasMenuItems = $('#extensionsMenu').children().filter((_, child) => $(child).css('display') !== 'none').length > 0;
231
232 // We have menu items, so we can stop checking
233 if (hasMenuItems) {
234 clearInterval(menuInterval);
235 }
236
237 // Show or hide the menu button
238 $('#extensionsMenuButton').toggle(hasMenuItems);
239}
240
241// Periodically check for new extensions
242const menuInterval = setInterval(showHideExtensionsMenu, 1000);
243
244/**
245 * Gets the type of an extension based on its external ID.
246 * @param {string} externalId External ID of the extension (excluding or including the leading 'third-party/')
247 * @returns {string} Type of the extension (global, local, system, or empty string if not found)
248 */
249function getExtensionType(externalId) {
250 const id = Object.keys(extensionTypes).find(id => id === externalId || (id.startsWith('third-party') && id.endsWith(externalId)));
251 return id ? extensionTypes[id] : '';
252}
253
254/**
255 * Performs a fetch of the Extras API.
256 * @param {string|URL} endpoint Extras API endpoint
257 * @param {RequestInit} args Request arguments
258 * @returns {Promise<Response>} Response from the fetch
259 */
260export async function doExtrasFetch(endpoint, args = {}) {
261 if (!args) {
262 args = {};
263 }
264
265 if (!args.method) {
266 Object.assign(args, { method: 'GET' });
267 }
268
269 if (!args.headers) {
270 args.headers = {};
271 }
272
273 if (extension_settings.apiKey) {
274 Object.assign(args.headers, {
275 'Authorization': `Bearer ${extension_settings.apiKey}`,
276 });
277 }
278
279 return await fetch(endpoint, args);
280}
281
282/**
283 * Generates a CSS selector for an extension based on its name, allowing omission of a common prefix.
284 * @param {string} name Name of the extension, with or without the "third-party" prefix
285 * @param {object} [options] Optional parameters
286 * @param {string} [options.prefix] Optional prefix to ignore when generating the selector (e.g. "third-party")
287 * @returns {string} CSS selector for the extension, with the prefix removed if it was present and specified in options
288 */
289function getNameSelector(name, { prefix = 'third-party' } = {}) {
290 const nameWithoutPrefix = prefix && name.startsWith(prefix) ? name.slice(prefix.length) : name;
291 return CSS.escape(nameWithoutPrefix);
292}
293
294/**
295 * Discovers extensions from the API.
296 * @returns {Promise<{name: string, type: string}[]>}
297 */
298async function discoverExtensions() {
299 try {
300 const response = await fetch('/api/extensions/discover');
301
302 if (response.ok) {
303 const extensions = await response.json();
304 return extensions;
305 } else {
306 return [];
307 }
308 } catch (err) {
309 console.error(err);
310 return [];
311 }
312}
313
314function onDisableExtensionClick() {
315 const name = $(this).data('name');
316 disableExtension(name, false);
317}
318
319function onEnableExtensionClick() {
320 const name = $(this).data('name');
321 enableExtension(name, false);
322}
323
324/**
325 * Handles toggling all extensions on or off.
326 * @param {Object[]} extensionsToToggle
327 * @param {JQuery<HTMLElement>} toggleContainer
328 * @returns {Object[]} Updated extensionsToToggle array
329 */
330function onToggleAllExtensions(extensionsToToggle, toggleContainer) {
331 const extensionNames = Object.keys(manifests);
332 const thirdPartyExtensions = extensionNames.filter(name => ['local', 'global'].includes(getExtensionType(name)));
333
334 const checkIfDisabled = (name) => {
335 const toggle = extensionsToToggle.find(ext => ext.name === name);
336 return toggle
337 ? !toggle.enable
338 : extension_settings.disabledExtensions.includes(name);
339 };
340
341 if (thirdPartyExtensions.length === 0) return [];
342
343 let enable = true;
344
345 for (const name of thirdPartyExtensions) {
346 const isEnabled = !checkIfDisabled(name);
347
348 if (isEnabled) {
349 enable = false;
350 break;
351 }
352 }
353
354 const toggleHandler = enable ? enableExtension : disableExtension;
355
356 for (const name of thirdPartyExtensions) {
357 const isDisabled = checkIfDisabled(name);
358 const doToggleExtension = enable ? isDisabled : !isDisabled;
359
360 if (doToggleExtension) {
361 const toggle = extensionsToToggle.find(ext => ext.name === name);
362
363 if (toggle) {
364 toggle.toggleHandler = toggleHandler;
365 toggle.enable = enable;
366 } else {
367 extensionsToToggle.push({ name, toggleHandler, enable });
368 }
369
370 toggleContainer
371 .find(`.extension_block[data-name="${getNameSelector(name)}"] .extension_toggle input`)
372 .prop('checked', enable)
373 .toggleClass('toggle_enable', !enable)
374 .toggleClass('toggle_disable', enable)
375 .toggleClass('checkbox_disabled', !enable);
376 }
377 }
378
379 return extensionsToToggle;
380}
381
382/**
383 * Checks whether an extension has a specific hook defined in its manifest.
384 * @param {string} name Extension name (with or without 'third-party' prefix)
385 * @param {'install' | 'update' | 'delete' | 'clean' | 'enable' | 'disable' | 'activate'} hookName The hook to check
386 * @returns {boolean}
387 */
388function hasExtensionHook(name, hookName) {
389 const fullName = name.startsWith('third-party') ? name : `third-party${name}`;
390 const manifest = manifests[fullName];
391 if (!manifest || !manifest.hooks || typeof manifest.hooks !== 'object') {
392 return false;
393 }
394 const hookFunctionName = manifest.hooks[hookName];
395 return typeof hookFunctionName === 'string' && hookFunctionName.length > 0;
396}
397
398/**
399 * Calls a manifest hook for an extension.
400 * Hooks are optional function names exported from the extension's JS entry point module.
401 * The hook function can optionally return a Promise that will be awaited.
402 * @param {string} name Extension name
403 * @param {'install' | 'update' | 'delete' | 'clean' | 'enable' | 'disable' | 'activate'} hookName The hook to call
404 * @returns {Promise<void>}
405 */
406async function callExtensionHook(name, hookName) {
407 const manifest = manifests[name];
408
409 if (!manifest) {
410 console.debug(`callExtensionHook: Extension "${name}" has no manifest, skipping hook "${hookName}"`);
411 return;
412 }
413
414 if (!manifest.hooks || typeof manifest.hooks !== 'object') {
415 return;
416 }
417
418 if (!Object.hasOwn(manifest.hooks, hookName)) {
419 return;
420 }
421
422 const hookFunctionName = manifest.hooks[hookName];
423
424 if (typeof hookFunctionName !== 'string' || !hookFunctionName) {
425 console.warn(`callExtensionHook: Extension "${name}" hook "${hookName}" is not a valid string`);
426 return;
427 }
428
429 if (!manifest.js) {
430 console.warn(`callExtensionHook: Extension "${name}" has hook "${hookName}" but no JS entry point defined in manifest`);
431 return;
432 }
433
434 const url = `/scripts/extensions/${name}/${manifest.js}`;
435 console.debug(`callExtensionHook: Calling hook "${hookName}" (function "${hookFunctionName}") for extension "${name}"`);
436
437 try {
438 const module = await import(url);
439
440 if (typeof module[hookFunctionName] !== 'function') {
441 console.warn(`callExtensionHook: Extension "${name}" hook "${hookName}" references "${hookFunctionName}" which is not an exported function`);
442 return;
443 }
444
445 const hookCallResult = module[hookFunctionName]();
446
447 const HOOK_TIMEOUT = 5000;
448 const HOOK_RESULT = {
449 OK: 'ok',
450 TIMEOUT: 'timeout',
451 };
452
453 const result = await Promise.race([
454 (hookCallResult instanceof Promise ? hookCallResult : Promise.resolve(hookCallResult)).then(() => HOOK_RESULT.OK),
455 delay(HOOK_TIMEOUT).then(() => HOOK_RESULT.TIMEOUT),
456 ]);
457
458 if (result === HOOK_RESULT.TIMEOUT) {
459 console.warn(`callExtensionHook: Hook "${hookName}" for extension "${name}" timed out after ${HOOK_TIMEOUT}ms`);
460 } else {
461 console.debug(`callExtensionHook: Hook "${hookName}" completed for extension "${name}"`);
462 }
463 } catch (error) {
464 console.error(`callExtensionHook: Error calling hook "${hookName}" for extension "${name}":`, error);
465 }
466}
467
468/**
469 * Enables an extension by name.
470 * @param {string} name Extension name
471 * @param {boolean} [reload=true] If true, reload the page after enabling the extension
472 */
473export async function enableExtension(name, reload = true) {
474 await callExtensionHook(name, 'enable');
475 extension_settings.disabledExtensions = extension_settings.disabledExtensions.filter(x => x !== name);
476 stateChanged = true;
477 await saveSettings();
478 if (reload) {
479 location.reload();
480 } else {
481 requiresReload = true;
482 }
483}
484
485/**
486 * Disables an extension by name.
487 * @param {string} name Extension name
488 * @param {boolean} [reload=true] If true, reload the page after disabling the extension
489 */
490export async function disableExtension(name, reload = true) {
491 await callExtensionHook(name, 'disable');
492 extension_settings.disabledExtensions.push(name);
493 stateChanged = true;
494 await saveSettings();
495 if (reload) {
496 location.reload();
497 } else {
498 requiresReload = true;
499 }
500}
501
502/**
503 * Finds an extension by name, allowing omission of the "third-party/" prefix.
504 *
505 * @param {string} name - The name of the extension to find
506 * @returns {{name: string, enabled: boolean}|null} Object with name and enabled properties, or null if not found
507 */
508export function findExtension(name) {
509 const internalExtensionName = extensionNames.find(extName => {
510 return equalsIgnoreCaseAndAccents(extName, name) || equalsIgnoreCaseAndAccents(extName, `third-party/${name}`);
511 });
512 if (!internalExtensionName) return null;
513 const isEnabled = !extension_settings.disabledExtensions.includes(internalExtensionName);
514 return { name: internalExtensionName, enabled: isEnabled };
515}
516
517/**
518 * Returns a deep clone of the manifest for the given extension name.
519 * Accepts either the short name (e.g. `SillyTavern-MyExtension`) or the full internal key
520 * (e.g. `third-party/SillyTavern-MyExtension`). Returns null if the extension is not found.
521 * @param {string} name - Extension name or internal key
522 * @returns {object|null} Cloned manifest object, or null if not found
523 */
524export function getExtensionManifest(name) {
525 const found = extensionNames.find(extName =>
526 equalsIgnoreCaseAndAccents(extName, name) || equalsIgnoreCaseAndAccents(extName, `third-party/${name}`),
527 );
528 const manifest = found ? manifests[found] : null;
529 return manifest ? structuredClone(manifest) : null;
530}
531
532/**
533 * Loads manifest.json files for extensions.
534 * @param {string[]} names Array of extension names
535 * @returns {Promise<Record<string, object>>} Object with extension names as keys and their manifests as values
536 */
537async function getManifests(names) {
538 const obj = {};
539 const promises = [];
540
541 for (const name of names) {
542 const promise = new Promise((resolve, reject) => {
543 fetch(`/scripts/extensions/${name}/manifest.json`).then(async response => {
544 if (response.ok) {
545 const json = await response.json();
546 obj[name] = json;
547 resolve();
548 } else {
549 reject();
550 }
551 }).catch(err => {
552 reject();
553 console.log('Could not load manifest.json for ' + name, err);
554 });
555 });
556
557 promises.push(promise);
558 }
559
560 await Promise.allSettled(promises);
561 return obj;
562}
563
564/**
565 * Tries to activate all available extensions that are not already active.
566 * @returns {Promise<void>}
567 */
568async function activateExtensions() {
569 extensionLoadErrors.clear();
570 const clientVersion = CLIENT_VERSION.split(':')[1];
571 const extensions = Object.entries(manifests).sort((a, b) => sortManifestsByOrder(a[1], b[1]));
572 const extensionNames = extensions.map(x => x[0]);
573 const promises = [];
574
575 for (let entry of extensions) {
576 const name = entry[0];
577 const manifest = entry[1];
578 const extrasRequirements = manifest.requires;
579 const extensionDependencies = manifest.dependencies;
580 const minClientVersion = manifest.minimum_client_version;
581 const displayName = manifest.display_name || name;
582
583 if (activeExtensions.has(name)) {
584 continue;
585 }
586 // Client version requirement: pass if 'minimum_client_version' is undefined or null.
587 let meetsClientMinimumVersion = true;
588 if (minClientVersion !== undefined) {
589 meetsClientMinimumVersion = versionCompare(clientVersion, minClientVersion);
590 }
591
592 // Module requirements: pass if 'requires' is undefined, null, or not an array; check subset if it's an array
593 let meetsModuleRequirements = true;
594 let missingModules = [];
595 if (extrasRequirements !== undefined) {
596 if (Array.isArray(extrasRequirements)) {
597 meetsModuleRequirements = isSubsetOf(modules, extrasRequirements);
598 missingModules = extrasRequirements.filter(req => !modules.includes(req));
599 } else {
600 console.warn(`Extension ${name}: manifest.json 'requires' field is not an array. Loading allowed, but any intended requirements were not verified to exist.`);
601 }
602 }
603
604 // Extension dependencies: pass if 'dependencies' is undefined or not an array; check subset and disabled status if it's an array
605 let meetsExtensionDeps = true;
606 let missingDependencies = [];
607 let disabledDependencies = [];
608 if (extensionDependencies !== undefined) {
609 if (Array.isArray(extensionDependencies)) {
610 // Check if all dependencies exist
611 meetsExtensionDeps = isSubsetOf(extensionNames, extensionDependencies);
612 missingDependencies = extensionDependencies.filter(dep => !extensionNames.includes(dep));
613 // Check for disabled dependencies
614 if (meetsExtensionDeps) {
615 disabledDependencies = extensionDependencies.filter(dep => extension_settings.disabledExtensions.includes(dep));
616 if (disabledDependencies.length > 0) {
617 // Fail if any dependencies are disabled
618 meetsExtensionDeps = false;
619 }
620 }
621 } else {
622 console.warn(`Extension ${name}: manifest.json 'dependencies' field is not an array. Loading allowed, but any intended requirements were not verified to exist.`);
623 }
624 }
625
626 const isDisabled = extension_settings.disabledExtensions.includes(name);
627
628 if (meetsModuleRequirements && meetsExtensionDeps && meetsClientMinimumVersion && !isDisabled) {
629 try {
630 console.debug('Activating extension', name);
631 const promise = addExtensionLocale(name, manifest).finally(() =>
632 Promise.all([addExtensionScript(name, manifest), addExtensionStyle(name, manifest)]),
633 );
634 await promise
635 .then(() => {
636 activeExtensions.add(name);
637 return callExtensionHook(name, 'activate');
638 })
639 .catch(err => {
640 console.log('Could not activate extension', name, err);
641 extensionLoadErrors.add(t`Extension "${displayName}" failed to load: ${err}`);
642 });
643 promises.push(promise);
644 } catch (error) {
645 console.error('Could not activate extension', name, error);
646 }
647 } else if (!meetsModuleRequirements && !isDisabled) {
648 console.warn(t`Extension "${name}" did not load. Missing required Extras module(s): "${missingModules.join(', ')}"`);
649 extensionLoadErrors.add(t`Extension "${displayName}" did not load. Missing required Extras module(s): "${missingModules.join(', ')}"`);
650 } else if (!meetsExtensionDeps && !isDisabled) {
651 if (disabledDependencies.length > 0) {
652 console.warn(t`Extension "${name}" did not load. Required extensions exist but are disabled: "${disabledDependencies.join(', ')}". Enable them first, then reload.`);
653 extensionLoadErrors.add(t`Extension "${displayName}" did not load. Required extensions exist but are disabled: "${disabledDependencies.join(', ')}". Enable them first, then reload.`);
654 } else {
655 console.warn(t`Extension "${name}" did not load. Missing required extensions: "${missingDependencies.join(', ')}"`);
656 extensionLoadErrors.add(t`Extension "${displayName}" did not load. Missing required extensions: "${missingDependencies.join(', ')}"`);
657 }
658 } else if (!meetsClientMinimumVersion && !isDisabled) {
659 console.warn(t`Extension "${name}" did not load. Requires ST client version ${minClientVersion}, but current version is ${clientVersion}.`);
660 extensionLoadErrors.add(t`Extension "${displayName}" did not load. Requires ST client version ${minClientVersion}, but current version is ${clientVersion}.`);
661 }
662 }
663
664 await Promise.allSettled(promises);
665 $('#extensions_details').toggleClass('warning', extensionLoadErrors.size > 0);
666}
667
668async function connectClickHandler() {
669 const baseUrl = String($('#extensions_url').val());
670 extension_settings.apiUrl = baseUrl;
671 const testApiKey = $('#extensions_api_key').val();
672 extension_settings.apiKey = String(testApiKey);
673 saveSettingsDebounced();
674 await connectToApi(baseUrl);
675}
676
677function autoConnectInputHandler() {
678 const value = $(this).prop('checked');
679 extension_settings.autoConnect = !!value;
680
681 if (value && !connectedToApi) {
682 $('#extensions_connect').trigger('click');
683 }
684
685 saveSettingsDebounced();
686}
687
688async function addExtensionsButtonAndMenu() {
689 const buttonHTML = await renderTemplateAsync('wandButton');
690 const extensionsMenuHTML = await renderTemplateAsync('wandMenu');
691
692 $(document.body).append(extensionsMenuHTML);
693 $('#leftSendForm').append(buttonHTML);
694
695 const button = $('#extensionsMenuButton');
696 const dropdown = $('#extensionsMenu');
697 let isDropdownVisible = false;
698
699 let popper = Popper.createPopper(button.get(0), dropdown.get(0), {
700 placement: 'top-start',
701 });
702
703 $(button).on('click', function () {
704 if (isDropdownVisible) {
705 dropdown.fadeOut(animation_duration);
706 isDropdownVisible = false;
707 } else {
708 dropdown.fadeIn(animation_duration);
709 isDropdownVisible = true;
710 }
711 popper.update();
712 });
713
714 $('html').on('click', function (e) {
715 if (!isDropdownVisible) return;
716 const clickTarget = $(e.target);
717 const noCloseTargets = ['#sd_gen', '#extensionsMenuButton', '#roll_dice'];
718 if (!noCloseTargets.some(id => clickTarget.closest(id).length > 0)) {
719 dropdown.fadeOut(animation_duration);
720 isDropdownVisible = false;
721 }
722 });
723}
724
725function notifyUpdatesInputHandler() {
726 extension_settings.notifyUpdates = !!$('#extensions_notify_updates').prop('checked');
727 saveSettingsDebounced();
728
729 if (extension_settings.notifyUpdates) {
730 checkForExtensionUpdates(true);
731 }
732}
733
734/**
735 * Connects to the Extras API.
736 * @param {string} baseUrl Extras API base URL
737 * @returns {Promise<void>}
738 */
739async function connectToApi(baseUrl) {
740 if (!baseUrl) {
741 return;
742 }
743
744 const url = new URL(baseUrl);
745 url.pathname = '/api/modules';
746
747 try {
748 const getExtensionsResult = await doExtrasFetch(url);
749
750 if (getExtensionsResult.ok) {
751 const data = await getExtensionsResult.json();
752 modules = data.modules;
753 await activateExtensions();
754 await eventSource.emit(event_types.EXTRAS_CONNECTED, modules);
755 }
756
757 updateStatus(getExtensionsResult.ok);
758 } catch {
759 updateStatus(false);
760 }
761}
762
763/**
764 * Updates the status of Extras API connection.
765 * @param {boolean} success Whether the connection was successful
766 */
767function updateStatus(success) {
768 connectedToApi = success;
769 const _text = success ? t`Connected to API` : t`Could not connect to API`;
770 const _class = success ? 'success' : 'failure';
771 $('#extensions_status').text(_text);
772 $('#extensions_status').attr('class', _class);
773}
774
775/**
776 * Adds a CSS file for an extension.
777 * @param {string} name Extension name
778 * @param {object} manifest Extension manifest
779 * @returns {Promise<void>} When the CSS is loaded
780 */
781function addExtensionStyle(name, manifest) {
782 if (!manifest.css) {
783 return Promise.resolve();
784 }
785
786 return new Promise((resolve, reject) => {
787 const url = `/scripts/extensions/${name}/${manifest.css}`;
788 const id = sanitizeSelector(`${name}-css`);
789
790 if ($(`link[id="${id}"]`).length === 0) {
791 const link = document.createElement('link');
792 link.id = id;
793 link.rel = 'stylesheet';
794 link.type = 'text/css';
795 link.href = url;
796 link.onload = function () {
797 resolve();
798 };
799 link.onerror = function (e) {
800 reject(e);
801 };
802 document.head.appendChild(link);
803 }
804 });
805}
806
807/**
808 * Loads a JS file for an extension.
809 * @param {string} name Extension name
810 * @param {object} manifest Extension manifest
811 * @returns {Promise<void>} When the script is loaded
812 */
813function addExtensionScript(name, manifest) {
814 if (!manifest.js) {
815 return Promise.resolve();
816 }
817
818 return new Promise((resolve, reject) => {
819 const url = `/scripts/extensions/${name}/${manifest.js}`;
820 const id = sanitizeSelector(`${name}-js`);
821 let ready = false;
822
823 if ($(`script[id="${id}"]`).length === 0) {
824 const script = document.createElement('script');
825 script.id = id;
826 script.type = 'module';
827 script.src = url;
828 script.async = true;
829 script.onerror = function (err) {
830 reject(err);
831 };
832 script.onload = function () {
833 if (!ready) {
834 ready = true;
835 resolve();
836 }
837 };
838 document.body.appendChild(script);
839 }
840 });
841}
842
843/**
844 * Adds a localization data for an extension.
845 * @param {string} name Extension name
846 * @param {object} manifest Manifest object
847 */
848function addExtensionLocale(name, manifest) {
849 // No i18n data in the manifest
850 if (!manifest.i18n || typeof manifest.i18n !== 'object') {
851 return Promise.resolve();
852 }
853
854 const currentLocale = getCurrentLocale();
855 const localeFile = manifest.i18n[currentLocale];
856
857 // Manifest doesn't provide a locale file for the current locale
858 if (!localeFile) {
859 return Promise.resolve();
860 }
861
862 return fetch(`/scripts/extensions/${name}/${localeFile}`)
863 .then(async response => {
864 if (!response.ok) {
865 throw new Error(`HTTP ${response.status}: ${response.statusText}`);
866 }
867
868 const data = await response.json();
869
870 if (data && typeof data === 'object') {
871 addLocaleData(currentLocale, data);
872 }
873 })
874 .catch(err => {
875 console.log('Could not load extension locale data for ' + name, err);
876 });
877}
878
879/**
880 * Generates an element for displaying an extension in the UI.
881 *
882 * @param {string} name - The name of the extension.
883 * @param {object} manifest - The manifest of the extension.
884 * @param {boolean} isActive - Whether the extension is active or not.
885 * @param {boolean} isDisabled - Whether the extension is disabled or not.
886 * @param {boolean} isExternal - Whether the extension is external or not.
887 * @param {string} checkboxClass - The class for the checkbox HTML element.
888 * @return {HTMLElement} - The element that represents the extension.
889 */
890function generateExtensionElement(name, manifest, isActive, isDisabled, isExternal, checkboxClass) {
891 function getExtensionIcon() {
892 const type = getExtensionType(name);
893 const icon = document.createElement('i');
894 icon.classList.add('fa-sm', 'fa-fw', 'fa-solid');
895 switch (type) {
896 case 'global':
897 icon.classList.add('fa-server');
898 icon.title = t`This is a global extension, available for all users.`;
899 break;
900 case 'local':
901 icon.classList.add('fa-user');
902 icon.title = t`This is a local extension, available only for you.`;
903 break;
904 case 'system':
905 icon.classList.add('fa-cog');
906 icon.title = t`This is a built-in extension. It cannot be deleted and updates with the app.`;
907 break;
908 default:
909 icon.classList.add('fa-question');
910 icon.title = t`Unknown extension type.`;
911 break;
912 }
913 return icon;
914 }
915
916 const isUserAdmin = isAdmin();
917 const displayName = manifest.display_name;
918 const displayVersion = manifest.version || '';
919 const externalId = name.replace('third-party', '');
920
921 // Root block
922 const block = document.createElement('div');
923 block.classList.add('extension_block');
924 block.dataset.name = externalId;
925
926 // Toggle
927 const toggleDiv = document.createElement('div');
928 toggleDiv.classList.add('extension_toggle');
929 const toggle = document.createElement('input');
930 toggle.type = 'checkbox';
931 toggle.dataset.name = name;
932 if (isActive || isDisabled) {
933 toggle.title = t`Click to toggle`;
934 toggle.classList.add(isActive ? 'toggle_disable' : 'toggle_enable');
935 if (checkboxClass) toggle.classList.add(checkboxClass);
936 toggle.checked = isActive;
937 } else {
938 toggle.title = t`Cannot enable extension`;
939 toggle.classList.add('extension_missing');
940 if (checkboxClass) toggle.classList.add(checkboxClass);
941 toggle.disabled = true;
942 }
943 toggleDiv.appendChild(toggle);
944 block.appendChild(toggleDiv);
945
946 // Icon
947 const iconDiv = document.createElement('div');
948 iconDiv.classList.add('extension_icon');
949 iconDiv.appendChild(getExtensionIcon());
950 block.appendChild(iconDiv);
951
952 // Text block
953 const textBlock = document.createElement('div');
954 textBlock.classList.add('flexGrow', 'extension_text_block');
955
956 const statusSpan = document.createElement('span');
957 statusSpan.className = isActive ? 'extension_enabled' : isDisabled ? 'extension_disabled' : 'extension_missing';
958
959 const nameSpan = document.createElement('span');
960 nameSpan.classList.add('extension_name');
961 nameSpan.textContent = displayName;
962
963 const authorSpan = document.createElement('span');
964 authorSpan.classList.add('extension_author');
965
966 const versionSpan = document.createElement('span');
967 versionSpan.classList.add('extension_version');
968 versionSpan.textContent = displayVersion;
969
970 statusSpan.append(nameSpan, authorSpan, versionSpan);
971
972 if (isActive && Array.isArray(manifest.optional)) {
973 const optional = new Set(manifest.optional);
974 modules.forEach(x => optional.delete(x));
975 if (optional.size > 0) {
976 const modulesDiv = document.createElement('div');
977 modulesDiv.classList.add('extension_modules');
978 const optionalSpan = document.createElement('span');
979 optionalSpan.classList.add('optional');
980 optionalSpan.textContent = [...optional].join(', ');
981 modulesDiv.append(t`Optional modules:`, ' ', optionalSpan);
982 statusSpan.appendChild(modulesDiv);
983 }
984 } else if (!isDisabled) {
985 // Neither active nor disabled
986 const requirements = new Set(manifest.requires);
987 modules.forEach(x => requirements.delete(x));
988 if (requirements.size > 0) {
989 const modulesDiv = document.createElement('div');
990 modulesDiv.classList.add('extension_modules');
991 const failureSpan = document.createElement('span');
992 failureSpan.classList.add('failure');
993 failureSpan.textContent = [...requirements].join(', ');
994 modulesDiv.append(t`Missing modules:`, ' ', failureSpan);
995 statusSpan.appendChild(modulesDiv);
996 }
997 }
998
999 // if external, wrap the name in a link to the repo
1000 if (isExternal) {
1001 const originLink = document.createElement('a');
1002 originLink.appendChild(statusSpan);
1003 textBlock.appendChild(originLink);
1004 } else {
1005 textBlock.appendChild(statusSpan);
1006 }
1007
1008 block.appendChild(textBlock);
1009
1010 // Actions
1011 const actionsDiv = document.createElement('div');
1012 actionsDiv.classList.add('extension_actions', 'flex-container', 'alignItemsCenter');
1013
1014 /**
1015 * Helper function to create an action button for an extension.
1016 * @param {string} cls Class name
1017 * @param {string} dataName Name of the extension
1018 * @param {string} title Title of the button
1019 * @param {string} iconClasses Classes for the icon
1020 * @returns {HTMLButtonElement} The created button element
1021 */
1022 function makeActionButton(cls, dataName, title, iconClasses) {
1023 const btn = document.createElement('button');
1024 btn.classList.add(cls, 'menu_button');
1025 btn.dataset.name = dataName;
1026 btn.title = title;
1027 const icon = document.createElement('i');
1028 icon.classList.add(...iconClasses.split(' '));
1029 btn.appendChild(icon);
1030 return btn;
1031 }
1032
1033 if (isExternal) {
1034 const updateBtn = makeActionButton('btn_update', externalId, t`Update available`, 'fa-solid fa-download fa-fw');
1035 updateBtn.classList.add('displayNone');
1036 actionsDiv.appendChild(updateBtn);
1037 }
1038
1039 if (isExternal && hasExtensionHook(externalId, 'clean')) {
1040 actionsDiv.appendChild(makeActionButton('btn_clean', externalId, t`Clean extension data`, 'fa-fw fa-solid fa-broom'));
1041 }
1042
1043 if (isExternal && isUserAdmin) {
1044 actionsDiv.appendChild(makeActionButton('btn_branch', externalId, t`Switch branch`, 'fa-solid fa-code-branch fa-fw'));
1045 actionsDiv.appendChild(makeActionButton('btn_move', externalId, t`Move`, 'fa-solid fa-folder-tree fa-fw'));
1046 }
1047
1048 if (isExternal) {
1049 actionsDiv.appendChild(makeActionButton('btn_delete', externalId, t`Delete`, 'fa-fw fa-solid fa-trash-can'));
1050 }
1051
1052 block.appendChild(actionsDiv);
1053
1054 return block;
1055}
1056
1057/**
1058 * Gets extension data and generates the corresponding element for displaying the extension.
1059 *
1060 * @param {Array} extension - An array where the first element is the extension name and the second element is the extension manifest.
1061 * @return {{isExternal: boolean, extensionElement: HTMLElement}} - An object with 'isExternal' indicating whether the extension is external, and 'extensionElement' for the extension's HTML element.
1062 */
1063function getExtensionData(extension) {
1064 const name = extension[0];
1065 const manifest = extension[1];
1066 const isActive = activeExtensions.has(name);
1067 const isDisabled = extension_settings.disabledExtensions.includes(name);
1068 const isExternal = name.startsWith('third-party');
1069
1070 const checkboxClass = isDisabled ? 'checkbox_disabled' : '';
1071 const extensionElement = generateExtensionElement(name, manifest, isActive, isDisabled, isExternal, checkboxClass);
1072
1073 return { isExternal, extensionElement };
1074}
1075
1076
1077/**
1078 * Gets the module information to be displayed.
1079 *
1080 * @return {HTMLElement} - The element containing the module information.
1081 */
1082function getModuleInformation() {
1083 const container = document.createElement('div');
1084
1085 const heading = document.createElement('h3');
1086 heading.textContent = t`Modules provided by your Extras API:`;
1087 container.appendChild(heading);
1088
1089 const moduleInfo = document.createElement('p');
1090 if (modules.length) {
1091 moduleInfo.textContent = modules.join(', ');
1092 } else {
1093 moduleInfo.classList.add('failure');
1094 moduleInfo.textContent = t`Not connected to the API!`;
1095 }
1096 container.appendChild(moduleInfo);
1097
1098 return container;
1099}
1100
1101/**
1102 * Generates HTMLElement for the extension load errors.
1103 * @returns {HTMLElement} - The element containing the extension load errors.
1104 */
1105function getExtensionLoadErrors() {
1106 if (extensionLoadErrors.size === 0) {
1107 return document.createElement('div');
1108 }
1109
1110 const container = document.createElement('div');
1111 container.classList.add('info-block', 'error');
1112
1113 for (const error of extensionLoadErrors) {
1114 const errorElement = document.createElement('div');
1115 errorElement.textContent = error;
1116 container.appendChild(errorElement);
1117 }
1118
1119 return container;
1120}
1121
1122/**
1123 * Generates the HTML strings for all extensions and displays them in a popup.
1124 */
1125async function showExtensionsDetails() {
1126 const abortController = new AbortController();
1127 let popupPromise;
1128 try {
1129 // If we are updating an extension, the "old" popup is still active. We should close that.
1130 let initialScrollTop = 0;
1131 const oldPopup = Popup.util.popups.find(popup => popup.content.querySelector('.extensions_info'));
1132 if (oldPopup) {
1133 initialScrollTop = oldPopup.content.scrollTop;
1134 await oldPopup.completeCancelled();
1135 }
1136 const errors = getExtensionLoadErrors();
1137
1138 const defaultContainer = document.createElement('div');
1139 defaultContainer.classList.add('marginBot10');
1140 const defaultHeading = document.createElement('h3');
1141 defaultHeading.textContent = t`Built-in Extensions:`;
1142 defaultContainer.appendChild(defaultHeading);
1143
1144 const externalContainer = document.createElement('div');
1145 externalContainer.classList.add('marginBot10');
1146 const externalHeader = document.createElement('div');
1147 externalHeader.classList.add('flex-container', 'alignitemscenter', 'spaceBetween', 'flexnowrap', 'marginBot10');
1148 const externalHeading = document.createElement('h3');
1149 externalHeading.classList.add('margin0');
1150 externalHeading.textContent = t`Installed Extensions:`;
1151 const thirdPartyToolbar = document.createElement('div');
1152 thirdPartyToolbar.classList.add('flex-container', 'third_party_toolbar');
1153 externalHeader.append(externalHeading, thirdPartyToolbar);
1154 externalContainer.appendChild(externalHeader);
1155
1156 const loadingEl = document.createElement('div');
1157 loadingEl.classList.add('flex-container', 'alignItemsCenter', 'justifyCenter', 'marginTop10', 'marginBot5');
1158 const loadingIcon = document.createElement('i');
1159 loadingIcon.classList.add('fa-solid', 'fa-spinner', 'fa-spin');
1160 const loadingSpan = document.createElement('span');
1161 loadingSpan.textContent = t`Loading third-party extensions... Please wait...`;
1162 loadingEl.append(loadingIcon, loadingSpan);
1163
1164 externalContainer.appendChild(loadingEl);
1165
1166 const sortOrderKey = 'extensions_sortByName';
1167 const sortByName = accountStorage.getItem(sortOrderKey) === 'true';
1168 const sortFn = sortByName ? sortManifestsByName : sortManifestsByOrder;
1169 const extensions = Object.entries(manifests).sort((a, b) => sortFn(a[1], b[1])).map(getExtensionData);
1170 let extensionsToToggle = [];
1171
1172 extensions.forEach(value => {
1173 const { isExternal, extensionElement } = value;
1174 const container = isExternal ? externalContainer : defaultContainer;
1175 container.appendChild(extensionElement);
1176 });
1177
1178 const extensionsMenu = $('<div></div>')
1179 .addClass('extensions_info')
1180 .append(errors)
1181 .append(defaultContainer)
1182 .append(externalContainer)
1183 .append(getModuleInformation());
1184
1185 {
1186 const updateAction = async (force) => {
1187 requiresReload = true;
1188 await autoUpdateExtensions(force);
1189 await popup.complete(POPUP_RESULT.AFFIRMATIVE);
1190 };
1191
1192 const toolbar = document.createElement('div');
1193 toolbar.classList.add('extensions_toolbar');
1194
1195 const updateAllButton = document.createElement('button');
1196 updateAllButton.classList.add('menu_button', 'menu_button_icon');
1197 updateAllButton.textContent = t`Update all`;
1198 updateAllButton.addEventListener('click', () => updateAction(true));
1199
1200 const updateEnabledOnlyButton = document.createElement('button');
1201 updateEnabledOnlyButton.classList.add('menu_button', 'menu_button_icon');
1202 updateEnabledOnlyButton.textContent = t`Update enabled`;
1203 updateEnabledOnlyButton.addEventListener('click', () => updateAction(false));
1204
1205 const toggleAllExtensionsButton = document.createElement('div');
1206 toggleAllExtensionsButton.classList.add('menu_button', 'menu_button_icon');
1207 toggleAllExtensionsButton.title = t`Bulk toggle third-party extensions.`;
1208 const toggleAllLabel = document.createElement('span');
1209 toggleAllLabel.textContent = t`Toggle extensions`;
1210 const toggleAllIcon = document.createElement('div');
1211 toggleAllIcon.classList.add('fa-solid', 'fa-circle-info', 'opacity50p');
1212 toggleAllExtensionsButton.append(toggleAllLabel, toggleAllIcon);
1213
1214 const restoreBulkToggledExtensionsButton = document.createElement('div');
1215 restoreBulkToggledExtensionsButton.classList.add('menu_button', 'menu_button_icon', 'fa-solid', 'fa-arrow-right-rotate', 'displayNone');
1216 restoreBulkToggledExtensionsButton.title = t`Restore toggled extensions.\n\nIt does not restore extensions toggled individually.`;
1217
1218 toggleAllExtensionsButton.addEventListener('click', () => {
1219 extensionsToToggle = onToggleAllExtensions(extensionsToToggle, $(externalContainer));
1220
1221 for (const extension of extensionsToToggle) {
1222 const { name } = extension;
1223
1224 $(externalContainer)
1225 .find(`.extension_block[data-name="${getNameSelector(name)}"] .extension_toggle input`)
1226 .off('click')
1227 .one('click', () => {
1228 extensionsToToggle = extensionsToToggle.filter(ext => ext.name !== name);
1229 });
1230 }
1231
1232 const restoreButtonHandler = extensionsToToggle.length > 0 ? 'remove' : 'add';
1233
1234 restoreBulkToggledExtensionsButton.classList[restoreButtonHandler]('displayNone');
1235 });
1236
1237 restoreBulkToggledExtensionsButton.addEventListener('click', () => {
1238 for (const extension of extensionsToToggle) {
1239 const { name } = extension;
1240 const isDisabled = extension_settings.disabledExtensions.includes(name);
1241
1242 $(externalContainer)
1243 .find(`.extension_block[data-name="${getNameSelector(name)}"] .extension_toggle input`)
1244 .prop('checked', !isDisabled)
1245 .toggleClass('toggle_enable', isDisabled)
1246 .toggleClass('toggle_disable', !isDisabled)
1247 .toggleClass('checkbox_disabled', isDisabled);
1248 }
1249
1250 extensionsToToggle = [];
1251 restoreBulkToggledExtensionsButton.classList.add('displayNone');
1252 });
1253
1254 const flexExpander = document.createElement('div');
1255 flexExpander.classList.add('expander');
1256
1257 const sortOrderButton = document.createElement('button');
1258 sortOrderButton.classList.add('menu_button', 'menu_button_icon');
1259 sortOrderButton.textContent = sortByName ? t`Sort: Display Name` : t`Sort: Loading Order`;
1260 sortOrderButton.addEventListener('click', async () => {
1261 abortController.abort();
1262 accountStorage.setItem(sortOrderKey, sortByName ? 'false' : 'true');
1263 await showExtensionsDetails();
1264 });
1265
1266 toolbar.append(updateAllButton, updateEnabledOnlyButton, flexExpander, sortOrderButton);
1267 thirdPartyToolbar.append(restoreBulkToggledExtensionsButton, toggleAllExtensionsButton);
1268 extensionsMenu.prepend(toolbar);
1269 }
1270
1271 let waitingForSave = false;
1272
1273 const popup = new Popup(extensionsMenu, POPUP_TYPE.TEXT, '', {
1274 okButton: t`Close`,
1275 wide: true,
1276 large: true,
1277 customButtons: [],
1278 allowVerticalScrolling: true,
1279 onClosing: async () => {
1280 if (waitingForSave) {
1281 return false;
1282 }
1283
1284 for (const extension of extensionsToToggle) {
1285 const { name, toggleHandler, enable } = extension;
1286 const isDisabled = extension_settings.disabledExtensions.includes(name);
1287
1288 try {
1289 if (isDisabled && !enable) continue;
1290 if (!isDisabled && enable) continue;
1291
1292 requiresReload = true;
1293
1294 await toggleHandler(name, false);
1295 } catch (error) {
1296 console.error(`Could not toggle extension ${name}:`, error);
1297 toastr.error(t`Could not toggle extension ${name}. See console for details.`);
1298 }
1299 }
1300
1301 if (stateChanged) {
1302 waitingForSave = true;
1303 const toast = toastr.info(t`The page will be reloaded shortly...`, t`Extensions state changed`);
1304 await saveSettings();
1305 toastr.clear(toast);
1306 waitingForSave = false;
1307 requiresReload = true;
1308 }
1309
1310 return true;
1311 },
1312 });
1313 popupPromise = popup.show();
1314 popup.content.scrollTop = initialScrollTop;
1315 checkForUpdatesManual(sortFn, abortController.signal).finally(() => loadingEl.remove());
1316 } catch (error) {
1317 toastr.error(t`Error loading extensions. See browser console for details.`);
1318 console.error(error);
1319 }
1320 if (popupPromise) {
1321 await popupPromise;
1322 abortController.abort();
1323 }
1324 if (requiresReload) {
1325 location.reload();
1326 }
1327}
1328
1329/**
1330 * Handles the click event for the update button of an extension.
1331 * This function makes a POST request to '/api/extensions/update' with the extension's name.
1332 * If the extension is already up to date, it displays a success message.
1333 * If the extension is not up to date, it updates the extension and displays a success message with the new commit hash.
1334 */
1335async function onUpdateClick() {
1336 const isCurrentUserAdmin = isAdmin();
1337 const extensionName = $(this).data('name');
1338 const isGlobal = getExtensionType(extensionName) === 'global';
1339 if (isGlobal && !isCurrentUserAdmin) {
1340 toastr.error(t`You don't have permission to update global extensions.`);
1341 return;
1342 }
1343
1344 const icon = $(this).find('i');
1345 icon.addClass('fa-spin');
1346 await updateExtension(extensionName, false);
1347 // updateExtension eats the error, but we can at least stop the spinner
1348 icon.removeClass('fa-spin');
1349}
1350
1351/**
1352 * Updates a third-party extension via the API.
1353 * @param {string} extensionName Extension folder name
1354 * @param {boolean} quiet If true, don't show a success message
1355 * @param {number?} timeout Timeout in milliseconds to wait for the update to complete. If null, no timeout is set.
1356 */
1357async function updateExtension(extensionName, quiet, timeout = null) {
1358 try {
1359 const signal = timeout ? AbortSignal.timeout(timeout) : undefined;
1360 const response = await fetch('/api/extensions/update', {
1361 method: 'POST',
1362 signal: signal,
1363 headers: getRequestHeaders(),
1364 body: JSON.stringify({
1365 extensionName,
1366 global: getExtensionType(extensionName) === 'global',
1367 }),
1368 });
1369
1370 if (!response.ok) {
1371 const text = await response.text();
1372 toastr.error(text || response.statusText, t`Extension update failed`, { timeOut: 5000 });
1373 console.error('Extension update failed', response.status, response.statusText, text);
1374 return;
1375 }
1376
1377 const data = await response.json();
1378
1379 if (!quiet) {
1380 void showExtensionsDetails();
1381 }
1382
1383 if (data.isUpToDate) {
1384 if (!quiet) {
1385 toastr.success('Extension is already up to date');
1386 }
1387 } else {
1388 const fullExtensionName = extensionName.startsWith('third-party') ? extensionName : `third-party${extensionName}`;
1389 await callExtensionHook(fullExtensionName, 'update');
1390 toastr.success(t`Extension ${extensionName} updated to ${data.shortCommitHash}`, t`Reload the page to apply updates`);
1391 }
1392 } catch (error) {
1393 console.error('Extension update error:', error);
1394 }
1395}
1396
1397/**
1398 * Handles the click event for the delete button of an extension.
1399 * This function makes a POST request to '/api/extensions/delete' with the extension's name.
1400 * If the extension is deleted, it displays a success message.
1401 * Creates a popup for the user to confirm before delete.
1402 * If the extension has a 'clean' hook, an optional checkbox to also run the cleanup is shown.
1403 */
1404async function onDeleteClick() {
1405 const extensionName = $(this).data('name');
1406 const isCurrentUserAdmin = isAdmin();
1407 const isGlobal = getExtensionType(extensionName) === 'global';
1408 if (isGlobal && !isCurrentUserAdmin) {
1409 toastr.error(t`You don't have permission to delete global extensions.`);
1410 return;
1411 }
1412
1413 const hasCleanHook = hasExtensionHook(extensionName, 'clean');
1414
1415 /** @type {import('./popup.js').CustomPopupInput[]} */
1416 const customInputs = hasCleanHook ? [{ id: 'extension_delete_cleanup', label: t`Also clean up extension data`, defaultState: false }] : null;
1417
1418 const popup = new Popup(t`Are you sure you want to delete ${escapeHtml(extensionName)}?`, POPUP_TYPE.CONFIRM, '', { customInputs });
1419 const confirmation = await popup.show();
1420 if (confirmation === POPUP_RESULT.AFFIRMATIVE) {
1421 const shouldClean = hasCleanHook && Boolean(popup.inputResults?.get('extension_delete_cleanup'));
1422 await deleteExtension(extensionName, shouldClean);
1423 }
1424}
1425
1426/**
1427 * Handles the click event for the clean button of an extension.
1428 * Runs the extension's 'clean' hook after user confirmation, then reloads the page.
1429 */
1430async function onCleanClick() {
1431 const extensionName = $(this).data('name');
1432
1433 const confirmation = await Popup.show.confirm(t`Clean extension data`, t`Are you sure you want to clean up data for ${escapeHtml(extensionName)}? This action cannot be undone.`);
1434 if (!confirmation) {
1435 return;
1436 }
1437
1438 await cleanExtension(extensionName);
1439}
1440
1441/**
1442 * Runs the 'clean' hook for an extension and reloads the page.
1443 * @param {string} extensionName Extension name (without 'third-party' prefix)
1444 * @returns {Promise<void>}
1445 */
1446async function cleanExtension(extensionName) {
1447 const fullExtensionName = extensionName.startsWith('third-party') ? extensionName : `third-party${extensionName}`;
1448 await callExtensionHook(fullExtensionName, 'clean');
1449
1450 // Clean might have updated settings, which could race with the page reload, so we'll force save here
1451 await saveSettings();
1452
1453 toastr.success(t`Extension ${extensionName} data cleaned`);
1454 delay(1000).then(() => location.reload());
1455}
1456
1457async function onBranchClick() {
1458 const extensionName = $(this).data('name');
1459 const isCurrentUserAdmin = isAdmin();
1460 const isGlobal = getExtensionType(extensionName) === 'global';
1461 if (isGlobal && !isCurrentUserAdmin) {
1462 toastr.error(t`You don't have permission to switch branch.`);
1463 return;
1464 }
1465
1466 let newBranch = '';
1467
1468 const branches = await getExtensionBranches(extensionName, isGlobal);
1469 const selectElement = document.createElement('select');
1470 selectElement.classList.add('text_pole', 'wide100p');
1471 selectElement.addEventListener('change', function () {
1472 newBranch = this.value;
1473 });
1474 for (const branch of branches) {
1475 const option = document.createElement('option');
1476 option.value = branch.name;
1477 option.textContent = `${branch.name} (${branch.commit}) [${branch.label}]`;
1478 option.selected = branch.current;
1479 selectElement.appendChild(option);
1480 }
1481
1482 const popup = new Popup(selectElement, POPUP_TYPE.CONFIRM, '', {
1483 okButton: t`Switch`,
1484 cancelButton: t`Cancel`,
1485 });
1486 const popupResult = await popup.show();
1487
1488 if (!popupResult || !newBranch) {
1489 return;
1490 }
1491
1492 await switchExtensionBranch(extensionName, isGlobal, newBranch);
1493}
1494
1495async function onMoveClick() {
1496 const extensionName = $(this).data('name');
1497 const isCurrentUserAdmin = isAdmin();
1498 const isGlobal = getExtensionType(extensionName) === 'global';
1499 if (isGlobal && !isCurrentUserAdmin) {
1500 toastr.error(t`You don't have permission to move extensions.`);
1501 return;
1502 }
1503
1504 const source = getExtensionType(extensionName);
1505 const destination = source === 'global' ? 'local' : 'global';
1506
1507 const confirmationHeader = t`Move extension`;
1508 const confirmationText = source == 'global'
1509 ? t`Are you sure you want to move ${escapeHtml(extensionName)} to your local extensions? This will make it available only for you.`
1510 : t`Are you sure you want to move ${escapeHtml(extensionName)} to the global extensions? This will make it available for all users.`;
1511
1512 const confirmation = await Popup.show.confirm(confirmationHeader, confirmationText);
1513
1514 if (!confirmation) {
1515 return;
1516 }
1517
1518 $(this).find('i').addClass('fa-spin');
1519 await moveExtension(extensionName, source, destination);
1520}
1521
1522/**
1523 * Moves an extension via the API.
1524 * @param {string} extensionName Extension name
1525 * @param {string} source Source type
1526 * @param {string} destination Destination type
1527 * @returns {Promise<void>}
1528 */
1529async function moveExtension(extensionName, source, destination) {
1530 try {
1531 const result = await fetch('/api/extensions/move', {
1532 method: 'POST',
1533 headers: getRequestHeaders(),
1534 body: JSON.stringify({
1535 extensionName,
1536 source,
1537 destination,
1538 }),
1539 });
1540
1541 if (!result.ok) {
1542 const text = await result.text();
1543 toastr.error(text || result.statusText, t`Extension move failed`, { timeOut: 5000 });
1544 console.error('Extension move failed', result.status, result.statusText, text);
1545 return;
1546 }
1547
1548 toastr.success(t`Extension ${extensionName} moved.`);
1549 await loadExtensionSettings({}, false, false);
1550 void showExtensionsDetails();
1551 } catch (error) {
1552 console.error('Error:', error);
1553 }
1554}
1555
1556/**
1557 * Deletes an extension via the API.
1558 * @param {string} extensionName Extension name to delete
1559 * @param {boolean} [shouldClean=false] Whether to also run the 'clean' hook before deleting
1560 */
1561export async function deleteExtension(extensionName, shouldClean = false) {
1562 const fullExtensionName = extensionName.startsWith('third-party') ? extensionName : `third-party${extensionName}`;
1563
1564 if (shouldClean) {
1565 await callExtensionHook(fullExtensionName, 'clean');
1566 }
1567
1568 await callExtensionHook(fullExtensionName, 'delete');
1569
1570 try {
1571 await fetch('/api/extensions/delete', {
1572 method: 'POST',
1573 headers: getRequestHeaders(),
1574 body: JSON.stringify({
1575 extensionName,
1576 global: getExtensionType(extensionName) === 'global',
1577 }),
1578 });
1579 } catch (error) {
1580 console.error('Error:', error);
1581 }
1582
1583 // Delete or clean might have updated settings, which could race with the page reload, so we'll force save here
1584 await saveSettings();
1585
1586 toastr.success(t`Extension ${extensionName} deleted`);
1587 delay(1000).then(() => location.reload());
1588}
1589
1590/**
1591 * Fetches the version details of a specific extension.
1592 *
1593 * @param {string} extensionName - The name of the extension.
1594 * @param {AbortSignal} [abortSignal] - The signal to abort the operation.
1595 * @return {Promise<object>} - An object containing the extension's version details.
1596 * This object includes the currentBranchName, currentCommitHash, isUpToDate, and remoteUrl.
1597 * @throws {error} - If there is an error during the fetch operation, it logs the error to the console.
1598 */
1599async function getExtensionVersion(extensionName, abortSignal) {
1600 try {
1601 const response = await fetch('/api/extensions/version', {
1602 method: 'POST',
1603 headers: getRequestHeaders(),
1604 body: JSON.stringify({
1605 extensionName,
1606 global: getExtensionType(extensionName) === 'global',
1607 }),
1608 signal: abortSignal,
1609 });
1610
1611 const data = await response.json();
1612 return data;
1613 } catch (error) {
1614 if (error instanceof Error && error.name === 'AbortError') {
1615 return;
1616 }
1617 console.error('Error:', error);
1618 }
1619}
1620
1621/**
1622 * Gets the list of branches for a specific extension.
1623 * @param {string} extensionName The name of the extension
1624 * @param {boolean} isGlobal Whether the extension is global or not
1625 * @returns {Promise<ExtensionBranch[]>} List of branches for the extension
1626 * @typedef {object} ExtensionBranch
1627 * @property {string} name The name of the branch
1628 * @property {string} commit The commit hash of the branch
1629 * @property {boolean} current Whether this branch is the current one
1630 * @property {string} label The commit label of the branch
1631 */
1632async function getExtensionBranches(extensionName, isGlobal) {
1633 try {
1634 const response = await fetch('/api/extensions/branches', {
1635 method: 'POST',
1636 headers: getRequestHeaders(),
1637 body: JSON.stringify({
1638 extensionName,
1639 global: isGlobal,
1640 }),
1641 });
1642
1643 if (!response.ok) {
1644 const text = await response.text();
1645 toastr.error(text || response.statusText, t`Extension branches fetch failed`);
1646 console.error('Extension branches fetch failed', response.status, response.statusText, text);
1647 return [];
1648 }
1649
1650 return await response.json();
1651 } catch (error) {
1652 console.error('Error:', error);
1653 return [];
1654 }
1655}
1656
1657/**
1658 * Switches the branch of an extension.
1659 * @param {string} extensionName The name of the extension
1660 * @param {boolean} isGlobal If the extension is global
1661 * @param {string} branch Branch name to switch to
1662 * @returns {Promise<void>}
1663 */
1664async function switchExtensionBranch(extensionName, isGlobal, branch) {
1665 try {
1666 const response = await fetch('/api/extensions/switch', {
1667 method: 'POST',
1668 headers: getRequestHeaders(),
1669 body: JSON.stringify({
1670 extensionName,
1671 branch,
1672 global: isGlobal,
1673 }),
1674 });
1675
1676 if (!response.ok) {
1677 const text = await response.text();
1678 toastr.error(text || response.statusText, t`Extension branch switch failed`);
1679 console.error('Extension branch switch failed', response.status, response.statusText, text);
1680 return;
1681 }
1682
1683 toastr.success(t`Extension ${extensionName} switched to ${branch}`, t`Reload the page to apply updates`);
1684 await loadExtensionSettings({}, false, false);
1685 void showExtensionsDetails();
1686 } catch (error) {
1687 console.error('Error:', error);
1688 }
1689}
1690
1691/**
1692 * Installs a third-party extension via the API.
1693 * @param {string} url Extension repository URL
1694 * @param {boolean} global Is the extension global?
1695 * @param {string} [branch] Optional branch to install, if not provided the default branch will be used
1696 * @returns {Promise<boolean>} True if the extension was installed successfully, false otherwise
1697 */
1698export async function installExtension(url, global, branch = '') {
1699 try {
1700 const parsedUrl = new URL(url);
1701 if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
1702 throw new Error('Invalid URL protocol');
1703 }
1704
1705 // Normalize the URL (resolve relative paths, remove redundant segments, etc.)
1706 url = parsedUrl.href;
1707 } catch (error) {
1708 console.error('Invalid URL:', error);
1709 toastr.error(t`Only valid HTTP and HTTPS URLs are allowed.`, t`Invalid URL`);
1710 return false;
1711 }
1712
1713 if (!isOfficialExtension(url)) {
1714 const extensionInstallationWarningKey = 'extensionInstallationWarningShown';
1715 if (accountStorage.getItem(extensionInstallationWarningKey)) {
1716 console.debug('Bypassed URL check for third-party extension (account preference).', url);
1717 } else {
1718 let dismissWarning = false;
1719 const confirmation = await Popup.show.confirm(
1720 t`Install a third-party extension?`,
1721 await renderTemplateAsync('thirdPartyExtensionWarning'),
1722 {
1723 customInputs: [{ id: 'dontAskAgain', type: 'checkbox', label: t`Don't show this warning again`, defaultState: false }],
1724 onClose: (popup) => {
1725 if (!popup.result) {
1726 return;
1727 }
1728 dismissWarning = Boolean(popup.inputResults?.get('dontAskAgain') ?? false);
1729 },
1730 okButton: t`Yes, install it`,
1731 cancelButton: t`No, cancel`,
1732 });
1733 if (!confirmation) {
1734 return false;
1735 }
1736 if (dismissWarning) {
1737 accountStorage.setItem(extensionInstallationWarningKey, '1');
1738 }
1739 }
1740 }
1741
1742 console.debug('Extension installation started', url);
1743
1744 toastr.info(t`Please wait...`, t`Installing extension`);
1745
1746 const request = await fetch('/api/extensions/install', {
1747 method: 'POST',
1748 headers: getRequestHeaders(),
1749 body: JSON.stringify({
1750 url,
1751 global,
1752 branch,
1753 }),
1754 });
1755
1756 if (!request.ok) {
1757 const text = await request.text();
1758 toastr.warning(text || request.statusText, t`Extension installation failed`, { timeOut: 5000 });
1759 console.error('Extension installation failed', request.status, request.statusText, text);
1760 return false;
1761 }
1762
1763 const response = await request.json();
1764 toastr.success(t`Extension '${response.display_name}' has been installed successfully!`, t`Extension installation successful`);
1765 console.debug(`Extension "${response.display_name}" has been installed successfully at ${response.extensionPath}`);
1766 await loadExtensionSettings({}, false, false);
1767 await eventSource.emit(event_types.EXTENSION_SETTINGS_LOADED, response);
1768
1769 if (response.folderName) {
1770 const extensionName = `third-party/${response.folderName}`;
1771 await callExtensionHook(extensionName, 'install');
1772 }
1773
1774 return true;
1775}
1776
1777/**
1778 * Loads extension settings from the app settings.
1779 * @param {object} settings App Settings
1780 * @param {boolean} versionChanged Is this a version change?
1781 * @param {boolean} enableAutoUpdate Enable auto-update
1782 */
1783export async function loadExtensionSettings(settings, versionChanged, enableAutoUpdate) {
1784 if (settings.extension_settings) {
1785 Object.assign(extension_settings, settings.extension_settings);
1786 }
1787
1788 $('#extensions_url').val(extension_settings.apiUrl);
1789 $('#extensions_api_key').val(extension_settings.apiKey);
1790 $('#extensions_autoconnect').prop('checked', extension_settings.autoConnect);
1791 $('#extensions_notify_updates').prop('checked', extension_settings.notifyUpdates);
1792
1793 // Activate offline extensions
1794 await eventSource.emit(event_types.EXTENSIONS_FIRST_LOAD);
1795 const extensions = await discoverExtensions();
1796 extensionNames = extensions.map(x => x.name);
1797 extensionTypes = Object.fromEntries(extensions.map(x => [x.name, x.type]));
1798 manifests = await getManifests(extensionNames);
1799
1800 if (versionChanged && enableAutoUpdate) {
1801 await autoUpdateExtensions(false);
1802 }
1803
1804 await activateExtensions();
1805 if (extension_settings.autoConnect && extension_settings.apiUrl) {
1806 connectToApi(extension_settings.apiUrl);
1807 }
1808}
1809
1810export function doDailyExtensionUpdatesCheck() {
1811 setTimeout(() => {
1812 if (extension_settings.notifyUpdates) {
1813 checkForExtensionUpdates(false);
1814 }
1815 }, 1);
1816}
1817
1818const concurrencyLimit = 5;
1819let activeRequestsCount = 0;
1820const versionCheckQueue = [];
1821
1822function enqueueVersionCheck(fn) {
1823 return new Promise((resolve, reject) => {
1824 versionCheckQueue.push(() => fn().then(resolve).catch(reject));
1825 processVersionCheckQueue();
1826 });
1827}
1828
1829function processVersionCheckQueue() {
1830 if (activeRequestsCount >= concurrencyLimit || versionCheckQueue.length === 0) {
1831 return;
1832 }
1833 activeRequestsCount++;
1834 const fn = versionCheckQueue.shift();
1835 fn().finally(() => {
1836 activeRequestsCount--;
1837 processVersionCheckQueue();
1838 });
1839}
1840
1841/**
1842 * Performs a manual check for updates on all 3rd-party extensions.
1843 * @param {function} sortFn Sort function
1844 * @param {AbortSignal} abortSignal Signal to abort the operation
1845 * @returns {Promise<any[]>}
1846 */
1847async function checkForUpdatesManual(sortFn, abortSignal) {
1848 const promises = [];
1849 for (const id of Object.keys(manifests).filter(x => x.startsWith('third-party')).sort((a, b) => sortFn(manifests[a], manifests[b]))) {
1850 const externalId = id.replace('third-party', '');
1851 const promise = enqueueVersionCheck(async () => {
1852 try {
1853 const data = await getExtensionVersion(externalId, abortSignal);
1854 if (!data) {
1855 return;
1856 }
1857 const selector = getNameSelector(externalId, { prefix: '' });
1858 const extensionBlock = document.querySelector(`.extension_block[data-name="${selector}"]`);
1859 if (extensionBlock && data) {
1860 if (data.isUpToDate === false) {
1861 const buttonElement = extensionBlock.querySelector('.btn_update');
1862 if (buttonElement) {
1863 buttonElement.classList.remove('displayNone');
1864 }
1865 const nameElement = extensionBlock.querySelector('.extension_name');
1866 if (nameElement) {
1867 nameElement.classList.add('update_available');
1868 }
1869 }
1870 let branch = data.currentBranchName;
1871 let commitHash = data.currentCommitHash;
1872 let origin = data.remoteUrl;
1873
1874 const originLink = extensionBlock.querySelector('a');
1875 if (originLink) {
1876 try {
1877 const url = new URL(origin);
1878 if (!['https:', 'http:'].includes(url.protocol)) {
1879 throw new Error('Invalid protocol');
1880 }
1881 originLink.href = url.href;
1882 originLink.target = '_blank';
1883 originLink.rel = 'noopener noreferrer';
1884 } catch (error) {
1885 console.log('Error setting origin link', originLink, error);
1886 }
1887 }
1888
1889 const authorElement = extensionBlock.querySelector('.extension_author');
1890 if (authorElement) {
1891 const author = getAuthorFromUrl(origin) || EMPTY_AUTHOR;
1892 if (author.name) {
1893 const icon = document.createElement('i');
1894 icon.classList.add('fa-solid', 'fa-at', 'fa-xs');
1895 const name = document.createElement('span');
1896 name.textContent = author.name;
1897 authorElement.append(icon, name);
1898 }
1899 }
1900
1901 const versionElement = extensionBlock.querySelector('.extension_version');
1902 if (versionElement) {
1903 versionElement.textContent += ` (${branch}-${commitHash.substring(0, 7)})`;
1904 }
1905 }
1906 } catch (error) {
1907 console.error('Error checking for extension updates', error);
1908 }
1909 });
1910 promises.push(promise);
1911 }
1912 return Promise.allSettled(promises);
1913}
1914
1915/**
1916 * Checks if there are updates available for enabled 3rd-party extensions.
1917 * @param {boolean} force Skip nag check
1918 * @returns {Promise<any>}
1919 */
1920async function checkForExtensionUpdates(force) {
1921 if (!force) {
1922 const STORAGE_NAG_KEY = 'extension_update_nag';
1923 const currentDate = new Date().toDateString();
1924
1925 // Don't nag more than once a day
1926 if (accountStorage.getItem(STORAGE_NAG_KEY) === currentDate) {
1927 return;
1928 }
1929
1930 accountStorage.setItem(STORAGE_NAG_KEY, currentDate);
1931 }
1932
1933 const isCurrentUserAdmin = isAdmin();
1934 const updatesAvailable = [];
1935 const promises = [];
1936
1937 for (const [id, manifest] of Object.entries(manifests)) {
1938 const isDisabled = extension_settings.disabledExtensions.includes(id);
1939 if (isDisabled) {
1940 console.debug(`Skipping extension: ${manifest.display_name} (${id}) for non-admin user`);
1941 continue;
1942 }
1943 const isGlobal = getExtensionType(id) === 'global';
1944 if (isGlobal && !isCurrentUserAdmin) {
1945 console.debug(`Skipping global extension: ${manifest.display_name} (${id}) for non-admin user`);
1946 continue;
1947 }
1948
1949 if (manifest.auto_update && id.startsWith('third-party')) {
1950 const promise = enqueueVersionCheck(async () => {
1951 try {
1952 const data = await getExtensionVersion(id.replace('third-party', ''));
1953 if (!data) {
1954 return;
1955 }
1956 if (!data.isUpToDate) {
1957 updatesAvailable.push(manifest.display_name);
1958 }
1959 } catch (error) {
1960 console.error('Error checking for extension updates', error);
1961 }
1962 });
1963 promises.push(promise);
1964 }
1965 }
1966
1967 await Promise.allSettled(promises);
1968
1969 if (updatesAvailable.length > 0) {
1970 toastr.info(`${updatesAvailable.map(x => `• ${x}`).join('\n')}`, t`Extension updates available`);
1971 }
1972}
1973
1974/**
1975 * Updates all enabled 3rd-party extensions that have auto-update enabled.
1976 * @param {boolean} forceAll Include disabled and not auto-updating
1977 * @returns {Promise<void>}
1978 */
1979async function autoUpdateExtensions(forceAll) {
1980 if (!Object.values(manifests).some(x => x.auto_update)) {
1981 return;
1982 }
1983
1984 const banner = toastr.info(t`Auto-updating extensions. This may take several minutes.`, t`Please wait...`, { timeOut: 10000, extendedTimeOut: 10000 });
1985 const isCurrentUserAdmin = isAdmin();
1986 const promises = [];
1987 const autoUpdateTimeout = 60 * 1000;
1988 for (const [id, manifest] of Object.entries(manifests)) {
1989 const isDisabled = extension_settings.disabledExtensions.includes(id);
1990 if (!forceAll && isDisabled) {
1991 console.debug(`Skipping extension: ${manifest.display_name} (${id}) for non-admin user`);
1992 continue;
1993 }
1994 const isGlobal = getExtensionType(id) === 'global';
1995 if (isGlobal && !isCurrentUserAdmin) {
1996 console.debug(`Skipping global extension: ${manifest.display_name} (${id}) for non-admin user`);
1997 continue;
1998 }
1999 if ((forceAll || manifest.auto_update) && id.startsWith('third-party')) {
2000 console.debug(`Auto-updating 3rd-party extension: ${manifest.display_name} (${id})`);
2001 promises.push(updateExtension(id.replace('third-party', ''), true, autoUpdateTimeout));
2002 }
2003 }
2004 await Promise.allSettled(promises);
2005 toastr.clear(banner);
2006}
2007
2008/**
2009 * Runs the generate interceptors for all extensions.
2010 * @param {any[]} chat Chat array
2011 * @param {number} contextSize Context size
2012 * @param {string} type Generation type
2013 * @returns {Promise<boolean>} True if generation should be aborted
2014 */
2015export async function runGenerationInterceptors(chat, contextSize, type) {
2016 let aborted = false;
2017 let exitImmediately = false;
2018
2019 const abort = (/** @type {boolean} */ immediately) => {
2020 aborted = true;
2021 exitImmediately = immediately;
2022 };
2023
2024 for (const manifest of Object.values(manifests).filter(x => x.generate_interceptor).sort((a, b) => sortManifestsByOrder(a, b))) {
2025 const interceptorKey = manifest.generate_interceptor;
2026 if (typeof globalThis[interceptorKey] === 'function') {
2027 try {
2028 await globalThis[interceptorKey](chat, contextSize, abort, type);
2029 } catch (e) {
2030 console.error(`Failed running interceptor for ${manifest.display_name}`, e);
2031 }
2032 }
2033
2034 if (exitImmediately) {
2035 break;
2036 }
2037 }
2038
2039 return aborted;
2040}
2041
2042/**
2043 * Sentinel value that signals a field should be completely removed (unset)
2044 * from the character card rather than being set to any value. Pass this as
2045 * the `value` argument to {@link writeExtensionField} or
2046 * {@link writeExtensionFieldBulk} to delete the key entirely.
2047 *
2048 * Using `null` as a value will set the field to `null` (the key remains).
2049 * Using this sentinel will delete the key from the character card.
2050 * @type {string}
2051 */
2052export const UNSET_VALUE = '__@@UNSET@@__';
2053
2054/**
2055 * Writes a field to the character's data extensions object.
2056 * @param {number|string} characterId Index in the character array
2057 * @param {string} key Field name
2058 * @param {any} value Field value
2059 * @returns {Promise<void>} When the field is written
2060 */
2061export async function writeExtensionField(characterId, key, value) {
2062 const context = getContext();
2063 const character = context.characters[characterId];
2064 if (!character) {
2065 console.warn('Character not found', characterId);
2066 return;
2067 }
2068 const extensionPath = `data.extensions.${key}`;
2069 const isUnset = value === UNSET_VALUE;
2070
2071 if (isUnset) {
2072 deleteValueByPath(character, extensionPath);
2073 } else {
2074 setValueByPath(character, extensionPath, value);
2075 }
2076
2077 // Process JSON data
2078 if (character.json_data) {
2079 const jsonData = JSON.parse(character.json_data);
2080 if (isUnset) {
2081 deleteValueByPath(jsonData, extensionPath);
2082 } else {
2083 setValueByPath(jsonData, extensionPath, value);
2084 }
2085 character.json_data = JSON.stringify(jsonData);
2086
2087 // Make sure the data doesn't get lost when saving the current character
2088 if (Number(characterId) === Number(context.characterId)) {
2089 $('#character_json_data').val(character.json_data);
2090 }
2091 }
2092
2093 // Save data to the server
2094 const saveDataRequest = {
2095 avatar: character.avatar,
2096 data: {
2097 extensions: {
2098 [key]: value,
2099 },
2100 },
2101 };
2102 const mergeResponse = await fetch('/api/characters/merge-attributes', {
2103 method: 'POST',
2104 headers: getRequestHeaders(),
2105 body: JSON.stringify(saveDataRequest),
2106 });
2107
2108 if (!mergeResponse.ok) {
2109 console.error('Failed to save extension field', mergeResponse.statusText);
2110 }
2111}
2112
2113/**
2114 * @typedef {object} BulkExtensionFieldResult
2115 * @property {string[]} updated Avatar filenames that were successfully updated
2116 * @property {string[]} skipped Avatar filenames skipped (filter didn't match or unreadable)
2117 * @property {string[]} failed Avatar filenames where the update failed
2118 */
2119
2120/**
2121 * Writes (or deletes) an extension field for multiple characters in a single
2122 * bulk request. Unlike {@link writeExtensionField}, this sends one API call
2123 * for all characters, and the server processes them in parallel.
2124 *
2125 * When `value` is {@link UNSET_VALUE} the extension key is **deleted** from
2126 * each matching character card. Passing `null` sets the field to `null`
2127 * (the key is preserved).
2128 *
2129 * @param {string[]|null} avatars Avatar filenames to update. Pass `null` or an
2130 * empty array to target **all** characters in the user's character directory.
2131 * @param {string} key Extension field name (e.g. "greeting_tools")
2132 * @param {any} value Field value, `null` to set null, or
2133 * {@link UNSET_VALUE} to delete the key entirely
2134 * @param {object} [options={}] Optional settings
2135 * @param {string} [options.filterPath] Dot-path filter — the server will only
2136 * update characters where this path is present and not `undefined`;
2137 * `null` still counts as a match. Useful when the frontend has shallow
2138 * character data and cannot pre-filter.
2139 * Defaults to `data.extensions.<key>` when unsetting, so deletion requests
2140 * automatically skip characters where the field is missing/`undefined`.
2141 * @returns {Promise<BulkExtensionFieldResult>} Summary of the bulk operation
2142 */
2143export async function writeExtensionFieldBulk(avatars, key, value, { filterPath } = {}) {
2144 const context = getContext();
2145 const extensionPath = `data.extensions.${key}`;
2146 const isUnset = value === UNSET_VALUE;
2147
2148 // Build the server request
2149 const requestBody = {
2150 avatars: Array.isArray(avatars) && avatars.length > 0 ? avatars : [],
2151 data: {
2152 data: {
2153 extensions: {
2154 [key]: value,
2155 },
2156 },
2157 },
2158 };
2159
2160 // Default filter: when unsetting, only touch characters that have the field
2161 const resolvedFilterPath = filterPath ?? (isUnset ? extensionPath : undefined);
2162 if (resolvedFilterPath) {
2163 requestBody.filter = { path: resolvedFilterPath };
2164 }
2165
2166 const mergeResponse = await fetch('/api/characters/merge-attributes', {
2167 method: 'POST',
2168 headers: getRequestHeaders(),
2169 body: JSON.stringify(requestBody),
2170 });
2171
2172 if (!mergeResponse.ok) {
2173 console.error('Bulk extension field update failed', mergeResponse.statusText);
2174 return { updated: [], skipped: [], failed: [] };
2175 }
2176
2177 /** @type {BulkExtensionFieldResult} */
2178 const result = await mergeResponse.json();
2179
2180 // Sync in-memory character objects for successfully updated characters
2181 const updatedSet = new Set(result.updated);
2182 for (const character of context.characters) {
2183 if (!character || !updatedSet.has(character.avatar)) continue;
2184
2185 if (isUnset) {
2186 deleteValueByPath(character, extensionPath);
2187 } else {
2188 setValueByPath(character, extensionPath, value);
2189 }
2190
2191 // Keep json_data in sync
2192 if (character.json_data) {
2193 const jsonData = JSON.parse(character.json_data);
2194 if (isUnset) {
2195 deleteValueByPath(jsonData, extensionPath);
2196 } else {
2197 setValueByPath(jsonData, extensionPath, value);
2198 }
2199 character.json_data = JSON.stringify(jsonData);
2200 }
2201 }
2202
2203 // If the currently active character was updated, sync the hidden input
2204 if (context.characterId !== undefined) {
2205 const activeChar = context.characters[context.characterId];
2206 if (activeChar && updatedSet.has(activeChar.avatar) && activeChar.json_data) {
2207 $('#character_json_data').val(activeChar.json_data);
2208 }
2209 }
2210
2211 return result;
2212}
2213
2214/**
2215 * Prompts the user to enter the Git URL of the extension to import.
2216 * After obtaining the Git URL, makes a POST request to '/api/extensions/install' to import the extension.
2217 * If the extension is imported successfully, a success message is displayed.
2218 * If the extension import fails, an error message is displayed and the error is logged to the console.
2219 * After successfully importing the extension, the extension settings are reloaded and a 'EXTENSION_SETTINGS_LOADED' event is emitted.
2220 * @param {string} [suggestUrl] Suggested URL to install
2221 * @returns {Promise<void>}
2222 */
2223export async function openThirdPartyExtensionMenu(suggestUrl = '') {
2224 const isCurrentUserAdmin = isAdmin();
2225 const html = await renderTemplateAsync('installExtension', { isCurrentUserAdmin });
2226 const okButton = isCurrentUserAdmin ? t`Install just for me` : t`Install`;
2227
2228 let global = false;
2229 const installForAllButton = {
2230 text: t`Install for all users`,
2231 appendAtEnd: false,
2232 action: async () => {
2233 global = true;
2234 await popup.complete(POPUP_RESULT.AFFIRMATIVE);
2235 },
2236 };
2237 /** @type {import('./popup.js').CustomPopupInput} */
2238 const branchNameInput = {
2239 id: 'extension_branch_name',
2240 label: t`Branch or tag name (optional)`,
2241 type: 'text',
2242 tooltip: 'e.g. main, dev, v1.0.0',
2243 };
2244
2245 const customButtons = isCurrentUserAdmin ? [installForAllButton] : [];
2246 const customInputs = [branchNameInput];
2247 const popup = new Popup(html, POPUP_TYPE.INPUT, suggestUrl ?? '', { okButton, customButtons, customInputs });
2248 const input = await popup.show();
2249
2250 if (!input) {
2251 console.debug('Extension install cancelled');
2252 return;
2253 }
2254
2255 const url = String(input).trim();
2256 const branchName = String(popup.inputResults.get('extension_branch_name') ?? '').trim();
2257 await installExtension(url, global, branchName);
2258}
2259
2260/**
2261 * Sentinel value representing an empty author, used when author information cannot be extracted from a URL.
2262 * @type {{name: string, url: string}}
2263 */
2264export const EMPTY_AUTHOR = Object.freeze({
2265 name: '',
2266 url: '',
2267});
2268
2269/**
2270 * Extracts the repository author from a given URL.
2271 * @param {string} url - The URL of the repository.
2272 * @returns {{name: string, url: string}} Object containing the author's name and URL, or empty strings if not found.
2273 */
2274export function getAuthorFromUrl(url) {
2275 const result = structuredClone(EMPTY_AUTHOR);
2276
2277 try {
2278 const parsedUrl = new URL(url);
2279 const pathSegments = parsedUrl.pathname.split('/').filter(s => s.length > 0);
2280
2281 // TODO: Handle non-GitHub URLs if needed
2282 if (parsedUrl.host === 'github.com' && pathSegments.length >= 2) {
2283 result.name = pathSegments[0];
2284 result.url = `${parsedUrl.protocol}//${parsedUrl.hostname}/${result.name}`;
2285 }
2286 } catch (error) {
2287 console.debug('Error parsing URL:', error);
2288 }
2289
2290 return result;
2291}
2292
2293export async function initExtensions() {
2294 await addExtensionsButtonAndMenu();
2295 $('#extensionsMenuButton').css('display', 'flex');
2296
2297 $('#extensions_connect').on('click', connectClickHandler);
2298 $('#extensions_autoconnect').on('input', autoConnectInputHandler);
2299 $('#extensions_details').on('click', showExtensionsDetails);
2300 $('#extensions_notify_updates').on('input', notifyUpdatesInputHandler);
2301 $(document).on('click', '.extensions_info .extension_block .toggle_disable', onDisableExtensionClick);
2302 $(document).on('click', '.extensions_info .extension_block .toggle_enable', onEnableExtensionClick);
2303 $(document).on('click', '.extensions_info .extension_block .btn_update', onUpdateClick);
2304 $(document).on('click', '.extensions_info .extension_block .btn_delete', onDeleteClick);
2305 $(document).on('click', '.extensions_info .extension_block .btn_clean', onCleanClick);
2306 $(document).on('click', '.extensions_info .extension_block .btn_move', onMoveClick);
2307 $(document).on('click', '.extensions_info .extension_block .btn_branch', onBranchClick);
2308
2309 /**
2310 * Handles the click event for the third-party extension import button.
2311 *
2312 * @listens #third_party_extension_button#click - The click event of the '#third_party_extension_button' element.
2313 */
2314 $('#third_party_extension_button').on('click', () => openThirdPartyExtensionMenu());
2315}