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 | import { DOMPurify, Popper } from '../lib.js'; | 1 | import { DOMPurify, Popper } from '../lib.js'; |
| 2 | 2 | ||
| 3 | import { eventSource, event_types, saveSettings, saveSettingsDebounced, getRequestHeaders, animation_duration, CLIENT_VERSION } from '../script.js'; | 3 | import { eventSource, event_types, saveSettings, saveSettingsDebounced, getRequestHeaders, animation_duration, CLIENT_VERSION } from '../script.js'; |
| 4 | import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js'; | 4 | import { POPUP_RESULT, POPUP_TYPE, Popup } from './popup.js'; |
| 5 | import { renderTemplate, renderTemplateAsync } from './templates.js'; | 5 | import { renderTemplate, renderTemplateAsync } from './templates.js'; |
| 6 | import { delay, equalsIgnoreCaseAndAccents, isSubsetOf, sanitizeSelector, setValueByPath, versionCompare } from './utils.js'; | 6 | import { delay, equalsIgnoreCaseAndAccents, isSubsetOf, sanitizeSelector, setValueByPath, versionCompare } from './utils.js'; |
| 7 | import { getContext } from './st-context.js'; | 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 | * Calls a manifest hook for an extension. | 374 | * Calls a manifest hook for an extension. |
| 359 | * Hooks are optional function names exported from the extension's JS entry point module. | 375 | * Hooks are optional function names exported from the extension's JS entry point module. |
| 360 | * The hook function can optionally return a Promise that will be awaited. | 376 | * The hook function can optionally return a Promise that will be awaited. |
| 361 | * @param {string} name Extension name | 377 | * @param {string} name Extension name |
| 362 | * @param {'install' | 'update' | 'delete' | 'enable' | 'disable' | 'activate'} hookName The hook to call | 378 | * @param {'install' | 'update' | 'delete' | 'clean' | 'enable' | 'disable' | 'activate'} hookName The hook to call |
| 363 | * @returns {Promise<void>} | 379 | * @returns {Promise<void>} |
| 364 | */ | 380 | */ |
| 365 | async function callExtensionHook(name, hookName) { | 381 | async function callExtensionHook(name, hookName) { |
| @@ -879,6 +895,7 @@ function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal, | |||
| 879 | 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>` : ''; | 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 | 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>` : ''; | 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 | 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>` : ''; | 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 | let modulesInfo = ''; | 899 | let modulesInfo = ''; |
| 883 | 900 | ||
| 884 | if (isActive && Array.isArray(manifest.optional)) { | 901 | if (isActive && Array.isArray(manifest.optional)) { |
| @@ -919,6 +936,7 @@ function generateExtensionHtml(name, manifest, isActive, isDisabled, isExternal, | |||
| 919 | 936 | ||
| 920 | <div class="extension_actions flex-container alignItemsCenter"> | 937 | <div class="extension_actions flex-container alignItemsCenter"> |
| 921 | ${updateButton} | 938 | ${updateButton} |
| 939 | ${cleanButton} | ||
| 922 | ${branchButton} | 940 | ${branchButton} |
| 923 | ${moveButton} | 941 | ${moveButton} |
| 924 | ${deleteButton} | 942 | ${deleteButton} |
| @@ -1249,6 +1267,7 @@ async function updateExtension(extensionName, quiet, timeout = null) { | |||
| 1249 | * This function makes a POST request to '/api/extensions/delete' with the extension's name. | 1267 | * This function makes a POST request to '/api/extensions/delete' with the extension's name. |
| 1250 | * If the extension is deleted, it displays a success message. | 1268 | * If the extension is deleted, it displays a success message. |
| 1251 | * Creates a popup for the user to confirm before delete. | 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 | async function onDeleteClick() { | 1272 | async function onDeleteClick() { |
| 1254 | const extensionName = $(this).data('name'); | 1273 | const extensionName = $(this).data('name'); |
| @@ -1259,11 +1278,48 @@ async function onDeleteClick() { | |||
| 1259 | return; | 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 | if (confirmation === POPUP_RESULT.AFFIRMATIVE) { | 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 | async function onBranchClick() { | 1325 | async function onBranchClick() { |
| @@ -1368,9 +1424,16 @@ async function moveExtension(extensionName, source, destination) { | |||
| 1368 | /** | 1424 | /** |
| 1369 | * Deletes an extension via the API. | 1425 | * Deletes an extension via the API. |
| 1370 | * @param {string} extensionName Extension name to delete | 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 | export async function deleteExtension(extensionName) { | 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 | try { | 1438 | try { |
| 1376 | await fetch('/api/extensions/delete', { | 1439 | await fetch('/api/extensions/delete', { |
| @@ -1385,6 +1448,9 @@ export async function deleteExtension(extensionName) { | |||
| 1385 | console.error('Error:', error); | 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 | toastr.success(t`Extension ${extensionName} deleted`); | 1454 | toastr.success(t`Extension ${extensionName} deleted`); |
| 1389 | delay(1000).then(() => location.reload()); | 1455 | delay(1000).then(() => location.reload()); |
| 1390 | } | 1456 | } |
| @@ -1880,6 +1946,7 @@ export async function initExtensions() { | |||
| 1880 | $(document).on('click', '.extensions_info .extension_block .toggle_enable', onEnableExtensionClick); | 1946 | $(document).on('click', '.extensions_info .extension_block .toggle_enable', onEnableExtensionClick); |
| 1881 | $(document).on('click', '.extensions_info .extension_block .btn_update', onUpdateClick); | 1947 | $(document).on('click', '.extensions_info .extension_block .btn_update', onUpdateClick); |
| 1882 | $(document).on('click', '.extensions_info .extension_block .btn_delete', onDeleteClick); | 1948 | $(document).on('click', '.extensions_info .extension_block .btn_delete', onDeleteClick); |
| 1949 | $(document).on('click', '.extensions_info .extension_block .btn_clean', onCleanClick); | ||
| 1883 | $(document).on('click', '.extensions_info .extension_block .btn_move', onMoveClick); | 1950 | $(document).on('click', '.extensions_info .extension_block .btn_move', onMoveClick); |
| 1884 | $(document).on('click', '.extensions_info .extension_block .btn_branch', onBranchClick); | 1951 | $(document).on('click', '.extensions_info .extension_block .btn_branch', onBranchClick); |
| 1885 | 1952 | ||