From 6527b6fe1d779388e47cba16a9111d3c79b39d4f Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Tue, 27 Oct 2026 19:28:49 -0700 Subject: [PATCH] refactor(gallery): open System for settings and maintenance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gallery's Settings opens System › Appearance in a new tab. Its settings dialog becomes Page options (a new header button) with only the page's own settings: thumbnail startup check, lazy loading, items per page, grid columns, infinite scrolling, debug mode and API timeout. Save and Reset keep every other stored key, so the Appearance values System saves survive (S13-13). Generate Thumbnails, Clear cache, Rescan and Find duplicates leave the page; Page options links System › Maintenance and Sources & scans (rows B21–B28). --- TESTING.md | 27 +- tests/browser/legacy-system-links.spec.mjs | 56 ++ tests/browser/workflows.spec.mjs | 124 +--- tests/js/gallery-settings.test.js | 82 ++- tests/js/gallery-thumbnails.test.js | 27 +- tests/js/legacy-refusals.test.js | 81 --- web/gallery.html | 233 +------ web/js/gallery.js | 714 +-------------------- 8 files changed, 198 insertions(+), 1146 deletions(-) diff --git a/TESTING.md b/TESTING.md index d871719b..e012bc6e 100644 --- a/TESTING.md +++ b/TESTING.md @@ -4138,6 +4138,15 @@ legacy page workflows), not by this check. | Maintenance select all / clear all / run | admin | `#selectAllMaintenanceBtn`, `#clearAllMaintenanceBtn` | `selectAllMaintenance()`, `clearAllMaintenance()` | plan dialog shows the server's scope, protected, backup policy and media lines with the backup box (D100); Cancel writes nothing (workspace-system-maintenance.spec.mjs) | | Database backup | admin | — | `backupDatabase()` | Back up downloads through the api client and revokes the object URL (D103) (workspace-system-backups.spec.mjs) | | Restore from file | admin | `#restoreModal`, `#restoreFileInput`, `#confirmRestore`, `#cancelRestore` | `showRestoreModal()`, `confirmRestore()` | Review restore stages the chosen file and shows the real summary with the backup box (D105) (workspace-system-backups.spec.mjs) | +| Gallery thumbnail generation | gallery | `#generateThumbnailsBtn`, `#thumbnailStatus`, `#thumbnailProgress`, `#progressText`, `#progressPercent`, `#cancelThumbnailsBtn`, `#progressBar`, `#progressDetails`, `#progressETA` | `generateThumbnails()`, `generateThumbnailsWithProgress()` | Start closes the dialog, toasts, and opens the job in Activity (workspace-system-sources.spec.mjs) | +| Default view mode, image quality | gallery | `#imageQualitySelect`, `#defaultViewModeSelect` | — | defaults apply to a new visit but not to the current view or a saved view (workspace-system.spec.mjs) | +| Image info on cards; sidebar collapsed | gallery | `#showImageInfoToggle`, `#sidebarCollapsedByDefaultToggle` | — | added rows reach their consumers (workspace-system.spec.mjs) | +| Video autoplay, mute, loop | gallery | `#videoAutoplayToggle`, `#videoMuteToggle`, `#videoLoopToggle` | — | video defaults reach the viewer (workspace-system.spec.mjs) | +| Auto-load metadata, cache metadata, show file paths | gallery | `#autoLoadMetadataToggle`, `#cacheMetadataToggle`, `#showFilePathsToggle` | — | metadata off shows an explicit load and loads once on request (workspace-system.spec.mjs) | +| Browser cache clear | gallery | `#clearCacheBtn` | `clearCache()` | Metadata cache reviews and clears only owned browser entries when there are some (D102) (workspace-system-maintenance.spec.mjs) | +| Clear server thumbnails | gallery | — | — | Thumbnail cache opens the S5 dialog (workspace-system-maintenance.spec.mjs) | +| Refresh file listing | gallery | `#rescanFolderBtn` | `rescanFolder()` | Refresh files calls /gallery/rescan and toasts its counts (workspace-system-sources.spec.mjs) | +| Duplicate image scan, review, delete | gallery | `#scanDuplicatesBtn`, `#dupScanPill`, `#dupScanPillText`, `#duplicatesModal`, `#closeDuplicatesBtn`, `#duplicatesScanStatus`, `#dupProgressText`, `#dupProgressBar`, `#duplicatesContent`, `#duplicatesList`, `#duplicatesFooter`, `#duplicatesCount`, `#selectAllBtn`, `#removeDuplicatesBtn` | `scanDuplicates()`, `attachDuplicateStream()`, `resumeDuplicateScan()`, `applyDuplicateEvent()`, `renderDuplicateProgress()`, `setDuplicateButton()`, `showDuplicatePill()`, `hideDuplicatePill()`, `openDuplicatesModal()`, `showDuplicatesModal()`, `hideDuplicatesModal()`, `displayDuplicates()`, `selectAllDuplicates()`, `removeDuplicates()` | Find duplicates starts a job; its view links Review groups; Keep radios build a duplicate_files plan (D101) (workspace-system-maintenance.spec.mjs) | ### Browser cache ownership (#222) @@ -4156,17 +4165,15 @@ page writes a key that is neither a preference nor inventoried, writes one it ca CacheStorage or IndexedDB; `the storage scan reports every way of writing browser storage` pins those patterns. It skips `web/lib` (third-party builds) and the pinned Toolkit build (`web/js/shared/toolkit`, never hand-edited, verified by `npm run check`; -its storage belongs to the Toolkit repository and a new pin is reviewed on its own). The -gallery's server thumbnail clear stays its own request and runs first: refused or -unreachable, it changes nothing and says so (the old code toasted success when the -request failed); refused browser storage is reported, never hidden -(`tests/js/legacy-refusals.test.js`; the browser case `the gallery cache clear keeps -preferences and other tools caches` in `workflows.spec.mjs`). +its storage belongs to the Toolkit repository and a new pin is reviewed on its own). Since +System S13 (#330) the legacy gallery no longer offers Clear cache: System › Maintenance's +Metadata cache and Thumbnail cache reviews own both parts (removal table above), and +`cache-ownership.js` serves the native review. -The legacy duplicates dialog's View Full now names its source (`?root=`, with the -path URL-encoded) when the gallery's output folder is a listed source; before, the serve -route opened the first source holding a file at that path -(`test_view_full_names_the_gallery_folder_source`). +The duplicates dialog's View Full named its source (`?root=`, with the path +URL-encoded) when the gallery's output folder is a listed source +(`test_view_full_names_the_gallery_folder_source`); S13 removed the legacy dialog, and the +route keeps that behaviour for System › Maintenance's duplicate review. ### Library location contract (#249 System S6b) diff --git a/tests/browser/legacy-system-links.spec.mjs b/tests/browser/legacy-system-links.spec.mjs index 21b2cb83..56250bea 100644 --- a/tests/browser/legacy-system-links.spec.mjs +++ b/tests/browser/legacy-system-links.spec.mjs @@ -15,6 +15,11 @@ const LABELS = Object.freeze({ diagnostics: 'Diagnostics & logs', }); +async function openPageOptions(page) { + await page.locator('#pageOptionsBtn').click(); + await expect(page.locator('#settingsModal')).toBeVisible(); +} + // One row per System link of the design's §2 map: the page, the control's label, how to find it, the section it opens // and what a test must arrange before the control shows. const LINKS = Object.freeze([ @@ -22,6 +27,11 @@ const LINKS = Object.freeze([ { page: 'admin', label: 'Maintenance', locate: page => page.locator('#maintenanceBtn'), section: 'maintenance' }, { page: 'admin', label: 'Backup DB', locate: page => page.locator('#backupBtn'), section: 'backups' }, { page: 'admin', label: 'Restore DB', locate: page => page.locator('#restoreBtn'), section: 'backups' }, + { page: 'gallery', label: 'Settings', locate: page => page.locator('#settingsBtn'), section: 'appearance' }, + { page: 'gallery', label: 'Page options Maintenance', reveal: openPageOptions, + locate: page => page.locator('#pageOptionsMaintenanceLink'), section: 'maintenance' }, + { page: 'gallery', label: 'Page options Sources & scans', reveal: openPageOptions, + locate: page => page.locator('#pageOptionsSourcesLink'), section: 'sources' }, { page: 'admin', label: 'Diagnostics', locate: page => page.locator('#diagnosticsBtn'), section: 'diagnostics' }, { page: 'admin', label: 'View Logs', locate: page => page.locator('#logsBtn'), section: 'diagnostics' }, ]); @@ -80,6 +90,10 @@ for (const link of LINKS) { } test('a System link sends no request from the legacy page', async ({ page, context, request }) => { + // The gallery's thumbnail startup check (K9) reads its status on a timer of its own; it is off here. + await page.addInitScript(() => { + localStorage.setItem('gallerySettings', JSON.stringify({ checkThumbnailsAtStartup: false })); + }); for (const link of LINKS) { const control = await openLink(page, request, link); // The page's own start-up reads (list, film strips, settings) finish first; then the click must add none. @@ -193,3 +207,45 @@ test('Page options keeps the page-only settings admin', async ({ page, request } await page.locator('#pageOptionsBtn').click(); await expect(page.locator('#infiniteScrollDefault')).toBeChecked(); }); + +test('Page options keeps the page-only settings gallery', async ({ page }) => { + // Native Appearance keys and the startup prompt's own keys are in the shared object before the page opens. + const stored = { defaultViewMode: 'list', imageQuality: 'high', showImageInfo: false, videoMute: false, + autoLoadMetadata: false, workspaceLayout: 'masonry', thumbnailPromptDismissed: 'sig', thumbnailsGenerated: true, + checkThumbnailsAtStartup: false }; + await page.addInitScript(value => { + if (!sessionStorage.getItem('seeded')) { + sessionStorage.setItem('seeded', '1'); + localStorage.setItem('gallerySettings', value); + } + }, JSON.stringify(stored)); + const listed = page.waitForResponse(item => item.url().includes('/prompt_manager/images/output?')); + await page.goto(PAGES.gallery); + await listed; + await openPageOptions(page); + const dialog = page.locator('#settingsModal'); + await expect(dialog.locator('h3')).toHaveText('Page options'); + // Exactly the page-only settings (G5). + expect(await dialog.locator('input, select, textarea').evaluateAll(fields => fields.map(field => field.id))).toEqual([ + 'checkThumbnailsAtStartup', 'lazyLoadingToggle', 'defaultLimitSelect', 'gridColumnsSelect', 'infiniteScrollToggle', + 'debugModeToggle', 'apiTimeoutInput']); + await page.locator('#defaultLimitSelect').selectOption('50'); + await page.locator('#debugModeToggle').check(); + await page.locator('#saveSettingsBtn').click(); + await expect(dialog).toBeHidden(); + const read = () => page.evaluate(() => JSON.parse(localStorage.getItem('gallerySettings'))); + let saved = await read(); + expect(saved).toMatchObject({ ...stored, defaultLimit: 50, debugMode: true }); + + // Reset puts back only the page options' defaults; the Appearance and startup-prompt keys stay. + await openPageOptions(page); + page.once('dialog', dialog => dialog.accept()); + await page.locator('#resetSettingsBtn').click(); + await expect(page.getByText('Page options reset to defaults')).toBeVisible(); + await expect(page.locator('#defaultLimitSelect')).toHaveValue('100'); + saved = await read(); + expect(saved).toMatchObject({ defaultViewMode: 'list', imageQuality: 'high', showImageInfo: false, videoMute: false, + autoLoadMetadata: false, workspaceLayout: 'masonry', thumbnailPromptDismissed: 'sig', thumbnailsGenerated: true, + defaultLimit: 100, debugMode: false, checkThumbnailsAtStartup: true, lazyLoading: true, gridColumns: 8, + infiniteScroll: false, apiTimeout: 30 }); +}); diff --git a/tests/browser/workflows.spec.mjs b/tests/browser/workflows.spec.mjs index e68e0695..849839b1 100644 --- a/tests/browser/workflows.spec.mjs +++ b/tests/browser/workflows.spec.mjs @@ -104,33 +104,6 @@ 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'] }); -}); // #379: the reviewed tags are that image's own tags; no prompt changes. test('actual gallery review generates and persists tags for the selected root\'s image', async ({ page, request }) => { @@ -226,7 +199,7 @@ test('gallery infinite scroll appends a real backend page while retaining existi await expect(await request.post('/__test__/reset', { data: { extra_images: 108 } })).toBeOK(); await page.goto('/prompt_manager/gallery'); await expect(page.locator('#galleryGrid .image-item')).toHaveCount(100); - await page.locator('#settingsBtn').click(); + await page.locator('#pageOptionsBtn').click(); await page.locator('#infiniteScrollToggle').check(); await page.locator('#saveSettingsBtn').click(); await expect(page.locator('#settingsModal')).toBeHidden(); @@ -288,98 +261,3 @@ test('prompt deletion requires confirmation and can retry after a network failur await expect(await request.get(url)).toBeOK(); } }); - -// The duplicate scan reads the output root only, but the fixture's input root holds a different -// shared/same.png. A bare /images/serve/ URL tries every gallery root in turn, so once the -// output copy is deleted it answers 200 with the input copy: check each file in its own root. -const serveIn = (root, path) => `/prompt_manager/images/serve/${path.split(/[\\/]/).map(encodeURIComponent).join('/')}?root=${root}`; -const rootOf = url => new URL(url, 'http://gallery.test').searchParams.get('root'); - -// Which copy the scan keeps depends on file times: the fixture stamps shared/same.png with the -// wall clock, and the kernel can stamp its bulk copies a fraction of a millisecond earlier. -// listOrder rewrites the finished scan's groups so a test can pick the order it needs. -async function openDuplicateScan(page, request, listOrder = null) { - await expect(await request.post('/__test__/reset', { data: { extra_images: 2 } })).toBeOK(); - if (listOrder) { - await page.route('**/prompt_manager/scan_duplicates/stream', async route => { - const response = await route.fetch(); - const body = (await response.text()).replace(/^data: (.*)$/gm, (line, json) => { - const event = JSON.parse(json); - if (event.type !== 'complete') return line; - for (const group of event.duplicates) group.images = listOrder(group.images); - return `data: ${JSON.stringify(event)}`; - }); - await route.fulfill({ response, body }); - }); - } - await page.goto('/prompt_manager/gallery'); - await expect(page.locator('#galleryGrid .image-item')).toHaveCount(4); - const originals = await page.locator('#galleryGrid img[data-original]').evaluateAll(images => images.map(image => image.dataset.original)); - const output = rootOf(originals.find(url => url.includes('/bulk/'))); - const input = rootOf(originals.find(url => url.includes('/shared/same.png') && rootOf(url) !== output)); - expect(output).not.toBeNull(); - expect(input).not.toBeNull(); - await page.locator('#settingsBtn').click(); - await page.locator('#scanDuplicatesBtn').click(); - await expect(page.locator('#duplicatesCount')).toHaveText('1'); - const candidates = page.locator('.duplicate-checkbox'); - await expect(candidates).toHaveCount(2); - return { originals, output, input, candidates }; -} - -async function expectOnlySelectedGone(page, request, { originals, output, input }, selected, retained) { - expect((await request.get(serveIn(output, selected))).status()).toBe(404); - await expect(await request.get(serveIn(output, retained))).toBeOK(); - await expect(await request.get(serveIn(input, 'shared/same.png'))).toBeOK(); - await page.reload(); - await expect(page.locator('#galleryGrid .image-item')).toHaveCount(3); - const remaining = await page.locator('#galleryGrid img[data-original]').evaluateAll(images => images.map(image => image.dataset.original)); - expect(remaining).toHaveLength(originals.length - 1); - for (const url of remaining) { - expect(originals).toContain(url); - await expect(await request.get(url)).toBeOK(); - } -} - -test('duplicate cleanup cancels safely and deletes only the selected file after a retry', async ({ page, request }) => { - const scan = await openDuplicateScan(page, request); - const { output, candidates } = scan; - const selected = await candidates.first().getAttribute('data-image-path'); - const retained = await candidates.last().getAttribute('data-image-path'); - await candidates.first().check(); - let deletes = 0; - page.on('request', request => { if (request.url().endsWith('/delete_duplicate_images')) deletes++; }); - confirmNextDialog(page, false, 'permanently delete 1 duplicate image files'); - await page.locator('#removeDuplicatesBtn').click(); - expect(deletes).toBe(0); - await expect(await request.get(serveIn(output, selected))).toBeOK(); - await page.route('**/prompt_manager/delete_duplicate_images', route => route.abort('failed'), { times: 1 }); - confirmNextDialog(page, true, 'permanently delete 1 duplicate image files'); - await page.locator('#removeDuplicatesBtn').click(); - await expect(page.getByText('Failed to delete duplicate images', { exact: true })).toBeVisible(); - await expect(page.locator('#removeDuplicatesBtn')).toBeEnabled(); - await expect(await request.get(serveIn(output, selected))).toBeOK(); - confirmNextDialog(page, true, 'permanently delete 1 duplicate image files'); - const deleted = page.waitForResponse(response => response.url().endsWith('/delete_duplicate_images')); - await page.locator('#removeDuplicatesBtn').click(); - expect((await (await deleted).json())).toMatchObject({ success: true, deleted_count: 1, failed_count: 0 }); - await expect(page.locator('#duplicatesModal')).toBeHidden(); - await expectOnlySelectedGone(page, request, scan, selected, retained); -}); - -test('deleting a duplicate whose path another gallery root also holds removes only the scanned copy', async ({ page, request }) => { - // The order CI saw: a bulk copy stamped first, so shared/same.png is the first Delete candidate. - const ciOrder = ['bulk/0000.png', 'shared/same.png', 'bulk/0001.png']; - const rank = image => ciOrder.indexOf(image.path.replace(/\\/g, '/')); - const scan = await openDuplicateScan(page, request, images => [...images].sort((a, b) => rank(a) - rank(b))); - const selected = await scan.candidates.first().getAttribute('data-image-path'); - const retained = await scan.candidates.last().getAttribute('data-image-path'); - expect(selected.replace(/\\/g, '/')).toBe('shared/same.png'); - await scan.candidates.first().check(); - confirmNextDialog(page, true, 'permanently delete 1 duplicate image files'); - const deleted = page.waitForResponse(response => response.url().endsWith('/delete_duplicate_images')); - await page.locator('#removeDuplicatesBtn').click(); - expect((await (await deleted).json())).toMatchObject({ success: true, deleted_count: 1, failed_count: 0 }); - await expect(page.locator('#duplicatesModal')).toBeHidden(); - await expectOnlySelectedGone(page, request, scan, selected, retained); -}); diff --git a/tests/js/gallery-settings.test.js b/tests/js/gallery-settings.test.js index a8fbf658..172deccd 100644 --- a/tests/js/gallery-settings.test.js +++ b/tests/js/gallery-settings.test.js @@ -76,35 +76,79 @@ test("the workspace keys have their own defaults and validation", () => { assert.equal(normalizeGallerySettings({ defaultViewMode: "masonry" }).defaultViewMode, "grid"); }); -test("a legacy gallery Save keeps the workspace keys and every other stored key", () => { +// #330 S13: the legacy gallery's Page options edit only the page-only keys; System › Appearance owns the rest, so a Save +// or a Reset from the legacy page keeps every other stored key. +function legacyGallery(stored, controls, extra = {}) { const fs = require("node:fs"); const path = require("node:path"); const vm = require("node:vm"); const settings = require("../../web/js/gallery-settings.js"); - const storage = new Map([["gallerySettings", JSON.stringify({ workspaceLayout: "masonry", workspaceDensity: "compact", someOtherTool: 1 })]]); - const controls = { - lazyLoadingToggle: { checked: true }, imageQualitySelect: { value: "high" }, defaultViewModeSelect: { value: "list" }, - defaultLimitSelect: { value: "200" }, gridColumnsSelect: { value: "6" }, showImageInfoToggle: { checked: false }, - autoLoadMetadataToggle: { checked: true }, cacheMetadataToggle: { checked: true }, showFilePathsToggle: { checked: false }, - sidebarCollapsedByDefaultToggle: { checked: true }, infiniteScrollToggle: { checked: false }, debugModeToggle: { checked: false }, - apiTimeoutInput: { value: "30" }, checkThumbnailsAtStartup: { checked: true }, videoAutoplayToggle: { checked: true }, - videoMuteToggle: { checked: false }, videoLoopToggle: { checked: true }, - }; + const storage = new Map([["gallerySettings", JSON.stringify(stored)]]); const context = vm.createContext({ document: { addEventListener() {}, getElementById: id => controls[id] }, window: {}, console: { debug() {}, warn() {}, error() {} }, GallerySettings: settings, - localStorage: { getItem: key => storage.get(key) ?? null, setItem: (key, value) => storage.set(key, value) }, + localStorage: { getItem: key => storage.get(key) ?? null, setItem: (key, value) => storage.set(key, value), + removeItem: key => storage.delete(key) }, + ...extra, }); const file = path.join(__dirname, "../../web/js/gallery.js"); const Manager = vm.runInContext(`${fs.readFileSync(file, "utf8")}\nGalleryManager;`, context, { filename: file }); const manager = Object.create(Manager.prototype); - Object.assign(manager, { showNotification() {}, hideSettings() {}, applySettings() {} }); + const calls = []; + Object.assign(manager, { showNotification: (message, type) => calls.push([type, message]), hideSettings() {}, + applySettings: applied => calls.push(["apply", applied.lazyLoading]) }); + return { manager, storage, calls, saved: () => JSON.parse(storage.get("gallerySettings")) }; +} + +const PAGE_OPTION_CONTROLS = () => ({ + checkThumbnailsAtStartup: { checked: false }, lazyLoadingToggle: { checked: false }, defaultLimitSelect: { value: "200" }, + gridColumnsSelect: { value: "6" }, infiniteScrollToggle: { checked: true }, debugModeToggle: { checked: true }, + apiTimeoutInput: { value: "45" }, +}); + +test("a legacy gallery Save keeps the workspace keys and every other stored key", () => { + const { manager, saved } = legacyGallery({ workspaceLayout: "masonry", workspaceDensity: "compact", someOtherTool: 1, + defaultViewMode: "list", showImageInfo: false, videoMute: false, thumbnailPromptDismissed: "sig" }, PAGE_OPTION_CONTROLS()); manager.saveSettings(); - const saved = JSON.parse(storage.get("gallerySettings")); - assert.equal(saved.workspaceLayout, "masonry"); - assert.equal(saved.workspaceDensity, "compact"); - assert.equal(saved.someOtherTool, 1); - assert.equal(saved.defaultViewMode, "list"); - assert.equal(saved.showImageInfo, false); - assert.equal(saved.videoMute, false); + const result = saved(); + assert.equal(result.workspaceLayout, "masonry"); + assert.equal(result.workspaceDensity, "compact"); + assert.equal(result.someOtherTool, 1); + assert.equal(result.defaultViewMode, "list"); + assert.equal(result.showImageInfo, false); + assert.equal(result.videoMute, false); + assert.equal(result.thumbnailPromptDismissed, "sig"); + // The page-only keys come from the dialog. + assert.equal(result.lazyLoading, false); + assert.equal(result.debugMode, true); + assert.equal(result.checkThumbnailsAtStartup, false); + assert.equal(result.defaultLimit, 200); + assert.equal(result.gridColumns, 6); + assert.equal(result.infiniteScroll, true); + assert.equal(result.apiTimeout, 45); +}); + +test("a legacy gallery Reset restores only the page options and keeps every other stored key", () => { + const controls = PAGE_OPTION_CONTROLS(); + const { manager, saved, calls } = legacyGallery({ workspaceLayout: "masonry", defaultViewMode: "list", videoMute: false, + thumbnailPromptDismissed: "sig", thumbnailsGenerated: true, lazyLoading: false, debugMode: true, defaultLimit: 200, + gridColumns: 6, infiniteScroll: true, apiTimeout: 45, checkThumbnailsAtStartup: false }, controls, + { confirm: message => (calls.push(["confirm", message]), true) }); + manager.resetSettings(); + const result = saved(); + const { DEFAULT_SETTINGS } = require("../../web/js/gallery-settings.js"); + for (const key of ["checkThumbnailsAtStartup", "lazyLoading", "defaultLimit", "gridColumns", "infiniteScroll", "debugMode", + "apiTimeout"]) { + assert.equal(result[key], DEFAULT_SETTINGS[key], key); + } + assert.equal(result.workspaceLayout, "masonry"); + assert.equal(result.defaultViewMode, "list"); + assert.equal(result.videoMute, false); + assert.equal(result.thumbnailPromptDismissed, "sig"); + assert.equal(result.thumbnailsGenerated, true); + // The dialog shows the defaults again and the page applies them. + assert.equal(controls.lazyLoadingToggle.checked, DEFAULT_SETTINGS.lazyLoading); + assert.equal(controls.apiTimeoutInput.value, DEFAULT_SETTINGS.apiTimeout); + assert.deepEqual(calls, [["confirm", "Reset these page options to their defaults?"], ["apply", DEFAULT_SETTINGS.lazyLoading], + ["success", "Page options reset to defaults"]]); }); diff --git a/tests/js/gallery-thumbnails.test.js b/tests/js/gallery-thumbnails.test.js index cb135161..118306a2 100644 --- a/tests/js/gallery-thumbnails.test.js +++ b/tests/js/gallery-thumbnails.test.js @@ -112,11 +112,12 @@ test('saving gallery settings retains the remembered thumbnail dismissal', () => }); // #249 System S5: the stream's work is now an Activity job that outlives the page's connection, so the gallery's -// Cancel asks the server to stop the job. The stream's first frame, `job {id, started}`, names the job; the gallery +// Cancel asks the server to stop the job. Since #330 the startup dialog is the gallery's only thumbnail stream (System +// › Sources & scans generates the rest). The stream's first frame, `job {id, started}`, names the job; the gallery // cancels by that id, only a job this stream started, and a Cancel before the id waits for it (review I1). const JOB = 'b'.repeat(32); -function streamFixture({ modal = false, postFails = false } = {}) { +function streamFixture({ postFails = false } = {}) { const posts = []; const warnings = []; const gets = []; @@ -146,18 +147,20 @@ function streamFixture({ modal = false, postFails = false } = {}) { return { success: true }; } } }); const emit = (type, data) => (listeners[type] || []).forEach(handler => handler({ data: JSON.stringify(data) })); + // The dialog's Cancel runs the handler and from then on reads as cancelled, as the startup dialog does. + let cancelled = false; const ui = { addStatusMessage() {}, progressText: element(), progressPercent: element(), progressBar: element(), - progressDetails: element(), progressETA: element(), isCancelled: () => false, - onCancel: handler => { ui.cancel = handler; } }; - const result = modal ? manager.generateThumbnailsWithProgressInModal(ui) - : manager.generateThumbnailsWithProgress({ quality: 'medium' }); + progressDetails: element(), progressETA: element(), isCancelled: () => cancelled, + onCancel: handler => { ui.cancel = () => { cancelled = true; handler(); }; } }; + const result = manager.generateThumbnailsWithProgressInModal(ui); const outcome = result.then(value => ({ value }), error => ({ error: error.message })); - const cancel = () => (modal ? ui.cancel() : elements.cancelThumbnailsBtn.click()); + const cancel = () => ui.cancel(); const settle = () => new Promise(resolve => setImmediate(resolve)); return { emit, source, posts, gets, warnings, outcome, cancel, settle }; } const cancelUrl = id => `/prompt_manager/jobs/${id}/cancel`; +const CANCELLED = 'Thumbnail generation cancelled; previews already written are kept'; test('Cancel before the job id arrives cancels that job once it does', async () => { const f = streamFixture(); @@ -165,8 +168,8 @@ test('Cancel before the job id arrives cancels that job once it does', async () await f.settle(); assert.deepEqual(f.posts, []); assert.equal(f.source.closed, 0); // kept open until the stream names its job - assert.deepEqual(await f.outcome, { error: 'Thumbnail generation cancelled by user' }); - f.emit('status', { phase: 'scanning', message: 'Scanning…' }); + assert.deepEqual(await f.outcome, { error: CANCELLED }); + // The job frame is the stream's first (the dialog closes the stream on any later frame once cancelled). f.emit('job', { id: JOB, started: true }); await f.settle(); assert.equal(JSON.stringify(f.posts), JSON.stringify([[cancelUrl(JOB), {}]])); @@ -185,7 +188,7 @@ test('Cancel after the id cancels it at once, and a double Cancel posts once', a await f.settle(); assert.equal(JSON.stringify(f.posts), JSON.stringify([[cancelUrl(JOB), {}]])); assert.equal(f.source.closed, 1); - assert.deepEqual(await f.outcome, { error: 'Thumbnail generation cancelled by user' }); + assert.deepEqual(await f.outcome, { error: CANCELLED }); assert.deepEqual(f.gets, []); }); @@ -216,11 +219,11 @@ test('a cancel post that fails is reported, not thrown', async () => { await f.settle(); assert.equal(f.posts.length, 1); assert.equal(f.warnings.length, 1); - assert.deepEqual(await f.outcome, { error: 'Thumbnail generation cancelled by user' }); + assert.deepEqual(await f.outcome, { error: CANCELLED }); }); test('the startup dialog cancels its own job by id too', async () => { - const f = streamFixture({ modal: true }); + const f = streamFixture(); f.cancel(); await f.settle(); assert.deepEqual(f.posts, []); diff --git a/tests/js/legacy-refusals.test.js b/tests/js/legacy-refusals.test.js index f43f912b..78d15c9b 100644 --- a/tests/js/legacy-refusals.test.js +++ b/tests/js/legacy-refusals.test.js @@ -65,87 +65,6 @@ function load(file, className, fetchUrl, extra = {}, dom = elements()) { const BUSY = 'Scan for metadata already running'; -test('gallery cache clear reports a refused server clear and never claims success', async () => { - const calls = []; - const { page, dom, notifications, storage } = load('gallery.js', 'GalleryManager', async url => { - calls.push(url); - return jsonResponse(409, { success: false, error: BUSY }); - }); - page.getSettings = () => ({ thumbnailsGenerated: true }); - dom.getElementById('thumbnailStatus').textContent = 'Generated'; - - await page.clearCache(); - - assert.deepEqual(calls, ['/prompt_manager/images/clear-thumbnails']); - assert.deepEqual(notifications, [['error', `Thumbnails were not cleared: ${BUSY}`]]); - assert.equal(dom.nodes.thumbnailStatus.textContent, 'Generated'); - assert.equal(storage.get('gallerySettings'), undefined); // thumbnailsGenerated is not reset -}); - -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(); - - // 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.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 settings save shows why config.json refused it', async () => { const message = 'config.json cannot be read, so nothing was saved. Fix or remove the file, then save again.'; const { page, notifications } = load('admin.js', 'PromptAdmin', diff --git a/web/gallery.html b/web/gallery.html index 5613555f..3ce82d2a 100644 --- a/web/gallery.html +++ b/web/gallery.html @@ -8,8 +8,6 @@ - - @@ -76,9 +74,14 @@ class="px-3 py-1 bg-pm-input hover:bg-pm-hover text-pm text-xs font-medium rounded-pm-sm transition-colors"> List View - - -
- Status: Not generated -
- Thumbnails speed up gallery loading. Original images always used for viewing and metadata. -
-
+
-
+

Prompt to generate thumbnails if missing when opening gallery

@@ -218,43 +205,9 @@
- - -
- - -
-
Image Loading
-
-
- - -
-
- - -
+
+ +
@@ -264,13 +217,6 @@

Display Settings

-
- - -
-
- - -
-
- - -
@@ -305,78 +243,6 @@
- - -
-
Video Settings
-
- - -
-
- - -
-
- - -
-
-
- - -
-

Metadata Settings

- -
-
- - -
-
- - -
-
- - -
-
-
- - -
-

Maintenance

- -
-
-
-
Clear Cache
-

Clear cached metadata and thumbnails

-
- -
-
-
-
Rescan Output Folder
-

Refresh the image list from disk

-
- -
-
-
-
Find Duplicate Images
-

Scan for duplicate images based on file content

-
- -
-
@@ -395,6 +261,14 @@
+

Thumbnails, cache, file listing and duplicates are in System. + Maintenance + · + Sources & scans +

+
- - - - - -