Files
Oxicloud/static/js/features/files/search.js
T
Edouard Vanbelle 6f4abfcec4 refactor(js): avoid use of window.XXX and move to import/export
- change worker: do not cache html pages (not necessary)
- remove use of window.XXX and maximize import/export,
  this will provide more clarety,
  show circular dependencies + you will benefit IDE help
2026-04-14 19:05:01 +02:00

226 lines
9.5 KiB
JavaScript

/**
* OxiCloud - Search Module (thin client)
*
* All search processing (filtering, scoring, sorting, categorization,
* icon mapping, size formatting) is performed on the Rust backend.
* This module is a pure rendering client — it sends requests and
* displays the enriched results returned by the server.
*/
import { loadFiles } from '../../app/filesView.js';
import { app } from '../../app/state.js';
import { ui } from '../../app/ui.js';
import { getAuthHeaders } from './fileOperations.js';
const search = {
/**
* Perform a search using query parameters.
* The backend handles all processing and returns enriched results with
* relevance_score, icon_class, category, size_formatted, etc.
*
* @param {string} query - Search query
* @param {Object} options - Additional search options
* @returns {Promise<Object>} - Enriched search results from backend
*/
async searchFiles(query, options = {}) {
try {
const params = new URLSearchParams();
params.append('query', query);
if (options.folder_id) params.append('folder_id', options.folder_id);
if (options.recursive !== undefined) params.append('recursive', options.recursive);
if (options.file_types) params.append('type', options.file_types);
if (options.min_size) params.append('min_size', options.min_size);
if (options.max_size) params.append('max_size', options.max_size);
if (options.created_after) params.append('created_after', options.created_after);
if (options.created_before) params.append('created_before', options.created_before);
if (options.modified_after) params.append('modified_after', options.modified_after);
if (options.modified_before) params.append('modified_before', options.modified_before);
if (options.limit) params.append('limit', options.limit);
if (options.offset) params.append('offset', options.offset);
if (options.sort_by) params.append('sort_by', options.sort_by);
const url = `/api/search?${params.toString()}`;
console.log(`[search] GET ${url}`);
const response = await fetch(url, { headers: getAuthHeaders() });
if (response.ok) {
return await response.json();
} else {
let errorText = '';
try {
const errorJson = await response.json();
errorText = errorJson.error || response.statusText;
} catch (_e) {
errorText = response.statusText;
}
console.error(`Search error: ${errorText}`);
throw new Error(`Search failed: ${errorText}`);
}
} catch (error) {
console.error('Error performing search:', error);
ui.showNotification('Error', 'Error performing search');
return {
files: [],
folders: [],
total_count: 0,
query_time_ms: 0,
sort_by: 'relevance'
};
}
},
/**
* Get autocomplete suggestions from the backend.
* Returns lightweight name suggestions without full search overhead.
*
* @param {string} query - Prefix to search for
* @param {Object} options - { folder_id, limit }
* @returns {Promise<Object>} - { suggestions: [...], query_time_ms }
*/
async getSuggestions(query, options = {}) {
try {
const params = new URLSearchParams();
params.append('query', query);
if (options.folder_id) params.append('folder_id', options.folder_id);
if (options.limit) params.append('limit', options.limit);
const url = `/api/search/suggest?${params.toString()}`;
const response = await fetch(url, { headers: getAuthHeaders() });
if (response.ok) {
return await response.json();
} else {
return { suggestions: [], query_time_ms: 0 };
}
} catch (error) {
console.error('Error getting suggestions:', error);
return { suggestions: [], query_time_ms: 0 };
}
},
/**
* Display search results in the UI.
*
* Uses server-computed enriched data:
* - icon_class: Font Awesome class for file type icon
* - size_formatted: Human-readable file size (e.g. "2.5 MB")
* - category: Content category (image, video, document, code, archive, audio, other)
* - relevance_score: 0-100 match quality
* - query_time_ms: Server-side query execution time
* - sort_by: Active sort order
*
* @param {Object} results - Enriched search results from backend
*/
displaySearchResults(results) {
ui.resetFilesList(); // ensure also list visible & error hidden
//FIXME: move into action rather bage sticky header + hide bread crumb ? + note also that search result does not consider section
const pageStickyHeader = document.getElementById('page-sticky-header');
// Search results header with query time and sort controls
const totalCount = results.total_count || results.files.length + results.folders.length;
const queryTimeText = results.query_time_ms !== undefined ? ` <span class="search-time">(${results.query_time_ms}ms)</span>` : '';
const searchHeader = document.createElement('div');
searchHeader.className = 'search-results-header';
searchHeader.innerHTML = `
<h3>Search results (${totalCount})${queryTimeText}</h3>
<div class="search-controls">
<select id="search-sort-select" class="search-sort-select" title="Sort by">
<option value="relevance"${results.sort_by === 'relevance' ? ' selected' : ''}>Relevance</option>
<option value="name"${results.sort_by === 'name' ? ' selected' : ''}>Name A-Z</option>
<option value="name_desc"${results.sort_by === 'name_desc' ? ' selected' : ''}>Name Z-A</option>
<option value="date_desc"${results.sort_by === 'date_desc' ? ' selected' : ''}>Newest first</option>
<option value="date"${results.sort_by === 'date' ? ' selected' : ''}>Oldest first</option>
<option value="size_desc"${results.sort_by === 'size_desc' ? ' selected' : ''}>Largest first</option>
<option value="size"${results.sort_by === 'size' ? ' selected' : ''}>Smallest first</option>
</select>
<button class="btn btn-secondary" id="clear-search-btn">
<i class="fas fa-times"></i> Clear search
</button>
</div>
`;
const previousSearchHeader = document.querySelector('.search-results-header');
if (previousSearchHeader) {
previousSearchHeader.replaceWith(searchHeader);
} else {
pageStickyHeader.appendChild(searchHeader);
}
// Sort dropdown — re-searches with new sort order (server-side)
const sortSelect = document.getElementById('search-sort-select');
if (sortSelect) {
sortSelect.addEventListener('change', () => {
const searchInput = document.querySelector('.search-container input');
if (searchInput?.value.trim()) {
const event = new CustomEvent('search-resort', {
detail: { sort_by: sortSelect.value }
});
document.dispatchEvent(event);
}
});
}
// Clear search
const clearSearchBtn = document.getElementById('clear-search-btn');
if (clearSearchBtn) {
clearSearchBtn.addEventListener('click', () => {
document.querySelector('.search-container input').value = '';
app.currentPath = '';
app.isSearchMode = false;
document.querySelector('.search-results-header')?.remove();
ui.updateBreadcrumb('');
loadFiles();
});
}
// Empty state
if (results.files.length === 0 && results.folders.length === 0) {
ui.showError(`
<i class="fas fa-search empty-state-icon"></i>
<p class="search-empty-text">No results found for this search</p>
`);
return;
}
// Render folders (server-provided enriched data)
results.folders.forEach((folder) => {
ui.addFolderToView(folder);
});
// Render files (server-provided enriched data)
results.files.forEach((file) => {
ui.addFileToView(file);
});
},
/**
* Clear the search cache on the server
* @returns {Promise<boolean>}
*/
async clearSearchCache() {
try {
const response = await fetch('/api/search/cache', {
method: 'DELETE',
headers: getAuthHeaders()
});
if (response.ok) {
ui.showNotification('Cache cleared', 'Search cache cleared successfully');
return true;
} else {
ui.showNotification('Error', 'Error clearing search cache');
return false;
}
} catch (error) {
console.error('Error clearing search cache:', error);
ui.showNotification('Error', 'Error clearing search cache');
return false;
}
}
};
export { search };