feat(search): add content-finding filters, saved searches, and text quality to Search view

- Add tags, sender, text_quality filters to search API and Meilisearch client
- Add sender and ocr_text_length to Meilisearch filterable attributes
- Expand saved search allowed filter keys to include q, document_type, language, sender, text_quality
- Add filters panel and saved searches UI to the Search view template
- Add tests for new search filters, saved search keys, and search view elements

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-01 13:46:15 +00:00
parent e797832561
commit 5bf0a4c0b9
7 changed files with 468 additions and 34 deletions
+268 -32
View File
@@ -7,7 +7,7 @@
.search-container { max-width: 800px; margin: 0 auto; }
.search-box {
display: flex; gap: 0.5rem; align-items: center;
margin-bottom: 1.5rem;
margin-bottom: 0.75rem;
}
.search-box input {
flex: 1; padding: 0.75rem 1rem;
@@ -24,11 +24,48 @@
white-space: nowrap;
}
.search-box button:hover { background-color: #2563eb; }
.search-filters {
display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: flex-end;
margin-bottom: 0.75rem; padding: 0.75rem;
border: 1px solid #e5e7eb; border-radius: 0.5rem;
background: #f9fafb;
}
.search-filter-item { display: flex; flex-direction: column; gap: 0.2rem; }
.search-filter-item label {
font-size: 0.75rem; font-weight: 600; color: #4b5563;
}
.search-filter-item select,
.search-filter-item input {
padding: 0.4rem 0.5rem; border: 1px solid #d1d5db;
border-radius: 0.375rem; font-size: 0.8rem; min-width: 120px;
}
.search-saved-bar {
display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;
margin-bottom: 1rem; font-size: 0.85rem;
}
.search-saved-bar .saved-label {
font-weight: 600; white-space: nowrap;
}
.saved-search-tag {
display: inline-flex; align-items: center; gap: 0.25rem;
background: #e5e7eb; padding: 0.2rem 0.5rem; border-radius: 0.25rem;
font-size: 0.8rem;
}
.saved-search-tag a { text-decoration: none; color: inherit; }
.saved-search-tag button {
border: none; background: none; cursor: pointer; color: #6b7280;
padding: 0; line-height: 1;
}
.btn-save-search {
margin-left: auto; font-size: 0.8rem; padding: 0.25rem 0.5rem;
cursor: pointer; border: 1px solid #d1d5db; border-radius: 0.25rem;
background: white; color: #374151; white-space: nowrap;
}
.btn-save-search:hover { background: #f3f4f6; }
.search-summary {
font-size: 0.875rem; color: #6b7280;
margin-bottom: 1rem;
}
/* Google-style result cards */
.search-result {
margin-bottom: 1.5rem;
}
@@ -101,6 +138,60 @@
</button>
</div>
<!-- Content-finding filters -->
<div class="search-filters" id="search-filters">
<div class="search-filter-item">
<label for="filter-document-type">Document Type</label>
<input type="text" id="filter-document-type" placeholder="e.g. Invoice" aria-label="Filter by document type">
</div>
<div class="search-filter-item">
<label for="filter-tags">Tags</label>
<input type="text" id="filter-tags" placeholder="e.g. amazon" aria-label="Filter by tag">
</div>
<div class="search-filter-item">
<label for="filter-sender">Sender</label>
<input type="text" id="filter-sender" placeholder="e.g. ACME Corp" aria-label="Filter by sender">
</div>
<div class="search-filter-item">
<label for="filter-language">Language</label>
<input type="text" id="filter-language" placeholder="e.g. de" aria-label="Filter by language code">
</div>
<div class="search-filter-item">
<label for="filter-text-quality">Text Quality</label>
<select id="filter-text-quality" aria-label="Filter by OCR text quality">
<option value="">All</option>
<option value="high">High</option>
<option value="medium">Medium</option>
<option value="low">Low</option>
<option value="no_text">No text</option>
</select>
</div>
<div class="search-filter-item">
<label for="filter-date-from">Date From</label>
<input type="date" id="filter-date-from" aria-label="Filter from date">
</div>
<div class="search-filter-item">
<label for="filter-date-to">Date To</label>
<input type="date" id="filter-date-to" aria-label="Filter to date">
</div>
<div class="search-filter-item" style="align-self: flex-end;">
<button type="button" id="clear-filters-btn" style="padding: 0.4rem 0.75rem; border: 1px solid #d1d5db; border-radius: 0.375rem; font-size: 0.8rem; cursor: pointer; background: white; color: #374151;" aria-label="Clear all filters">
Clear Filters
</button>
</div>
</div>
<!-- Saved Searches -->
<div class="search-saved-bar" id="saved-searches-bar">
<span class="saved-label"><i class="fas fa-bookmark" aria-hidden="true"></i> Saved Searches:</span>
<span id="saved-searches-list" aria-live="polite" style="display: flex; gap: 0.25rem; flex-wrap: wrap;">
<span style="color: #9ca3af; font-size: 0.85rem;">Loading...</span>
</span>
<button type="button" id="save-search-btn" class="btn-save-search" aria-label="Save current search and filters as a saved search">
<i class="fas fa-plus" aria-hidden="true"></i> Save Current
</button>
</div>
<!-- Summary -->
<div id="search-summary" class="search-summary" style="display:none;" aria-live="polite"></div>
@@ -113,18 +204,58 @@
</div>
<script>
const searchInput = document.getElementById('search-input');
const searchBtn = document.getElementById('search-btn');
const resultsDiv = document.getElementById('search-results');
const summaryDiv = document.getElementById('search-summary');
const paginationDiv = document.getElementById('search-pagination');
var searchInput = document.getElementById('search-input');
var searchBtn = document.getElementById('search-btn');
var resultsDiv = document.getElementById('search-results');
var summaryDiv = document.getElementById('search-summary');
var paginationDiv = document.getElementById('search-pagination');
let _debounce = null;
let _currentPage = 1;
const PER_PAGE = 20;
// Filter elements
var filterDocType = document.getElementById('filter-document-type');
var filterTags = document.getElementById('filter-tags');
var filterSender = document.getElementById('filter-sender');
var filterLanguage = document.getElementById('filter-language');
var filterTextQuality = document.getElementById('filter-text-quality');
var filterDateFrom = document.getElementById('filter-date-from');
var filterDateTo = document.getElementById('filter-date-to');
var _debounce = null;
var _currentPage = 1;
var PER_PAGE = 20;
/** Collect active filter values into an object. */
function getActiveFilters() {
var filters = {};
var dt = filterDocType.value.trim();
if (dt) filters.document_type = dt;
var tg = filterTags.value.trim();
if (tg) filters.tags = tg;
var sn = filterSender.value.trim();
if (sn) filters.sender = sn;
var ln = filterLanguage.value.trim();
if (ln) filters.language = ln;
var tq = filterTextQuality.value;
if (tq) filters.text_quality = tq;
var df = filterDateFrom.value;
if (df) filters.date_from = Math.floor(new Date(df + 'T00:00:00').getTime() / 1000);
var dTo = filterDateTo.value;
if (dTo) filters.date_to = Math.floor(new Date(dTo + 'T23:59:59').getTime() / 1000);
return filters;
}
/** Apply filter values from an object to the UI inputs. */
function applyFiltersToUI(filters) {
filterDocType.value = filters.document_type || '';
filterTags.value = filters.tags || '';
filterSender.value = filters.sender || '';
filterLanguage.value = filters.language || '';
filterTextQuality.value = filters.text_quality || '';
filterDateFrom.value = filters.date_from || '';
filterDateTo.value = filters.date_to || '';
}
function doSearch(page) {
const q = searchInput.value.trim();
var q = searchInput.value.trim();
if (!q) {
resultsDiv.innerHTML = '';
summaryDiv.style.display = 'none';
@@ -134,8 +265,18 @@
_currentPage = page || 1;
// Update URL without reload
const url = new URL(window.location);
var url = new URL(window.location);
url.searchParams.set('q', q);
// Sync filter params to URL
var activeFilters = getActiveFilters();
var filterKeys = ['document_type', 'tags', 'sender', 'language', 'text_quality', 'date_from', 'date_to'];
filterKeys.forEach(function(key) {
if (activeFilters[key]) {
url.searchParams.set(key, activeFilters[key]);
} else {
url.searchParams.delete(key);
}
});
window.history.replaceState({}, '', url);
// Loading indicator
@@ -143,17 +284,22 @@
summaryDiv.style.display = 'none';
paginationDiv.style.display = 'none';
const params = new URLSearchParams({ q, page: _currentPage, per_page: PER_PAGE });
var params = new URLSearchParams({ q: q, page: _currentPage, per_page: PER_PAGE });
// Append filter params (date_from/date_to already converted to timestamps)
Object.keys(activeFilters).forEach(function(key) {
params.set(key, activeFilters[key]);
});
fetch('/api/search?' + params.toString())
.then(r => { if (!r.ok) throw new Error('Search returned ' + r.status); return r.json(); })
.then(data => renderResults(data, q))
.catch(err => {
.then(function(r) { if (!r.ok) throw new Error('Search returned ' + r.status); return r.json(); })
.then(function(data) { renderResults(data, q); })
.catch(function(err) {
resultsDiv.innerHTML = '<div style="text-align:center;padding:2rem;color:#dc2626;"><i class="fas fa-exclamation-triangle fa-2x"></i><p style="margin-top:0.5rem;">Search is temporarily unavailable. Please try again in a moment.</p><p style="font-size:0.75rem;color:#9ca3af;margin-top:0.25rem;">' + escapeHtml(err.message) + '</p></div>';
});
}
function escapeHtml(str) {
const d = document.createElement('div');
var d = document.createElement('div');
d.textContent = str;
return d.innerHTML;
}
@@ -163,13 +309,10 @@
* escape everything else to prevent XSS from indexed content.
*/
function sanitizeHighlight(html) {
// Temporarily replace <mark> and </mark> with placeholders
var safe = html
.replace(/<mark>/gi, '\x00MARK_OPEN\x00')
.replace(/<\/mark>/gi, '\x00MARK_CLOSE\x00');
// Escape all remaining HTML
safe = escapeHtml(safe);
// Restore the <mark> tags
safe = safe
.replace(/\x00MARK_OPEN\x00/g, '<mark>')
.replace(/\x00MARK_CLOSE\x00/g, '</mark>');
@@ -177,14 +320,16 @@
}
function renderResults(data, q) {
const { results, total, page, pages } = data;
var results = data.results;
var total = data.total;
var page = data.page;
var pages = data.pages;
// Summary
summaryDiv.textContent = total + ' result' + (total !== 1 ? 's' : '') + ' for "' + q + '"';
summaryDiv.style.display = 'block';
if (!results || results.length === 0) {
resultsDiv.innerHTML = '<div class="search-empty"><i class="fas fa-search"></i><p>No documents found matching your query.</p></div>';
resultsDiv.innerHTML = '<div class="search-empty"><i class="fas fa-search" aria-hidden="true"></i><p>No documents found matching your query.</p></div>';
paginationDiv.style.display = 'none';
return;
}
@@ -199,20 +344,17 @@
var sender = hit.sender || hit.absender || '';
var fileUrl = '/files/' + hit.file_id;
// Build badges
var badges = '';
if (docType) badges += '<span class="search-result-badge badge-type">' + escapeHtml(docType) + '</span>';
if (sender) badges += '<span class="search-result-badge badge-sender"><i class="fas fa-user"></i> ' + escapeHtml(sender) + '</span>';
if (sender) badges += '<span class="search-result-badge badge-sender"><i class="fas fa-user" aria-hidden="true"></i> ' + escapeHtml(sender) + '</span>';
tags.forEach(function(t) { badges += '<span class="search-result-badge badge-tag">' + escapeHtml(t) + '</span>'; });
// Snippet: use highlighted text, truncate if very long
var snippetHtml = '';
if (snippet) {
var trimmed = snippet.length > 500 ? snippet.substring(0, 500) + '…' : snippet;
snippetHtml = '<div class="search-result-snippet">…' + sanitizeHighlight(trimmed) + '…</div>';
}
// Sanitize title (may contain <mark> highlights from _formatted)
var safeTitle = (fmt.document_title) ? sanitizeHighlight(title) : escapeHtml(title);
return '<div class="search-result">' +
@@ -223,7 +365,6 @@
'</div>';
}).join('');
// Pagination
if (pages > 1) {
var btns = [];
if (page > 1) btns.push('<button data-page="' + (page - 1) + '">« Previous</button>');
@@ -242,7 +383,7 @@
if (btn) doSearch(parseInt(btn.getAttribute('data-page'), 10));
});
// Event listeners
// Event listeners for search
searchBtn.addEventListener('click', function() { doSearch(1); });
searchInput.addEventListener('keydown', function(e) {
if (e.key === 'Enter') { doSearch(1); }
@@ -259,9 +400,104 @@
_debounce = setTimeout(function() { doSearch(1); }, 400);
});
// If q was provided via URL, search immediately
if (searchInput.value.trim().length >= 2) {
doSearch(1);
// Clear filters button
document.getElementById('clear-filters-btn').addEventListener('click', function() {
applyFiltersToUI({});
if (searchInput.value.trim().length >= 2) doSearch(1);
});
// Re-run search when filters change
var filterInputs = [filterDocType, filterTags, filterSender, filterLanguage, filterTextQuality, filterDateFrom, filterDateTo];
filterInputs.forEach(function(el) {
el.addEventListener('change', function() {
if (searchInput.value.trim().length >= 2) doSearch(1);
});
});
// ---- Saved searches functionality ----
function loadSavedSearches() {
fetch('/api/saved-searches')
.then(function(response) { return response.json(); })
.then(function(searches) {
var container = document.getElementById('saved-searches-list');
if (!container) return;
if (!searches || searches.length === 0) {
container.innerHTML = '<span style="color: #9ca3af; font-size: 0.85rem;">No saved searches yet</span>';
return;
}
container.innerHTML = searches.map(function(s) {
var params = new URLSearchParams(s.filters);
return '<span class="saved-search-tag">' +
'<a href="/search?' + escapeHtml(params.toString()) + '">' + escapeHtml(s.name) + '</a>' +
'<button type="button" onclick="deleteSavedSearch(' + s.id + ')" aria-label="Delete saved search ' + escapeHtml(s.name) + '">' +
'<i class="fas fa-times" aria-hidden="true"></i>' +
'</button>' +
'</span>';
}).join('');
})
.catch(function() {
var container = document.getElementById('saved-searches-list');
if (container) container.innerHTML = '<span style="color: #9ca3af; font-size: 0.85rem;">Could not load saved searches</span>';
});
}
document.getElementById('save-search-btn').addEventListener('click', function() {
var filters = getActiveFilters();
var q = searchInput.value.trim();
// For saved searches on this page, store date_from/date_to as ISO strings
// instead of timestamps so the URL params are human-readable.
if (filterDateFrom.value) filters.date_from = filterDateFrom.value;
if (filterDateTo.value) filters.date_to = filterDateTo.value;
if (q) filters.q = q;
if (Object.keys(filters).length === 0) {
alert('No search query or filters to save. Please enter a query or set at least one filter.');
return;
}
var name = prompt('Enter a name for this saved search:');
if (!name || !name.trim()) return;
fetch('/api/saved-searches', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: name.trim(), filters: filters })
})
.then(function(response) {
if (!response.ok) return response.json().then(function(d) { throw new Error(d.detail || 'Failed to save'); });
return response.json();
})
.then(function() { loadSavedSearches(); })
.catch(function(error) { alert(error.message); });
});
function deleteSavedSearch(id) {
if (!confirm('Delete this saved search?')) return;
fetch('/api/saved-searches/' + id, { method: 'DELETE' })
.then(function(response) {
if (!response.ok) throw new Error('Failed to delete');
loadSavedSearches();
})
.catch(function(error) { alert(error.message); });
}
// ---- Initialize from URL params ----
(function() {
var urlParams = new URLSearchParams(window.location.search);
// Populate filters from URL params (for saved search links)
if (urlParams.get('document_type')) filterDocType.value = urlParams.get('document_type');
if (urlParams.get('tags')) filterTags.value = urlParams.get('tags');
if (urlParams.get('sender')) filterSender.value = urlParams.get('sender');
if (urlParams.get('language')) filterLanguage.value = urlParams.get('language');
if (urlParams.get('text_quality')) filterTextQuality.value = urlParams.get('text_quality');
if (urlParams.get('date_from')) filterDateFrom.value = urlParams.get('date_from');
if (urlParams.get('date_to')) filterDateTo.value = urlParams.get('date_to');
// Load saved searches
loadSavedSearches();
// If q was provided via URL, search immediately
if (searchInput.value.trim().length >= 2) {
doSearch(1);
}
})();
</script>
{% endblock %}