feat: Add dedicated NOT (Exclude) filter bars and inline negative keyword (-word / !word) search support across all tabs

This commit is contained in:
david
2026-08-17 15:43:31 -04:00
parent 155e187400
commit 0524dd4a93
+91 -23
View File
@@ -377,6 +377,7 @@
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
flex-wrap: wrap;
} }
.search-input { .search-input {
@@ -386,7 +387,30 @@
color: var(--text-main); color: var(--text-main);
padding: 0.4rem 0.75rem; padding: 0.4rem 0.75rem;
font-size: 0.8rem; font-size: 0.8rem;
min-width: 240px; min-width: 210px;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.search-input:focus {
outline: none;
border-color: var(--accent-cyan);
box-shadow: 0 0 0 2px rgba(6, 182, 212, 0.2);
}
.not-filter-input {
border-color: rgba(244, 63, 94, 0.4);
background: rgba(244, 63, 94, 0.04);
color: #fda4af;
min-width: 200px;
}
.not-filter-input::placeholder {
color: rgba(253, 164, 175, 0.55);
}
.not-filter-input:focus {
border-color: var(--accent-rose);
box-shadow: 0 0 0 2px rgba(244, 63, 94, 0.25);
} }
.torrent-table { .torrent-table {
@@ -1349,7 +1373,8 @@
</div> </div>
<div class="search-filter"> <div class="search-filter">
<input type="text" class="search-input" id="search-input" placeholder="Filter torrents by name, performer..." /> <input type="text" class="search-input" id="search-input" placeholder="🔍 Filter torrents by name, performer..." />
<input type="text" class="search-input not-filter-input" id="not-filter-input" placeholder="⛔ Exclude / NOT (e.g. 720p, VR)..." title="Exclude torrents matching these comma/space-separated terms" />
</div> </div>
</div> </div>
@@ -1455,7 +1480,8 @@
</div> </div>
<div class="search-filter"> <div class="search-filter">
<input type="text" class="search-input" id="smb-search-input" placeholder="Filter SMB files by name, performer..." /> <input type="text" class="search-input" id="smb-search-input" placeholder="🔍 Filter SMB files by name, performer..." />
<input type="text" class="search-input not-filter-input" id="smb-not-filter-input" placeholder="⛔ Exclude / NOT (e.g. 720p, VR)..." title="Exclude SMB files matching these comma/space-separated terms" />
</div> </div>
</div> </div>
@@ -1567,7 +1593,8 @@
</label> </label>
<div class="search-filter" style="margin-left: auto;"> <div class="search-filter" style="margin-left: auto;">
<input type="text" class="search-input" id="stash-images-search-input" placeholder="Filter performers by name..." /> <input type="text" class="search-input" id="stash-images-search-input" placeholder="🔍 Filter performers by name..." />
<input type="text" class="search-input not-filter-input" id="stash-images-not-filter-input" placeholder="⛔ Exclude / NOT..." title="Exclude performers matching these terms" />
</div> </div>
</div> </div>
</div> </div>
@@ -1708,6 +1735,34 @@
.replace(/'/g, '&#039;'); .replace(/'/g, '&#039;');
} }
// Unified Positive and Negative (NOT) Filter Matcher
function matchFilterCriteria(textToSearch, positiveQuery, notQuery) {
const text = (textToSearch || '').toLowerCase();
// 1. Process explicit NOT filter (comma or semicolon separated terms)
if (notQuery) {
const notTerms = notQuery.toLowerCase().split(/[,|;]+/).map(s => s.trim()).filter(s => s.length > 0);
for (const term of notTerms) {
if (text.includes(term)) return false;
}
}
// 2. Process positive search input (also supports inline -term or !term exclusions)
if (positiveQuery) {
const tokens = positiveQuery.toLowerCase().split(/\s+/).filter(t => t.length > 0);
for (const tok of tokens) {
if (tok.startsWith('-') || tok.startsWith('!')) {
const negTerm = tok.slice(1);
if (negTerm && text.includes(negTerm)) return false;
} else {
if (!text.includes(tok)) return false;
}
}
}
return true;
}
// ==================== Performer Image Hover Card Controller ==================== // ==================== Performer Image Hover Card Controller ====================
const hoverCard = document.getElementById('performer-hover-card'); const hoverCard = document.getElementById('performer-hover-card');
const hoverCardImg = document.getElementById('hover-card-img'); const hoverCardImg = document.getElementById('hover-card-img');
@@ -1941,6 +1996,7 @@
const selectAllCheckbox = document.getElementById('th-select-all'); const selectAllCheckbox = document.getElementById('th-select-all');
const selectionCountLabel = document.getElementById('table-selection-count'); const selectionCountLabel = document.getElementById('table-selection-count');
const searchInput = document.getElementById('search-input'); const searchInput = document.getElementById('search-input');
const notFilterInput = document.getElementById('not-filter-input');
const btnFetch = document.getElementById('btn-fetch-torrents'); const btnFetch = document.getElementById('btn-fetch-torrents');
const btnAnalyzeSelected = document.getElementById('btn-ai-analyze-selected'); const btnAnalyzeSelected = document.getElementById('btn-ai-analyze-selected');
const btnAnalyzeAll = document.getElementById('btn-ai-analyze-all'); const btnAnalyzeAll = document.getElementById('btn-ai-analyze-all');
@@ -1959,6 +2015,7 @@
const smbSelectAllCheckbox = document.getElementById('th-smb-select-all'); const smbSelectAllCheckbox = document.getElementById('th-smb-select-all');
const smbSelectionCountLabel = document.getElementById('smb-table-selection-count'); const smbSelectionCountLabel = document.getElementById('smb-table-selection-count');
const smbSearchInput = document.getElementById('smb-search-input'); const smbSearchInput = document.getElementById('smb-search-input');
const smbNotFilterInput = document.getElementById('smb-not-filter-input');
const btnFetchSmb = document.getElementById('btn-fetch-smb'); const btnFetchSmb = document.getElementById('btn-fetch-smb');
const btnSmbAnalyzeSelected = document.getElementById('btn-smb-analyze-selected'); const btnSmbAnalyzeSelected = document.getElementById('btn-smb-analyze-selected');
const btnSmbAnalyzeAll = document.getElementById('btn-smb-analyze-all'); const btnSmbAnalyzeAll = document.getElementById('btn-smb-analyze-all');
@@ -3483,12 +3540,11 @@ Return ONLY valid JSON matching:
} }
function renderTable() { function renderTable() {
const query = searchInput.value.trim().toLowerCase(); const query = searchInput ? searchInput.value.trim() : '';
const notQuery = notFilterInput ? notFilterInput.value.trim() : '';
const filtered = qbState.torrents.filter(t => { const filtered = qbState.torrents.filter(t => {
if (!query) return true; const fullText = `${t.name} ${t.selectedPerformer || ''} ${t.save_path || ''} ${t.category || ''}`;
return t.name.toLowerCase().includes(query) || return matchFilterCriteria(fullText, query, notQuery);
(t.selectedPerformer && t.selectedPerformer.toLowerCase().includes(query)) ||
(t.save_path && t.save_path.toLowerCase().includes(query));
}); });
if (filtered.length === 0) { if (filtered.length === 0) {
@@ -3780,10 +3836,11 @@ Return ONLY valid JSON matching:
selectAllCheckbox.addEventListener('change', (e) => { selectAllCheckbox.addEventListener('change', (e) => {
const isChecked = e.target.checked; const isChecked = e.target.checked;
const query = searchInput.value.trim().toLowerCase(); const query = searchInput ? searchInput.value.trim() : '';
const notQuery = notFilterInput ? notFilterInput.value.trim() : '';
qbState.torrents.forEach(t => { qbState.torrents.forEach(t => {
const matches = !query || t.name.toLowerCase().includes(query) || (t.selectedPerformer && t.selectedPerformer.toLowerCase().includes(query)); const fullText = `${t.name} ${t.selectedPerformer || ''} ${t.save_path || ''} ${t.category || ''}`;
if (matches) { if (matchFilterCriteria(fullText, query, notQuery)) {
if (isChecked) qbState.selectedHashes.add(t.hash); if (isChecked) qbState.selectedHashes.add(t.hash);
else qbState.selectedHashes.delete(t.hash); else qbState.selectedHashes.delete(t.hash);
} }
@@ -3793,6 +3850,7 @@ Return ONLY valid JSON matching:
}); });
searchInput.addEventListener('input', () => renderTable()); searchInput.addEventListener('input', () => renderTable());
if (notFilterInput) notFilterInput.addEventListener('input', () => renderTable());
// ==================== TAB 2: SMB Video Downloader Engine ==================== // ==================== TAB 2: SMB Video Downloader Engine ====================
async function scanSmbFiles() { async function scanSmbFiles() {
@@ -4355,11 +4413,11 @@ Return ONLY a valid JSON object with an array named "results":
} }
function renderSmbTable() { function renderSmbTable() {
const query = smbSearchInput.value.trim().toLowerCase(); const query = smbSearchInput ? smbSearchInput.value.trim() : '';
const notQuery = smbNotFilterInput ? smbNotFilterInput.value.trim() : '';
const filtered = smbState.files.filter(f => { const filtered = smbState.files.filter(f => {
if (!query) return true; const fullText = `${f.name} ${f.selectedPerformer || ''} ${f.path || ''}`;
return f.name.toLowerCase().includes(query) || return matchFilterCriteria(fullText, query, notQuery);
(f.selectedPerformer && f.selectedPerformer.toLowerCase().includes(query));
}); });
if (filtered.length === 0) { if (filtered.length === 0) {
@@ -4588,10 +4646,11 @@ Return ONLY a valid JSON object with an array named "results":
smbSelectAllCheckbox.addEventListener('change', (e) => { smbSelectAllCheckbox.addEventListener('change', (e) => {
const isChecked = e.target.checked; const isChecked = e.target.checked;
const query = smbSearchInput.value.trim().toLowerCase(); const query = smbSearchInput ? smbSearchInput.value.trim() : '';
const notQuery = smbNotFilterInput ? smbNotFilterInput.value.trim() : '';
smbState.files.forEach(f => { smbState.files.forEach(f => {
const matches = !query || f.name.toLowerCase().includes(query) || (f.selectedPerformer && f.selectedPerformer.toLowerCase().includes(query)); const fullText = `${f.name} ${f.selectedPerformer || ''} ${f.path || ''}`;
if (matches) { if (matchFilterCriteria(fullText, query, notQuery)) {
if (isChecked) smbState.selectedPaths.add(f.path); if (isChecked) smbState.selectedPaths.add(f.path);
else smbState.selectedPaths.delete(f.path); else smbState.selectedPaths.delete(f.path);
} }
@@ -4601,6 +4660,7 @@ Return ONLY a valid JSON object with an array named "results":
}); });
smbSearchInput.addEventListener('input', () => renderSmbTable()); smbSearchInput.addEventListener('input', () => renderSmbTable());
if (smbNotFilterInput) smbNotFilterInput.addEventListener('input', () => renderSmbTable());
// Modal Close // Modal Close
document.getElementById('modal-close').addEventListener('click', () => { document.getElementById('modal-close').addEventListener('click', () => {
@@ -5032,6 +5092,7 @@ Return ONLY a valid JSON object with an array named "results":
const toggleStashFullName = document.getElementById('toggle-stash-full-name'); const toggleStashFullName = document.getElementById('toggle-stash-full-name');
const toggleStashHasScenes = document.getElementById('toggle-stash-has-scenes'); const toggleStashHasScenes = document.getElementById('toggle-stash-has-scenes');
const stashImagesSearchInput = document.getElementById('stash-images-search-input'); const stashImagesSearchInput = document.getElementById('stash-images-search-input');
const stashImagesNotFilterInput = document.getElementById('stash-images-not-filter-input');
const thStashImgSelectAll = document.getElementById('th-stash-img-select-all'); const thStashImgSelectAll = document.getElementById('th-stash-img-select-all');
const stashImgSelectionCount = document.getElementById('stash-img-selection-count'); const stashImgSelectionCount = document.getElementById('stash-img-selection-count');
const stashPerformerCardsContainer = document.getElementById('stash-performer-cards-container'); const stashPerformerCardsContainer = document.getElementById('stash-performer-cards-container');
@@ -5464,10 +5525,11 @@ Return ONLY a valid JSON object with an array named "results":
function renderStashPerformerCards() { function renderStashPerformerCards() {
if (!stashPerformerCardsContainer) return; if (!stashPerformerCardsContainer) return;
const query = (stashImagesSearchInput ? stashImagesSearchInput.value.trim().toLowerCase() : ''); const query = (stashImagesSearchInput ? stashImagesSearchInput.value.trim() : '');
const notQuery = (stashImagesNotFilterInput ? stashImagesNotFilterInput.value.trim() : '');
const filtered = stashImgState.performers.filter(p => { const filtered = stashImgState.performers.filter(p => {
if (!query) return true; const fullText = `${p.name || ''} ${p.disambiguation || ''} ${p.country || ''} ${(p.alias_list || []).join(' ')}`;
return (p.name || '').toLowerCase().includes(query); return matchFilterCriteria(fullText, query, notQuery);
}); });
if (filtered.length === 0) { if (filtered.length === 0) {
@@ -5475,7 +5537,7 @@ Return ONLY a valid JSON object with an array named "results":
<div class="empty-state" style="grid-column: 1 / -1;"> <div class="empty-state" style="grid-column: 1 / -1;">
<div class="empty-state-icon">🔍</div> <div class="empty-state-icon">🔍</div>
<h3>No matching performers found</h3> <h3>No matching performers found</h3>
<p>${query ? 'Try adjusting your search filter' : 'Click "Scan Stash Missing Images" to load performers'}</p> <p>${query || notQuery ? 'Try adjusting your search or exclude filters' : 'Click "Scan Stash Missing Images" to load performers'}</p>
</div> </div>
`; `;
updateStashImagesStats(); updateStashImagesStats();
@@ -5529,6 +5591,12 @@ Return ONLY a valid JSON object with an array named "results":
}); });
} }
if (stashImagesNotFilterInput) {
stashImagesNotFilterInput.addEventListener('input', () => {
renderStashPerformerCards();
});
}
if (thStashImgSelectAll) { if (thStashImgSelectAll) {
thStashImgSelectAll.addEventListener('change', () => { thStashImgSelectAll.addEventListener('change', () => {
const checked = thStashImgSelectAll.checked; const checked = thStashImgSelectAll.checked;