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:
@@ -23,10 +23,14 @@ router = APIRouter(prefix="/saved-searches", tags=["saved-searches"])
|
||||
|
||||
DbSession = Annotated[Session, Depends(get_db)]
|
||||
|
||||
# Allowed filter keys that can be saved
|
||||
# Allowed filter keys that can be saved.
|
||||
# Files-view keys: search, mime_type, status, storage_provider, sort_by, sort_order
|
||||
# Search-view keys: q, document_type, language, sender, text_quality
|
||||
# Shared keys: tags, date_from, date_to
|
||||
ALLOWED_FILTER_KEYS = frozenset(
|
||||
{
|
||||
"search",
|
||||
"q",
|
||||
"mime_type",
|
||||
"status",
|
||||
"date_from",
|
||||
@@ -35,6 +39,10 @@ ALLOWED_FILTER_KEYS = frozenset(
|
||||
"tags",
|
||||
"sort_by",
|
||||
"sort_order",
|
||||
"document_type",
|
||||
"language",
|
||||
"sender",
|
||||
"text_quality",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
+13
-1
@@ -29,6 +29,12 @@ def search_api(
|
||||
mime_type: Optional[str] = Query(None, description="Filter by MIME type (e.g. application/pdf)"),
|
||||
document_type: Optional[str] = Query(None, description="Filter by document type (e.g. Invoice)"),
|
||||
language: Optional[str] = Query(None, description="Filter by language code (e.g. de, en)"),
|
||||
tags: Optional[str] = Query(None, description="Filter by tag (exact match)"),
|
||||
sender: Optional[str] = Query(None, description="Filter by sender/absender (exact match)"),
|
||||
text_quality: Optional[str] = Query(
|
||||
None,
|
||||
description="Filter by OCR text quality: no_text, low, medium, high",
|
||||
),
|
||||
date_from: Optional[int] = Query(None, description="Filter results created after this Unix timestamp"),
|
||||
date_to: Optional[int] = Query(None, description="Filter results created before this Unix timestamp"),
|
||||
page: int = Query(1, ge=1, description="Page number (1-based)"),
|
||||
@@ -50,6 +56,9 @@ def search_api(
|
||||
- mime_type: Filter by MIME type
|
||||
- document_type: Filter by document type
|
||||
- language: Filter by language code
|
||||
- tags: Filter by tag (exact match on a single tag)
|
||||
- sender: Filter by sender/absender (exact match)
|
||||
- text_quality: Filter by OCR text quality (no_text, low, medium, high)
|
||||
- date_from: Unix timestamp lower bound
|
||||
- date_to: Unix timestamp upper bound
|
||||
- page: Page number (default: 1)
|
||||
@@ -57,7 +66,7 @@ def search_api(
|
||||
|
||||
Example:
|
||||
```
|
||||
GET /api/search?q=invoice&document_type=Invoice&date_from=1704067200&page=1&per_page=20
|
||||
GET /api/search?q=invoice&document_type=Invoice&tags=amazon&date_from=1704067200&page=1&per_page=20
|
||||
```
|
||||
|
||||
Response:
|
||||
@@ -90,6 +99,9 @@ def search_api(
|
||||
mime_type=mime_type,
|
||||
document_type=document_type,
|
||||
language=language,
|
||||
tags=tags,
|
||||
sender=sender,
|
||||
text_quality=text_quality,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
page=page,
|
||||
|
||||
@@ -33,8 +33,10 @@ _INDEX_SETTINGS = {
|
||||
"document_type",
|
||||
"language",
|
||||
"tags",
|
||||
"sender",
|
||||
"created_at_ts",
|
||||
"file_id",
|
||||
"ocr_text_length",
|
||||
],
|
||||
"sortableAttributes": [
|
||||
"created_at_ts",
|
||||
@@ -55,6 +57,7 @@ _INDEX_SETTINGS = {
|
||||
"file_size",
|
||||
"created_at_ts",
|
||||
"ocr_text",
|
||||
"ocr_text_length",
|
||||
],
|
||||
"rankingRules": [
|
||||
"words",
|
||||
@@ -142,6 +145,7 @@ def _build_document(file_record: "FileRecord", text: str, metadata: dict) -> dic
|
||||
"file_size": file_record.file_size or 0,
|
||||
"created_at_ts": created_at_ts,
|
||||
"ocr_text": text or "",
|
||||
"ocr_text_length": len(text) if text else 0,
|
||||
}
|
||||
|
||||
|
||||
@@ -202,6 +206,9 @@ def search_documents(
|
||||
mime_type: Optional[str] = None,
|
||||
document_type: Optional[str] = None,
|
||||
language: Optional[str] = None,
|
||||
tags: Optional[str] = None,
|
||||
sender: Optional[str] = None,
|
||||
text_quality: Optional[str] = None,
|
||||
date_from: Optional[int] = None,
|
||||
date_to: Optional[int] = None,
|
||||
page: int = 1,
|
||||
@@ -214,6 +221,9 @@ def search_documents(
|
||||
mime_type: Optional MIME-type filter.
|
||||
document_type: Optional document type filter.
|
||||
language: Optional language filter (ISO 639-1, e.g. "de").
|
||||
tags: Optional tag filter (exact match on a single tag).
|
||||
sender: Optional sender/absender filter (exact match).
|
||||
text_quality: Optional text quality filter: no_text, low, medium, high.
|
||||
date_from: Optional lower bound Unix timestamp for created_at.
|
||||
date_to: Optional upper bound Unix timestamp for created_at.
|
||||
page: 1-based page number.
|
||||
@@ -240,6 +250,21 @@ def search_documents(
|
||||
filters.append(f'document_type = "{document_type}"')
|
||||
if language:
|
||||
filters.append(f'language = "{language}"')
|
||||
if tags:
|
||||
filters.append(f'tags = "{tags}"')
|
||||
if sender:
|
||||
filters.append(f'sender = "{sender}"')
|
||||
if text_quality:
|
||||
# Translate text_quality labels into ocr_text_length ranges
|
||||
_tq_filters = {
|
||||
"no_text": "ocr_text_length = 0",
|
||||
"low": "ocr_text_length > 0 AND ocr_text_length < 500",
|
||||
"medium": "ocr_text_length >= 500 AND ocr_text_length < 2000",
|
||||
"high": "ocr_text_length >= 2000",
|
||||
}
|
||||
tq_expr = _tq_filters.get(text_quality)
|
||||
if tq_expr:
|
||||
filters.append(tq_expr)
|
||||
if date_from is not None:
|
||||
filters.append(f"created_at_ts >= {date_from}")
|
||||
if date_to is not None:
|
||||
|
||||
+265
-29
@@ -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);
|
||||
});
|
||||
|
||||
// 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 %}
|
||||
|
||||
@@ -348,3 +348,36 @@ class TestSavedSearchesCRUD:
|
||||
assert len(data["filters"]) == 8
|
||||
assert data["filters"]["search"] == "invoice"
|
||||
assert data["filters"]["tags"] == "invoice,amazon"
|
||||
|
||||
def test_saved_search_with_fulltext_query(self, client: TestClient):
|
||||
"""Saved search can include full-text query (q) for the search view."""
|
||||
payload = {
|
||||
"name": "Invoice Search",
|
||||
"filters": {"q": "invoice total amount", "document_type": "Invoice"},
|
||||
}
|
||||
response = client.post("/api/saved-searches", json=payload)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["filters"]["q"] == "invoice total amount"
|
||||
assert data["filters"]["document_type"] == "Invoice"
|
||||
|
||||
def test_saved_search_content_finding_filters(self, client: TestClient):
|
||||
"""Saved search accepts content-finding filter keys (language, sender, text_quality)."""
|
||||
payload = {
|
||||
"name": "German Invoices",
|
||||
"filters": {
|
||||
"q": "rechnung",
|
||||
"language": "de",
|
||||
"sender": "ACME GmbH",
|
||||
"text_quality": "high",
|
||||
"tags": "invoice",
|
||||
},
|
||||
}
|
||||
response = client.post("/api/saved-searches", json=payload)
|
||||
assert response.status_code == 201
|
||||
data = response.json()
|
||||
assert data["filters"]["q"] == "rechnung"
|
||||
assert data["filters"]["language"] == "de"
|
||||
assert data["filters"]["sender"] == "ACME GmbH"
|
||||
assert data["filters"]["text_quality"] == "high"
|
||||
assert data["filters"]["tags"] == "invoice"
|
||||
|
||||
@@ -97,6 +97,7 @@ class TestMeilisearchIndexDocument:
|
||||
assert doc["file_id"] == 1
|
||||
assert doc["document_title"] == "Invoice January 2026"
|
||||
assert "invoice" in doc["tags"]
|
||||
assert doc["ocr_text_length"] == len("This is an invoice for services rendered")
|
||||
|
||||
def test_index_document_meilisearch_error(self):
|
||||
"""index_document returns False on Meilisearch exception."""
|
||||
@@ -179,6 +180,62 @@ class TestMeilisearchSearchDocuments:
|
||||
assert 'mime_type = "application/pdf"' in search_params["filter"]
|
||||
assert 'language = "de"' in search_params["filter"]
|
||||
|
||||
def test_search_with_tags_filter(self):
|
||||
"""search_documents passes tags filter to Meilisearch."""
|
||||
mock_client, mock_index = self._make_mock_client(hits=[], total=0)
|
||||
|
||||
with patch("app.utils.meilisearch_client.get_meilisearch_client", return_value=mock_client):
|
||||
from app.utils.meilisearch_client import search_documents
|
||||
|
||||
search_documents("test", tags="invoice", page=1, per_page=10)
|
||||
|
||||
call_kwargs = mock_index.search.call_args
|
||||
search_params = call_kwargs[0][1]
|
||||
assert "filter" in search_params
|
||||
assert 'tags = "invoice"' in search_params["filter"]
|
||||
|
||||
def test_search_with_sender_filter(self):
|
||||
"""search_documents passes sender filter to Meilisearch."""
|
||||
mock_client, mock_index = self._make_mock_client(hits=[], total=0)
|
||||
|
||||
with patch("app.utils.meilisearch_client.get_meilisearch_client", return_value=mock_client):
|
||||
from app.utils.meilisearch_client import search_documents
|
||||
|
||||
search_documents("test", sender="ACME Corp", page=1, per_page=10)
|
||||
|
||||
call_kwargs = mock_index.search.call_args
|
||||
search_params = call_kwargs[0][1]
|
||||
assert "filter" in search_params
|
||||
assert 'sender = "ACME Corp"' in search_params["filter"]
|
||||
|
||||
def test_search_with_text_quality_high(self):
|
||||
"""search_documents translates text_quality=high to ocr_text_length filter."""
|
||||
mock_client, mock_index = self._make_mock_client(hits=[], total=0)
|
||||
|
||||
with patch("app.utils.meilisearch_client.get_meilisearch_client", return_value=mock_client):
|
||||
from app.utils.meilisearch_client import search_documents
|
||||
|
||||
search_documents("test", text_quality="high", page=1, per_page=10)
|
||||
|
||||
call_kwargs = mock_index.search.call_args
|
||||
search_params = call_kwargs[0][1]
|
||||
assert "filter" in search_params
|
||||
assert "ocr_text_length >= 2000" in search_params["filter"]
|
||||
|
||||
def test_search_with_text_quality_no_text(self):
|
||||
"""search_documents translates text_quality=no_text to ocr_text_length filter."""
|
||||
mock_client, mock_index = self._make_mock_client(hits=[], total=0)
|
||||
|
||||
with patch("app.utils.meilisearch_client.get_meilisearch_client", return_value=mock_client):
|
||||
from app.utils.meilisearch_client import search_documents
|
||||
|
||||
search_documents("test", text_quality="no_text", page=1, per_page=10)
|
||||
|
||||
call_kwargs = mock_index.search.call_args
|
||||
search_params = call_kwargs[0][1]
|
||||
assert "filter" in search_params
|
||||
assert "ocr_text_length = 0" in search_params["filter"]
|
||||
|
||||
def test_search_pagination(self):
|
||||
"""search_documents applies correct offset for page 2."""
|
||||
mock_client, mock_index = self._make_mock_client(hits=[], total=50)
|
||||
@@ -271,6 +328,9 @@ class TestSearchAPIEndpoint:
|
||||
mime_type="application/pdf",
|
||||
document_type=None,
|
||||
language="en",
|
||||
tags=None,
|
||||
sender=None,
|
||||
text_quality=None,
|
||||
date_from=None,
|
||||
date_to=None,
|
||||
page=2,
|
||||
@@ -294,6 +354,9 @@ class TestSearchAPIEndpoint:
|
||||
mime_type=None,
|
||||
document_type=None,
|
||||
language=None,
|
||||
tags=None,
|
||||
sender=None,
|
||||
text_quality=None,
|
||||
date_from=None,
|
||||
date_to=None,
|
||||
page=1,
|
||||
@@ -310,3 +373,30 @@ class TestSearchAPIEndpoint:
|
||||
call_kwargs = mock_search.call_args
|
||||
assert call_kwargs[1]["date_from"] == 1704067200
|
||||
assert call_kwargs[1]["date_to"] == 1735689600
|
||||
|
||||
def test_search_endpoint_tags_filter(self, client):
|
||||
"""GET /api/search?q=...&tags=invoice passes tags to search_documents."""
|
||||
mock_result = {"results": [], "total": 0, "page": 1, "pages": 0, "query": "test"}
|
||||
with patch("app.api.search.search_documents", return_value=mock_result) as mock_search:
|
||||
response = client.get("/api/search?q=test&tags=invoice")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert mock_search.call_args[1]["tags"] == "invoice"
|
||||
|
||||
def test_search_endpoint_sender_filter(self, client):
|
||||
"""GET /api/search?q=...&sender=ACME passes sender to search_documents."""
|
||||
mock_result = {"results": [], "total": 0, "page": 1, "pages": 0, "query": "test"}
|
||||
with patch("app.api.search.search_documents", return_value=mock_result) as mock_search:
|
||||
response = client.get("/api/search?q=test&sender=ACME")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert mock_search.call_args[1]["sender"] == "ACME"
|
||||
|
||||
def test_search_endpoint_text_quality_filter(self, client):
|
||||
"""GET /api/search?q=...&text_quality=high passes text_quality to search_documents."""
|
||||
mock_result = {"results": [], "total": 0, "page": 1, "pages": 0, "query": "test"}
|
||||
with patch("app.api.search.search_documents", return_value=mock_result) as mock_search:
|
||||
response = client.get("/api/search?q=test&text_quality=high")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert mock_search.call_args[1]["text_quality"] == "high"
|
||||
|
||||
@@ -39,3 +39,33 @@ class TestSearchPage:
|
||||
long_query = "a" * 600
|
||||
response = client.get(f"/search?q={long_query}")
|
||||
assert response.status_code == 422
|
||||
|
||||
def test_search_page_contains_filter_elements(self, client):
|
||||
"""GET /search contains content-finding filter UI elements."""
|
||||
response = client.get("/search")
|
||||
assert response.status_code == 200
|
||||
assert 'id="filter-document-type"' in response.text
|
||||
assert 'id="filter-tags"' in response.text
|
||||
assert 'id="filter-sender"' in response.text
|
||||
assert 'id="filter-language"' in response.text
|
||||
assert 'id="filter-text-quality"' in response.text
|
||||
assert 'id="filter-date-from"' in response.text
|
||||
assert 'id="filter-date-to"' in response.text
|
||||
|
||||
def test_search_page_contains_saved_searches(self, client):
|
||||
"""GET /search contains saved searches UI elements."""
|
||||
response = client.get("/search")
|
||||
assert response.status_code == 200
|
||||
assert 'id="saved-searches-list"' in response.text
|
||||
assert 'id="save-search-btn"' in response.text
|
||||
assert "/api/saved-searches" in response.text
|
||||
|
||||
def test_search_page_text_quality_options(self, client):
|
||||
"""GET /search contains text quality filter with expected options."""
|
||||
response = client.get("/search")
|
||||
assert response.status_code == 200
|
||||
text = response.text
|
||||
assert 'value="high"' in text
|
||||
assert 'value="medium"' in text
|
||||
assert 'value="low"' in text
|
||||
assert 'value="no_text"' in text
|
||||
|
||||
Reference in New Issue
Block a user