refactor(gallery): open System for settings and maintenance
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).
This commit is contained in:
+17
-10
@@ -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=<index>`, 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=<index>`, 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)
|
||||
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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/<path> 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);
|
||||
});
|
||||
|
||||
@@ -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"]]);
|
||||
});
|
||||
|
||||
@@ -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, []);
|
||||
|
||||
@@ -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',
|
||||
|
||||
+21
-212
@@ -8,8 +8,6 @@
|
||||
<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>
|
||||
<script src="js/list-state.js"></script>
|
||||
@@ -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
|
||||
</button>
|
||||
<button id="settingsBtn"
|
||||
class="px-3 py-1 bg-pm-input hover:bg-pm-hover text-pm text-xs font-medium rounded-pm-sm transition-colors">
|
||||
<a id="settingsBtn" href="workspace#/system/appearance" target="_blank" rel="noopener"
|
||||
title="Opens System › Appearance in a new tab"
|
||||
class="px-3 py-1 bg-pm-input hover:bg-pm-hover text-pm text-xs font-medium rounded-pm-sm transition-colors">
|
||||
Settings
|
||||
</a>
|
||||
<button id="pageOptionsBtn" type="button"
|
||||
class="px-3 py-1 bg-pm-input hover:bg-pm-hover text-pm text-xs font-medium rounded-pm-sm transition-colors">
|
||||
Page options
|
||||
</button>
|
||||
<button id="autoTagBtn"
|
||||
class="px-3 py-1 bg-pm-accent hover:bg-pm-accent-hover text-pm-accent-fg text-xs font-medium rounded-pm-sm transition-colors">
|
||||
@@ -176,7 +179,7 @@
|
||||
<div class="bg-pm-surface rounded-pm-md max-w-2xl w-full max-h-[90vh] overflow-hidden mx-4 border border-pm">
|
||||
<div class="p-3 border-b border-pm">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-pm">Gallery Settings</h3>
|
||||
<h3 class="text-sm font-semibold text-pm">Page options</h3>
|
||||
<button id="closeSettingsBtn" class="p-2 rounded-pm-sm hover:bg-pm-hover transition-colors">
|
||||
<svg class="w-4 h-4 text-pm-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
@@ -190,25 +193,9 @@
|
||||
<div class="space-y-3">
|
||||
<h4 class="text-sm font-semibold text-pm">Performance Settings</h4>
|
||||
|
||||
<!-- Thumbnail Generation -->
|
||||
<div class="bg-pm-input rounded-pm-sm p-3">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<h5 class="text-pm text-xs font-medium">Generate Thumbnails</h5>
|
||||
<p class="text-xs text-pm-secondary">Create optimized thumbnails for faster loading</p>
|
||||
</div>
|
||||
<button id="generateThumbnailsBtn" class="px-3 py-1 bg-pm-accent hover:bg-pm-accent-hover text-pm-accent-fg text-xs rounded-pm-sm transition-colors">
|
||||
Generate
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-xs text-pm-muted mb-2">
|
||||
Status: <span id="thumbnailStatus">Not generated</span>
|
||||
<div class="text-xs text-pm-muted mt-1">
|
||||
Thumbnails speed up gallery loading. Original images always used for viewing and metadata.
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-pm-input rounded-pm-sm p-3 space-y-2">
|
||||
<!-- Check thumbnails at startup -->
|
||||
<div class="flex items-center justify-between py-2 border-t border-pm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="text-xs text-pm-secondary">Check thumbnails at startup</label>
|
||||
<p class="text-xs text-pm-muted">Prompt to generate thumbnails if missing when opening gallery</p>
|
||||
@@ -218,43 +205,9 @@
|
||||
<div class="w-11 h-6 bg-pm-input peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-pm-accent rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-pm after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-pm-accent"></div>
|
||||
</label>
|
||||
</div>
|
||||
<!-- Progress bar (hidden by default) -->
|
||||
<div id="thumbnailProgress" class="hidden">
|
||||
<div class="flex items-center justify-between text-xs text-pm-secondary mb-1">
|
||||
<span id="progressText">Processing...</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span id="progressPercent">0%</span>
|
||||
<button id="cancelThumbnailsBtn" class="text-pm-error hover:text-pm-error text-xs px-2 py-1 bg-pm-error-tint rounded-pm-sm hidden">
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full bg-pm-input rounded-full h-2">
|
||||
<div id="progressBar" class="bg-pm-accent h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs text-pm-muted mt-1">
|
||||
<span id="progressDetails">Scanning images...</span>
|
||||
<span id="progressETA" class="text-pm-muted"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Image Loading -->
|
||||
<div class="bg-pm-input rounded-pm-sm p-3">
|
||||
<h5 class="text-pm text-xs font-medium mb-2">Image Loading</h5>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-xs text-pm-secondary">Lazy Loading</label>
|
||||
<input type="checkbox" id="lazyLoadingToggle" checked class="w-4 h-4 text-pm-accent bg-pm-input border-pm rounded focus:ring-pm-accent">
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-xs text-pm-secondary">Image Quality</label>
|
||||
<select id="imageQualitySelect" class="px-2 py-1 bg-pm-input border border-pm rounded-pm-sm text-pm text-xs">
|
||||
<option value="high">High (Original)</option>
|
||||
<option value="medium" selected>Medium (Optimized)</option>
|
||||
<option value="low">Low (Fast)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-xs text-pm-secondary">Lazy Loading</label>
|
||||
<input type="checkbox" id="lazyLoadingToggle" checked class="w-4 h-4 text-pm-accent bg-pm-input border-pm rounded focus:ring-pm-accent">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -264,13 +217,6 @@
|
||||
<h4 class="text-sm font-semibold text-pm">Display Settings</h4>
|
||||
|
||||
<div class="bg-pm-input rounded-pm-sm p-3 space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-xs text-pm-secondary">Default View Mode</label>
|
||||
<select id="defaultViewModeSelect" class="px-2 py-1 bg-pm-input border border-pm rounded-pm-sm text-pm text-xs">
|
||||
<option value="grid" selected>Grid View</option>
|
||||
<option value="list">List View</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-xs text-pm-secondary">Items per Page</label>
|
||||
<select id="defaultLimitSelect" class="px-2 py-1 bg-pm-input border border-pm rounded-pm-sm text-pm text-xs">
|
||||
@@ -289,14 +235,6 @@
|
||||
<option value="10">10 Columns</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-xs text-pm-secondary">Show Media Info</label>
|
||||
<input type="checkbox" id="showImageInfoToggle" checked class="w-4 h-4 text-pm-accent bg-pm-input border-pm rounded focus:ring-pm-accent">
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-xs text-pm-secondary">Collapse Sidebar by Default</label>
|
||||
<input type="checkbox" id="sidebarCollapsedByDefaultToggle" class="w-4 h-4 text-pm-accent bg-pm-input border-pm rounded focus:ring-pm-accent">
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="text-xs text-pm-secondary">Infinite Scrolling</label>
|
||||
@@ -305,78 +243,6 @@
|
||||
<input type="checkbox" id="infiniteScrollToggle" class="w-4 h-4 text-pm-accent bg-pm-input border-pm rounded focus:ring-pm-accent">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Video Settings -->
|
||||
<div class="bg-pm-input rounded-pm-sm p-3 space-y-2">
|
||||
<h5 class="text-pm text-xs font-medium mb-2">Video Settings</h5>
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-xs text-pm-secondary">Autoplay Videos</label>
|
||||
<input type="checkbox" id="videoAutoplayToggle" class="w-4 h-4 text-pm-accent bg-pm-input border-pm rounded focus:ring-pm-accent">
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-xs text-pm-secondary">Mute Videos by Default</label>
|
||||
<input type="checkbox" id="videoMuteToggle" checked class="w-4 h-4 text-pm-accent bg-pm-input border-pm rounded focus:ring-pm-accent">
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-xs text-pm-secondary">Loop Videos</label>
|
||||
<input type="checkbox" id="videoLoopToggle" checked class="w-4 h-4 text-pm-accent bg-pm-input border-pm rounded focus:ring-pm-accent">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Metadata Settings -->
|
||||
<div class="space-y-3">
|
||||
<h4 class="text-sm font-semibold text-pm">Metadata Settings</h4>
|
||||
|
||||
<div class="bg-pm-input rounded-pm-sm p-3 space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-xs text-pm-secondary">Auto-load Metadata</label>
|
||||
<input type="checkbox" id="autoLoadMetadataToggle" checked class="w-4 h-4 text-pm-accent bg-pm-input border-pm rounded focus:ring-pm-accent">
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-xs text-pm-secondary">Cache Metadata</label>
|
||||
<input type="checkbox" id="cacheMetadataToggle" checked class="w-4 h-4 text-pm-accent bg-pm-input border-pm rounded focus:ring-pm-accent">
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-xs text-pm-secondary">Show File Paths</label>
|
||||
<input type="checkbox" id="showFilePathsToggle" checked class="w-4 h-4 text-pm-accent bg-pm-input border-pm rounded focus:ring-pm-accent">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Maintenance -->
|
||||
<div class="space-y-3">
|
||||
<h4 class="text-sm font-semibold text-pm">Maintenance</h4>
|
||||
|
||||
<div class="bg-pm-input rounded-pm-sm p-3 space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h5 class="text-pm text-xs font-medium">Clear Cache</h5>
|
||||
<p class="text-xs text-pm-secondary">Clear cached metadata and thumbnails</p>
|
||||
</div>
|
||||
<button id="clearCacheBtn" class="px-3 py-1 bg-pm-error hover:bg-pm-error text-pm text-xs rounded-pm-sm transition-colors">
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h5 class="text-pm text-xs font-medium">Rescan Output Folder</h5>
|
||||
<p class="text-xs text-pm-secondary">Refresh the image list from disk</p>
|
||||
</div>
|
||||
<button id="rescanFolderBtn" class="px-3 py-1 bg-pm-accent hover:bg-pm-accent-hover text-pm-accent-fg text-xs rounded-pm-sm transition-colors">
|
||||
Rescan
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h5 class="text-pm text-xs font-medium">Find Duplicate Images</h5>
|
||||
<p class="text-xs text-pm-secondary">Scan for duplicate images based on file content</p>
|
||||
</div>
|
||||
<button id="scanDuplicatesBtn" class="px-3 py-1 bg-pm-warning hover:bg-pm-warning text-pm text-xs rounded-pm-sm transition-colors">
|
||||
Scan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Advanced Settings -->
|
||||
@@ -395,6 +261,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-pm-secondary">Thumbnails, cache, file listing and duplicates are in System.
|
||||
<a id="pageOptionsMaintenanceLink" href="workspace#/system/maintenance" target="_blank" rel="noopener"
|
||||
title="Opens System › Maintenance in a new tab" class="text-pm-accent hover:underline">Maintenance</a>
|
||||
·
|
||||
<a id="pageOptionsSourcesLink" href="workspace#/system/sources" target="_blank" rel="noopener"
|
||||
title="Opens System › Sources & scans in a new tab" class="text-pm-accent hover:underline">Sources & scans</a>
|
||||
</p>
|
||||
|
||||
</div>
|
||||
<div class="p-3 border-t border-pm flex justify-end gap-2">
|
||||
<button id="resetSettingsBtn" class="px-3 py-1 bg-pm-input hover:bg-pm-hover text-pm text-xs font-medium rounded-pm-sm transition-colors">
|
||||
@@ -407,71 +281,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Minimized duplicate scan: shown while it runs with its modal hidden, or when results are waiting -->
|
||||
<button id="dupScanPill" type="button" title="Show duplicate scan"
|
||||
class="hidden fixed bottom-4 right-4 z-40 px-3 py-1.5 bg-pm-surface border border-pm shadow-pm rounded-pm-md text-xs text-pm hover:bg-pm-hover transition-colors">
|
||||
<span class="inline-block w-1.5 h-1.5 rounded-full bg-pm-accent mr-1.5 align-middle animate-pulse"></span><span id="dupScanPillText" class="align-middle">Scanning...</span>
|
||||
</button>
|
||||
|
||||
<!-- Duplicates Modal -->
|
||||
<div id="duplicatesModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
||||
<div class="bg-pm-surface rounded-pm-md max-w-4xl w-full max-h-[90vh] overflow-hidden mx-4 border border-pm">
|
||||
<div class="p-3 border-b border-pm">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-pm">Duplicate Images Found</h3>
|
||||
<button id="closeDuplicatesBtn" class="p-2 rounded-pm-sm hover:bg-pm-hover transition-colors">
|
||||
<svg class="w-4 h-4 text-pm-secondary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-3 overflow-y-auto max-h-[calc(90vh-200px)]">
|
||||
<div id="duplicatesScanStatus" class="py-6">
|
||||
<div class="max-w-md mx-auto bg-pm-input rounded-pm-md p-3">
|
||||
<div class="flex items-center justify-between mb-1.5">
|
||||
<span class="text-pm text-xs font-medium">Scanning for duplicate images</span>
|
||||
<span id="dupProgressText" class="text-pm-secondary text-xs">Starting...</span>
|
||||
</div>
|
||||
<div class="w-full bg-pm-primary rounded-full h-1.5">
|
||||
<div id="dupProgressBar" class="bg-pm-accent h-1.5 rounded-full transition-all duration-300" style="width: 0%"></div>
|
||||
</div>
|
||||
<p class="text-[11px] text-pm-muted mt-2">Every file is hashed, so this takes a while on a large library. You can close this window; the scan keeps running and the results will be waiting.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="duplicatesContent" class="hidden">
|
||||
<div class="mb-3 p-3 bg-pm-error-tint border border-pm-accent rounded-pm-sm">
|
||||
<div class="flex items-start">
|
||||
<svg class="w-4 h-4 text-pm-error mt-0.5 mr-2 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
<div>
|
||||
<h4 class="text-pm-error text-xs font-medium">Warning: Removing duplicate images cannot be undone!</h4>
|
||||
<p class="text-pm-secondary text-xs mt-1">When you remove a duplicate image, the selected file will be permanently deleted from disk. Choose carefully which images to keep.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="duplicatesList" class="space-y-3">
|
||||
<!-- Duplicates will be populated here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="duplicatesFooter" class="hidden p-3 border-t border-pm flex justify-between items-center">
|
||||
<div class="text-xs text-pm-secondary">
|
||||
<span id="duplicatesCount">0</span> duplicate image groups found
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button id="selectAllBtn" class="px-3 py-1 bg-pm-input hover:bg-pm-hover text-pm text-xs font-medium rounded-pm-sm transition-colors">
|
||||
Select All to Delete
|
||||
</button>
|
||||
<button id="removeDuplicatesBtn" class="px-3 py-1 bg-pm-error hover:bg-pm-error text-pm text-xs font-medium rounded-pm-sm transition-colors">
|
||||
Delete Selected
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Auto Tag Modal -->
|
||||
<div id="autoTagModal" class="fixed inset-0 bg-black/50 hidden items-center justify-center z-50">
|
||||
<div class="bg-pm-surface rounded-pm-md p-3 max-w-lg w-full mx-4 border border-pm">
|
||||
|
||||
+25
-689
@@ -1,6 +1,9 @@
|
||||
class GalleryManager {
|
||||
// A Cancel on a thumbnail job another tab or System started (#249 System S5, review I1).
|
||||
static THUMBNAILS_STOPPED_FOLLOWING = 'Stopped following thumbnail generation started elsewhere; it keeps running in Activity';
|
||||
// The page-only settings Page options edits and resets (#330 S13-13); System › Appearance owns the others.
|
||||
static PAGE_OPTION_KEYS = Object.freeze(['checkThumbnailsAtStartup', 'lazyLoading', 'defaultLimit', 'gridColumns',
|
||||
'infiniteScroll', 'debugMode', 'apiTimeout']);
|
||||
|
||||
constructor() {
|
||||
this.images = [];
|
||||
@@ -14,10 +17,6 @@
|
||||
this.thumbSize = 'md'; // sm, md, lg
|
||||
this.infiniteScroll = false;
|
||||
this.loadingMore = false;
|
||||
// Duplicate scan: a server-side job the modal follows (see scanDuplicates)
|
||||
this.dupScan = ScanProgress.initialState();
|
||||
this.dupSource = null;
|
||||
this.dupResults = null;
|
||||
|
||||
this.api = ApiClient.createApiClient();
|
||||
// The page's lifetime ends when it starts to leave: WebKit fails a fetch a leaving page starts
|
||||
@@ -45,7 +44,6 @@
|
||||
this.applySettings(this.getSettings());
|
||||
this.loadFolderTree();
|
||||
this.loadImages();
|
||||
this.resumeDuplicateScan();
|
||||
|
||||
// Check thumbnails at startup if enabled
|
||||
this.checkThumbnailsAtStartup();
|
||||
@@ -87,7 +85,7 @@
|
||||
document.getElementById('limitSelector').addEventListener('change', (e) => this.changeLimit(parseInt(e.target.value)));
|
||||
document.getElementById('gridViewBtn').addEventListener('click', () => this.setViewMode('grid'));
|
||||
document.getElementById('listViewBtn').addEventListener('click', () => this.setViewMode('list'));
|
||||
document.getElementById('settingsBtn').addEventListener('click', () => this.showSettings());
|
||||
document.getElementById('pageOptionsBtn').addEventListener('click', () => this.showSettings());
|
||||
document.getElementById('autoTagBtn').addEventListener('click', () => this.showAutoTagModal());
|
||||
document.getElementById('addPromptBtn').addEventListener('click', () => this.showAddPromptModal());
|
||||
document.getElementById('retryBtn').addEventListener('click', () => this.loadImages());
|
||||
@@ -104,30 +102,14 @@
|
||||
document.getElementById('closeSettingsBtn').addEventListener('click', () => this.hideSettings());
|
||||
document.getElementById('saveSettingsBtn').addEventListener('click', () => this.saveSettings());
|
||||
document.getElementById('resetSettingsBtn').addEventListener('click', () => this.resetSettings());
|
||||
document.getElementById('generateThumbnailsBtn').addEventListener('click', () => this.generateThumbnails());
|
||||
document.getElementById('clearCacheBtn').addEventListener('click', () => this.clearCache());
|
||||
document.getElementById('rescanFolderBtn').addEventListener('click', () => this.rescanFolder());
|
||||
document.getElementById('scanDuplicatesBtn').addEventListener('click', () => this.scanDuplicates());
|
||||
document.getElementById('dupScanPill').addEventListener('click', () => this.openDuplicatesModal());
|
||||
document.getElementById('galleryScroll').addEventListener('scroll', () => this.maybeLoadMore(), { passive: true });
|
||||
|
||||
// Duplicates modal event listeners
|
||||
document.getElementById('closeDuplicatesBtn').addEventListener('click', () => this.hideDuplicatesModal());
|
||||
document.getElementById('selectAllBtn').addEventListener('click', () => this.selectAllDuplicates());
|
||||
document.getElementById('removeDuplicatesBtn').addEventListener('click', () => this.removeDuplicates());
|
||||
|
||||
// Close modal on outside click
|
||||
document.getElementById('settingsModal').addEventListener('click', (e) => {
|
||||
if (e.target.id === 'settingsModal') {
|
||||
this.hideSettings();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('duplicatesModal').addEventListener('click', (e) => {
|
||||
if (e.target.id === 'duplicatesModal') {
|
||||
this.hideDuplicatesModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Auto Tag modal event listeners
|
||||
document.getElementById('startAutoTagBtn').addEventListener('click', () => this.startAutoTag());
|
||||
@@ -1729,36 +1711,16 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
/** Page options: only the page's own settings (System › Appearance owns the rest of `gallerySettings`). */
|
||||
loadSettingsFromStorage() {
|
||||
const settings = this.getSettings();
|
||||
|
||||
// Load checkbox settings
|
||||
document.getElementById('lazyLoadingToggle').checked = settings.lazyLoading;
|
||||
document.getElementById('autoLoadMetadataToggle').checked = settings.autoLoadMetadata;
|
||||
document.getElementById('cacheMetadataToggle').checked = settings.cacheMetadata;
|
||||
document.getElementById('showFilePathsToggle').checked = settings.showFilePaths;
|
||||
document.getElementById('sidebarCollapsedByDefaultToggle').checked = settings.sidebarCollapsedByDefault;
|
||||
document.getElementById('infiniteScrollToggle').checked = settings.infiniteScroll;
|
||||
document.getElementById('showImageInfoToggle').checked = settings.showImageInfo;
|
||||
document.getElementById('debugModeToggle').checked = settings.debugMode;
|
||||
document.getElementById('checkThumbnailsAtStartup').checked = settings.checkThumbnailsAtStartup;
|
||||
|
||||
// Load video settings
|
||||
document.getElementById('videoAutoplayToggle').checked = settings.videoAutoplay;
|
||||
document.getElementById('videoMuteToggle').checked = settings.videoMute;
|
||||
document.getElementById('videoLoopToggle').checked = settings.videoLoop;
|
||||
|
||||
// Load select settings
|
||||
document.getElementById('imageQualitySelect').value = settings.imageQuality;
|
||||
document.getElementById('defaultViewModeSelect').value = settings.defaultViewMode;
|
||||
document.getElementById('lazyLoadingToggle').checked = settings.lazyLoading;
|
||||
document.getElementById('defaultLimitSelect').value = settings.defaultLimit;
|
||||
document.getElementById('gridColumnsSelect').value = settings.gridColumns;
|
||||
|
||||
// Load input settings
|
||||
document.getElementById('infiniteScrollToggle').checked = settings.infiniteScroll;
|
||||
document.getElementById('debugModeToggle').checked = settings.debugMode;
|
||||
document.getElementById('apiTimeoutInput').value = settings.apiTimeout;
|
||||
|
||||
// Update thumbnail status
|
||||
document.getElementById('thumbnailStatus').textContent = settings.thumbnailsGenerated ? 'Generated' : 'Not generated';
|
||||
}
|
||||
|
||||
getSettings() {
|
||||
@@ -1773,28 +1735,17 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
}
|
||||
|
||||
saveSettings() {
|
||||
// The workspace shares this object (its layout and density, #249 System S3), so a Save starts from what is
|
||||
// The workspace shares this object (System › Appearance, #249 System S3), so a Save starts from what is
|
||||
// stored and keeps every key this dialog does not edit.
|
||||
const settings = {
|
||||
...this.getSettings(),
|
||||
checkThumbnailsAtStartup: document.getElementById('checkThumbnailsAtStartup').checked,
|
||||
lazyLoading: document.getElementById('lazyLoadingToggle').checked,
|
||||
imageQuality: document.getElementById('imageQualitySelect').value,
|
||||
defaultViewMode: document.getElementById('defaultViewModeSelect').value,
|
||||
defaultLimit: parseInt(document.getElementById('defaultLimitSelect').value),
|
||||
gridColumns: parseInt(document.getElementById('gridColumnsSelect').value),
|
||||
showImageInfo: document.getElementById('showImageInfoToggle').checked,
|
||||
autoLoadMetadata: document.getElementById('autoLoadMetadataToggle').checked,
|
||||
cacheMetadata: document.getElementById('cacheMetadataToggle').checked,
|
||||
showFilePaths: document.getElementById('showFilePathsToggle').checked,
|
||||
sidebarCollapsedByDefault: document.getElementById('sidebarCollapsedByDefaultToggle').checked,
|
||||
infiniteScroll: document.getElementById('infiniteScrollToggle').checked,
|
||||
debugMode: document.getElementById('debugModeToggle').checked,
|
||||
apiTimeout: parseInt(document.getElementById('apiTimeoutInput').value),
|
||||
checkThumbnailsAtStartup: document.getElementById('checkThumbnailsAtStartup').checked,
|
||||
// Video settings
|
||||
videoAutoplay: document.getElementById('videoAutoplayToggle').checked,
|
||||
videoMute: document.getElementById('videoMuteToggle').checked,
|
||||
videoLoop: document.getElementById('videoLoopToggle').checked
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -1810,12 +1761,22 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
}
|
||||
}
|
||||
|
||||
/** Resets only the Page options keys to their defaults; every other stored key stays (#330 S13-13). */
|
||||
resetSettings() {
|
||||
if (confirm('Reset all settings to defaults? This cannot be undone.')) {
|
||||
localStorage.removeItem('gallerySettings');
|
||||
this.loadSettingsFromStorage();
|
||||
this.showNotification('Settings reset to defaults', 'success');
|
||||
if (!confirm('Reset these page options to their defaults?')) return;
|
||||
const defaults = GallerySettings.DEFAULT_SETTINGS;
|
||||
const settings = { ...this.getSettings() };
|
||||
for (const key of GalleryManager.PAGE_OPTION_KEYS) settings[key] = defaults[key];
|
||||
try {
|
||||
localStorage.setItem('gallerySettings', JSON.stringify(settings));
|
||||
} catch (e) {
|
||||
console.error('Failed to reset page options:', e);
|
||||
this.showNotification('Failed to reset page options', 'error');
|
||||
return;
|
||||
}
|
||||
this.loadSettingsFromStorage();
|
||||
this.applySettings(settings);
|
||||
this.showNotification('Page options reset to defaults', 'success');
|
||||
}
|
||||
|
||||
applySettings(settings) {
|
||||
@@ -1849,73 +1810,6 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
gridContainer.classList.add('gallery-columns-custom');
|
||||
}
|
||||
|
||||
async generateThumbnails() {
|
||||
const btn = document.getElementById('generateThumbnailsBtn');
|
||||
const status = document.getElementById('thumbnailStatus');
|
||||
const progressContainer = document.getElementById('thumbnailProgress');
|
||||
const progressBar = document.getElementById('progressBar');
|
||||
const progressText = document.getElementById('progressText');
|
||||
const progressPercent = document.getElementById('progressPercent');
|
||||
const progressDetails = document.getElementById('progressDetails');
|
||||
const progressETA = document.getElementById('progressETA');
|
||||
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Generating...';
|
||||
status.textContent = 'Initializing thumbnail generation...';
|
||||
progressContainer.classList.remove('hidden');
|
||||
|
||||
// Initialize progress display
|
||||
progressBar.style.width = '0%';
|
||||
progressPercent.textContent = 'Starting...';
|
||||
progressText.textContent = 'Connecting to server...';
|
||||
progressDetails.textContent = 'Preparing to scan output directory...';
|
||||
progressETA.textContent = '';
|
||||
|
||||
try {
|
||||
// Start thumbnail generation with progress updates
|
||||
const result = await this.generateThumbnailsWithProgress({
|
||||
quality: document.getElementById('imageQualitySelect').value
|
||||
});
|
||||
|
||||
// Update settings on success
|
||||
const settings = this.getSettings();
|
||||
settings.thumbnailsGenerated = true;
|
||||
localStorage.setItem('gallerySettings', JSON.stringify(settings));
|
||||
|
||||
status.textContent = 'Generation completed';
|
||||
progressText.textContent = 'Completed!';
|
||||
progressDetails.textContent = `Generated ${result.count} new, skipped ${result.skipped || 0} existing (${result.total_images} total) in ${result.elapsed_time}s`;
|
||||
|
||||
this.notifyThumbnailsDone(result);
|
||||
|
||||
// Hide progress after a delay
|
||||
setTimeout(() => {
|
||||
progressContainer.classList.add('hidden');
|
||||
}, 3000);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Thumbnail generation error:', error);
|
||||
console.error('Error details:', error.stack);
|
||||
status.textContent = 'Generation failed';
|
||||
progressText.textContent = 'Failed';
|
||||
progressDetails.textContent = error.message;
|
||||
progressBar.style.width = '0%';
|
||||
progressBar.classList.add('bg-pm-error');
|
||||
progressPercent.textContent = 'Error';
|
||||
|
||||
this.showNotification('Failed to generate thumbnails: ' + error.message, 'error');
|
||||
|
||||
// Hide progress after a delay
|
||||
setTimeout(() => {
|
||||
progressContainer.classList.add('hidden');
|
||||
progressBar.classList.remove('bg-pm-error');
|
||||
}, 5000);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Generate';
|
||||
}
|
||||
}
|
||||
|
||||
/** Ask the server to stop thumbnail job `jobId` at its next file boundary (#249 System S5). */
|
||||
async cancelThumbnailJob(jobId) {
|
||||
try {
|
||||
@@ -1964,564 +1858,6 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
};
|
||||
}
|
||||
|
||||
async generateThumbnailsWithProgress(options) {
|
||||
const progressBar = document.getElementById('progressBar');
|
||||
const progressText = document.getElementById('progressText');
|
||||
const progressPercent = document.getElementById('progressPercent');
|
||||
const progressDetails = document.getElementById('progressDetails');
|
||||
const progressETA = document.getElementById('progressETA');
|
||||
const cancelBtn = document.getElementById('cancelThumbnailsBtn');
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// Show cancel button
|
||||
cancelBtn.classList.remove('hidden');
|
||||
|
||||
|
||||
// Set up Server-Sent Events
|
||||
// The progress route is POST (it generates files), so read it with SseStream like the autotag streams
|
||||
const eventSource = SseStream.connect(`/prompt_manager/images/generate-thumbnails/progress?quality=${encodeURIComponent(options.quality)}`, { method: 'POST' });
|
||||
|
||||
// Log connection
|
||||
|
||||
let cancelled = false;
|
||||
let resultData = null;
|
||||
const canceller = this.thumbnailCanceller(eventSource);
|
||||
eventSource.addEventListener('job', event => canceller.identify(event.data));
|
||||
|
||||
// Handle cancellation. The work is an Activity job that outlives this stream, so closing it no
|
||||
// longer stops anything: ask the server to stop this stream's job by its id (review I1).
|
||||
const cancelHandler = () => {
|
||||
if (cancelled) return;
|
||||
cancelled = true;
|
||||
cancelBtn.removeEventListener('click', cancelHandler);
|
||||
cancelBtn.classList.add('hidden');
|
||||
const following = canceller.following;
|
||||
canceller.request();
|
||||
reject(new Error(following ? GalleryManager.THUMBNAILS_STOPPED_FOLLOWING : 'Thumbnail generation cancelled by user'));
|
||||
};
|
||||
cancelBtn.addEventListener('click', cancelHandler);
|
||||
|
||||
// Handle scanning/status events
|
||||
eventSource.addEventListener('status', (event) => {
|
||||
if (cancelled) return;
|
||||
const data = JSON.parse(event.data);
|
||||
progressText.textContent = data.message || 'Processing...';
|
||||
if (data.phase === 'scanning') {
|
||||
progressDetails.textContent = 'Scanning output folder for images and videos...';
|
||||
progressBar.style.width = '0%';
|
||||
progressPercent.textContent = 'Scanning...';
|
||||
} else {
|
||||
progressDetails.textContent = 'Preparing thumbnail generation...';
|
||||
}
|
||||
|
||||
// Log status updates
|
||||
if (this.getSettings().debugMode) {
|
||||
}
|
||||
});
|
||||
|
||||
// Handle start event
|
||||
eventSource.addEventListener('start', (event) => {
|
||||
if (cancelled) return;
|
||||
const data = JSON.parse(event.data);
|
||||
// Show detailed file type breakdown if available
|
||||
const fileBreakdown = (data.image_count !== undefined && data.video_count !== undefined)
|
||||
? ` (${data.image_count} images, ${data.video_count} videos)`
|
||||
: '';
|
||||
progressText.textContent = `Starting to process ${data.total_images} media files${fileBreakdown}...`;
|
||||
progressDetails.textContent = data.message || `Found ${data.total_images} images and videos to process`;
|
||||
progressBar.style.width = '0%';
|
||||
progressPercent.textContent = '0%';
|
||||
progressETA.textContent = 'Calculating...';
|
||||
|
||||
// Log start event
|
||||
if (this.getSettings().debugMode) {
|
||||
}
|
||||
});
|
||||
|
||||
// Handle progress updates
|
||||
eventSource.addEventListener('progress', (event) => {
|
||||
if (cancelled) return;
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
// Update progress bar
|
||||
progressBar.style.width = `${data.percentage}%`;
|
||||
progressPercent.textContent = `${data.percentage}%`;
|
||||
|
||||
// Update text with current file being processed and action
|
||||
const fileInfo = data.current_file ? ` - ${data.current_file}` : '';
|
||||
const actionText = data.action === 'skipped' ? ' [Skipping]' : data.action === 'generating' ? ' [Generating]' : '';
|
||||
progressText.textContent = `Processing ${data.file_type || 'file'}... (${data.processed}/${data.total_images})${fileInfo}${actionText}`;
|
||||
|
||||
// More detailed progress information
|
||||
const elapsedText = data.elapsed ? ` | Time: ${Math.floor(data.elapsed)}s` : '';
|
||||
const failedText = data.error_count ? `, Failed: ${data.error_count}` : '';
|
||||
progressDetails.textContent = `Generated: ${data.generated}, Skipped: ${data.skipped}${failedText} | Rate: ${data.rate} img/s${elapsedText}`;
|
||||
|
||||
// Update ETA
|
||||
if (data.eta > 0 && data.eta < 3600) {
|
||||
const etaText = data.eta > 60 ? `${Math.floor(data.eta / 60)}m ${Math.floor(data.eta % 60)}s` : `${Math.floor(data.eta)}s`;
|
||||
progressETA.textContent = `ETA: ${etaText}`;
|
||||
} else {
|
||||
progressETA.textContent = '';
|
||||
}
|
||||
|
||||
// Log to console for debugging if debug mode is enabled
|
||||
if (this.getSettings().debugMode) {
|
||||
}
|
||||
});
|
||||
|
||||
// Handle completion
|
||||
eventSource.addEventListener('complete', (event) => {
|
||||
if (cancelled) { canceller.ended(); return; }
|
||||
resultData = JSON.parse(event.data);
|
||||
|
||||
// Update final progress
|
||||
progressBar.style.width = '100%';
|
||||
progressPercent.textContent = '100%';
|
||||
progressText.textContent = 'Completed!';
|
||||
|
||||
// Show detailed completion message
|
||||
const errorCount = resultData.error_count || (resultData.errors ? resultData.errors.length : 0);
|
||||
const errors = errorCount > 0 ? ` (${errorCount} errors)` : '';
|
||||
const rateText = resultData.processing_rate ? ` at ${resultData.processing_rate} img/s` : '';
|
||||
progressDetails.textContent = `Generated: ${resultData.count} new, Skipped: ${resultData.skipped} existing | Total: ${resultData.total_images} files | Time: ${resultData.elapsed_time}s${rateText}${errors}`;
|
||||
|
||||
// Show first error if any occurred
|
||||
if (resultData.errors && resultData.errors.length > 0 && this.getSettings().debugMode) {
|
||||
console.error('First thumbnail error:', resultData.errors[0]);
|
||||
}
|
||||
progressETA.textContent = 'Done';
|
||||
|
||||
// Log completion details
|
||||
if (this.getSettings().debugMode) {
|
||||
if (resultData.errors && resultData.errors.length > 0) {
|
||||
console.warn('Thumbnail generation errors:', resultData.errors);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up
|
||||
eventSource.close();
|
||||
cancelBtn.removeEventListener('click', cancelHandler);
|
||||
cancelBtn.classList.add('hidden');
|
||||
|
||||
resolve(resultData);
|
||||
});
|
||||
|
||||
// Per-file failures: count them, keep listening
|
||||
eventSource.addEventListener('file_error', (event) => {
|
||||
if (cancelled) return;
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
console.warn('Thumbnail failed:', data.current_file || data.file || '', data.error || '');
|
||||
} catch (e) {
|
||||
console.warn('Thumbnail failed (unparseable event)');
|
||||
}
|
||||
});
|
||||
|
||||
// Handle errors
|
||||
eventSource.addEventListener('error', (event) => {
|
||||
if (cancelled) { canceller.ended(); return; }
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
// Clean up
|
||||
eventSource.close();
|
||||
cancelBtn.removeEventListener('click', cancelHandler);
|
||||
cancelBtn.classList.add('hidden');
|
||||
|
||||
reject(new Error(data.error || 'Thumbnail generation failed'));
|
||||
});
|
||||
|
||||
// Handle EventSource errors
|
||||
eventSource.onerror = (error) => {
|
||||
if (cancelled) { canceller.ended(); return; }
|
||||
console.error('EventSource error:', error);
|
||||
console.error('EventSource readyState:', eventSource.readyState);
|
||||
|
||||
// Check if connection is closing normally
|
||||
if (eventSource.readyState === eventSource.CLOSED) {
|
||||
} else {
|
||||
console.error('EventSource connection failed');
|
||||
}
|
||||
|
||||
// Clean up
|
||||
eventSource.close();
|
||||
cancelBtn.removeEventListener('click', cancelHandler);
|
||||
cancelBtn.classList.add('hidden');
|
||||
|
||||
// Only reject if we don't have result data
|
||||
if (!resultData) {
|
||||
reject(new Error('Connection to server lost during thumbnail generation'));
|
||||
}
|
||||
};
|
||||
|
||||
// Stream ended without a complete/error frame (close() does not trigger this)
|
||||
eventSource.onclose = () => {
|
||||
if (cancelled) canceller.ended();
|
||||
if (cancelled || resultData) return;
|
||||
cancelBtn.removeEventListener('click', cancelHandler);
|
||||
cancelBtn.classList.add('hidden');
|
||||
reject(new Error('Connection to server closed before thumbnail generation finished'));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 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.')) 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() {
|
||||
const btn = document.getElementById('rescanFolderBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Scanning...';
|
||||
|
||||
try {
|
||||
// The listing is cached on the server; ask it to rewalk the folders first
|
||||
const result = await this.api.post('/prompt_manager/gallery/rescan', {});
|
||||
this.hideSettings();
|
||||
await Promise.all([this.loadFolderTree(), this.loadImages(1)]);
|
||||
const where = result.roots === 1 ? 'the output folder' : `${result.roots} folders`;
|
||||
this.showNotification(`Rescanned ${where}: ${result.total} files`, 'success');
|
||||
} catch (error) {
|
||||
console.error('Rescan error:', error);
|
||||
this.showNotification('Failed to rescan folder', 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Rescan';
|
||||
}
|
||||
}
|
||||
|
||||
// ── Duplicate scan ──────────────────────────────────────────────
|
||||
// The scan runs on the server (POST /scan_duplicates/stream starts or attaches).
|
||||
// Closing the modal only hides it: a pill shows progress and reopens it, and a
|
||||
// reload re-attaches to a scan that is still running.
|
||||
scanDuplicates() {
|
||||
if (this.dupScan.status === ScanProgress.RUNNING) {
|
||||
this.openDuplicatesModal();
|
||||
return;
|
||||
}
|
||||
this.dupResults = null;
|
||||
this.dupScan = ScanProgress.reduceScanEvent(ScanProgress.initialState(), {
|
||||
type: 'progress', progress: 0, status: 'Starting...', processed: 0, found: 0,
|
||||
});
|
||||
this.setDuplicateButton(true);
|
||||
this.openDuplicatesModal();
|
||||
this.attachDuplicateStream();
|
||||
}
|
||||
|
||||
attachDuplicateStream() {
|
||||
if (this.dupSource) this.dupSource.close();
|
||||
const source = SseStream.connect('/prompt_manager/scan_duplicates/stream', { method: 'POST', body: '{}' });
|
||||
this.dupSource = source;
|
||||
source.onmessage = (e) => {
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(e.data);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
this.applyDuplicateEvent(data);
|
||||
};
|
||||
source.onerror = () => {
|
||||
if (this.dupSource !== source || this.dupScan.status !== ScanProgress.RUNNING) return;
|
||||
setTimeout(() => this.resumeDuplicateScan(), 2000);
|
||||
};
|
||||
source.onclose = () => {
|
||||
if (this.dupSource === source && this.dupScan.status === ScanProgress.RUNNING) this.resumeDuplicateScan();
|
||||
};
|
||||
}
|
||||
|
||||
async resumeDuplicateScan() {
|
||||
let status;
|
||||
try {
|
||||
const response = await ApiClient.fetchUrl('/prompt_manager/scan_duplicates/status');
|
||||
if (!response.ok) return;
|
||||
status = await response.json();
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
const state = ScanProgress.fromStatus(status);
|
||||
if (state.status !== ScanProgress.RUNNING) {
|
||||
if (this.dupScan.status === ScanProgress.RUNNING) {
|
||||
// The scan we were following ended while we were away
|
||||
const last = status.last_event;
|
||||
const terminal = last && (last.type === 'complete' || last.type === 'error');
|
||||
this.applyDuplicateEvent(terminal ? last : { type: 'error', message: 'the server no longer reports a running scan' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
this.dupScan = state;
|
||||
this.setDuplicateButton(true);
|
||||
this.renderDuplicateProgress();
|
||||
if (document.getElementById('duplicatesModal').classList.contains('hidden')) this.showDuplicatePill();
|
||||
this.attachDuplicateStream();
|
||||
}
|
||||
|
||||
applyDuplicateEvent(data) {
|
||||
const next = ScanProgress.reduceScanEvent(this.dupScan, data);
|
||||
if (next === this.dupScan) return;
|
||||
this.dupScan = next;
|
||||
this.renderDuplicateProgress();
|
||||
if (next.status === ScanProgress.DONE) {
|
||||
this.dupResults = Array.isArray(data.duplicates) ? data.duplicates : [];
|
||||
this.setDuplicateButton(false);
|
||||
const modalOpen = !document.getElementById('duplicatesModal').classList.contains('hidden');
|
||||
if (modalOpen) {
|
||||
this.hideDuplicatePill();
|
||||
this.displayDuplicates(this.dupResults);
|
||||
} else {
|
||||
this.showDuplicatePill(`Duplicate scan done: ${this.dupResults.length} groups · click to view`);
|
||||
}
|
||||
this.showNotification(`Found ${this.dupResults.length} duplicate image groups`, 'info');
|
||||
} else if (next.status === ScanProgress.FAILED) {
|
||||
this.setDuplicateButton(false);
|
||||
this.hideDuplicatePill();
|
||||
this.hideDuplicatesModal();
|
||||
this.showNotification(`Duplicate scan failed: ${next.message}`, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
renderDuplicateProgress() {
|
||||
const state = this.dupScan;
|
||||
const bar = document.getElementById('dupProgressBar');
|
||||
const text = document.getElementById('dupProgressText');
|
||||
if (bar) bar.style.width = `${state.progress}%`;
|
||||
if (text) text.textContent = state.status === ScanProgress.RUNNING ? `${state.progress}% · ${state.processed} files hashed` : state.statusText;
|
||||
const pill = document.getElementById('dupScanPillText');
|
||||
if (pill && state.status === ScanProgress.RUNNING) pill.textContent = `Finding duplicates ${state.progress}% · ${state.processed} files`;
|
||||
}
|
||||
|
||||
setDuplicateButton(running) {
|
||||
const btn = document.getElementById('scanDuplicatesBtn');
|
||||
if (!btn) return;
|
||||
btn.disabled = running;
|
||||
btn.textContent = running ? 'Scanning...' : 'Scan';
|
||||
}
|
||||
|
||||
showDuplicatePill(label) {
|
||||
const pill = document.getElementById('dupScanPill');
|
||||
if (label) document.getElementById('dupScanPillText').textContent = label;
|
||||
pill.classList.remove('hidden');
|
||||
}
|
||||
|
||||
hideDuplicatePill() {
|
||||
document.getElementById('dupScanPill').classList.add('hidden');
|
||||
}
|
||||
|
||||
/** Show the modal in whatever state the scan is in: progress, or results. */
|
||||
openDuplicatesModal() {
|
||||
this.hideDuplicatePill();
|
||||
document.getElementById('duplicatesModal').classList.remove('hidden');
|
||||
document.getElementById('duplicatesModal').classList.add('flex');
|
||||
document.body.style.overflow = 'hidden';
|
||||
if (this.dupScan.status === ScanProgress.DONE && this.dupResults) {
|
||||
this.displayDuplicates(this.dupResults);
|
||||
return;
|
||||
}
|
||||
document.getElementById('duplicatesScanStatus').classList.remove('hidden');
|
||||
document.getElementById('duplicatesContent').classList.add('hidden');
|
||||
document.getElementById('duplicatesFooter').classList.add('hidden');
|
||||
this.renderDuplicateProgress();
|
||||
}
|
||||
|
||||
showDuplicatesModal() {
|
||||
this.openDuplicatesModal();
|
||||
}
|
||||
|
||||
hideDuplicatesModal() {
|
||||
document.getElementById('duplicatesModal').classList.add('hidden');
|
||||
document.getElementById('duplicatesModal').classList.remove('flex');
|
||||
document.body.style.overflow = '';
|
||||
// Minimize rather than abandon: the scan keeps running on the server
|
||||
if (this.dupScan.status === ScanProgress.RUNNING) this.showDuplicatePill();
|
||||
}
|
||||
|
||||
displayDuplicates(duplicates) {
|
||||
const scanStatus = document.getElementById('duplicatesScanStatus');
|
||||
const content = document.getElementById('duplicatesContent');
|
||||
const footer = document.getElementById('duplicatesFooter');
|
||||
const list = document.getElementById('duplicatesList');
|
||||
const count = document.getElementById('duplicatesCount');
|
||||
|
||||
scanStatus.classList.add('hidden');
|
||||
|
||||
if (duplicates.length === 0) {
|
||||
list.innerHTML = '<div class="text-center py-4 text-pm-secondary">No duplicate images found! 🎉</div>';
|
||||
content.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
count.textContent = duplicates.length;
|
||||
list.innerHTML = '';
|
||||
|
||||
duplicates.forEach((group, groupIndex) => {
|
||||
const groupEl = document.createElement('div');
|
||||
groupEl.className = 'bg-pm-surface rounded-pm-md p-4 border border-pm';
|
||||
|
||||
groupEl.innerHTML = `
|
||||
<div class="mb-3 text-sm font-medium text-pm-secondary">
|
||||
Duplicate Group ${groupIndex + 1} (${group.images.length} identical ${escapeHtml(group.images[0].media_type)}s)
|
||||
</div>
|
||||
<div class="mb-3 p-3 bg-pm-primary rounded text-sm text-pm-secondary">
|
||||
<strong>Content Hash:</strong> ${escapeHtml(String(group.hash || '').substring(0, 16))}...
|
||||
</div>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
${group.images.map((image, imageIndex) => `
|
||||
<div class="flex items-start p-3 bg-pm-primary rounded border border-pm ${imageIndex === 0 ? 'border-pm-success' : ''}">
|
||||
<div class="flex-shrink-0 mr-3">
|
||||
${image.media_type === 'video' ? `
|
||||
<div class="w-20 h-20 bg-pm-input rounded flex items-center justify-center relative">
|
||||
${image.thumbnail_url ? `
|
||||
<img src="${escapeHtml(ApiClient.resolveUrl(image.thumbnail_url))}" alt="Video thumbnail" class="w-full h-full object-cover rounded">
|
||||
` : `
|
||||
<svg class="w-8 h-8 text-pm-secondary" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M2 6a2 2 0 012-2h6l2 2h6a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6zM14.553 7.106A1 1 0 0014 8v4a1 1 0 00.553.894l2 1A1 1 0 0018 13V7a1 1 0 00-1.447-.894l-2 1z"/>
|
||||
</svg>
|
||||
`}
|
||||
<div class="absolute bottom-0 right-0 bg-black/75 text-pm text-xs px-1 rounded">VIDEO</div>
|
||||
</div>
|
||||
` : `
|
||||
<img src="${escapeHtml(ApiClient.resolveUrl(image.thumbnail_url || image.url))}" alt="${escapeHtml(image.filename)}"
|
||||
class="w-20 h-20 object-cover rounded cursor-pointer hover:opacity-80"
|
||||
onclick="this.parentElement.parentElement.querySelector('.image-preview').click()">
|
||||
`}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm text-pm-secondary mb-2">
|
||||
<div class="font-medium truncate" title="${escapeHtml(image.filename)}">${escapeHtml(image.filename)}</div>
|
||||
<div class="text-xs text-pm-secondary mt-1">
|
||||
<span class="font-medium">Size:</span> ${this.formatFileSize(image.size)} •
|
||||
<span class="font-medium">Modified:</span> ${new Date(image.modified_time * 1000).toLocaleDateString()}
|
||||
</div>
|
||||
<div class="text-xs text-pm-muted mt-1 truncate" title="${escapeHtml(image.relative_path)}">
|
||||
${escapeHtml(image.relative_path)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-2">
|
||||
<button class="image-preview text-xs bg-pm-accent hover:bg-pm-accent-hover text-pm px-2 py-1 rounded"
|
||||
data-action="open-url" data-url="${escapeHtml(ApiClient.resolveUrl(image.url))}">
|
||||
View Full
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center">
|
||||
${imageIndex === 0 ? `
|
||||
<span class="px-3 py-1 bg-pm-success text-pm text-xs rounded">KEEP (Oldest)</span>
|
||||
` : `
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" class="duplicate-checkbox" data-group="${groupIndex}" data-image-path="${escapeHtml(image.path)}"
|
||||
class="w-4 h-4 text-pm-error bg-pm-input border-pm rounded focus:ring-pm-error">
|
||||
<span class="ml-2 text-sm text-pm-error">Delete</span>
|
||||
</label>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
list.appendChild(groupEl);
|
||||
});
|
||||
|
||||
content.classList.remove('hidden');
|
||||
footer.classList.remove('hidden');
|
||||
}
|
||||
|
||||
selectAllDuplicates() {
|
||||
const checkboxes = document.querySelectorAll('.duplicate-checkbox');
|
||||
const selectBtn = document.getElementById('selectAllBtn');
|
||||
|
||||
const allChecked = Array.from(checkboxes).every(cb => cb.checked);
|
||||
|
||||
checkboxes.forEach(cb => {
|
||||
cb.checked = !allChecked;
|
||||
});
|
||||
|
||||
selectBtn.textContent = allChecked ? 'Select All to Delete' : 'Deselect All';
|
||||
}
|
||||
|
||||
async removeDuplicates() {
|
||||
const checkboxes = document.querySelectorAll('.duplicate-checkbox:checked');
|
||||
|
||||
if (checkboxes.length === 0) {
|
||||
this.showNotification('No duplicate images selected for removal', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmation = confirm(`Are you sure you want to permanently delete ${checkboxes.length} duplicate image files? This action cannot be undone!`);
|
||||
if (!confirmation) return;
|
||||
|
||||
const btn = document.getElementById('removeDuplicatesBtn');
|
||||
btn.disabled = true;
|
||||
btn.textContent = 'Deleting...';
|
||||
|
||||
try {
|
||||
const imagePaths = Array.from(checkboxes).map(cb => cb.dataset.imagePath);
|
||||
|
||||
const response = await ApiClient.fetchUrl('/prompt_manager/delete_duplicate_images', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ image_paths: imagePaths })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
this.showNotification(`Successfully deleted ${imagePaths.length} duplicate image files`, 'success');
|
||||
this.hideDuplicatesModal();
|
||||
this.loadImages(); // Refresh the gallery
|
||||
} else {
|
||||
throw new Error(data.error || 'Failed to delete duplicate images');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Remove duplicates error:', error);
|
||||
this.showNotification('Failed to delete duplicate images', 'error');
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = 'Remove Selected';
|
||||
}
|
||||
}
|
||||
|
||||
formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
@@ -2621,7 +1957,7 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
${missingCount} images need new or updated thumbnails (${imageCount} total images). Generate them for faster loading?
|
||||
</p>
|
||||
<p class="text-sm text-pm-muted mb-3">
|
||||
Only missing or outdated thumbnails will be generated. Existing current thumbnails will be kept. You can change startup checks in Gallery Settings.
|
||||
Only missing or outdated thumbnails will be generated. Existing current thumbnails will be kept. You can change startup checks in Page options.
|
||||
</p>
|
||||
<div class="flex space-x-3">
|
||||
<button id="thumbnailPromptNo" class="flex-1 px-4 py-2 bg-pm-input hover:bg-pm-hover text-pm rounded-pm-md transition-colors">
|
||||
|
||||
Reference in New Issue
Block a user