/** * Duplicate Image Finder Dialog * Scans for duplicate images by hash and allows selective deletion */ import { api } from '../../../../scripts/api.js'; import { createDialog } from '../components/dialogManager.js'; import { handleError } from '../shared/errorHandler.js'; /** * Show duplicate finder dialog * @param {string} folderPath - Path to the folder to scan * @param {boolean} includeSubfolders - Whether to include subfolders in the scan * @param {Function} onComplete - Callback function when operation completes */ export async function showDuplicateFinderDialog(folderPath, includeSubfolders = false, onComplete = null) { const content = document.createElement('div'); content.style.cssText = ` min-width: 800px; max-width: 1000px; min-height: 500px; `; const dialog = createDialog({ title: 'Find Duplicate Images', content: content, width: '900px', height: 'auto', showFooter: true, closeOnOverlayClick: false }); // Create scanning section const scanSection = document.createElement('div'); scanSection.style.cssText = ` margin-bottom: 20px; padding: 15px; background: #1e1e1e; border-radius: 6px; border: 1px solid #444; `; const folderInfo = document.createElement('div'); folderInfo.style.cssText = 'color: #ccc; margin-bottom: 10px; font-size: 13px;'; folderInfo.innerHTML = `
Scanning folder: ${folderPath}
`; scanSection.appendChild(folderInfo); // Add checkbox for including subfolders const subfolderCheckboxContainer = document.createElement('div'); subfolderCheckboxContainer.style.cssText = ` display: flex; align-items: center; gap: 8px; margin-bottom: 15px; `; const subfolderCheckbox = document.createElement('input'); subfolderCheckbox.type = 'checkbox'; subfolderCheckbox.id = 'include-subfolders-checkbox'; subfolderCheckbox.checked = includeSubfolders; subfolderCheckbox.style.cssText = 'cursor: pointer;'; const subfolderLabel = document.createElement('label'); subfolderLabel.htmlFor = 'include-subfolders-checkbox'; subfolderLabel.textContent = 'Include subfolders in scan'; subfolderLabel.style.cssText = ` color: #ccc; font-size: 13px; cursor: pointer; user-select: none; `; subfolderCheckboxContainer.appendChild(subfolderCheckbox); subfolderCheckboxContainer.appendChild(subfolderLabel); scanSection.appendChild(subfolderCheckboxContainer); const progressContainer = document.createElement('div'); progressContainer.style.cssText = ` margin-top: 15px; padding: 12px; background: #2a2a2a; border-radius: 4px; border: 1px solid #555; `; const progressText = document.createElement('div'); progressText.style.cssText = 'color: #4CAF50; margin-bottom: 8px; font-size: 14px;'; progressText.textContent = 'Initializing scan...'; progressContainer.appendChild(progressText); const progressBarOuter = document.createElement('div'); progressBarOuter.style.cssText = ` width: 100%; height: 20px; background: #333; border-radius: 10px; overflow: hidden; border: 1px solid #555; position: relative; `; const progressBarInner = document.createElement('div'); progressBarInner.style.cssText = ` width: 0%; height: 100%; background: linear-gradient(90deg, #4CAF50, #66BB6A); transition: width 0.3s ease; `; progressBarOuter.appendChild(progressBarInner); const progressPercent = document.createElement('div'); progressPercent.style.cssText = ` position: absolute; top: 0; left: 0; right: 0; bottom: 0; display: flex; align-items: center; justify-content: center; color: white; font-weight: bold; font-size: 11px; text-shadow: 0 1px 2px rgba(0,0,0,0.8); `; progressPercent.textContent = '0%'; progressBarOuter.appendChild(progressPercent); progressContainer.appendChild(progressBarOuter); scanSection.appendChild(progressContainer); content.appendChild(scanSection); // Create results section (initially hidden) const resultsSection = document.createElement('div'); resultsSection.style.cssText = 'display: none;'; content.appendChild(resultsSection); // Show the dialog dialog.show(); // Add scan button to start the scan const scanButton = dialog.addFooterButton('Start Scan', async () => { // Disable scan button during scan scanButton.disabled = true; scanButton.style.opacity = '0.5'; scanButton.style.cursor = 'not-allowed'; // Get the current checkbox value const includeSubfoldersValue = subfolderCheckbox.checked; // Start scanning try { progressText.textContent = 'Scanning for duplicate images...'; progressBarInner.style.width = '50%'; progressPercent.textContent = '50%'; const response = await api.fetchApi('/sage_utils/find_duplicates', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ folder_path: folderPath, include_subfolders: includeSubfoldersValue }) }); if (!response.ok) { throw new Error(`Server error: ${response.statusText}`); } const result = await response.json(); if (!result.success) { throw new Error(result.error || 'Failed to find duplicates'); } // Update progress to complete progressBarInner.style.width = '100%'; progressPercent.textContent = '100%'; progressText.textContent = `Scan complete: ${result.total_images} images scanned`; // Hide scan section and show results setTimeout(() => { scanSection.style.display = 'none'; resultsSection.style.display = 'block'; displayDuplicates(resultsSection, result, dialog, folderPath, onComplete); }, 500); } catch (error) { progressText.textContent = 'Error: ' + error.message; progressText.style.color = '#f44336'; progressBarInner.style.background = '#f44336'; handleError(error, 'Failed to scan for duplicates'); // Re-enable scan button on error scanButton.disabled = false; scanButton.style.opacity = '1'; scanButton.style.cursor = 'pointer'; } }, { background: '#4CAF50' }); dialog.addFooterButton('Cancel', () => { dialog.close(); if (onComplete) onComplete(); }, { background: '#666' }); } /** * Display duplicate results and allow user to select which files to delete * @param {HTMLElement} container - Container element * @param {Object} result - Results from duplicate scan * @param {Object} dialog - Dialog object * @param {string} folderPath - Original folder path * @param {Function} onComplete - Callback function */ function displayDuplicates(container, result, dialog, folderPath, onComplete) { container.innerHTML = ''; // Summary section const summary = document.createElement('div'); summary.style.cssText = ` padding: 15px; background: #1e1e1e; border-radius: 6px; border: 1px solid #444; margin-bottom: 20px; `; if (result.duplicate_groups === 0) { summary.innerHTML = `
No duplicates found!
All ${result.total_images} images in this folder are unique.
`; container.appendChild(summary); dialog.addFooterButton('Close', () => { dialog.close(); if (onComplete) onComplete(); }, { background: '#4CAF50' }); return; } summary.innerHTML = `
Found ${result.duplicate_groups} duplicate group${result.duplicate_groups !== 1 ? 's' : ''}
Total images scanned: ${result.total_images}
Total duplicate files: ${result.total_duplicates} (can be deleted)
Tip: For each group, keep one image and delete the rest. Uncheck any duplicates you want to keep.
`; container.appendChild(summary); // Duplicate groups const groupsContainer = document.createElement('div'); groupsContainer.style.cssText = ` max-height: 450px; overflow-y: auto; padding: 10px; background: #2a2a2a; border-radius: 6px; border: 1px solid #444; `; // Track which images to delete (all duplicates except first by default) const imagesToDelete = new Set(); result.duplicates.forEach((group, groupIndex) => { const groupDiv = document.createElement('div'); groupDiv.style.cssText = ` margin-bottom: 20px; padding: 15px; background: #1e1e1e; border-radius: 6px; border: 1px solid #555; `; const groupHeader = document.createElement('div'); groupHeader.style.cssText = ` font-weight: bold; color: #fff; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 1px solid #444; display: flex; justify-content: space-between; align-items: center; `; groupHeader.innerHTML = ` Duplicate Group ${groupIndex + 1} - ${group.length} copies (${group[0].size_human}) `; groupDiv.appendChild(groupHeader); const imageGrid = document.createElement('div'); imageGrid.style.cssText = ` display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; `; group.forEach((image, index) => { const imageItem = createDuplicateImageItem(image, index === 0, (checked) => { if (checked) { imagesToDelete.add(image.path); } else { imagesToDelete.delete(image.path); } updateDeleteButton(); }); imageGrid.appendChild(imageItem); // Auto-select all duplicates except the first one if (index > 0) { imagesToDelete.add(image.path); } }); groupDiv.appendChild(imageGrid); groupsContainer.appendChild(groupDiv); // Select all button functionality const selectAllBtn = groupHeader.querySelector('.select-all-btn'); selectAllBtn.addEventListener('click', () => { group.forEach((image, index) => { if (index > 0) { const checkbox = imageGrid.children[index].querySelector('input[type="checkbox"]'); checkbox.checked = true; imagesToDelete.add(image.path); } }); updateDeleteButton(); }); }); container.appendChild(groupsContainer); // Footer buttons const deleteButton = dialog.addFooterButton('Delete Selected (0)', async () => { await deleteDuplicates(Array.from(imagesToDelete), dialog, folderPath, onComplete); }, { background: '#d32f2f' }); deleteButton.disabled = true; deleteButton.style.opacity = '0.5'; deleteButton.style.cursor = 'not-allowed'; dialog.addFooterButton('Cancel', () => { dialog.close(); if (onComplete) onComplete(); }, { background: '#666' }); // Update delete button text and state function updateDeleteButton() { const count = imagesToDelete.size; deleteButton.textContent = `Delete Selected (${count})`; deleteButton.disabled = count === 0; deleteButton.style.opacity = count === 0 ? '0.5' : '1'; deleteButton.style.cursor = count === 0 ? 'not-allowed' : 'pointer'; } // Initial update updateDeleteButton(); } /** * Create a duplicate image item with checkbox * @param {Object} image - Image object * @param {boolean} isOriginal - Whether this is marked as the original * @param {Function} onToggle - Callback when checkbox is toggled * @returns {HTMLElement} Image item element */ function createDuplicateImageItem(image, isOriginal, onToggle) { const item = document.createElement('div'); item.style.cssText = ` background: #2a2a2a; border: 2px solid ${isOriginal ? '#4CAF50' : '#555'}; border-radius: 6px; padding: 8px; position: relative; display: flex; flex-direction: column; `; // Thumbnail const thumbnail = document.createElement('div'); thumbnail.style.cssText = ` width: 100%; height: 120px; background: #1e1e1e; border-radius: 4px; margin-bottom: 8px; display: flex; align-items: center; justify-content: center; overflow: hidden; `; const img = document.createElement('img'); img.style.cssText = ` max-width: 100%; max-height: 100%; object-fit: contain; `; // Load thumbnail api.fetchApi('/sage_utils/thumbnail', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ image_path: image.path, size: 'small' }) }).then(response => { if (response.ok) { return response.blob(); } throw new Error('Failed to load thumbnail'); }).then(blob => { img.src = URL.createObjectURL(blob); }).catch(() => { thumbnail.innerHTML = '
No preview
'; }); thumbnail.appendChild(img); item.appendChild(thumbnail); // Info section const info = document.createElement('div'); info.style.cssText = 'color: #ccc; font-size: 11px; margin-bottom: 8px;'; info.innerHTML = `
${image.filename}
${image.size_human}
`; item.appendChild(info); // Checkbox section const checkboxContainer = document.createElement('div'); checkboxContainer.style.cssText = ` display: flex; align-items: center; gap: 6px; margin-top: auto; `; const checkbox = document.createElement('input'); checkbox.type = 'checkbox'; checkbox.checked = !isOriginal; checkbox.disabled = isOriginal; checkbox.style.cssText = 'cursor: pointer;'; const label = document.createElement('label'); label.style.cssText = ` color: ${isOriginal ? '#4CAF50' : '#fff'}; font-size: 11px; cursor: ${isOriginal ? 'default' : 'pointer'}; user-select: none; `; label.textContent = isOriginal ? 'Keep (Original)' : 'Delete'; if (!isOriginal) { checkbox.addEventListener('change', (e) => { onToggle(e.target.checked); item.style.borderColor = e.target.checked ? '#d32f2f' : '#555'; }); label.addEventListener('click', () => { checkbox.checked = !checkbox.checked; checkbox.dispatchEvent(new Event('change')); }); } checkboxContainer.appendChild(checkbox); checkboxContainer.appendChild(label); item.appendChild(checkboxContainer); // Set initial border color for checked items if (!isOriginal && checkbox.checked) { item.style.borderColor = '#d32f2f'; } return item; } /** * Delete selected duplicate images * @param {Array} imagePaths - Array of image paths to delete * @param {Object} dialog - Dialog object * @param {string} folderPath - Original folder path * @param {Function} onComplete - Callback function */ async function deleteDuplicates(imagePaths, dialog, folderPath, onComplete) { if (imagePaths.length === 0) { return; } // Show confirmation dialog const confirmMessage = ` Are you sure you want to delete ${imagePaths.length} duplicate image${imagePaths.length !== 1 ? 's' : ''}?

This action cannot be undone! `; const confirmed = await showConfirmDialog(confirmMessage); if (!confirmed) { return; } // Show progress const progressOverlay = document.createElement('div'); progressOverlay.style.cssText = ` position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.8); display: flex; align-items: center; justify-content: center; flex-direction: column; z-index: 1000; `; const progressText = document.createElement('div'); progressText.style.cssText = 'color: #4CAF50; font-size: 16px; margin-bottom: 15px;'; progressText.textContent = 'Deleting duplicates...'; progressOverlay.appendChild(progressText); const progressBarOuter = document.createElement('div'); progressBarOuter.style.cssText = ` width: 300px; height: 24px; background: #333; border-radius: 12px; overflow: hidden; border: 1px solid #555; position: relative; `; const progressBarInner = document.createElement('div'); progressBarInner.style.cssText = ` width: 0%; height: 100%; background: linear-gradient(90deg, #4CAF50, #66BB6A); transition: width 0.3s ease; `; progressBarOuter.appendChild(progressBarInner); progressOverlay.appendChild(progressBarOuter); dialog.contentArea.style.position = 'relative'; dialog.contentArea.appendChild(progressOverlay); try { const response = await api.fetchApi('/sage_utils/delete_images', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ image_paths: imagePaths }) }); if (!response.ok) { throw new Error(`Server error: ${response.statusText}`); } const result = await response.json(); progressBarInner.style.width = '100%'; // Show results setTimeout(async () => { progressOverlay.remove(); if (result.deleted > 0) { await showAlertDialog(` Successfully deleted ${result.deleted} duplicate image${result.deleted !== 1 ? 's' : ''}! ${result.failed > 0 ? `

Failed to delete ${result.failed} file${result.failed !== 1 ? 's' : ''}.` : ''} `, 'Deletion Complete'); } else { await showAlertDialog('No files were deleted.', 'Deletion Failed'); } dialog.close(); if (onComplete) onComplete(); }, 500); } catch (error) { progressOverlay.remove(); handleError(error, 'Failed to delete duplicates'); await showAlertDialog(`Error: ${error.message}`, 'Deletion Failed'); } } /** * Show confirmation dialog * @param {string} message - Message to display * @returns {Promise} True if confirmed */ async function showConfirmDialog(message) { return new Promise((resolve) => { const confirmDialog = createDialog({ title: 'Confirm Deletion', content: `
${message}
`, width: '450px', onClose: () => resolve(false) }); confirmDialog.addFooterButton('Cancel', () => { confirmDialog.close(); resolve(false); }, { background: '#666' }); confirmDialog.addFooterButton('Delete', () => { confirmDialog.close(); resolve(true); }, { background: '#d32f2f' }); confirmDialog.show(); }); } /** * Show alert dialog * @param {string} message - Message to display * @param {string} title - Dialog title * @returns {Promise} */ async function showAlertDialog(message, title = 'Alert') { return new Promise((resolve) => { const alertDialog = createDialog({ title: title, content: `
${message}
`, width: '400px', onClose: () => resolve() }); alertDialog.addFooterButton('OK', () => { alertDialog.close(); resolve(); }, { background: '#4CAF50' }); alertDialog.show(); }); }