diff --git a/public/js/audioBackup.js b/public/js/audioBackup.js index e5c8e3f..33d35cc 100644 --- a/public/js/audioBackup.js +++ b/public/js/audioBackup.js @@ -1,13 +1,14 @@ // ============================================================ -// AUDIO BACKUP — IndexedDB-based audio recording backup -// Saves audio blobs locally so failed transcriptions can be retried +// AUDIO BACKUP — Server-side audio backup with local IndexedDB fallback +// Saves audio to server (gzip compressed in PostgreSQL), auto-deletes 24h. +// Falls back to IndexedDB if server save fails. // ============================================================ (function() { var DB_NAME = 'PedScribeAudioBackup'; var STORE_NAME = 'recordings'; var DB_VERSION = 1; - var MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours + var MAX_AGE_MS = 24 * 60 * 60 * 1000; var _db = null; @@ -27,8 +28,43 @@ }); } - // Save audio blob with metadata + // Save audio — tries server first, falls back to IndexedDB window.saveAudioBackup = function(blob, module) { + // Try server save first + return saveToServer(blob, module).then(function(serverId) { + if (serverId) { + window._lastAudioBackupId = 'server_' + serverId; + return serverId; + } + // Fallback to IndexedDB + return saveToIndexedDB(blob, module); + }).catch(function() { + return saveToIndexedDB(blob, module); + }); + }; + + function saveToServer(blob, module) { + var token = window.AUTH_TOKEN || localStorage.getItem('ped_scribe_token') || ''; + if (!token) return Promise.resolve(null); + + var formData = new FormData(); + formData.append('audio', blob, 'audio.webm'); + formData.append('module', module || 'encounter'); + + return fetch('/api/audio-backups', { + method: 'POST', + headers: { 'Authorization': 'Bearer ' + token }, + body: formData + }) + .then(function(r) { return r.json(); }) + .then(function(data) { + if (data.success) return data.id; + return null; + }) + .catch(function() { return null; }); + } + + function saveToIndexedDB(blob, module) { return openDB().then(function(db) { return new Promise(function(resolve, reject) { var tx = db.transaction(STORE_NAME, 'readwrite'); @@ -41,38 +77,86 @@ mimeType: blob.type }; var req = store.add(record); - req.onsuccess = function() { resolve(req.result); }; // returns ID + req.onsuccess = function() { resolve(req.result); }; req.onerror = function() { reject(new Error('Failed to save audio backup')); }; }); }).then(function(id) { - window._lastAudioBackupId = id; - cleanupOldBackups(); + window._lastAudioBackupId = 'local_' + id; + cleanupOldLocalBackups(); return id; }).catch(function(err) { console.warn('[AudioBackup] Save failed:', err.message); return null; }); - }; + } - // Delete a specific backup + // Delete a specific backup (server or local) window.deleteAudioBackup = function(id) { + if (typeof id === 'string' && id.startsWith('server_')) { + var serverId = id.replace('server_', ''); + return fetch('/api/audio-backups/' + serverId, { + method: 'DELETE', + headers: getAuthHeaders() + }).then(function() {}).catch(function() {}); + } + // Local IndexedDB delete + var localId = typeof id === 'string' ? parseInt(id.replace('local_', '')) : id; return openDB().then(function(db) { return new Promise(function(resolve) { var tx = db.transaction(STORE_NAME, 'readwrite'); - tx.objectStore(STORE_NAME).delete(id); + tx.objectStore(STORE_NAME).delete(localId); tx.oncomplete = function() { resolve(); }; tx.onerror = function() { resolve(); }; }); }).catch(function() {}); }; - // Get all backups (for UI listing) + // Get all backups (merged: server + local) window.getAudioBackups = function() { + var serverPromise = fetchServerBackups(); + var localPromise = getLocalBackups(); + + return Promise.all([serverPromise, localPromise]).then(function(results) { + var server = results[0].map(function(b) { + return { + id: 'server_' + b.id, + module: b.module, + timestamp: new Date(b.created_at).getTime(), + size: b.size_bytes, + compressedSize: b.compressed_bytes, + source: 'server', + expiresAt: b.expires_at + }; + }); + var local = results[1].map(function(b) { + return { + id: 'local_' + b.id, + module: b.module, + timestamp: b.timestamp, + size: b.size, + source: 'local' + }; + }); + return server.concat(local).sort(function(a, b) { return b.timestamp - a.timestamp; }); + }); + }; + + function fetchServerBackups() { + var token = window.AUTH_TOKEN || localStorage.getItem('ped_scribe_token') || ''; + if (!token) return Promise.resolve([]); + return fetch('/api/audio-backups', { + headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' } + }) + .then(function(r) { return r.json(); }) + .then(function(data) { return data.success ? (data.backups || []) : []; }) + .catch(function() { return []; }); + } + + function getLocalBackups() { return openDB().then(function(db) { return new Promise(function(resolve) { var tx = db.transaction(STORE_NAME, 'readonly'); - var store = tx.objectStore(STORE_NAME); - var req = store.getAll(); + var req = tx.objectStore(STORE_NAME).getAll(); req.onsuccess = function() { var records = (req.result || []).filter(function(r) { return (Date.now() - r.timestamp) < MAX_AGE_MS; @@ -82,14 +166,43 @@ req.onerror = function() { resolve([]); }; }); }).catch(function() { return []; }); - }; + } // Retry transcription from backup window.retryAudioBackup = function(id) { + if (typeof id === 'string' && id.startsWith('server_')) { + var serverId = id.replace('server_', ''); + showLoading('Downloading and re-transcribing...'); + var token = window.AUTH_TOKEN || localStorage.getItem('ped_scribe_token') || ''; + return fetch('/api/audio-backups/' + serverId + '/audio', { + headers: { 'Authorization': 'Bearer ' + token } + }) + .then(function(r) { if (!r.ok) throw new Error('Download failed'); return r.blob(); }) + .then(function(blob) { + window._lastAudioBackupId = id; + return transcribeAudio(blob).then(function(data) { + hideLoading(); + if (data.success) { + showToast('Backup transcribed!', 'success'); + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(data.text); + showToast('Transcript copied to clipboard', 'info'); + } + } else { + showToast('Retry failed: ' + (data.error || 'unknown'), 'error'); + } + return data; + }); + }) + .catch(function(err) { hideLoading(); showToast('Retry failed: ' + err.message, 'error'); }); + } + + // Local IndexedDB retry + var localId = typeof id === 'string' ? parseInt(id.replace('local_', '')) : id; return openDB().then(function(db) { return new Promise(function(resolve, reject) { var tx = db.transaction(STORE_NAME, 'readonly'); - var req = tx.objectStore(STORE_NAME).get(id); + var req = tx.objectStore(STORE_NAME).get(localId); req.onsuccess = function() { if (!req.result) { reject(new Error('Backup not found')); return; } resolve(req.result); @@ -102,8 +215,7 @@ return transcribeAudio(record.blob).then(function(data) { hideLoading(); if (data.success) { - showToast('Backup transcribed successfully!', 'success'); - // Copy to clipboard + showToast('Backup transcribed!', 'success'); if (navigator.clipboard && navigator.clipboard.writeText) { navigator.clipboard.writeText(data.text); showToast('Transcript copied to clipboard', 'info'); @@ -116,8 +228,7 @@ }); }; - // Clean up old backups - function cleanupOldBackups() { + function cleanupOldLocalBackups() { openDB().then(function(db) { var tx = db.transaction(STORE_NAME, 'readwrite'); var store = tx.objectStore(STORE_NAME); @@ -127,10 +238,7 @@ var req = index.openCursor(range); req.onsuccess = function(e) { var cursor = e.target.result; - if (cursor) { - cursor.delete(); - cursor.continue(); - } + if (cursor) { cursor.delete(); cursor.continue(); } }; }).catch(function() {}); } @@ -141,7 +249,7 @@ if (!container) return; getAudioBackups().then(function(backups) { if (backups.length === 0) { - container.innerHTML = '
No audio backups. Recordings are saved here automatically and kept for 24 hours.
'; + container.innerHTML = 'No audio backups. Recordings are saved automatically to the server and kept for 24 hours.
'; return; } container.innerHTML = backups.map(function(b) { @@ -149,21 +257,25 @@ var sizeKb = Math.round(b.size / 1024); var age = Math.round((Date.now() - b.timestamp) / 60000); var ageStr = age < 60 ? age + 'm ago' : Math.round(age / 60) + 'h ago'; + var sourceTag = b.source === 'server' + ? 'server' + : 'local'; + var compInfo = b.compressedSize ? ' (' + Math.round(b.compressedSize / 1024) + ' KB compressed)' : ''; return '