From 737cb95adb3211db4052d4a88e90bb67021f17c6 Mon Sep 17 00:00:00 2001 From: Wolfsblvt Date: Mon, 13 Apr 2026 23:18:43 +0200 Subject: [PATCH] Add `clean` extension lifecycle hook for optional data cleanup (#5449) * Add 'clean' extension hook support with optional cleanup on delete - Add hasExtensionHook() helper to check if an extension defines a specific manifest hook - Add 'clean' hook type to callExtensionHook() JSDoc - Add clean button to extension UI when 'clean' hook is present - Add onCleanClick() handler to run clean hook with confirmation - Add cleanExtension() function to execute clean hook and reload page - Modify deleteExtension() to optionally run clean hook before deletion - Show cleanup checkbox on extension delete popup * fix lint Remove unused `callGenericPopup` import from extensions.js * Force save settings before page reload in extension clean and delete operations Add explicit saveSettings() calls in cleanExtension() and deleteExtension() to prevent race conditions where clean/delete hooks might update settings that get lost during the subsequent page reload. * Remove admin permission check from extension clean operation The clean hook is extension-defined and may not require admin privileges. Permission checks should be handled by the extension's clean hook implementation if needed, rather than enforcing a blanket restriction at the UI level. * fix: show clean button for built-ins * Update hasExtensionHook to support built-in extensions * Revert "Update hasExtensionHook to support built-in extensions" This reverts commit 31be55ea66430ffe6a8d149d519b3d6d149da9ea. * Revert "fix: show clean button for built-ins" This reverts commit 5f86fec70c2b7d5cd99e4dee7f14af5a3372d58f. --------- Co-authored-by: Cohee <18619528+Cohee1207@users.noreply.github.com> --- public/scripts/extensions.js | 81 ++++++++++++++++++++++++++++++++---- 1 file changed, 74 insertions(+), 7 deletions(-) diff --git a/public/scripts/extensions.js b/public/scripts/extensions.js index 3b19f0636..54f58597e 100644 --- a/public/scripts/extensions.js +++ b/public/scripts/extensions.js @@ -1,7 +1,7 @@ import { DOMPurify, Popper } from '../lib.js'; import { eventSource, event_types, saveSettings, saveSettingsDebounced, getRequestHeaders, animation_duration, CLIENT_VERSION } from '../script.js'; -import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js'; +import { POPUP_RESULT, POPUP_TYPE, Popup } from './popup.js'; import { renderTemplate, renderTemplateAsync } from './templates.js'; import { delay, equalsIgnoreCaseAndAccents, isSubsetOf, sanitizeSelector, setValueByPath, versionCompare } from './utils.js'; import { getContext } from './st-context.js'; @@ -354,12 +354,28 @@ function onToggleAllExtensions(extensionsToToggle, toggleContainer) { return extensionsToToggle; } +/** + * Checks whether an extension has a specific hook defined in its manifest. + * @param {string} name Extension name (with or without 'third-party' prefix) + * @param {'install' | 'update' | 'delete' | 'clean' | 'enable' | 'disable' | 'activate'} hookName The hook to check + * @returns {boolean} + */ +function hasExtensionHook(name, hookName) { + const fullName = name.startsWith('third-party') ? name : `third-party${name}`; + const manifest = manifests[fullName]; + if (!manifest || !manifest.hooks || typeof manifest.hooks !== 'object') { + return false; + } + const hookFunctionName = manifest.hooks[hookName]; + return typeof hookFunctionName === 'string' && hookFunctionName.length > 0; +} + /** * Calls a manifest hook for an extension. * Hooks are optional function names exported from the extension's JS entry point module. * The hook function can optionally return a Promise that will be awaited. * @param {string} name Extension name - * @param {'install' | 'update' | 'delete' | 'enable' | 'disable' | 'activate'} hookName The hook to call + * @param {'install' | 'update' | 'delete' | 'clean' | 'enable' | 'disable' | 'activate'} hookName The hook to call * @returns {Promise} */ async function callExtensionHook(name, hookName) { @@ -879,6 +895,7 @@ function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal, let updateButton = isExternal ? `` : ''; let moveButton = isExternal && isUserAdmin ? `` : ''; let branchButton = isExternal && isUserAdmin ? `` : ''; + let cleanButton = isExternal && hasExtensionHook(externalId, 'clean') ? `` : ''; let modulesInfo = ''; if (isActive && Array.isArray(manifest.optional)) { @@ -919,6 +936,7 @@ function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal,
${updateButton} + ${cleanButton} ${branchButton} ${moveButton} ${deleteButton} @@ -1249,6 +1267,7 @@ async function updateExtension(extensionName, quiet, timeout = null) { * This function makes a POST request to '/api/extensions/delete' with the extension's name. * If the extension is deleted, it displays a success message. * Creates a popup for the user to confirm before delete. + * If the extension has a 'clean' hook, an optional checkbox to also run the cleanup is shown. */ async function onDeleteClick() { const extensionName = $(this).data('name'); @@ -1259,13 +1278,50 @@ async function onDeleteClick() { return; } - // use callPopup to create a popup for the user to confirm before delete - const confirmation = await callGenericPopup(t`Are you sure you want to delete ${extensionName}?`, POPUP_TYPE.CONFIRM, '', {}); + const hasCleanHook = hasExtensionHook(extensionName, 'clean'); + + /** @type {import('./popup.js').CustomPopupInput[]} */ + const customInputs = hasCleanHook ? [{ id: 'extension_delete_cleanup', label: t`Also clean up extension data`, defaultState: false }] : null; + + const popup = new Popup(t`Are you sure you want to delete ${extensionName}?`, POPUP_TYPE.CONFIRM, '', { customInputs }); + const confirmation = await popup.show(); if (confirmation === POPUP_RESULT.AFFIRMATIVE) { - await deleteExtension(extensionName); + const shouldClean = hasCleanHook && Boolean(popup.inputResults?.get('extension_delete_cleanup')); + await deleteExtension(extensionName, shouldClean); } } +/** + * Handles the click event for the clean button of an extension. + * Runs the extension's 'clean' hook after user confirmation, then reloads the page. + */ +async function onCleanClick() { + const extensionName = $(this).data('name'); + + const confirmation = await Popup.show.confirm(t`Clean extension data`, t`Are you sure you want to clean up data for ${extensionName}? This action cannot be undone.`); + if (!confirmation) { + return; + } + + await cleanExtension(extensionName); +} + +/** + * Runs the 'clean' hook for an extension and reloads the page. + * @param {string} extensionName Extension name (without 'third-party' prefix) + * @returns {Promise} + */ +async function cleanExtension(extensionName) { + const fullExtensionName = extensionName.startsWith('third-party') ? extensionName : `third-party${extensionName}`; + await callExtensionHook(fullExtensionName, 'clean'); + + // Clean might have updated settings, which could race with the page reload, so we'll force save here + await saveSettings(); + + toastr.success(t`Extension ${extensionName} data cleaned`); + delay(1000).then(() => location.reload()); +} + async function onBranchClick() { const extensionName = $(this).data('name'); const isCurrentUserAdmin = isAdmin(); @@ -1368,9 +1424,16 @@ async function moveExtension(extensionName, source, destination) { /** * Deletes an extension via the API. * @param {string} extensionName Extension name to delete + * @param {boolean} [shouldClean=false] Whether to also run the 'clean' hook before deleting */ -export async function deleteExtension(extensionName) { - await callExtensionHook(extensionName, 'delete'); +export async function deleteExtension(extensionName, shouldClean = false) { + const fullExtensionName = extensionName.startsWith('third-party') ? extensionName : `third-party${extensionName}`; + + if (shouldClean) { + await callExtensionHook(fullExtensionName, 'clean'); + } + + await callExtensionHook(fullExtensionName, 'delete'); try { await fetch('/api/extensions/delete', { @@ -1385,6 +1448,9 @@ export async function deleteExtension(extensionName) { console.error('Error:', error); } + // Delete or clean might have updated settings, which could race with the page reload, so we'll force save here + await saveSettings(); + toastr.success(t`Extension ${extensionName} deleted`); delay(1000).then(() => location.reload()); } @@ -1880,6 +1946,7 @@ export async function initExtensions() { $(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_clean', onCleanClick); $(document).on('click', '.extensions_info .extension_block .btn_move', onMoveClick); $(document).on('click', '.extensions_info .extension_block .btn_branch', onBranchClick);