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:
@@ -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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user