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>

737cb95adb3211db4052d4a88e90bb67021f17c6

Wolfsblvt <wolfsblvt@gmail.com>

Signed
1 files changed, +74 -7Showing whitespace changes
public/scripts/extensions.js+74 -7
@@ -1,7 +1,7 @@
1import { DOMPurify, Popper } from '../lib.js';1import { DOMPurify, Popper } from '../lib.js';
22
3import { eventSource, event_types, saveSettings, saveSettingsDebounced, getRequestHeaders, animation_duration, CLIENT_VERSION } from '../script.js';3import { eventSource, event_types, saveSettings, saveSettingsDebounced, getRequestHeaders, animation_duration, CLIENT_VERSION } from '../script.js';
4import { POPUP_RESULT, POPUP_TYPE, Popup, callGenericPopup } from './popup.js';4import { POPUP_RESULT, POPUP_TYPE, Popup } from './popup.js';
5import { renderTemplate, renderTemplateAsync } from './templates.js';5import { renderTemplate, renderTemplateAsync } from './templates.js';
6import { delay, equalsIgnoreCaseAndAccents, isSubsetOf, sanitizeSelector, setValueByPath, versionCompare } from './utils.js';6import { delay, equalsIgnoreCaseAndAccents, isSubsetOf, sanitizeSelector, setValueByPath, versionCompare } from './utils.js';
7import { getContext } from './st-context.js';7import { getContext } from './st-context.js';
@@ -355,11 +355,27 @@ function onToggleAllExtensions(extensionsToToggle, toggleContainer) {
355}355}
356356
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 */
363function 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 name377 * @param {string} name Extension name
362 * @param {'install' | 'update' | 'delete' | 'enable' | 'disable' | 'activate'} hookName The hook to call378 * @param {'install' | 'update' | 'delete' | 'clean' | 'enable' | 'disable' | 'activate'} hookName The hook to call
363 * @returns {Promise<void>}379 * @returns {Promise<void>}
364 */380 */
365async function callExtensionHook(name, hookName) {381async 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 = '';
883900
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,
919936
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 */
1253async function onDeleteClick() {1272async function onDeleteClick() {
1254 const extensionName = $(this).data('name');1273 const extensionName = $(this).data('name');
@@ -1259,13 +1278,50 @@ async function onDeleteClick() {
1259 return;1278 return;
1260 }1279 }
12611280
1262 // use callPopup to create a popup for the user to confirm before delete1281 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);
1266 }1291 }
1267}1292}
12681293
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 */
1298async 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;
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 */
1314async 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());
1323}
1324
1269async function onBranchClick() {1325async function onBranchClick() {
1270 const extensionName = $(this).data('name');1326 const extensionName = $(this).data('name');
1271 const isCurrentUserAdmin = isAdmin();1327 const isCurrentUserAdmin = isAdmin();
@@ -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 delete1426 * @param {string} extensionName Extension name to delete
1427 * @param {boolean} [shouldClean=false] Whether to also run the 'clean' hook before deleting
1371 */1428 */
1372export async function deleteExtension(extensionName) {1429export 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');
13741437
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 }
13871450
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);
18851952