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>
Signed| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { DOMPurify, Popper } from '../lib.js'; |
| 2 | 2 | |
| 3 | 3 | import { eventSource, event_types, saveSettings, saveSettingsDebounced, getRequestHeaders, animation_duration, CLIENT_VERSION } from '../script.js'; |
| 4 | 4 | import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js'; |
| 5 | 5 | import { renderTemplate, renderTemplateAsync } from './templates.js'; |
| 6 | 6 | import { delay, equalsIgnoreCaseAndAccents, isSubsetOf, sanitizeSelector, setValueByPath, versionCompare } from './utils.js'; |
| 7 | 7 | import { getContext } from './st-context.js'; |
| @@ -355,11 +355,27 @@ function onToggleAllExtensions(extensionsToToggle, toggleContainer) { | ||
| 355 | 355 | } |
| 356 | 356 | |
| 357 | 357 | /** |
| 358 | + * Checks whether an extension has a specific hook defined in its manifest. | |
| 359 | + * @param {string} name Extension name (with or without 'third-party' prefix) | |
| 360 | + * @param {'install' | 'update' | 'delete' | 'clean' | 'enable' | 'disable' | 'activate'} hookName The hook to check | |
| 361 | + * @returns {boolean} | |
| 362 | + */ | |
| 363 | +function hasExtensionHook(name, hookName) { | |
| 364 | + const fullName = name.startsWith('third-party') ? name : `third-party${name}`; | |
| 365 | + const manifest = manifests[fullName]; | |
| 366 | + if (!manifest || !manifest.hooks || typeof manifest.hooks !== 'object') { | |
| 367 | + return false; | |
| 368 | + } | |
| 369 | + const hookFunctionName = manifest.hooks[hookName]; | |
| 370 | + return typeof hookFunctionName === 'string' && hookFunctionName.length > 0; | |
| 371 | +} | |
| 372 | + | |
| 373 | +/** | |
| 358 | 374 | * Calls a manifest hook for an extension. |
| 359 | 375 | * Hooks are optional function names exported from the extension's JS entry point module. |
| 360 | 376 | * The hook function can optionally return a Promise that will be awaited. |
| 361 | 377 | * @param {string} name Extension name |
| 362 | 378 | * @param {'install' | 'update' | 'delete' | 'clean' | 'enable' | 'disable' | 'activate'} hookName The hook to call |
| 363 | 379 | * @returns {Promise<void>} |
| 364 | 380 | */ |
| 365 | 381 | async function callExtensionHook(name, hookName) { |
| @@ -879,6 +895,7 @@ function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal, | ||
| 879 | 895 | 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>` : ''; |
| 880 | 896 | 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>` : ''; |
| 881 | 897 | 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>` : ''; |
| 898 | + 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>` : ''; | |
| 882 | 899 | let modulesInfo = ''; |
| 883 | 900 | |
| 884 | 901 | if (isActive && Array.isArray(manifest.optional)) { |
| @@ -919,6 +936,7 @@ function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal, | ||
| 919 | 936 | |
| 920 | 937 | <div class="extension_actions flex-container alignItemsCenter"> |
| 921 | 938 | ${updateButton} |
| 939 | + ${cleanButton} | |
| 922 | 940 | ${branchButton} |
| 923 | 941 | ${moveButton} |
| 924 | 942 | ${deleteButton} |
| @@ -1249,6 +1267,7 @@ async function updateExtension(extensionName, quiet, timeout = null) { | ||
| 1249 | 1267 | * This function makes a POST request to '/api/extensions/delete' with the extension's name. |
| 1250 | 1268 | * If the extension is deleted, it displays a success message. |
| 1251 | 1269 | * Creates a popup for the user to confirm before delete. |
| 1270 | + * If the extension has a 'clean' hook, an optional checkbox to also run the cleanup is shown. | |
| 1252 | 1271 | */ |
| 1253 | 1272 | async function onDeleteClick() { |
| 1254 | 1273 | const extensionName = $(this).data('name'); |
| @@ -1259,11 +1278,48 @@ async function onDeleteClick() { | ||
| 1259 | 1278 | return; |
| 1260 | 1279 | } |
| 1261 | 1280 | |
| 1262 | - // use callPopup to create a popup for the user to confirm before delete | |
| 1281 | + const hasCleanHook = hasExtensionHook(extensionName, 'clean'); | |
| 1263 | - const confirmation = await callGenericPopup(t`Are you sure you want to delete ${extensionName}?`, POPUP_TYPE.CONFIRM, '', {}); | |
| 1282 | + | |
| 1283 | + /** @type {import('./popup.js').CustomPopupInput[]} */ | |
| 1284 | + const customInputs = hasCleanHook ? [{ id: 'extension_delete_cleanup', label: t`Also clean up extension data`, defaultState: false }] : null; | |
| 1285 | + | |
| 1286 | + const popup = new Popup(t`Are you sure you want to delete ${extensionName}?`, POPUP_TYPE.CONFIRM, '', { customInputs }); | |
| 1287 | + const confirmation = await popup.show(); | |
| 1264 | 1288 | if (confirmation === POPUP_RESULT.AFFIRMATIVE) { |
| 1265 | - await deleteExtension(extensionName); | |
| 1289 | + const shouldClean = hasCleanHook && Boolean(popup.inputResults?.get('extension_delete_cleanup')); | |
| 1290 | + await deleteExtension(extensionName, shouldClean); | |
| 1291 | + } | |
| 1292 | +} | |
| 1293 | + | |
| 1294 | +/** | |
| 1295 | + * Handles the click event for the clean button of an extension. | |
| 1296 | + * Runs the extension's 'clean' hook after user confirmation, then reloads the page. | |
| 1297 | + */ | |
| 1298 | +async function onCleanClick() { | |
| 1299 | + const extensionName = $(this).data('name'); | |
| 1300 | + | |
| 1301 | + 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.`); | |
| 1302 | + if (!confirmation) { | |
| 1303 | + return; | |
| 1266 | 1304 | } |
| 1305 | + | |
| 1306 | + await cleanExtension(extensionName); | |
| 1307 | +} | |
| 1308 | + | |
| 1309 | +/** | |
| 1310 | + * Runs the 'clean' hook for an extension and reloads the page. | |
| 1311 | + * @param {string} extensionName Extension name (without 'third-party' prefix) | |
| 1312 | + * @returns {Promise<void>} | |
| 1313 | + */ | |
| 1314 | +async function cleanExtension(extensionName) { | |
| 1315 | + const fullExtensionName = extensionName.startsWith('third-party') ? extensionName : `third-party${extensionName}`; | |
| 1316 | + await callExtensionHook(fullExtensionName, 'clean'); | |
| 1317 | + | |
| 1318 | + // Clean might have updated settings, which could race with the page reload, so we'll force save here | |
| 1319 | + await saveSettings(); | |
| 1320 | + | |
| 1321 | + toastr.success(t`Extension ${extensionName} data cleaned`); | |
| 1322 | + delay(1000).then(() => location.reload()); | |
| 1267 | 1323 | } |
| 1268 | 1324 | |
| 1269 | 1325 | async function onBranchClick() { |
| @@ -1368,9 +1424,16 @@ async function moveExtension(extensionName, source, destination) { | ||
| 1368 | 1424 | /** |
| 1369 | 1425 | * Deletes an extension via the API. |
| 1370 | 1426 | * @param {string} extensionName Extension name to delete |
| 1427 | + * @param {boolean} [shouldClean=false] Whether to also run the 'clean' hook before deleting | |
| 1371 | 1428 | */ |
| 1372 | 1429 | export async function deleteExtension(extensionName, shouldClean = false) { |
| 1373 | - await callExtensionHook(extensionName, 'delete'); | |
| 1430 | + const fullExtensionName = extensionName.startsWith('third-party') ? extensionName : `third-party${extensionName}`; | |
| 1431 | + | |
| 1432 | + if (shouldClean) { | |
| 1433 | + await callExtensionHook(fullExtensionName, 'clean'); | |
| 1434 | + } | |
| 1435 | + | |
| 1436 | + await callExtensionHook(fullExtensionName, 'delete'); | |
| 1374 | 1437 | |
| 1375 | 1438 | try { |
| 1376 | 1439 | await fetch('/api/extensions/delete', { |
| @@ -1385,6 +1448,9 @@ export async function deleteExtension(extensionName) { | ||
| 1385 | 1448 | console.error('Error:', error); |
| 1386 | 1449 | } |
| 1387 | 1450 | |
| 1451 | + // Delete or clean might have updated settings, which could race with the page reload, so we'll force save here | |
| 1452 | + await saveSettings(); | |
| 1453 | + | |
| 1388 | 1454 | toastr.success(t`Extension ${extensionName} deleted`); |
| 1389 | 1455 | delay(1000).then(() => location.reload()); |
| 1390 | 1456 | } |
| @@ -1880,6 +1946,7 @@ export async function initExtensions() { | ||
| 1880 | 1946 | $(document).on('click', '.extensions_info .extension_block .toggle_enable', onEnableExtensionClick); |
| 1881 | 1947 | $(document).on('click', '.extensions_info .extension_block .btn_update', onUpdateClick); |
| 1882 | 1948 | $(document).on('click', '.extensions_info .extension_block .btn_delete', onDeleteClick); |
| 1949 | + $(document).on('click', '.extensions_info .extension_block .btn_clean', onCleanClick); | |
| 1883 | 1950 | $(document).on('click', '.extensions_info .extension_block .btn_move', onMoveClick); |
| 1884 | 1951 | $(document).on('click', '.extensions_info .extension_block .btn_branch', onBranchClick); |
| 1885 | 1952 | |