This repository has been archived on 2026-05-31. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
imagerepo/app/static/js/bulk-select.js
T
Bryan Van Deusen 042a69f9c3 Optimize scan process with quick/deep modes and archive tag merging
Quick scan (default):
- Pre-load all ImportTask records for O(1) duplicate checks
- Batch task creation commits (50 at a time)
- Cache import settings (5-min TTL)
- Skip pHash comparison for speed

Deep scan (on-demand):
- Full reprocessing of all files
- pHash similarity detection
- Optional thumbnail regeneration
- Optional sidecar re-application

Archive tag merging:
- Add archive tags to existing images when duplicates found
- Works for both hash and pHash duplicate detection

Also fixes:
- Add missing source/post tag icons to gallery templates
2026-01-31 15:18:43 -05:00

593 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// /app/static/js/bulk-select.js
// Multi-select mode controller for bulk tag operations
// Works with gallery-infinite.js and showcase.js
(function() {
'use strict';
const state = {
isSelectMode: false,
selectedIds: new Set(),
selectionOrder: [] // Array to track order of selection
};
// DOM elements (populated on init)
const dom = {};
// Autocomplete state
let autocompleteItems = [];
let autocompleteSelectedIndex = -1;
let autocompleteDebounce = null;
function init() {
dom.selectBtn = document.getElementById('selectModeBtn');
dom.panel = document.getElementById('bulkEditorPanel');
dom.closeBtn = document.getElementById('closeBulkEditor');
dom.countEl = document.getElementById('selectedCount');
dom.clearBtn = document.getElementById('clearSelectionBtn');
dom.addForm = document.getElementById('bulkAddTagForm');
dom.tagInput = document.getElementById('bulkTagInput');
dom.autocomplete = document.getElementById('bulkTagAutocomplete');
dom.commonTags = document.getElementById('commonTagsList');
if (!dom.selectBtn || !dom.panel) return;
setupEventListeners();
}
function setupEventListeners() {
// Toggle select mode
dom.selectBtn.addEventListener('click', toggleSelectMode);
dom.closeBtn?.addEventListener('click', exitSelectMode);
dom.clearBtn?.addEventListener('click', clearSelection);
// Image click handling (delegated) - use capture phase to intercept before modal
document.addEventListener('click', handleImageClick, true);
// Bulk add tag form
dom.addForm?.addEventListener('submit', handleBulkAddTag);
// Tag autocomplete
setupAutocomplete();
// Listen for gallery updates (new images loaded)
document.addEventListener('galleryUpdated', updateSelectionUI);
document.addEventListener('showcaseUpdated', updateSelectionUI);
// Keyboard shortcut: Escape to exit select mode
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && state.isSelectMode) {
// Only exit if modal is not open
const modal = document.getElementById('imageModal');
if (!modal || !modal.classList.contains('active')) {
exitSelectMode();
}
}
});
}
function toggleSelectMode() {
state.isSelectMode = !state.isSelectMode;
document.body.classList.toggle('select-mode', state.isSelectMode);
dom.selectBtn.textContent = state.isSelectMode ? 'Cancel' : 'Select';
dom.selectBtn.classList.toggle('active', state.isSelectMode);
if (state.isSelectMode) {
dom.panel.classList.add('active');
updateCommonTagsDisplay();
} else {
dom.panel.classList.remove('active');
clearSelection();
}
}
function exitSelectMode() {
if (state.isSelectMode) {
toggleSelectMode();
}
}
function handleImageClick(e) {
if (!state.isSelectMode) return;
const item = e.target.closest('.img-clickable');
if (!item) return;
// Don't interfere with tag chip clicks
if (e.target.closest('.tag-chip')) return;
// Prevent modal from opening in select mode
e.stopPropagation();
e.preventDefault();
const id = parseInt(item.dataset.id);
if (isNaN(id)) return;
if (state.selectedIds.has(id)) {
state.selectedIds.delete(id);
// Remove from order array without creating new array (preserves window.selectionOrder reference)
const idx = state.selectionOrder.indexOf(id);
if (idx !== -1) state.selectionOrder.splice(idx, 1);
item.classList.remove('selected');
removeOrderBadge(item);
} else {
state.selectedIds.add(id);
state.selectionOrder.push(id);
item.classList.add('selected');
addOrderBadge(item, state.selectionOrder.length);
}
// Update all badges to reflect current order
updateAllOrderBadges();
updateCount();
loadCommonTags();
dispatchSelectionChanged();
}
function clearSelection() {
state.selectedIds.clear();
// Clear array without creating new one (preserves window.selectionOrder reference)
state.selectionOrder.length = 0;
document.querySelectorAll('.img-clickable.selected').forEach(el => {
el.classList.remove('selected');
removeOrderBadge(el);
});
updateCount();
updateCommonTagsDisplay();
dispatchSelectionChanged();
}
function updateCount() {
if (dom.countEl) {
dom.countEl.textContent = state.selectedIds.size;
}
}
function dispatchSelectionChanged() {
document.dispatchEvent(new CustomEvent('selectionChanged', {
detail: {
count: state.selectedIds.size,
orderedIds: [...state.selectionOrder]
}
}));
}
// Enable select mode programmatically, optionally without opening the bulk panel
function enableSelectMode(skipPanel = false) {
if (state.isSelectMode) return;
state.isSelectMode = true;
document.body.classList.add('select-mode');
dom.selectBtn.textContent = 'Cancel';
dom.selectBtn.classList.add('active');
if (!skipPanel) {
dom.panel.classList.add('active');
updateCommonTagsDisplay();
}
}
// Disable select mode
function disableSelectMode() {
if (!state.isSelectMode) return;
state.isSelectMode = false;
document.body.classList.remove('select-mode');
dom.selectBtn.textContent = 'Select';
dom.selectBtn.classList.remove('active');
dom.panel.classList.remove('active');
clearSelection();
}
function updateSelectionUI() {
// Re-apply selected class and order badges to any newly loaded images that are in selection
document.querySelectorAll('.img-clickable').forEach(item => {
const id = parseInt(item.dataset.id);
if (state.selectedIds.has(id)) {
item.classList.add('selected');
const orderIndex = state.selectionOrder.indexOf(id);
if (orderIndex !== -1) {
addOrderBadge(item, orderIndex + 1);
}
}
});
}
// ---------------------------
// Order Badge Helpers
// ---------------------------
function addOrderBadge(item, orderNumber) {
removeOrderBadge(item); // Remove existing badge first
const badge = document.createElement('div');
badge.className = 'selection-order-badge';
badge.textContent = orderNumber;
item.appendChild(badge);
}
function removeOrderBadge(item) {
const existing = item.querySelector('.selection-order-badge');
if (existing) existing.remove();
}
function updateAllOrderBadges() {
document.querySelectorAll('.img-clickable').forEach(item => {
const id = parseInt(item.dataset.id);
const orderIndex = state.selectionOrder.indexOf(id);
if (orderIndex !== -1) {
addOrderBadge(item, orderIndex + 1);
} else {
removeOrderBadge(item);
}
});
}
function updateCommonTagsDisplay() {
if (state.selectedIds.size === 0) {
if (dom.commonTags) {
dom.commonTags.innerHTML = '<span class="text-muted">No images selected</span>';
}
}
}
async function loadCommonTags() {
if (state.selectedIds.size === 0) {
updateCommonTagsDisplay();
return;
}
const ids = Array.from(state.selectedIds);
try {
const response = await fetch('/api/images/common-tags', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image_ids: ids })
});
const data = await response.json();
if (data.ok) {
renderCommonTags(data.tags);
}
} catch (err) {
console.error('Failed to load common tags:', err);
}
}
function renderCommonTags(tags) {
if (!dom.commonTags) return;
if (!tags || tags.length === 0) {
dom.commonTags.innerHTML = '<span class="text-muted">No common tags</span>';
return;
}
dom.commonTags.innerHTML = tags.map(t => {
const icon = getTagIcon(t.kind);
const display = t.name.includes(':') ? t.name.split(':')[1] : t.name;
const escapedName = escapeHtml(t.name);
const escapedDisplay = escapeHtml(display);
return `<span class="tag-chip removable" data-name="${escapedName}">
${icon} ${escapedDisplay}
<button class="x" title="Remove from all selected">×</button>
</span>`;
}).join('');
// Add remove handlers
dom.commonTags.querySelectorAll('.tag-chip .x').forEach(btn => {
btn.addEventListener('click', async (e) => {
e.preventDefault();
e.stopPropagation();
const chip = e.target.closest('.tag-chip');
const name = chip.dataset.name;
chip.style.opacity = '0.5';
await bulkRemoveTag(name);
});
});
}
async function handleBulkAddTag(e) {
e.preventDefault();
const name = dom.tagInput.value.trim();
if (!name || state.selectedIds.size === 0) return;
dom.tagInput.disabled = true;
await bulkAddTag(name);
dom.tagInput.value = '';
dom.tagInput.disabled = false;
dom.tagInput.focus();
hideAutocomplete();
}
async function bulkAddTag(name) {
const ids = Array.from(state.selectedIds);
try {
const response = await fetch('/api/images/bulk-add-tag', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image_ids: ids, tag_name: name })
});
const data = await response.json();
if (data.ok) {
loadCommonTags(); // Refresh common tags
refreshSelectedItemsTags(); // Update tag display on gallery items
} else {
console.error('Failed to add tag:', data.error);
}
} catch (err) {
console.error('Failed to add tag:', err);
}
}
async function bulkRemoveTag(name) {
const ids = Array.from(state.selectedIds);
try {
const response = await fetch('/api/images/bulk-remove-tag', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ image_ids: ids, tag_name: name })
});
const data = await response.json();
if (data.ok) {
loadCommonTags(); // Refresh common tags
refreshSelectedItemsTags(); // Update tag display on gallery items
} else {
console.error('Failed to remove tag:', data.error);
}
} catch (err) {
console.error('Failed to remove tag:', err);
}
}
// ---------------------------
// Autocomplete
// ---------------------------
function setupAutocomplete() {
if (!dom.tagInput) return;
// Move autocomplete dropdown to body to escape transform containing block
// (CSS transform on parent creates new stacking context that breaks fixed positioning)
if (dom.autocomplete && dom.autocomplete.parentNode !== document.body) {
document.body.appendChild(dom.autocomplete);
}
if (!dom.autocomplete) return;
// Show autocomplete on focus
dom.tagInput.addEventListener('focus', () => {
const val = dom.tagInput.value.trim();
fetchAutocomplete(val);
});
// Hide autocomplete on blur (with delay for click)
dom.tagInput.addEventListener('blur', () => {
setTimeout(hideAutocomplete, 200);
});
// Search as user types
dom.tagInput.addEventListener('input', () => {
clearTimeout(autocompleteDebounce);
autocompleteDebounce = setTimeout(() => {
fetchAutocomplete(dom.tagInput.value.trim());
}, 150);
});
// Keyboard navigation
dom.tagInput.addEventListener('keydown', (e) => {
if (!dom.autocomplete.classList.contains('active')) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
const nextIndex = Math.min(autocompleteSelectedIndex + 1, autocompleteItems.length - 1);
selectAutocompleteItem(nextIndex);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
const prevIndex = Math.max(autocompleteSelectedIndex - 1, 0);
selectAutocompleteItem(prevIndex);
} else if (e.key === 'Enter' && autocompleteSelectedIndex >= 0) {
e.preventDefault();
const selected = autocompleteItems[autocompleteSelectedIndex];
if (selected) {
dom.tagInput.value = selected.name;
hideAutocomplete();
// Submit the form
dom.addForm.dispatchEvent(new Event('submit', { cancelable: true }));
}
} else if (e.key === 'Escape') {
hideAutocomplete();
}
});
// Click on autocomplete item
dom.autocomplete.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
const item = e.target.closest('.tag-autocomplete-item');
if (!item) return;
const name = item.dataset.name;
if (name) {
dom.tagInput.value = name;
hideAutocomplete();
dom.addForm.dispatchEvent(new Event('submit', { cancelable: true }));
}
});
}
function hideAutocomplete() {
if (dom.autocomplete) {
dom.autocomplete.classList.remove('active');
dom.autocomplete.innerHTML = '';
}
autocompleteItems = [];
autocompleteSelectedIndex = -1;
}
function positionAutocomplete() {
if (!dom.autocomplete || !dom.tagInput) return;
const rect = dom.tagInput.getBoundingClientRect();
const viewportWidth = window.innerWidth;
const dropdownWidth = rect.width;
// Calculate left position, ensuring dropdown doesn't overflow right edge
let leftPos = rect.left;
if (leftPos + dropdownWidth > viewportWidth - 8) {
leftPos = viewportWidth - dropdownWidth - 8;
}
dom.autocomplete.style.top = `${rect.bottom + 4}px`;
dom.autocomplete.style.left = `${leftPos}px`;
dom.autocomplete.style.width = `${dropdownWidth}px`;
}
async function fetchAutocomplete(query) {
try {
// Exclude system-managed tag kinds from autocomplete
const excludeKinds = 'archive,post,source';
const url = query
? `/api/tags/search?q=${encodeURIComponent(query)}&limit=8&exclude_kind=${excludeKinds}`
: `/api/tags/search?limit=8&exclude_kind=${excludeKinds}`;
const r = await fetch(url);
const j = await r.json();
renderAutocomplete(j.tags || []);
} catch {
hideAutocomplete();
}
}
function renderAutocomplete(tags) {
if (!dom.autocomplete) return;
autocompleteItems = tags;
autocompleteSelectedIndex = -1;
if (tags.length === 0) {
dom.autocomplete.innerHTML = '<div class="tag-autocomplete-empty">No matching tags</div>';
positionAutocomplete();
dom.autocomplete.classList.add('active');
return;
}
dom.autocomplete.innerHTML = tags.map((t, i) => {
const icon = getTagIcon(t.kind);
const displayName = t.name.includes(':') ? t.name.split(':')[1] : t.name;
return `<div class="tag-autocomplete-item" data-index="${i}" data-name="${escapeHtml(t.name)}">
<span>${icon} ${escapeHtml(displayName)}</span>
<span class="tag-kind">${t.kind || 'user'}</span>
</div>`;
}).join('');
positionAutocomplete();
dom.autocomplete.classList.add('active');
}
function selectAutocompleteItem(index) {
const items = dom.autocomplete?.querySelectorAll('.tag-autocomplete-item');
if (!items) return;
items.forEach((el, i) => {
el.classList.toggle('selected', i === index);
});
autocompleteSelectedIndex = index;
}
// ---------------------------
// Helpers
// ---------------------------
function getTagIcon(kind) {
const icons = {
artist: '🎨',
archive: '🗜️',
character: '👤',
series: '📺',
fandom: '🎭',
rating: '⚠️',
source: '🌐',
post: '📌'
};
return icons[kind] || '#';
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// ---------------------------
// Tag Refresh for Gallery Items
// ---------------------------
function getTagDisplayName(name, kind) {
// Strip prefix for known prefixed kinds
if (['artist', 'character', 'series', 'fandom', 'rating'].includes(kind)) {
return name.includes(':') ? name.split(':', 2)[1] : name;
}
// Regular tags get a # prefix
return '#' + name;
}
async function refreshItemTags(imageId) {
// Find the gallery item
const item = document.querySelector(`.img-clickable[data-id="${imageId}"]`);
if (!item) return;
try {
const r = await fetch(`/image/${imageId}/tags`);
const data = await r.json();
if (!data.ok) return;
// Filter out archive tags (they're not shown)
const visibleTags = (data.tags || []).filter(t => t.kind !== 'archive');
// Find or create tag overlay
let overlay = item.querySelector('.tag-overlay');
if (visibleTags.length === 0) {
// No visible tags - remove overlay if exists
if (overlay) overlay.remove();
return;
}
if (!overlay) {
overlay = document.createElement('div');
overlay.className = 'tag-overlay';
item.appendChild(overlay);
}
// Render tags with icons
overlay.innerHTML = visibleTags.map(t => {
const displayName = getTagDisplayName(t.name, t.kind);
const icon = getTagIcon(t.kind);
const encodedName = encodeURIComponent(t.name);
const prefix = icon !== '#' ? icon + ' ' : '#';
return `<a class="tag-chip" href="/gallery?tag=${encodedName}" title="Filter by ${escapeHtml(t.name)}" onclick="event.stopPropagation()">${prefix}${escapeHtml(displayName)}</a>`;
}).join('');
} catch (e) {
console.error('Failed to refresh tags for image', imageId, e);
}
}
async function refreshSelectedItemsTags() {
// Refresh tags for all currently selected items
const ids = Array.from(state.selectedIds);
await Promise.all(ids.map(id => refreshItemTags(id)));
}
// Initialize on DOM ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
// Expose global API for other scripts (like series editor, modal)
window.selectedImages = state.selectedIds;
window.selectionOrder = state.selectionOrder; // Ordered array of selected IDs
window.clearSelection = clearSelection;
window.enableSelectMode = enableSelectMode;
window.disableSelectMode = disableSelectMode;
window.refreshItemTags = refreshItemTags; // Refresh tags display on a single gallery item
})();