diff --git a/tests/browser/workflows.spec.mjs b/tests/browser/workflows.spec.mjs index 633c0a00..a3007d54 100644 --- a/tests/browser/workflows.spec.mjs +++ b/tests/browser/workflows.spec.mjs @@ -99,6 +99,34 @@ for (const leave of ['beforeunload', 'pagehide']) { }); } +// #222: the gallery's Clear cache clears the server's thumbnails by its own request and keeps this browser's +// preferences and other tools' storage; nothing of Prompt Manager's is cached in a browser, and the toast says so. +test('the gallery cache clear keeps preferences and other tools caches', async ({ page }) => { + await page.addInitScript(() => { + if (sessionStorage.getItem('seeded')) return; + sessionStorage.setItem('seeded', '1'); + localStorage.setItem('pm_workspace_theme', 'light'); + localStorage.setItem('other-tool-setting', 'keep'); + localStorage.setItem('pm_other_tool', 'keep'); + }); + await page.goto('/prompt_manager/gallery'); + await expect(page.locator('#galleryGrid .image-item')).toHaveCount(2); + await page.evaluate(async () => { await (await caches.open('other-tool-gallery-thumbnails')).put('/x', new Response('x')); }); + const clears = []; + page.on('request', req => { if (req.url().includes('/clear-thumbnails')) clears.push(req.method()); }); + page.once('dialog', dialog => dialog.accept()); + await page.locator('#settingsBtn').click(); + await page.locator('#clearCacheBtn').click(); + await expect(page.getByText("Thumbnails cleared; original images untouched. Nothing of Prompt Manager's is cached in this browser.")) + .toBeVisible(); + expect(clears).toEqual(['POST']); + expect(await page.evaluate(async () => ({ + theme: localStorage.getItem('pm_workspace_theme'), other: localStorage.getItem('other-tool-setting'), + prefixed: localStorage.getItem('pm_other_tool'), notice: localStorage.getItem('pm_update_notice_dismissed'), + caches: await caches.keys(), + }))).toEqual({ theme: 'light', other: 'keep', prefixed: 'keep', notice: '3.2.0', caches: ['other-tool-gallery-thumbnails'] }); +}); + test('actual gallery review generates and persists tags for the selected root', async ({ page, request }) => { await page.goto('/prompt_manager/gallery'); await expect(page.locator('#galleryGrid .image-item')).toHaveCount(2); diff --git a/tests/js/cache-ownership.test.js b/tests/js/cache-ownership.test.js new file mode 100644 index 00000000..b70fac5f --- /dev/null +++ b/tests/js/cache-ownership.test.js @@ -0,0 +1,154 @@ +// #222: what "clear the cache" may remove from this browser. Only entries Prompt Manager itself writes as a cache count, +// from an inventory of the real writers (never a guessed prefix): preferences (the workspace theme, the update notice, +// the display defaults) and other tools' storage and caches on the shared ComfyUI origin always stay. +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const CacheOwnership = require('../../web/js/cache-ownership.js'); + +const WEB = path.join(__dirname, '../../web'); + +function memoryStorage(entries) { + const map = new Map(Object.entries(entries)); + return { + map, + get length() { return map.size; }, + key: index => [...map.keys()][index] ?? null, + getItem: key => (map.has(key) ? map.get(key) : null), + setItem: (key, value) => map.set(key, String(value)), + removeItem: key => map.delete(key), + }; +} + +function memoryCaches(names, { failDelete = new Set() } = {}) { + const set = new Set(names); + return { + set, + keys: async () => [...set], + delete: async name => { + if (failDelete.has(name)) throw new Error('denied'); + return set.delete(name); + }, + }; +} + +const PREFERENCES = { gallerySettings: '{"viewMode":"grid"}', pm_workspace_theme: 'light', pm_update_notice_dismissed: '3.3.0' }; +const OWNED = Object.freeze({ storageKeys: ['pm-owned-test-entry'], cacheNames: ['pm-owned-test-cache'] }); + +test('keeps pm_workspace_theme, pm_update_notice_dismissed and gallerySettings', async () => { + const storage = memoryStorage({ ...PREFERENCES, 'pm-owned-test-entry': 'x', pm_cached: '1', promptManager_x: '1' }); + const result = await CacheOwnership.clearOwned({ storage, cacheStorage: null, owned: OWNED }); + assert.deepEqual(result.removed, ['pm-owned-test-entry']); + for (const [key, value] of Object.entries(PREFERENCES)) assert.equal(storage.getItem(key), value, key); + // The old prefix rule's matches are not Prompt Manager caches either: nothing writes them. + assert.equal(storage.getItem('pm_cached'), '1'); + assert.equal(storage.getItem('promptManager_x'), '1'); + for (const key of Object.keys(PREFERENCES)) assert.ok(CacheOwnership.PREFERENCE_KEYS.includes(key), key); +}); + +test("keeps other tools' CacheStorage named gallery/thumbnail", async () => { + const caches = memoryCaches(['other-gallery', 'thumbnail-cache', 'my-gallery-thumbnails', 'pm-owned-test-cache']); + const result = await CacheOwnership.clearOwned({ storage: memoryStorage({}), cacheStorage: caches, owned: OWNED }); + assert.deepEqual([...caches.set].sort(), ['my-gallery-thumbnails', 'other-gallery', 'thumbnail-cache']); + assert.deepEqual(result.removed, ['pm-owned-test-cache']); +}); + +test('removes only inventoried owned keys', async () => { + // The shipped inventory: no Prompt Manager writer stores a cache entry in this browser. + assert.deepEqual(CacheOwnership.OWNED, { storageKeys: [], cacheNames: [] }); + assert.ok(Object.isFrozen(CacheOwnership.OWNED.storageKeys) && Object.isFrozen(CacheOwnership.OWNED.cacheNames)); + const storage = memoryStorage({ ...PREFERENCES, gallery_cache_1: 'a', metadata_cache_2: 'b', other: 'c' }); + const caches = memoryCaches(['gallery', 'thumbnail']); + const result = await CacheOwnership.clearOwned({ storage, cacheStorage: caches }); + assert.deepEqual(result, { removed: [], failed: [], denied: false, partial: false }); + assert.equal(storage.map.size, 6); + assert.equal(caches.set.size, 2); + assert.equal(CacheOwnership.clearMessage(result), CacheOwnership.NOTHING_CACHED); +}); + +test('the inventory covers every browser storage writer in web/', () => { + // Every key a Prompt Manager page writes is a preference or an inventoried cache entry; a new writer must be + // classified here before a cache clear may remove it. + const files = []; + const walk = dir => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { if (!['lib', 'toolkit'].includes(entry.name)) walk(full); } + else if (/\.(m?js|html)$/.test(entry.name)) files.push(full); + } + }; + walk(WEB); + const written = new Set(); + const constants = {}; + for (const file of files) { + const text = fs.readFileSync(file, 'utf8'); + for (const match of text.matchAll(/(?:const|export const)\s+([A-Z_]+_KEY)\s*=\s*'([^']+)'/g)) constants[match[1]] = match[2]; + assert.equal(/caches\.open\(/.test(text), false, `${path.relative(WEB, file)} opens a CacheStorage cache`); + assert.equal(/indexedDB\.open\(/.test(text), false, `${path.relative(WEB, file)} opens IndexedDB`); + for (const match of text.matchAll(/\.setItem\(\s*([^,)]+)/g)) { + const literal = /^(?:'([^']+)'|"([^"]+)")$/.exec(match[1].trim()); + const constant = /^[A-Z_]+_KEY$/.test(match[1].trim()) ? `$${match[1].trim()}` : null; + // A key computed at run time could be anything: it must be a literal or a named key constant. + assert.ok(literal || constant, `${path.relative(WEB, file)} writes a computed key: ${match[1]}`); + written.add(literal ? literal[1] ?? literal[2] : constant); + } + } + const keys = [...written].map(key => (key.startsWith('$') ? constants[key.slice(1)] : key)); + assert.ok(keys.length >= 3, 'the writers were found'); + for (const key of keys) { + assert.ok(key, 'every written key resolves'); + assert.ok(CacheOwnership.PREFERENCE_KEYS.includes(key) || CacheOwnership.OWNED.storageKeys.includes(key), key); + } +}); + +test('storage denial reports partial', async () => { + const denied = { get length() { throw new Error('SecurityError'); }, key() { throw new Error('SecurityError'); }, + getItem() { throw new Error('SecurityError'); }, removeItem() { throw new Error('SecurityError'); } }; + const result = await CacheOwnership.clearOwned({ storage: denied, cacheStorage: null, owned: OWNED }); + assert.equal(result.denied, true); + assert.equal(result.partial, true); + assert.equal(CacheOwnership.clearMessage(result), CacheOwnership.STORAGE_DENIED); + + const caches = memoryCaches(['pm-owned-test-cache'], { failDelete: new Set(['pm-owned-test-cache']) }); + const storage = memoryStorage({ 'pm-owned-test-entry': 'x' }); + const partial = await CacheOwnership.clearOwned({ storage, cacheStorage: caches, owned: OWNED }); + assert.deepEqual(partial.removed, ['pm-owned-test-entry']); + assert.deepEqual(partial.failed, ['pm-owned-test-cache']); + assert.equal(partial.partial, true); + assert.equal(CacheOwnership.clearMessage(partial), 'Cleared 1 of 2 cached entries; this browser refused the rest.'); + + const keysDenied = { keys: async () => { throw new Error('denied'); } }; + const unreadable = await CacheOwnership.clearOwned({ storage: memoryStorage({}), cacheStorage: keysDenied, owned: OWNED }); + assert.equal(unreadable.denied, true); +}); + +test('server thumbnail clear stays a separate explicit call', async () => { + const calls = []; + const original = globalThis.fetch; + globalThis.fetch = async (...args) => { calls.push(args); return { ok: true }; }; + try { + const storage = memoryStorage({ 'pm-owned-test-entry': 'x' }); + const result = await CacheOwnership.clearOwned({ storage, cacheStorage: memoryCaches([]), owned: OWNED }); + assert.deepEqual(result.removed, ['pm-owned-test-entry']); + assert.equal(CacheOwnership.clearMessage(result), 'Cleared 1 cached entry from this browser.'); + } finally { + globalThis.fetch = original; + } + assert.deepEqual(calls, []); + assert.equal(/fetch|clear-thumbnails|ApiClient/.test(fs.readFileSync(path.join(WEB, 'js/cache-ownership.js'), 'utf8')), false); +}); + +test('ownedEntries lists what a clear would remove, reading nothing it cannot', async () => { + const storage = memoryStorage({ ...PREFERENCES, 'pm-owned-test-entry': 'x' }); + const caches = memoryCaches(['pm-owned-test-cache', 'gallery']); + assert.deepEqual(await CacheOwnership.ownedEntries({ storage, cacheStorage: caches, owned: OWNED }), + { keys: ['pm-owned-test-entry'], caches: ['pm-owned-test-cache'], denied: false }); + assert.deepEqual(await CacheOwnership.ownedEntries({ storage, cacheStorage: caches }), + { keys: [], caches: [], denied: false }); + assert.deepEqual(await CacheOwnership.ownedEntries({ storage: null, cacheStorage: undefined, owned: OWNED }), + { keys: [], caches: [], denied: false }); + assert.equal(CacheOwnership.clearMessage({ removed: ['a', 'b'], failed: [], denied: false, partial: false }), + 'Cleared 2 cached entries from this browser.'); +}); diff --git a/tests/js/legacy-refusals.test.js b/tests/js/legacy-refusals.test.js index 6daec1c7..c06daedc 100644 --- a/tests/js/legacy-refusals.test.js +++ b/tests/js/legacy-refusals.test.js @@ -32,8 +32,11 @@ function sseResponse(frames) { function load(file, className, fetchUrl, extra = {}, dom = elements()) { const timers = []; const notifications = []; - const storage = new Map([['pm_cached', '1'], ['userData', 'keep']]); + const storage = new Map([['pm_cached', '1'], ['userData', 'keep'], ['pm_workspace_theme', 'light'], + ['pm_update_notice_dismissed', '3.3.0']]); const localStorage = { + get length() { return storage.size; }, + key: index => [...storage.keys()][index] ?? null, getItem: key => storage.get(key) ?? null, setItem: (key, value) => storage.set(key, String(value)), removeItem: key => storage.delete(key), @@ -45,11 +48,12 @@ function load(file, className, fetchUrl, extra = {}, dom = elements()) { setTimeout: callback => timers.push(callback), confirm: () => true, localStorage, - Object: { ...Object, keys: target => (target === localStorage ? [...storage.keys()] : Object.keys(target)) }, TextDecoder, TextEncoder, JSON, ApiClient: { fetchUrl }, ...extra, }); + // gallery.html loads cache-ownership.js before gallery.js (#222). + vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/cache-ownership.js'), 'utf8'), context); const source = fs.readFileSync(path.join(__dirname, `../../web/js/${file}`), 'utf8'); const Page = vm.runInContext(`${source}\n${className};`, context, { filename: path.join(__dirname, `../../web/js/${file}`) }); const page = Object.create(Page.prototype); @@ -78,18 +82,68 @@ test('gallery cache clear reports a refused server clear and never claims succes assert.equal(storage.get('gallerySettings'), undefined); // thumbnailsGenerated is not reset }); -test('gallery cache clear still says so when the server clears', async () => { - const { page, dom, notifications, storage } = load('gallery.js', 'GalleryManager', - async () => jsonResponse(200, { success: true, cleared_count: 3 })); +test('gallery cache clear says what it cleared and keeps preferences and other data (#222)', async () => { + const calls = []; + const { page, dom, notifications, storage } = load('gallery.js', 'GalleryManager', async url => { + calls.push(url); + return jsonResponse(200, { success: true, cleared_count: 3 }); + }); page.getSettings = () => ({ thumbnailsGenerated: true }); await page.clearCache(); - assert.deepEqual(notifications, [['success', 'PromptManager cache cleared successfully! Original images untouched.']]); + // The server's thumbnails are cleared by their own explicit call; nothing Prompt Manager owns is cached in this + // browser, so no browser entry is removed and the toast says exactly that. + assert.deepEqual(calls, ['/prompt_manager/images/clear-thumbnails']); + assert.deepEqual(notifications, [['success', + "Thumbnails cleared; original images untouched. Nothing of Prompt Manager's is cached in this browser."]]); assert.equal(dom.nodes.thumbnailStatus.textContent, 'Not generated'); assert.equal(JSON.parse(storage.get('gallerySettings')).thumbnailsGenerated, false); assert.equal(storage.get('userData'), 'keep'); - assert.equal(storage.has('pm_cached'), false); + assert.equal(storage.get('pm_cached'), '1'); + assert.equal(storage.get('pm_workspace_theme'), 'light'); + assert.equal(storage.get('pm_update_notice_dismissed'), '3.3.0'); +}); + +test("gallery cache clear never deletes another tool's gallery or thumbnail caches (#222)", async () => { + const names = new Set(['comfy-gallery', 'thumbnail-cache']); + const caches = { keys: async () => [...names], delete: async name => names.delete(name) }; + const { page, notifications } = load('gallery.js', 'GalleryManager', + async () => jsonResponse(200, { success: true }), { caches, window: { caches } }); + page.getSettings = () => ({}); + + await page.clearCache(); + + assert.deepEqual([...names], ['comfy-gallery', 'thumbnail-cache']); + assert.equal(notifications.at(-1)[0], 'success'); +}); + +test('gallery cache clear reports a lost server clear instead of success', async () => { + const { page, dom, notifications, storage } = load('gallery.js', 'GalleryManager', async () => { + throw new TypeError('Failed to fetch'); + }); + page.getSettings = () => ({ thumbnailsGenerated: true }); + dom.getElementById('thumbnailStatus').textContent = 'Generated'; + + await page.clearCache(); + + assert.deepEqual(notifications, [['error', 'Thumbnails were not cleared: the server could not be reached.']]); + assert.equal(dom.nodes.thumbnailStatus.textContent, 'Generated'); + assert.equal(storage.get('gallerySettings'), undefined); +}); + +test('gallery cache clear says so when browser storage is denied', async () => { + const denied = { get length() { throw new Error('SecurityError'); }, key() { throw new Error('SecurityError'); }, + getItem() { throw new Error('SecurityError'); }, setItem() { throw new Error('SecurityError'); }, + removeItem() { throw new Error('SecurityError'); } }; + const { page, notifications } = load('gallery.js', 'GalleryManager', + async () => jsonResponse(200, { success: true }), { localStorage: denied }); + page.getSettings = () => ({}); + + await page.clearCache(); + + assert.deepEqual(notifications, [['warning', + 'Thumbnails cleared; original images untouched. This browser refused access to its storage, so nothing there was cleared.']]); }); test('admin LoRA import shows a refusal frame and closes its modal', async () => { diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index 49f91a58..59f1afa1 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -11,6 +11,12 @@ "functions": 98, "statements": 98 }, + "cache-ownership.js": { + "lines": 80, + "branches": 80, + "functions": 80, + "statements": 80 + }, "classic-node.js": { "lines": 98, "branches": 79, diff --git a/web/gallery.html b/web/gallery.html index 36543893..ce1c8547 100644 --- a/web/gallery.html +++ b/web/gallery.html @@ -8,6 +8,7 @@ + diff --git a/web/js/cache-ownership.js b/web/js/cache-ownership.js new file mode 100644 index 00000000..804d1dae --- /dev/null +++ b/web/js/cache-ownership.js @@ -0,0 +1,119 @@ +/** + * What "clear the cache" may remove from this browser (#222). + * + * ComfyUI serves every custom node from one origin, so a browser's localStorage and CacheStorage are shared with other + * tools. A cache clear removes only the entries Prompt Manager itself writes as a cache, listed by name in `OWNED` from + * an inventory of the real writers, never by a guessed prefix. The inventory (2026-10-24, enforced by + * tests/js/cache-ownership.test.js): every key a Prompt Manager page writes is a preference (`PREFERENCE_KEYS`: the + * display defaults, the workspace theme, the dismissed update notice), and no page opens a CacheStorage cache or + * IndexedDB. So `OWNED` is empty: nothing of Prompt Manager's is cached in this browser, and a clear says so instead of + * claiming it removed something. + * + * Server thumbnails are not browser data: clearing them is the caller's own, separate request. This module never makes + * a request. + * + * Loadable in the browser (window.CacheOwnership) and in Node for tests. + */ +(function (root) { + "use strict"; + + const PREFERENCE_KEYS = Object.freeze(["gallerySettings", "pm_workspace_theme", "pm_update_notice_dismissed"]); + const OWNED = Object.freeze({ storageKeys: Object.freeze([]), cacheNames: Object.freeze([]) }); + const NOTHING_CACHED = "Nothing of Prompt Manager's is cached in this browser."; + const STORAGE_DENIED = "This browser refused access to its storage, so nothing there was cleared."; + + // What a storage getter that threw (the browser refused storage) resolves to. + const REFUSED = Object.freeze({}); + + /** `storage`, or what its getter returns; `REFUSED` when the getter throws. */ + function resolveStorage(storage) { + if (typeof storage !== "function") return storage; + try { + return storage() ?? null; + } catch { + return REFUSED; + } + } + + /** The keys `storage` holds, or null when the browser refuses to say. */ + function storageKeys(storage) { + if (storage === REFUSED) return null; + if (!storage) return []; + try { + const keys = []; + for (let index = 0; index < storage.length; index += 1) keys.push(storage.key(index)); + return keys; + } catch { + return null; + } + } + + async function cacheNames(cacheStorage) { + if (!cacheStorage || typeof cacheStorage.keys !== "function") return []; + try { + return await cacheStorage.keys(); + } catch { + return null; + } + } + + /** + * The owned entries present now: `{keys, caches, denied}`; `denied` when the browser refused to list its storage or + * caches. + */ + async function ownedEntries({ storage, cacheStorage, owned = OWNED } = {}) { + const present = storageKeys(resolveStorage(storage)); + const names = await cacheNames(cacheStorage); + return { + keys: (present ?? []).filter(key => owned.storageKeys.includes(key)), + caches: (names ?? []).filter(name => owned.cacheNames.includes(name)), + denied: present === null || names === null, + }; + } + + /** + * Remove the owned entries present now. `storage` is a Storage or a getter for one (`() => localStorage`, which a + * browser refusing storage makes throw). Returns `{removed, failed, denied, partial}`: what was removed and what the + * browser refused to remove, by name; `partial` when anything was refused, including listing the storage. + */ + async function clearOwned({ storage, cacheStorage, owned = OWNED } = {}) { + storage = resolveStorage(storage); + const { keys, caches, denied } = await ownedEntries({ storage, cacheStorage, owned }); + const removed = []; + const failed = []; + for (const key of keys) { + try { + storage.removeItem(key); + removed.push(key); + } catch { + failed.push(key); + } + } + for (const name of caches) { + try { + await cacheStorage.delete(name); + removed.push(name); + } catch { + failed.push(name); + } + } + return { removed, failed, denied, partial: denied || failed.length > 0 }; + } + + const entries = count => `${count.toLocaleString("en-US")} cached ${count === 1 ? "entry" : "entries"}`; + + /** What a clear's result says, truthfully. */ + function clearMessage(result) { + const total = result.removed.length + result.failed.length; + if (result.failed.length) { + return `Cleared ${result.removed.length.toLocaleString("en-US")} of ${entries(total)}; this browser refused the rest.`; + } + if (result.denied) return STORAGE_DENIED; + if (!total) return NOTHING_CACHED; + return `Cleared ${entries(total)} from this browser.`; + } + + const api = { PREFERENCE_KEYS, OWNED, NOTHING_CACHED, STORAGE_DENIED, ownedEntries, clearOwned, clearMessage }; + if (typeof module !== "undefined" && module.exports) module.exports = api; + else root.CacheOwnership = api; +})(typeof window !== "undefined" ? window : globalThis); diff --git a/web/js/gallery.js b/web/js/gallery.js index 77781e5a..97834b04 100644 --- a/web/js/gallery.js +++ b/web/js/gallery.js @@ -2166,61 +2166,44 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`; } + /** + * Clear cache (#222): the server's thumbnails by their own request, then only the browser entries Prompt + * Manager owns as a cache (cache-ownership.js; preferences and other tools' storage stay). Nothing is + * claimed that did not happen: a refused or lost server clear changes nothing here, and the toast says what + * the browser part removed, or that nothing of Prompt Manager's is cached, or that storage was refused. + */ async clearCache() { - if (confirm('Clear PromptManager cache and thumbnails? This will NOT affect your original images.')) { - try { - // Clear only PromptManager-related browser caches (never touch user images) - if ('caches' in window) { - const cacheNames = await caches.keys(); - // Only clear caches that are specifically ours - const ourCaches = cacheNames.filter(name => - name.includes('prompt-manager') || - name.includes('gallery') || - name.includes('thumbnail') - ); - await Promise.all(ourCaches.map(name => caches.delete(name))); - } - - // Clear only our localStorage entries (never touch user data) - Object.keys(localStorage).forEach(key => { - if (key.startsWith('gallery_cache_') || - key.startsWith('metadata_cache_') || - key.startsWith('promptManager_') || - key.startsWith('pm_')) { - localStorage.removeItem(key); - } - }); - - // Call backend to clear server-side thumbnails (only our generated thumbnails) - try { - const response = await ApiClient.fetchUrl('/prompt_manager/images/clear-thumbnails', { - method: 'POST', - headers: { 'Content-Type': 'application/json' } - }); - - if (!response.ok) { - // Refused (a running job holds the thumbnails): say why and claim nothing. - const data = await response.json().catch(() => ({})); - this.showNotification(`Thumbnails were not cleared: ${data.error || `HTTP ${response.status}`}`, 'error'); - return; - } - } catch (e) { - console.warn('Could not clear server thumbnails:', e); - // Not a critical error - continue with local cleanup - } - - // Update thumbnail status - const settings = this.getSettings(); - settings.thumbnailsGenerated = false; - localStorage.setItem('gallerySettings', JSON.stringify(settings)); - document.getElementById('thumbnailStatus').textContent = 'Not generated'; - - this.showNotification('PromptManager cache cleared successfully! Original images untouched.', 'success'); - } catch (error) { - console.error('Clear cache error:', error); - this.showNotification('Failed to clear cache', 'error'); - } + if (!confirm('Clear PromptManager cache and thumbnails? This will NOT affect your original images.')) return; + let response; + try { + response = await ApiClient.fetchUrl('/prompt_manager/images/clear-thumbnails', { + method: 'POST', + headers: { 'Content-Type': 'application/json' } + }); + } catch (error) { + console.warn('Could not clear server thumbnails:', error); + this.showNotification('Thumbnails were not cleared: the server could not be reached.', 'error'); + return; } + if (!response.ok) { + // Refused (a running job holds the thumbnails): say why and claim nothing. + const data = await response.json().catch(() => ({})); + this.showNotification(`Thumbnails were not cleared: ${data.error || `HTTP ${response.status}`}`, 'error'); + return; + } + // Read through a getter: a browser that refuses storage throws on `localStorage` itself. + const browser = await window.CacheOwnership.clearOwned({ storage: () => localStorage, + cacheStorage: 'caches' in window ? window.caches : null }); + try { + const settings = this.getSettings(); + settings.thumbnailsGenerated = false; + localStorage.setItem('gallerySettings', JSON.stringify(settings)); + } catch (error) { + console.warn('Could not record the cleared thumbnails:', error); + } + document.getElementById('thumbnailStatus').textContent = 'Not generated'; + this.showNotification(`Thumbnails cleared; original images untouched. ${window.CacheOwnership.clearMessage(browser)}`, + browser.partial ? 'warning' : 'success'); } async rescanFolder() {