// ============================================================ // 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; var _db = null; function openDB() { if (_db) return Promise.resolve(_db); return new Promise(function(resolve, reject) { var request = indexedDB.open(DB_NAME, DB_VERSION); request.onupgradeneeded = function(e) { var db = e.target.result; if (!db.objectStoreNames.contains(STORE_NAME)) { var store = db.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true }); store.createIndex('timestamp', 'timestamp', { unique: false }); } }; request.onsuccess = function(e) { _db = e.target.result; resolve(_db); }; request.onerror = function() { reject(new Error('IndexedDB open failed')); }; }); } // 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'); var store = tx.objectStore(STORE_NAME); var record = { blob: blob, module: module || 'unknown', timestamp: Date.now(), size: blob.size, mimeType: blob.type }; var req = store.add(record); req.onsuccess = function() { resolve(req.result); }; req.onerror = function() { reject(new Error('Failed to save audio backup')); }; }); }).then(function(id) { window._lastAudioBackupId = 'local_' + id; cleanupOldLocalBackups(); return id; }).catch(function(err) { console.warn('[AudioBackup] Save failed:', err.message); return null; }); } // 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(localId); tx.oncomplete = function() { resolve(); }; tx.onerror = function() { resolve(); }; }); }).catch(function() {}); }; // 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 req = tx.objectStore(STORE_NAME).getAll(); req.onsuccess = function() { var records = (req.result || []).filter(function(r) { return (Date.now() - r.timestamp) < MAX_AGE_MS; }); resolve(records); }; 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(localId); req.onsuccess = function() { if (!req.result) { reject(new Error('Backup not found')); return; } resolve(req.result); }; req.onerror = function() { reject(new Error('Failed to read backup')); }; }); }).then(function(record) { showLoading('Re-transcribing audio backup...'); window._lastAudioBackupId = id; return transcribeAudio(record.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; }); }); }; function cleanupOldLocalBackups() { openDB().then(function(db) { var tx = db.transaction(STORE_NAME, 'readwrite'); var store = tx.objectStore(STORE_NAME); var index = store.index('timestamp'); var cutoff = Date.now() - MAX_AGE_MS; var range = IDBKeyRange.upperBound(cutoff); var req = index.openCursor(range); req.onsuccess = function(e) { var cursor = e.target.result; if (cursor) { cursor.delete(); cursor.continue(); } }; }).catch(function() {}); } // Render audio backups list in settings window.renderAudioBackups = function() { var container = document.getElementById('audio-backups-list'); if (!container) return; getAudioBackups().then(function(backups) { if (backups.length === 0) { container.innerHTML = '

No audio backups. Recordings are saved automatically to the server and kept for 24 hours.

'; return; } container.innerHTML = backups.map(function(b) { var date = new Date(b.timestamp); 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 '
' + '
' + '
' + esc(b.module) + ' recording ' + sourceTag + '
' + '
' + date.toLocaleString() + ' · ' + sizeKb + ' KB' + compInfo + ' · ' + ageStr + '
' + '
' + '' + '' + '
'; }).join(''); container.querySelectorAll('.audio-backup-retry').forEach(function(btn) { btn.addEventListener('click', function() { retryAudioBackup(btn.dataset.id); }); }); container.querySelectorAll('.audio-backup-delete').forEach(function(btn) { btn.addEventListener('click', function() { deleteAudioBackup(btn.dataset.id).then(function() { showToast('Backup deleted', 'info'); renderAudioBackups(); }); }); }); }); }; function esc(str) { if (!str) return ''; return String(str).replace(/&/g, '&').replace(//g, '>'); } cleanupOldLocalBackups(); })();