fix(gallery): clear only Prompt Manager cache entries (#222)

The legacy gallery's Clear cache deleted every localStorage key starting
pm_ or promptManager_ (the workspace theme and the dismissed update notice
among them) and every CacheStorage cache whose name held gallery or
thumbnail, which on ComfyUI's shared origin can belong to another tool.

web/js/cache-ownership.js names what a clear may remove from an inventory
of the real writers: every key Prompt Manager writes is a preference and no
page opens CacheStorage or IndexedDB, so nothing is removed and the toast
says so. A test fails when a new storage writer is not classified. The
server thumbnail clear stays its own request; a refused or unreachable
server changes nothing and is reported, and refused browser storage is
reported as such.
This commit is contained in:
Vito Sansevero
2026-10-24 17:04:42 -07:00
parent 8bf759d6f8
commit eaf4b112f7
7 changed files with 405 additions and 60 deletions
+1
View File
@@ -8,6 +8,7 @@
<script src="js/html-escape.js"></script>
<script src="js/image-helpers.js"></script>
<script src="js/gallery-settings.js"></script>
<script src="js/cache-ownership.js"></script>
<script src="js/scan-progress.js"></script>
<script src="js/api-client.js"></script>
<script src="js/png-metadata.js"></script>
+119
View File
@@ -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);
+36 -53
View File
@@ -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() {