feat(api): add advanced filtering and saved searches

- Add date range (date_from/date_to), storage provider, and tags filters to GET /api/files
- Add SavedSearch model and migration (005_add_saved_searches)
- Add CRUD API endpoints for saved searches at /api/saved-searches
- Update files.html template with new filter controls and saved searches UI
- Update files view to pass new filter parameters to template
- Add comprehensive tests for all new functionality (26 tests)

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot]
2026-03-01 11:22:07 +00:00
parent f45e9bfd70
commit e5a4c6c64a
9 changed files with 912 additions and 9 deletions
+113 -1
View File
@@ -475,7 +475,7 @@
<!-- Filters Section -->
<div class="filters-section">
<form method="get" action="/files" class="filter-group">
<form method="get" action="/files" class="filter-group" role="search" aria-label="Filter files">
<div class="filter-item">
<label for="search">Search Filename</label>
<input type="text" id="search" name="search" value="{{ search }}" placeholder="Enter filename...">
@@ -503,6 +503,36 @@
</select>
</div>
<div class="filter-item">
<label for="date_from">Date From</label>
<input type="date" id="date_from" name="date_from" value="{{ date_from }}" aria-label="Filter from date">
</div>
<div class="filter-item">
<label for="date_to">Date To</label>
<input type="date" id="date_to" name="date_to" value="{{ date_to }}" aria-label="Filter to date">
</div>
<div class="filter-item">
<label for="storage_provider">Storage Provider</label>
<select id="storage_provider" name="storage_provider">
<option value="">All Providers</option>
<option value="dropbox" {% if storage_provider == "dropbox" %}selected{% endif %}>Dropbox</option>
<option value="google_drive" {% if storage_provider == "google_drive" %}selected{% endif %}>Google Drive</option>
<option value="onedrive" {% if storage_provider == "onedrive" %}selected{% endif %}>OneDrive</option>
<option value="s3" {% if storage_provider == "s3" %}selected{% endif %}>S3</option>
<option value="nextcloud" {% if storage_provider == "nextcloud" %}selected{% endif %}>Nextcloud</option>
<option value="webdav" {% if storage_provider == "webdav" %}selected{% endif %}>WebDAV</option>
<option value="ftp" {% if storage_provider == "ftp" %}selected{% endif %}>FTP</option>
<option value="sftp" {% if storage_provider == "sftp" %}selected{% endif %}>SFTP</option>
</select>
</div>
<div class="filter-item">
<label for="tags">Tags</label>
<input type="text" id="tags" name="tags" value="{{ tags }}" placeholder="e.g. invoice,amazon" aria-label="Filter by tags (comma-separated)">
</div>
<div class="filter-item">
<label>&nbsp;</label>
<button type="submit">Apply Filters</button>
@@ -520,6 +550,21 @@
</form>
</div>
<!-- Saved Searches Section -->
<div class="filters-section" style="margin-top: 0.5rem;">
<div style="display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; width: 100%;">
<label style="font-weight: 600; font-size: 0.85rem; white-space: nowrap;">
<i class="fas fa-bookmark" aria-hidden="true"></i> Saved Searches:
</label>
<div id="saved-searches-list" style="display: flex; gap: 0.25rem; flex-wrap: wrap;" aria-live="polite">
<span style="color: var(--text-muted); font-size: 0.85rem;">Loading...</span>
</div>
<button type="button" onclick="saveCurrentFilters()" class="btn-save-search" style="margin-left: auto; font-size: 0.8rem; padding: 0.25rem 0.5rem; cursor: pointer;" aria-label="Save current filters as a saved search">
<i class="fas fa-plus" aria-hidden="true"></i> Save Current
</button>
</div>
</div>
<!-- Full-Text Search Section -->
<div class="filters-section" style="margin-top: 0.75rem;">
<div style="width: 100%;">
@@ -1055,6 +1100,73 @@
window.location.href = '/files';
}
// Saved searches functionality
function loadSavedSearches() {
fetch('/api/saved-searches')
.then(response => response.json())
.then(searches => {
const container = document.getElementById('saved-searches-list');
if (!container) return;
if (!searches || searches.length === 0) {
container.innerHTML = '<span style="color: var(--text-muted); font-size: 0.85rem;">No saved searches yet</span>';
return;
}
container.innerHTML = searches.map(s => {
const params = new URLSearchParams(s.filters);
return `<span class="saved-search-tag" style="display: inline-flex; align-items: center; gap: 0.25rem; background: var(--bg-tertiary, #e5e7eb); padding: 0.2rem 0.5rem; border-radius: 0.25rem; font-size: 0.8rem;">
<a href="/files?${params.toString()}" style="text-decoration: none; color: inherit;">${s.name}</a>
<button type="button" onclick="deleteSavedSearch(${s.id})" style="border: none; background: none; cursor: pointer; color: var(--text-muted); padding: 0; line-height: 1;" aria-label="Delete saved search ${s.name}">
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</span>`;
}).join('');
})
.catch(() => {
const container = document.getElementById('saved-searches-list');
if (container) container.innerHTML = '<span style="color: var(--text-muted); font-size: 0.85rem;">Could not load saved searches</span>';
});
}
function saveCurrentFilters() {
const urlParams = new URLSearchParams(window.location.search);
const filters = {};
const filterKeys = ['search', 'mime_type', 'status', 'date_from', 'date_to', 'storage_provider', 'tags', 'sort_by', 'sort_order'];
filterKeys.forEach(key => {
const val = urlParams.get(key);
if (val) filters[key] = val;
});
if (Object.keys(filters).length === 0) {
alert('No filters to save. Apply some filters first.');
return;
}
const 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(response => {
if (!response.ok) return response.json().then(d => { throw new Error(d.detail || 'Failed to save'); });
return response.json();
})
.then(() => loadSavedSearches())
.catch(error => alert(error.message));
}
function deleteSavedSearch(id) {
if (!confirm('Delete this saved search?')) return;
fetch(`/api/saved-searches/${id}`, { method: 'DELETE' })
.then(response => {
if (!response.ok) throw new Error('Failed to delete');
loadSavedSearches();
})
.catch(error => alert(error.message));
}
// Load saved searches on page load
document.addEventListener('DOMContentLoaded', loadSavedSearches);
// Bulk selection functionality
function toggleSelectAll() {
const selectAll = document.getElementById('selectAll');