Retrying a kept recording transcribed it and put the text on the clipboard, leaving you to find the right tab and paste. The app already had the answer: the module was recorded with the audio. Retry now opens that tab and puts the text in its transcript box. Two things had to be true first. The module was not actually being recorded. transcribeAudio never sent one, so the server stored its default for every upload — all 28 rows in audio_backups said "recording", and a retry had nowhere to send anything back to. Each module's call now tags its own upload. And the names disagreed. The recorders tagged 'encounter', 'soap', 'dictation' while the recording-started events said 'enc', 'sick', 'dict'. One table now holds the mapping and resolves the aliases, so the recorder that tags the upload, the backup row that labels it and the retry that delivers it cannot drift apart again. Existing text is appended to, never replaced: a retry usually recovers something on top of a live transcript, and overwriting would lose the words the browser did hear. The box only exists once its tab's markup has been fetched, so delivery polls briefly rather than guessing a delay, and falls back to the clipboard if the tab never opens. An empty result says so rather than claiming success. Backup rows now name their source and the button reads "Retry into SOAP Note" instead of "Retry". Verified in a browser: enc resolves to encounter, delivery switched tabs and produced 'existing live transcript\n\nRECOVERED TEXT'. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
484 lines
19 KiB
JavaScript
484 lines
19 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.
|
|
// ============================================================
|
|
|
|
var DB_NAME = 'PedScribeAudioBackup';
|
|
var STORE_NAME = 'recordings';
|
|
var DB_VERSION = 1;
|
|
var MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
|
|
var _db = null;
|
|
var boundary = window.AccountBoundary;
|
|
|
|
function guardTransaction(tx, owner) {
|
|
if (!boundary.valid(owner)) { tx.abort(); throw boundary.error(); }
|
|
function abort() { try { tx.abort(); } catch (e) {} }
|
|
window.addEventListener('account-boundary', abort, { once: true });
|
|
function done() { window.removeEventListener('account-boundary', abort); }
|
|
tx.addEventListener('complete', done);
|
|
tx.addEventListener('abort', done);
|
|
}
|
|
|
|
|
|
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) {
|
|
var owner = boundary.capture();
|
|
if (!owner) return Promise.resolve(null);
|
|
// Try server save first
|
|
return saveToServer(blob, module).then(function(serverId) {
|
|
if (!boundary.valid(owner)) return null;
|
|
if (serverId) {
|
|
window._lastAudioBackupId = 'server_' + serverId;
|
|
return serverId;
|
|
}
|
|
// Fallback to IndexedDB
|
|
return saveToIndexedDB(blob, module, owner);
|
|
}).catch(function() {
|
|
return saveToIndexedDB(blob, module, owner);
|
|
});
|
|
};
|
|
|
|
function saveToServer(blob, module) {
|
|
var formData = new FormData();
|
|
formData.append('audio', blob, 'audio.webm');
|
|
formData.append('module', module || 'encounter');
|
|
|
|
var headers = getAuthHeaders();
|
|
delete headers['Content-Type']; // FormData supplies its own boundary.
|
|
|
|
return fetch('/api/audio-backups', {
|
|
method: 'POST',
|
|
headers: headers,
|
|
credentials: 'same-origin',
|
|
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, owner) {
|
|
return openDB().then(function(db) {
|
|
if (!boundary.valid(owner)) return null;
|
|
return new Promise(function(resolve, reject) {
|
|
var tx = db.transaction(STORE_NAME, 'readwrite');
|
|
guardTransaction(tx, owner);
|
|
tx.onabort = function() { resolve(null); };
|
|
var store = tx.objectStore(STORE_NAME);
|
|
var record = {
|
|
owner: owner,
|
|
blob: blob,
|
|
module: module || 'unknown',
|
|
timestamp: Date.now(),
|
|
size: blob.size,
|
|
mimeType: blob.type
|
|
};
|
|
var req = store.add(record);
|
|
tx.oncomplete = function() { resolve(boundary.valid(owner) ? req.result : null); };
|
|
req.onerror = function() { reject(new Error('Failed to save audio backup')); };
|
|
});
|
|
}).then(function(id) {
|
|
if (!id || !boundary.valid(owner)) return null;
|
|
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) {
|
|
var owner = boundary.capture();
|
|
if (!owner) return Promise.resolve();
|
|
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) {
|
|
if (!boundary.valid(owner)) return;
|
|
return new Promise(function(resolve) {
|
|
var tx = db.transaction(STORE_NAME, 'readwrite');
|
|
guardTransaction(tx, owner);
|
|
var store = tx.objectStore(STORE_NAME);
|
|
var request = store.get(localId);
|
|
request.onsuccess = function() {
|
|
if (boundary.valid(owner) && request.result && request.result.owner === owner) store.delete(localId);
|
|
};
|
|
tx.onabort = function() { resolve(); };
|
|
tx.oncomplete = function() { resolve(); };
|
|
tx.onerror = function() { resolve(); };
|
|
});
|
|
}).catch(function() {});
|
|
};
|
|
|
|
// Get all backups (merged: server + local)
|
|
window.getAudioBackups = function() {
|
|
var owner = boundary.capture();
|
|
if (!owner) return Promise.resolve([]);
|
|
var serverPromise = fetchServerBackups();
|
|
var localPromise = getLocalBackups();
|
|
|
|
return Promise.all([serverPromise, localPromise]).then(function(results) {
|
|
if (!boundary.valid(owner)) return [];
|
|
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 headers = getAuthHeaders();
|
|
return fetch('/api/audio-backups', {
|
|
headers: headers,
|
|
credentials: 'same-origin'
|
|
})
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) { return data.success ? (data.backups || []) : []; })
|
|
.catch(function() { return []; });
|
|
}
|
|
|
|
function getLocalBackups() {
|
|
var owner = boundary.capture();
|
|
if (!owner) return Promise.resolve([]);
|
|
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 boundary.valid(owner) && r.owner === owner && (Date.now() - r.timestamp) < MAX_AGE_MS;
|
|
});
|
|
resolve(records);
|
|
};
|
|
req.onerror = function() { resolve([]); };
|
|
});
|
|
}).catch(function() { return []; });
|
|
}
|
|
|
|
// Retry transcription from backup
|
|
// A recording is significant clinical material, so it must be possible to
|
|
// take a copy out of the app — onto a phone's Files, a shared drive, an
|
|
// external recorder's card. Both stores can hand back the original audio;
|
|
// nothing exposed it before.
|
|
function backupBlob(id) {
|
|
var owner = boundary.capture();
|
|
if (!owner) return Promise.reject(boundary.error());
|
|
if (typeof id === 'string' && id.startsWith('server_')) {
|
|
return fetch('/api/audio-backups/' + id.replace('server_', '') + '/audio', { headers: getAuthHeaders(), credentials: 'same-origin' })
|
|
.then(function(r) { if (!r.ok) throw new Error('Download failed'); return r.blob(); });
|
|
}
|
|
var localId = Number(String(id).replace('local_', ''));
|
|
return openDB().then(function(db) {
|
|
return new Promise(function(resolve, reject) {
|
|
var req = db.transaction(STORE_NAME, 'readonly').objectStore(STORE_NAME).get(localId);
|
|
req.onsuccess = function() {
|
|
var record = req.result;
|
|
if (!record || !boundary.valid(owner) || record.owner !== owner) { reject(new Error('Recording unavailable')); return; }
|
|
resolve(record.blob);
|
|
};
|
|
req.onerror = function() { reject(new Error('Recording unavailable')); };
|
|
});
|
|
});
|
|
}
|
|
|
|
window.downloadAudioBackup = function(id, stamp) {
|
|
return backupBlob(id).then(function(blob) {
|
|
var url = URL.createObjectURL(blob);
|
|
var link = document.createElement('a');
|
|
link.href = url;
|
|
// Extension follows the recorded type: webm on most browsers, mp4 on iOS.
|
|
var ext = (blob.type || '').indexOf('mp4') !== -1 ? 'm4a' : (blob.type || '').indexOf('ogg') !== -1 ? 'ogg' : 'webm';
|
|
link.download = 'recording-' + (stamp || Date.now()) + '.' + ext;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
setTimeout(function() { URL.revokeObjectURL(url); }, 30000);
|
|
showToast('Recording downloaded', 'success');
|
|
}).catch(function(err) {
|
|
showToast(err.message || 'Could not download the recording', 'error');
|
|
});
|
|
};
|
|
|
|
|
|
// Put a retried transcript back where the audio came from.
|
|
//
|
|
// The module travelled with the recording, so the app already knows which tab
|
|
// and which box. Opening that tab and inserting the text is what someone was
|
|
// going to do by hand with the clipboard copy; doing it for them is the whole
|
|
// point of having stored the module.
|
|
//
|
|
// Existing text is never destroyed — a retry is usually recovering something
|
|
// ON TOP of a live transcript, and silently replacing it would lose the words
|
|
// the browser did hear. The new text is appended and revealed.
|
|
function deliverTranscript(module, text) {
|
|
var entry = window.RecordingModules && window.RecordingModules.lookup(module);
|
|
if (!entry || !text) return false;
|
|
if (typeof window.activateTab !== 'function') return false;
|
|
if (!window.activateTab(entry.tab)) return false;
|
|
|
|
// The tab's markup is fetched on first activation, so the box may not exist
|
|
// for a moment. Poll briefly rather than guessing a delay.
|
|
var attempts = 0;
|
|
(function place() {
|
|
var box = document.getElementById(entry.target);
|
|
if (!box) {
|
|
if (attempts++ > 40) {
|
|
copyToClipboard(text);
|
|
showToast('Transcript copied — could not open ' + entry.label, 'info');
|
|
return;
|
|
}
|
|
return setTimeout(place, 50);
|
|
}
|
|
var existing = (box.textContent || '').trim();
|
|
box.textContent = existing ? existing + '\n\n' + text : text;
|
|
box.scrollIntoView({ block: 'center' });
|
|
try { box.focus(); } catch (e) {}
|
|
showToast('Transcript added to ' + entry.label, 'success');
|
|
}());
|
|
return true;
|
|
}
|
|
|
|
function copyToClipboard(text) {
|
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
navigator.clipboard.writeText(text);
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// One place decides what happens with a retried transcript, so the server and
|
|
// local paths cannot drift apart.
|
|
function handleRetryResult(data, module) {
|
|
if (!data || !data.success) {
|
|
showToast('Retry failed: ' + ((data && data.error) || 'unknown'), 'error');
|
|
return data;
|
|
}
|
|
if (!data.text) {
|
|
showToast('That recording still transcribes to nothing', 'error');
|
|
return data;
|
|
}
|
|
if (deliverTranscript(module, data.text)) return data;
|
|
// Unknown module, or no tab to open: the clipboard is still better than
|
|
// nothing, and says so rather than claiming success.
|
|
if (copyToClipboard(data.text)) showToast('Transcript copied to clipboard', 'info');
|
|
else showToast('Transcribed, but there was nowhere to put it', 'info');
|
|
return data;
|
|
}
|
|
|
|
window.retryAudioBackup = function(id, module) {
|
|
var owner = boundary.capture();
|
|
if (!owner) return Promise.reject(boundary.error());
|
|
if (typeof id === 'string' && id.startsWith('server_')) {
|
|
var serverId = id.replace('server_', '');
|
|
showLoading('Downloading and re-transcribing...');
|
|
return fetch('/api/audio-backups/' + serverId + '/audio', {
|
|
headers: getAuthHeaders()
|
|
})
|
|
.then(function(r) { if (!r.ok) throw new Error('Download failed'); return r.blob(); })
|
|
.then(function(blob) {
|
|
if (!boundary.valid(owner)) throw boundary.error();
|
|
window._lastAudioBackupId = id;
|
|
return transcribeAudio(blob, module).then(function(data) {
|
|
if (!boundary.valid(owner)) throw boundary.error();
|
|
hideLoading();
|
|
return handleRetryResult(data, module);
|
|
});
|
|
})
|
|
.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 (!boundary.valid(owner) || !req.result || req.result.owner !== owner) { reject(new Error('Backup not found')); return; }
|
|
resolve(req.result);
|
|
};
|
|
req.onerror = function() { reject(new Error('Failed to read backup')); };
|
|
});
|
|
}).then(function(record) {
|
|
if (!boundary.valid(owner)) throw boundary.error();
|
|
showLoading('Re-transcribing audio backup...');
|
|
window._lastAudioBackupId = id;
|
|
return transcribeAudio(record.blob, module || record.module).then(function(data) {
|
|
if (!boundary.valid(owner)) throw boundary.error();
|
|
hideLoading();
|
|
return handleRetryResult(data, module || record.module);
|
|
});
|
|
});
|
|
};
|
|
|
|
function cleanupOldLocalBackups() {
|
|
openDB().then(function(db) {
|
|
var tx = db.transaction(STORE_NAME, 'readwrite');
|
|
var store = tx.objectStore(STORE_NAME);
|
|
var cutoff = Date.now() - MAX_AGE_MS;
|
|
var req = store.openCursor();
|
|
req.onsuccess = function(e) {
|
|
var cursor = e.target.result;
|
|
if (cursor) {
|
|
if (cursor.value.timestamp <= cutoff) 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;
|
|
var owner = boundary.capture();
|
|
getAudioBackups().then(function(backups) {
|
|
if (!boundary.valid(owner)) return;
|
|
container.textContent = '';
|
|
if (backups.length === 0) {
|
|
var empty = document.createElement('p');
|
|
empty.style.color = 'var(--g400)';
|
|
empty.style.fontSize = '13px';
|
|
empty.textContent = 'No audio backups. Recordings are saved automatically to the server and kept for 24 hours.';
|
|
container.appendChild(empty);
|
|
return;
|
|
}
|
|
backups.forEach(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 compInfo = b.compressedSize ? ' (' + Math.round(b.compressedSize / 1024) + ' KB compressed)' : '';
|
|
var row = document.createElement('div');
|
|
row.className = 'saved-enc-item';
|
|
row.style.padding = '8px 12px';
|
|
|
|
var body = document.createElement('div');
|
|
body.style.flex = '1';
|
|
|
|
var title = document.createElement('div');
|
|
title.style.fontWeight = '600';
|
|
title.style.fontSize = '13px';
|
|
title.style.display = 'flex';
|
|
title.style.alignItems = 'center';
|
|
title.style.gap = '6px';
|
|
title.appendChild(document.createTextNode(
|
|
(window.RecordingModules ? window.RecordingModules.label(b.module) : (b.module || 'Recording')) + ' '));
|
|
|
|
var sourceTag = document.createElement('span');
|
|
sourceTag.style.fontSize = '10px';
|
|
sourceTag.style.padding = '1px 5px';
|
|
sourceTag.style.borderRadius = '3px';
|
|
sourceTag.style.background = b.source === 'server' ? 'var(--blue-light)' : 'var(--g100)';
|
|
sourceTag.style.color = b.source === 'server' ? 'var(--blue)' : 'var(--g500)';
|
|
sourceTag.textContent = b.source === 'server' ? 'server' : 'local';
|
|
title.appendChild(sourceTag);
|
|
|
|
var meta = document.createElement('div');
|
|
meta.style.fontSize = '11px';
|
|
meta.style.color = 'var(--g500)';
|
|
meta.textContent = date.toLocaleString() + ' · ' + sizeKb + ' KB' + compInfo + ' · ' + ageStr;
|
|
|
|
body.appendChild(title);
|
|
body.appendChild(meta);
|
|
|
|
var retryBtn = document.createElement('button');
|
|
retryBtn.className = 'btn-sm btn-primary audio-backup-retry';
|
|
retryBtn.dataset.id = b.id;
|
|
retryBtn.dataset.module = b.module || '';
|
|
var retryIcon = document.createElement('i');
|
|
retryIcon.className = 'fas fa-rotate-right';
|
|
retryBtn.appendChild(retryIcon);
|
|
var retryTarget = window.RecordingModules && window.RecordingModules.lookup(b.module);
|
|
retryBtn.appendChild(document.createTextNode(retryTarget ? ' Retry into ' + retryTarget.label : ' Retry'));
|
|
retryBtn.title = retryTarget
|
|
? 'Transcribe again and add the text to ' + retryTarget.label
|
|
: 'Transcribe again and copy the text to the clipboard';
|
|
|
|
var downloadBtn = document.createElement('button');
|
|
downloadBtn.className = 'btn-sm btn-ghost audio-backup-download';
|
|
downloadBtn.dataset.id = b.id;
|
|
downloadBtn.dataset.stamp = String(b.timestamp);
|
|
downloadBtn.title = 'Download this recording';
|
|
var downloadIcon = document.createElement('i');
|
|
downloadIcon.className = 'fas fa-download';
|
|
downloadBtn.appendChild(downloadIcon);
|
|
|
|
var deleteBtn = document.createElement('button');
|
|
deleteBtn.className = 'btn-sm btn-ghost audio-backup-delete';
|
|
deleteBtn.dataset.id = b.id;
|
|
deleteBtn.style.color = 'var(--red)';
|
|
var deleteIcon = document.createElement('i');
|
|
deleteIcon.className = 'fas fa-trash';
|
|
deleteBtn.appendChild(deleteIcon);
|
|
|
|
row.appendChild(body);
|
|
row.appendChild(retryBtn);
|
|
row.appendChild(downloadBtn);
|
|
row.appendChild(deleteBtn);
|
|
container.appendChild(row);
|
|
});
|
|
container.querySelectorAll('.audio-backup-retry').forEach(function(btn) {
|
|
btn.addEventListener('click', function() { retryAudioBackup(btn.dataset.id, btn.dataset.module); });
|
|
});
|
|
container.querySelectorAll('.audio-backup-download').forEach(function(btn) {
|
|
btn.addEventListener('click', function() { downloadAudioBackup(btn.dataset.id, btn.dataset.stamp); });
|
|
});
|
|
container.querySelectorAll('.audio-backup-delete').forEach(function(btn) {
|
|
btn.addEventListener('click', function() {
|
|
deleteAudioBackup(btn.dataset.id).then(function() {
|
|
showToast('Backup deleted', 'info');
|
|
renderAudioBackups();
|
|
});
|
|
});
|
|
});
|
|
});
|
|
};
|
|
|
|
cleanupOldLocalBackups();
|