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>
This commit is contained in:
Wolfsblvt
2026-04-13 23:18:43 +02:00
committed by GitHub
parent 1a561f3bb0
commit 737cb95adb
+74 -7
View File
@@ -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<void>}
*/
async function callExtensionHook(name, hookName) {
@@ -879,6 +895,7 @@ function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal,
let updateButton = isExternal ? `<button class="btn_update menu_button displayNone" data-name="${externalId}" title="Update available"><i class="fa-solid fa-download fa-fw"></i></button>` : '';
let moveButton = isExternal && isUserAdmin ? `<button class="btn_move menu_button" data-name="${externalId}" data-i18n="[title]Move" title="Move"><i class="fa-solid fa-folder-tree fa-fw"></i></button>` : '';
let branchButton = isExternal && isUserAdmin ? `<button class="btn_branch menu_button" data-name="${externalId}" data-i18n="[title]Switch branch" title="Switch branch"><i class="fa-solid fa-code-branch fa-fw"></i></button>` : '';
let cleanButton = isExternal && hasExtensionHook(externalId, 'clean') ? `<button class="btn_clean menu_button" data-name="${externalId}" data-i18n="[title]Clean extension data" title="Clean extension data"><i class="fa-fw fa-solid fa-broom"></i></button>` : '';
let modulesInfo = '';
if (isActive && Array.isArray(manifest.optional)) {
@@ -919,6 +936,7 @@ function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal,
<div class="extension_actions flex-container alignItemsCenter">
${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<void>}
*/
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);