// Enhanced HF Service with Versioning and Standardized Responses const CACHE_TTL = 60 * 1000; // 60 seconds (aligned with backend HF cache) class HFService { constructor() { this.apiBase = null; this.apiBasePromise = null; this.cache = new Map(); this.retryLimit = 3; this.retryDelay = 1000; } async getApiBase() { if (this.apiBase) return this.apiBase; if (this.apiBasePromise) return this.apiBasePromise; this.apiBasePromise = (async () => { const configuredBase = window.__DOCVAULT_API_BASE__; const remoteFallbackBase = window.__DOCVAULT_REMOTE_API_BASE__; const { protocol, hostname, origin, port } = window.location; const host = hostname || '127.0.0.1'; const candidates = []; if (configuredBase && typeof configuredBase === 'string') { candidates.push(configuredBase.replace(/\/$/, '')); } candidates.push(`${origin}/api`); if (port !== '5000') candidates.push(`${protocol}//${host}:5000/api`); if (port !== '7860') candidates.push(`${protocol}//${host}:7860/api`); if (remoteFallbackBase && typeof remoteFallbackBase === 'string') { candidates.push(remoteFallbackBase.replace(/\/$/, '')); } const uniqueCandidates = [...new Set(candidates)]; for (const candidate of uniqueCandidates) { try { const response = await fetch(`${candidate}/health`, { method: 'GET', headers: { 'X-User-ID': 'default_user' } }); if (response.ok) { this.apiBase = candidate; return candidate; } } catch (err) { // Try the next candidate quietly. } } throw new Error('DocVault backend is unreachable. Expected /api/health on the current host, ports 5000/7860, or the configured remote Space API.'); })(); return this.apiBasePromise; } async fetchWithRetry(url, options = {}, retries = this.retryLimit) { try { const response = await fetch(url, options); if (!response.ok) { if (response.status >= 500 && retries > 0) throw new Error('Server error'); const errorData = await response.json().catch(() => ({})); throw new Error(errorData.error || `Request failed: ${response.status}`); } return response; } catch (err) { if (retries > 0) { const delay = this.retryDelay * Math.pow(2, this.retryLimit - retries); await new Promise(resolve => setTimeout(resolve, delay)); return this.fetchWithRetry(url, options, retries - 1); } throw err; } } async listFiles(path = '') { const cacheKey = `list-${path}`; const cached = this.cache.get(cacheKey); if (cached && (Date.now() - cached.timestamp < CACHE_TTL)) { return cached.data; } const queryPath = path ? `?folder_path=${encodeURIComponent(path)}` : ''; const apiBase = await this.getApiBase(); const url = `${apiBase}/list${queryPath}`; const res = await this.fetchWithRetry(url, { headers: { 'X-User-ID': 'default_user' } }); const data = await res.json(); const result = { files: [], folders: [] }; // Validate response structure if (!data || typeof data !== 'object' || data.success !== true) { console.warn('Invalid API response structure:', data); this.cache.set(cacheKey, { data: result, timestamp: Date.now() }); return result; } if (Array.isArray(data.files)) { for (const item of data.files) { result.files.push({ path: item.path || '', name: item.name || 'unnamed', size: item.size || 0, type: 'file', lastModified: item.modified_at, storage: item.storage }); } } if (Array.isArray(data.folders)) { for (const item of data.folders) { result.folders.push({ path: item.path || '', name: item.name || 'unnamed', type: 'folder', storage: item.storage }); } } this.cache.set(cacheKey, { data: result, timestamp: Date.now() }); return result; } async uploadFile(file, destPath) { const formData = new FormData(); // standardized folder path extraction const folderPath = destPath.includes('/') ? destPath.substring(0, destPath.lastIndexOf('/')) : ''; const filename = file instanceof File ? file.name : destPath.split('/').pop(); const fileBlob = file instanceof File ? file : new Blob([file.content || '']); formData.append('folder_path', folderPath); formData.append('file', fileBlob, filename); const apiBase = await this.getApiBase(); const url = `${apiBase}/upload`; const res = await this.fetchWithRetry(url, { method: 'POST', headers: { 'X-User-ID': 'default_user' }, body: formData }); this.clearCache(); return await res.json(); } async createFolder(folderPath) { const apiBase = await this.getApiBase(); const url = `${apiBase}/create-folder`; const res = await this.fetchWithRetry(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-User-ID': 'default_user' }, body: JSON.stringify({ folder_path: folderPath }), }); this.clearCache(); return await res.json(); } async deleteFile(path) { const apiBase = await this.getApiBase(); const url = `${apiBase}/delete`; const res = await this.fetchWithRetry(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-User-ID': 'default_user' }, body: JSON.stringify({ path, type: 'file' }), }); this.clearCache(); const data = await res.json(); if (!data.success) { throw new Error(data.error || 'Failed to delete file'); } return data; } async deleteFolder(folderPath) { const apiBase = await this.getApiBase(); const url = `${apiBase}/delete`; const res = await this.fetchWithRetry(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-User-ID': 'default_user' }, body: JSON.stringify({ path: folderPath, type: 'folder' }), }); this.clearCache(); const data = await res.json(); if (!data.success) { throw new Error(data.error || 'Failed to delete folder'); } return data; } async getHistory(path) { const apiBase = await this.getApiBase(); const url = `${apiBase}/history?path=${encodeURIComponent(path)}`; const res = await this.fetchWithRetry(url, { headers: { 'X-User-ID': 'default_user' } }); const data = await res.json(); if (!data || !data.success) { console.warn('Failed to get history:', data?.error || 'Unknown error'); return []; } return Array.isArray(data.history) ? data.history : []; } async restoreVersion(path, revision, asCopy = false) { const apiBase = await this.getApiBase(); const url = `${apiBase}/restore`; const res = await this.fetchWithRetry(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-User-ID': 'default_user' }, body: JSON.stringify({ path, revision, as_copy: asCopy }), }); this.clearCache(); return await res.json(); } async renameItem(itemPath, newName) { const apiBase = await this.getApiBase(); const url = `${apiBase}/rename`; const res = await this.fetchWithRetry(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-User-ID': 'default_user' }, body: JSON.stringify({ item_path: itemPath, new_name: newName }), }); this.clearCache(); return await res.json(); } async listLinks() { const apiBase = await this.getApiBase(); const res = await this.fetchWithRetry(`${apiBase}/links/list`, { headers: { 'X-User-ID': 'default_user' } }); const data = await res.json(); return Array.isArray(data.links) ? data.links : []; } async addLink(payload) { const apiBase = await this.getApiBase(); const res = await this.fetchWithRetry(`${apiBase}/links/add`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-User-ID': 'default_user' }, body: JSON.stringify(payload) }); return await res.json(); } async updateLink(payload) { const apiBase = await this.getApiBase(); const res = await this.fetchWithRetry(`${apiBase}/links/update`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-User-ID': 'default_user' }, body: JSON.stringify(payload) }); return await res.json(); } async deleteLink(linkId) { const apiBase = await this.getApiBase(); const res = await this.fetchWithRetry(`${apiBase}/links/delete`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-User-ID': 'default_user' }, body: JSON.stringify({ link_id: linkId }) }); return await res.json(); } clearCache() { this.cache.clear(); } } export const hfService = new HFService();