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
bvandeusen 0f35a0c484 feat: ML tag suggestions, character/fandom integrity, underscores, modal polish
Consolidated merge of feat/tag-suggestions branch. Original 64-commit history
was lost to git-object corruption in a Nextcloud-synced checkout; this single
commit captures the equivalent diff.

Includes:
- pgvector-backed tag suggestion infra (WD14 + SigLIP centroids, ml-worker
  container, Celery tasks, suggestion service, accept/reject endpoints + modal
  UI with green/red chip buttons)
- Character/fandom integrity: title-case normalization on every write path,
  fandom-id backfill, maintenance task + settings button, migrations g26041901
  + h26041901 to canonicalize legacy rows with case-only duplicate merging
- Tag-underscores + modal polish: WD14 name canonicalization at emit + accept
  + add/bulk-add paths, migration i26041901 for legacy-row rename-or-merge
  across character/fandom/NULL kinds, suggestion-accept refresh parity via
  awaited loadTags, persistent chip tint
2026-04-19 19:50:58 -04:00

559 lines
17 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';
// Use shared TagUtils module
const { getTagIcon, getTagDisplayName, getTagDisplayNameForOverlay, escapeHtml, createTagChipHtml } = window.TagUtils || {};
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 = getTagDisplayName(t.name, t.kind);
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 = getTagDisplayName(t.name, t.kind);
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;
}
// ---------------------------
// Tag Refresh for Gallery Items
// ---------------------------
async function refreshItemTags(imageId) {
// Only the gallery page renders tag overlays on thumbnails. Skip showcase items
// (class .masonry-item) so closing the modal doesn't inject a .tag-overlay there.
const item = document.querySelector(`.gallery-item[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 and source tags (they're not shown in overlays)
const visibleTags = (data.tags || []).filter(t => t.kind !== 'archive' && t.kind !== 'source');
// 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 using shared utility
overlay.innerHTML = visibleTags.map(t => createTagChipHtml(t)).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
})();