Audio Backups: - New audio_backups table in PostgreSQL (bytea, gzip compressed) - POST /api/audio-backups — upload with gzip compression (level 6) - GET /api/audio-backups — list user's backups - GET /api/audio-backups/:id/audio — download decompressed audio - DELETE /api/audio-backups/:id — delete backup - Auto-cleanup every hour (24h expiry) - Frontend saves to server first, falls back to IndexedDB - Settings shows source badge (server/local) per backup AI Corrections: - Corrections list is now expandable — click to view original vs corrected - Shows red "Original" and green "Corrected to" sections - Click arrow to expand/collapse each correction - Date shown on each correction
293 lines
11 KiB
JavaScript
293 lines
11 KiB
JavaScript
// ============================================================
|
|
// 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 = '<p style="color:var(--g400);font-size:13px;">No audio backups. Recordings are saved automatically to the server and kept for 24 hours.</p>';
|
|
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'
|
|
? '<span style="font-size:10px;padding:1px 5px;border-radius:3px;background:var(--blue-light);color:var(--blue);">server</span>'
|
|
: '<span style="font-size:10px;padding:1px 5px;border-radius:3px;background:var(--g100);color:var(--g500);">local</span>';
|
|
var compInfo = b.compressedSize ? ' (' + Math.round(b.compressedSize / 1024) + ' KB compressed)' : '';
|
|
return '<div class="saved-enc-item" style="padding:8px 12px;">' +
|
|
'<div style="flex:1;">' +
|
|
'<div style="font-weight:600;font-size:13px;display:flex;align-items:center;gap:6px;">' + esc(b.module) + ' recording ' + sourceTag + '</div>' +
|
|
'<div style="font-size:11px;color:var(--g500);">' + date.toLocaleString() + ' · ' + sizeKb + ' KB' + compInfo + ' · ' + ageStr + '</div>' +
|
|
'</div>' +
|
|
'<button class="btn-sm btn-primary audio-backup-retry" data-id="' + b.id + '"><i class="fas fa-rotate-right"></i> Retry</button>' +
|
|
'<button class="btn-sm btn-ghost audio-backup-delete" data-id="' + b.id + '" style="color:var(--red);"><i class="fas fa-trash"></i></button>' +
|
|
'</div>';
|
|
}).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, '<').replace(/>/g, '>');
|
|
}
|
|
|
|
cleanupOldLocalBackups();
|
|
})();
|