added dedup tools to settings. first pass of mass tag editting to gallery view.

This commit is contained in:
Bryan Van Deusen
2026-01-20 23:45:54 -05:00
parent 87bcb633f6
commit ede1457abc
10 changed files with 1411 additions and 182 deletions
+312 -1
View File
@@ -206,6 +206,46 @@
</div>
</div><!-- end settings-grid -->
<!-- Duplicate Detection (Full Width) -->
<div class="settings-container" style="max-width: 1200px; margin: 2rem auto;">
<div class="settings-section">
<h2>Duplicate Detection</h2>
<p class="settings-description">Scan for visually similar images using perceptual hash comparison. The first image in each group has the highest resolution and will be kept.</p>
<div class="form-card" style="margin-bottom: 1rem;">
<div class="form-group">
<label class="form-label">Hamming Distance Threshold</label>
<select id="dupThreshold" class="form-input">
<option value="5">Strict (5) - Nearly identical</option>
<option value="10" selected>Normal (10) - Minor variations</option>
<option value="15">Loose (15) - Similar images</option>
<option value="20">Very Loose (20) - Broadly similar</option>
</select>
<p class="form-help">Lower values find only very similar images. Higher values find more potential duplicates but may include false positives.</p>
</div>
<button type="button" id="scanDuplicatesBtn" class="btn primary-btn" style="width: 100%;">Scan for Duplicates</button>
</div>
<div id="dupResults" style="display: none;">
<div class="import-stats" style="margin-bottom: 1rem;">
<h3>Scan Results</h3>
<p>Found <strong id="dupGroupCount">0</strong> group(s) of potential duplicates.</p>
</div>
<div id="dupGroups"></div>
<div id="dupActions" style="margin-top: 1rem; display: none;">
<p class="form-help" style="margin-bottom: 0.75rem;">
Selected for deletion: <strong id="dupSelectedCount">0</strong> image(s)
<span style="color: var(--text-muted);">(The highest resolution image in each group is protected)</span>
</p>
<button type="button" id="deleteDuplicatesBtn" class="btn danger-btn" disabled>Delete Selected Duplicates</button>
</div>
</div>
</div>
</div>
<!-- Import Queue Status (Full Width) -->
<div class="settings-container" style="max-width: 1200px; margin: 2rem auto;">
<div class="settings-section">
@@ -256,7 +296,6 @@
<button id="retryFailedBtn" class="btn warning-btn">Retry Failed Tasks</button>
<button id="clearCompletedBtn" class="btn secondary-btn">Clear Completed</button>
<button id="regenMissingThumbsBtn" class="btn secondary-btn">Regenerate Missing Thumbnails</button>
<a href="/flower" target="_blank" class="btn secondary-btn" style="text-decoration: none;">Open Flower Dashboard</a>
</div>
<div id="celeryWorkerInfo" style="margin-top: 1rem;">
@@ -492,6 +531,83 @@
transform: translateY(0);
}
}
/* Duplicate detection styles */
.dup-group {
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
}
.dup-group-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.dup-group-header h4 {
margin: 0;
font-size: 0.9rem;
color: var(--text-dim);
}
.dup-group-images {
display: flex;
gap: 0.75rem;
flex-wrap: wrap;
}
.dup-image {
position: relative;
width: 150px;
border-radius: 6px;
overflow: hidden;
cursor: pointer;
border: 2px solid transparent;
transition: border-color 0.2s ease;
}
.dup-image.selected {
border-color: var(--btn-danger);
}
.dup-image.protected {
border-color: var(--btn-primary);
cursor: default;
}
.dup-image img {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
display: block;
}
.dup-image-info {
background: rgba(0, 0, 0, 0.75);
padding: 0.35rem 0.5rem;
font-size: 0.7rem;
color: #fff;
}
.dup-image-info .resolution {
font-weight: 600;
}
.dup-image-info .size {
color: rgba(255, 255, 255, 0.7);
}
.dup-badge {
position: absolute;
top: 4px;
left: 4px;
padding: 2px 6px;
border-radius: 4px;
font-size: 0.65rem;
font-weight: 600;
}
.dup-badge.keep {
background: var(--btn-primary);
color: #fff;
}
.dup-badge.delete {
background: var(--btn-danger);
color: #fff;
}
</style>
<script>
@@ -1044,6 +1160,201 @@ document.addEventListener('DOMContentLoaded', () => {
btn.textContent = 'Delete Selected Images';
}
});
// ========================================
// Duplicate Detection
// ========================================
let duplicateGroups = [];
let selectedDuplicates = new Set();
function formatFileSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
document.getElementById('scanDuplicatesBtn').addEventListener('click', async () => {
const btn = document.getElementById('scanDuplicatesBtn');
const threshold = document.getElementById('dupThreshold').value;
btn.disabled = true;
btn.textContent = 'Scanning...';
const fd = new FormData();
fd.append('threshold', threshold);
fd.append('limit', '50');
try {
const r = await fetch('/api/duplicates/scan', { method: 'POST', body: fd });
const data = await r.json();
if (data.ok) {
duplicateGroups = data.groups;
selectedDuplicates.clear();
renderDuplicateResults();
} else {
alert('Scan failed: ' + (data.error || 'Unknown error'));
}
} catch (e) {
alert('Scan failed: ' + e.message);
} finally {
btn.disabled = false;
btn.textContent = 'Scan for Duplicates';
}
});
function renderDuplicateResults() {
const resultsEl = document.getElementById('dupResults');
const countEl = document.getElementById('dupGroupCount');
const groupsEl = document.getElementById('dupGroups');
const actionsEl = document.getElementById('dupActions');
countEl.textContent = duplicateGroups.length;
resultsEl.style.display = 'block';
if (duplicateGroups.length === 0) {
groupsEl.innerHTML = '<p style="text-align: center; color: var(--text-muted); padding: 1rem;">No duplicate images found.</p>';
actionsEl.style.display = 'none';
return;
}
actionsEl.style.display = 'block';
// Auto-select all non-first images for deletion
duplicateGroups.forEach((group, groupIdx) => {
group.forEach((img, imgIdx) => {
if (imgIdx > 0) {
selectedDuplicates.add(img.id);
}
});
});
groupsEl.innerHTML = duplicateGroups.map((group, groupIdx) => `
<div class="dup-group" data-group="${groupIdx}">
<div class="dup-group-header">
<h4>Group ${groupIdx + 1} (${group.length} images)</h4>
<button type="button" class="btn secondary-btn btn-sm" onclick="toggleGroupSelection(${groupIdx})">Toggle Selection</button>
</div>
<div class="dup-group-images">
${group.map((img, imgIdx) => `
<div class="dup-image ${imgIdx === 0 ? 'protected' : 'selected'}"
data-id="${img.id}"
data-group="${groupIdx}"
data-protected="${imgIdx === 0}">
<img src="${img.thumb_url}" alt="${img.filename}" loading="lazy">
<span class="dup-badge ${imgIdx === 0 ? 'keep' : 'delete'}">${imgIdx === 0 ? 'KEEP' : 'DELETE'}</span>
<div class="dup-image-info">
<div class="resolution">${img.width}x${img.height}</div>
<div class="size">${formatFileSize(img.file_size)}</div>
</div>
</div>
`).join('')}
</div>
</div>
`).join('');
// Add click handlers
groupsEl.querySelectorAll('.dup-image:not(.protected)').forEach(item => {
item.addEventListener('click', () => {
const id = parseInt(item.dataset.id);
const badge = item.querySelector('.dup-badge');
if (selectedDuplicates.has(id)) {
selectedDuplicates.delete(id);
item.classList.remove('selected');
badge.textContent = 'KEEP';
badge.classList.remove('delete');
badge.classList.add('keep');
} else {
selectedDuplicates.add(id);
item.classList.add('selected');
badge.textContent = 'DELETE';
badge.classList.remove('keep');
badge.classList.add('delete');
}
updateDupSelectedCount();
});
});
updateDupSelectedCount();
}
window.toggleGroupSelection = function(groupIdx) {
const group = duplicateGroups[groupIdx];
const groupEl = document.querySelector(`.dup-group[data-group="${groupIdx}"]`);
// Check if any non-protected images are selected
const nonProtected = group.slice(1);
const allSelected = nonProtected.every(img => selectedDuplicates.has(img.id));
nonProtected.forEach(img => {
const itemEl = groupEl.querySelector(`.dup-image[data-id="${img.id}"]`);
const badge = itemEl.querySelector('.dup-badge');
if (allSelected) {
selectedDuplicates.delete(img.id);
itemEl.classList.remove('selected');
badge.textContent = 'KEEP';
badge.classList.remove('delete');
badge.classList.add('keep');
} else {
selectedDuplicates.add(img.id);
itemEl.classList.add('selected');
badge.textContent = 'DELETE';
badge.classList.remove('keep');
badge.classList.add('delete');
}
});
updateDupSelectedCount();
};
function updateDupSelectedCount() {
const countEl = document.getElementById('dupSelectedCount');
const btn = document.getElementById('deleteDuplicatesBtn');
countEl.textContent = selectedDuplicates.size;
btn.disabled = selectedDuplicates.size === 0;
}
document.getElementById('deleteDuplicatesBtn').addEventListener('click', async () => {
if (selectedDuplicates.size === 0) return;
const confirmMsg = `Are you sure you want to permanently delete ${selectedDuplicates.size} duplicate image(s)? This cannot be undone.`;
if (!confirm(confirmMsg)) return;
const btn = document.getElementById('deleteDuplicatesBtn');
btn.disabled = true;
btn.textContent = 'Deleting...';
try {
const r = await fetch('/api/duplicates/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image_ids: Array.from(selectedDuplicates) })
});
const data = await r.json();
if (data.ok) {
alert(`Successfully deleted ${data.deleted_count} duplicate image(s).`);
// Remove deleted images from groups
duplicateGroups = duplicateGroups.map(group =>
group.filter(img => !selectedDuplicates.has(img.id))
).filter(group => group.length > 1); // Remove groups with only 1 image left
selectedDuplicates.clear();
renderDuplicateResults();
} else {
alert('Delete failed: ' + (data.error || 'Unknown error'));
}
} catch (e) {
alert('Delete failed: ' + e.message);
} finally {
btn.disabled = false;
btn.textContent = 'Delete Selected Duplicates';
}
});
});
</script>
{% endblock %}