pediatric-ai-scribe-v3/public/js/app.js
Daniel f31afcdbf4 Fix biometric login, and keep recording alive across screen lock
Biometric sign-in has never worked. auth.js drove
window.Capacitor.Plugins.NativeBiometric — the API of
capacitor-native-biometric, which is not a dependency of this project. The
installed plugin is @aparajita/capacitor-biometric-auth, registered as
BiometricAuthNative with an entirely different API and no credential
storage at all. bioPlugin() therefore always returned null, bioAvailable()
always resolved {ok:false}, and the button was never revealed.

Rewritten against what is actually installed, with no new dependency:
BiometricAuthNative (checkBiometry/authenticate) presents the prompt, and
the already-working SecureStoragePlugin — via the window.SecureStorage
wrapper — holds the credentials. Credentials are only read after
authenticate() resolves, so the OS still gates access. biometryType is a
numeric enum in this plugin, so the old FACE_ID/TOUCH_ID string maps are
replaced with a single lookup exposed as typeName.

Secure storage itself was fine and is unchanged: SecureStoragePlugin
matches the name the wrapper looks up and is registered in
capacitor.settings.gradle.

Recording across screen lock: window.nativeKeepAwake() called Capacitor's
KeepAwake plugin, which is also not installed here, so it silently did
nothing and the device slept mid-encounter — taking the WebView's
MediaRecorder with it. keepAwake() is now a method on the existing
NativeRecording JavascriptInterface, which sets FLAG_KEEP_SCREEN_ON, and
is bound to the WebView so it survives the launcher's navigation to the
remote origin. The Capacitor plugin remains a fallback.

If the screen is locked anyway (power button, incoming call), the activity
pauses and Chromium throttles timers for hidden WebViews, starving
MediaRecorder's chunk delivery. MainActivity now calls resumeTimers() on
pause while recording. The foreground service was already correct — it
holds a partial wake lock and declares FOREGROUND_SERVICE_TYPE_MICROPHONE.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 01:12:47 +02:00

1044 lines
44 KiB
JavaScript

// ============================================================
// APP.JS — Core utilities, tabs, model selector, helpers
// ============================================================
// ── Client-side error logging ──────────────────────────────
function sendError(data) {
try {
navigator.sendBeacon('/api/logs/client-error', new Blob(
[JSON.stringify(data)], { type: 'application/json' }
));
} catch(e) {}
}
window.onerror = function(msg, src, line, col, err) {
// Ignore errors from browser extensions
if (src && src.indexOf('moz-extension') !== -1) return;
if (src && src.indexOf('chrome-extension') !== -1) return;
sendError({ type: 'uncaught', message: String(msg), source: src, line: line, col: col, stack: err && err.stack });
};
window.addEventListener('unhandledrejection', function(e) {
var msg = e.reason ? (e.reason.message || String(e.reason)) : 'Unhandled promise rejection';
sendError({ type: 'unhandledrejection', message: msg, stack: e.reason && e.reason.stack });
});
document.addEventListener('DOMContentLoaded', function() {
// --- COMPONENT LOADER (lazy-load tab HTML from /components/) ---
function getComponentVersion() {
try {
var script = document.currentScript || document.querySelector('script[src^="/js/app.js"]');
var version = script ? new URL(script.src, window.location.href).searchParams.get('v') : '';
return version || 'dev';
} catch(e) { return 'dev'; }
}
var COMPONENT_VERSION = getComponentVersion();
window.PEDSCRIBE_COMPONENT_VERSION = COMPONENT_VERSION;
var _componentCache = {};
var _componentLoading = {};
function loadComponent(tabEl) {
var component = tabEl.getAttribute('data-component');
if (!component || tabEl.dataset.loaded) return Promise.resolve();
if (_componentCache[component]) {
tabEl.innerHTML = _componentCache[component];
tabEl.dataset.loaded = '1';
return Promise.resolve();
}
if (_componentLoading[component]) return _componentLoading[component];
_componentLoading[component] = fetch('/components/' + component + '.html?v=' + COMPONENT_VERSION)
.then(function(r) { if (!r.ok) throw new Error(r.status); return r.text(); })
.then(function(html) {
_componentCache[component] = html;
tabEl.innerHTML = html;
tabEl.dataset.loaded = '1';
// Re-attach per-tab model selectors
tabEl.querySelectorAll('.tab-model-select').forEach(function(sel) {
if (typeof window._buildModelOptions === 'function') window._buildModelOptions(sel);
});
delete _componentLoading[component];
})
.catch(function(err) {
console.warn('[Component] Failed to load ' + component + ':', err);
tabEl.innerHTML = '<div style="padding:40px;text-align:center;color:var(--g400);">Failed to load. Please refresh.</div>';
delete _componentLoading[component];
});
return _componentLoading[component];
}
// Preload the first tab immediately
var firstTab = document.querySelector('.tab-content[data-component]');
if (firstTab) loadComponent(firstTab);
// --- TAB NAVIGATION ---
function activateTab(tabName) {
var btn = document.querySelector('.tab-btn[data-tab="' + tabName + '"]');
if (!btn || btn.classList.contains('hidden')) return false;
document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); });
document.querySelectorAll('.tab-content').forEach(function(c) { c.classList.remove('active'); });
btn.classList.add('active');
var tabEl = document.getElementById(tabName + '-tab');
if (tabEl) {
tabEl.classList.add('active');
// Lazy-load component HTML, then fire tabChanged after DOM is ready
loadComponent(tabEl).then(function() {
document.dispatchEvent(new CustomEvent('tabChanged', { detail: { tab: tabName } }));
});
} else {
document.dispatchEvent(new CustomEvent('tabChanged', { detail: { tab: tabName } }));
}
// Persist last tab in localStorage (restored after login in auth.js)
try { localStorage.setItem('ped_last_tab', tabName); } catch(e) {}
// Close sidebar on mobile after tab click
var sidebar = document.getElementById('sidebar');
if (sidebar && window.innerWidth <= 768) {
sidebar.classList.remove('open');
}
return true;
}
// Expose activateTab globally so auth.js can call it after login
window.activateTab = activateTab;
document.querySelectorAll('.tab-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
activateTab(btn.getAttribute('data-tab'));
var subtab = btn.getAttribute('data-subtab');
if (subtab && typeof window.wvSwitchSubtab === 'function') {
setTimeout(function() { window.wvSwitchSubtab(subtab); }, 0);
}
});
});
// --- SIDEBAR TOGGLE ---
var sidebar = document.getElementById('sidebar');
var sidebarOverlay = document.getElementById('sidebar-overlay');
document.addEventListener('click', function(e) {
if (e.target.closest('#btn-menu-toggle')) {
if (sidebar) sidebar.classList.toggle('open');
}
if (e.target.closest('#btn-sidebar-close') || e.target.closest('#sidebar-overlay')) {
if (sidebar) sidebar.classList.remove('open');
}
});
if (sidebarOverlay) {
sidebarOverlay.addEventListener('click', function() {
if (sidebar) sidebar.classList.remove('open');
});
}
// --- DESKTOP SIDEBAR COLLAPSE ---
var sidebarPinBtn = document.getElementById('btn-sidebar-pin');
var sidebarExpandBtn = document.getElementById('btn-sidebar-expand');
// Restore collapse state
try {
if (localStorage.getItem('ped_sidebar_collapsed') === '1') {
if (sidebar) sidebar.classList.add('collapsed');
if (sidebarExpandBtn) sidebarExpandBtn.style.display = 'flex';
if (sidebarPinBtn) sidebarPinBtn.style.display = 'none';
}
} catch(e) {}
if (sidebarPinBtn) {
sidebarPinBtn.addEventListener('click', function() {
if (sidebar) sidebar.classList.add('collapsed');
if (sidebarExpandBtn) sidebarExpandBtn.style.display = 'flex';
if (sidebarPinBtn) sidebarPinBtn.style.display = 'none';
try { localStorage.setItem('ped_sidebar_collapsed', '1'); } catch(e) {}
});
}
if (sidebarExpandBtn) {
sidebarExpandBtn.addEventListener('click', function() {
if (sidebar) sidebar.classList.remove('collapsed');
if (sidebarExpandBtn) sidebarExpandBtn.style.display = 'none';
if (sidebarPinBtn) sidebarPinBtn.style.display = 'flex';
try { localStorage.setItem('ped_sidebar_collapsed', '0'); } catch(e) {}
});
}
// Set footer year
var yearEl = document.getElementById('app-year');
if (yearEl) yearEl.textContent = new Date().getFullYear();
// Load settings data when settings tab is activated
document.addEventListener('tabChanged', function(e) {
if (e.detail && e.detail.tab === 'settings') {
if (typeof load2FAStatus === 'function') load2FAStatus();
if (typeof loadSessions === 'function') loadSessions();
if (typeof loadNextcloudStatus === 'function') loadNextcloudStatus();
if (typeof loadMemories === 'function') loadMemories();
if (typeof loadSavedEncountersList === 'function') loadSavedEncountersList();
if (typeof renderAudioBackups === 'function') renderAudioBackups();
if (typeof loadDocuments === 'function') loadDocuments();
}
if (e.detail && e.detail.tab === 'faq') {
// Wire FAQ accordion after component loads
setTimeout(function() {
document.querySelectorAll('.faq-question').forEach(function(btn) {
if (btn._faqWired) return;
btn._faqWired = true;
btn.addEventListener('click', function() {
var item = btn.parentElement;
var isOpen = item.classList.contains('open');
var section = item.closest('.faq-section');
if (section) section.querySelectorAll('.faq-item.open').forEach(function(i) { i.classList.remove('open'); });
if (!isOpen) item.classList.add('open');
});
});
}, 100);
}
});
// --- MODEL SELECTORS ---
window._currentModels = [];
window._currentProvider = 'openrouter';
window._defaultModelId = '';
fetch('/api/models')
.then(function(r) { return r.json(); })
.then(function(data) {
window._currentModels = data.models || [];
window._currentProvider = data.provider || 'openrouter';
window._buildModelOptions = function buildModelOptions(selectEl) {
selectEl.innerHTML = '';
window._currentModels.forEach(function(m) {
var opt = document.createElement('option');
opt.value = m.id;
opt.textContent = m.name;
selectEl.appendChild(opt);
});
if (window._defaultModelId && !Array.prototype.some.call(selectEl.options, function(opt) { return opt.value === window._defaultModelId; })) {
var saved = document.createElement('option');
saved.value = window._defaultModelId;
saved.textContent = window._defaultModelId + ' (saved default)';
selectEl.appendChild(saved);
}
if (window._defaultModelId) selectEl.value = window._defaultModelId;
}
// Determine default model (admin override or first model)
var defaultModelId = data.defaultModel || (window._currentModels.length > 0 ? window._currentModels[0].id : '');
window._defaultModelId = defaultModelId;
// Populate all per-tab model selectors already in DOM
document.querySelectorAll('.tab-model-select').forEach(function(sel) {
window._buildModelOptions(sel);
});
})
.catch(function(err) { console.warn('Models load failed:', err); });
console.log('✅ App.js DOM ready');
}); // end DOMContentLoaded
// ============================================================
// GLOBAL FUNCTIONS (must be outside DOMContentLoaded)
// ============================================================
// ── Set formatted text on output/contenteditable elements ──
// Converts \n to <br> so line breaks survive in contenteditable divs
// and are preserved when copying with innerText
function setOutputText(el, text) {
if (!el) return;
if (typeof el === 'string') el = document.getElementById(el);
if (!el) return;
// Escape HTML, then convert newlines to <br>
var safe = String(text || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
el.innerHTML = safe.replace(/\n/g, '<br>');
}
// ── Data-action event delegation (replaces inline onclick handlers) ──
// Handles data-action="copy|speak|nc-export" on any element,
// allowing removal of 'unsafe-inline' from the Content Security Policy.
document.addEventListener('click', function(e) {
var btn = e.target.closest('[data-action]');
if (!btn) return;
var action = btn.getAttribute('data-action');
var targetId = btn.getAttribute('data-target');
if (!action) return;
if (action === 'copy' && targetId) {
if (typeof copyText === 'function') copyText(targetId);
// Log PHI copy event (fire-and-forget, best-effort)
try {
var token = window.AUTH_TOKEN || localStorage.getItem('ped_scribe_token') || '';
if (token) {
fetch('/api/logs/client-event', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
body: JSON.stringify({ action: 'copy_to_clipboard', target: targetId })
}).catch(function() {});
}
} catch(e) {}
return;
}
if (action === 'speak' && targetId) {
if (typeof speakText === 'function') speakText(targetId);
return;
}
if (action === 'nc-export' && targetId) {
var label = btn.getAttribute('data-label') || 'export';
if (typeof exportToNextcloud === 'function') exportToNextcloud(targetId, label);
return;
}
});
// ── Announcement banner ────────────────────────────────────
function loadAnnouncement() {
fetch('/api/admin/config/announcement', { headers: getAuthHeaders() })
.then(function(r) { if (!r.ok) throw new Error('not ok'); return r.json(); })
.then(function(data) {
if (!data.success) return;
var banner = document.getElementById('announcement-banner');
var text = document.getElementById('announcement-text');
var icon = document.getElementById('announcement-icon');
if (!banner || !text) return;
if (data.enabled && data.text && data.text.trim()) {
text.textContent = data.text;
// type → icon + class
var icons = { info: 'fa-info-circle', warning: 'fa-triangle-exclamation', error: 'fa-circle-xmark', success: 'fa-circle-check' };
var type = data.type || 'info';
if (icon) { icon.className = 'fas ' + (icons[type] || icons.info); }
banner.className = 'announcement-banner ann-' + type;
} else {
banner.className = 'announcement-banner hidden';
}
})
.catch(function() {}); // silently ignore if endpoint not yet available
}
// Close button — dismiss for this page view only (reappears on refresh/re-login)
var closeBtn = document.getElementById('announcement-close');
if (closeBtn) {
closeBtn.addEventListener('click', function() {
var banner = document.getElementById('announcement-banner');
if (banner) banner.classList.add('hidden');
});
}
function getSelectedModel() {
// Prefer the active tab's own model selector if present
var activeTab = document.querySelector('.tab-content.active');
if (activeTab) {
var tabSel = activeTab.querySelector('.tab-model-select');
if (tabSel && tabSel.value) return tabSel.value;
}
return undefined;
}
function showLoading(text) {
var overlay = document.getElementById('loading-overlay');
var textEl = document.getElementById('loading-text');
if (textEl) textEl.textContent = text || 'Processing...';
if (overlay) { overlay.style.display = 'flex'; overlay.className = overlay.className.replace('hidden', '').trim(); }
}
function hideLoading() {
var overlay = document.getElementById('loading-overlay');
if (overlay) overlay.style.display = 'none';
}
// Non-blocking busy bar — user can keep working while AI processes
function showBusy(text) {
var bar = document.getElementById('busy-bar');
var textEl = document.getElementById('busy-text');
if (textEl) textEl.textContent = text || 'Processing...';
if (bar) bar.classList.add('active');
}
function hideBusy() {
var bar = document.getElementById('busy-bar');
if (bar) bar.classList.remove('active');
}
// Reusable confirmation modal — replaces browser confirm() and prompt()
// Usage: showConfirm('Are you sure?', function() { doSomething(); });
// With input: showConfirm('Enter email:', function(value) { send(value); }, { input: true, placeholder: 'email@example.com', inputType: 'email' });
// With danger styling: showConfirm('Delete user?', function() { deleteUser(); }, { danger: true, confirmText: 'Delete' });
window.showConfirm = function(message, onConfirm, opts) {
opts = opts || {};
var modal = document.getElementById('confirm-modal');
var text = document.getElementById('confirm-modal-text');
var okBtn = document.getElementById('confirm-modal-ok');
var cancelBtn = document.getElementById('confirm-modal-cancel');
var inputWrap = document.getElementById('confirm-modal-input-wrap');
var inputEl = document.getElementById('confirm-modal-input');
if (!modal || !text || !okBtn) return;
text.textContent = message;
okBtn.textContent = opts.confirmText || 'Confirm';
if (opts.danger) { okBtn.style.background = 'var(--red)'; okBtn.style.borderColor = 'var(--red)'; }
else { okBtn.style.background = ''; okBtn.style.borderColor = ''; }
if (opts.input) {
inputWrap.style.display = 'block';
inputEl.type = opts.inputType || 'text';
inputEl.placeholder = opts.placeholder || '';
inputEl.value = opts.defaultValue || '';
setTimeout(function() { inputEl.focus(); }, 50);
} else {
inputWrap.style.display = 'none';
}
modal.classList.remove('hidden');
function cleanup() {
modal.classList.add('hidden');
okBtn.onclick = null;
cancelBtn.onclick = null;
inputEl.value = '';
}
cancelBtn.onclick = function() { cleanup(); };
okBtn.onclick = function() {
if (opts.input) {
var val = inputEl.value;
if (opts.required && !val.trim()) { showToast(opts.requiredMsg || 'This field is required', 'error'); return; }
cleanup();
if (onConfirm) onConfirm(val);
} else {
cleanup();
if (onConfirm) onConfirm();
}
};
// Allow Enter key to confirm
inputEl.onkeydown = function(e) { if (e.key === 'Enter') okBtn.click(); };
};
function showToast(message, type) {
var container = document.getElementById('toast-container');
if (!container) { console.log('Toast:', type, message); return; }
var toast = document.createElement('div');
toast.className = 'toast toast-' + (type || 'success');
var icon = type === 'error' ? 'exclamation-circle' : type === 'info' ? 'info-circle' : 'check-circle';
var iconEl = document.createElement('i');
iconEl.className = 'fas fa-' + icon;
toast.appendChild(iconEl);
toast.appendChild(document.createTextNode(' ' + String(message == null ? '' : message)));
container.appendChild(toast);
setTimeout(function() { toast.remove(); }, 3500);
}
function copyText(elementId) {
var el = document.getElementById(elementId);
if (!el) return;
var text = el.innerText || el.textContent || '';
if (!text.trim()) { showToast('Nothing to copy', 'error'); return; }
// Try modern clipboard API (requires HTTPS or localhost)
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(function() {
showToast('Copied!', 'success');
}).catch(function(err) {
// Fallback: select + execCommand
try {
var range = document.createRange();
range.selectNodeContents(el);
window.getSelection().removeAllRanges();
window.getSelection().addRange(range);
var ok = document.execCommand('copy');
window.getSelection().removeAllRanges();
showToast(ok ? 'Copied!' : 'Copy failed — please select text manually', ok ? 'success' : 'error');
} catch (e) {
showToast('Copy not supported in this browser', 'error');
}
});
} else {
// No clipboard API — use execCommand directly
try {
var range = document.createRange();
range.selectNodeContents(el);
window.getSelection().removeAllRanges();
window.getSelection().addRange(range);
var ok = document.execCommand('copy');
window.getSelection().removeAllRanges();
showToast(ok ? 'Copied!' : 'Copy failed — please select text manually', ok ? 'success' : 'error');
} catch (e) {
showToast('Copy not supported in this browser', 'error');
}
}
}
// Speak / Stop
var currentlyReadingId = null;
var currentAudio = null;
function speakText(elementId) {
if (currentlyReadingId === elementId) { stopReading(); return; }
stopReading();
var el = document.getElementById(elementId);
if (!el) return;
var text = (el.innerText || el.textContent).trim();
if (!text) { showToast('Nothing to read', 'error'); return; }
currentlyReadingId = elementId;
var btn = findReadButton(elementId);
if (btn) { btn.classList.add('btn-reading'); btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Loading...'; }
fetch('/api/text-to-speech', {
method: 'POST',
headers: window.getAuthHeaders ? window.getAuthHeaders() : { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: text })
})
.then(function(r) {
if (!r.ok) throw new Error('TTS request failed (' + r.status + ')');
var ttsProvider = r.headers.get('X-TTS-Provider') || 'server';
return r.blob().then(function(blob) { return { blob: blob, provider: ttsProvider }; });
})
.then(function(result) {
var url = URL.createObjectURL(result.blob);
currentAudio = new Audio(url);
currentAudio.onended = function() { URL.revokeObjectURL(url); stopReading(); };
currentAudio.onerror = function() { URL.revokeObjectURL(url); stopReading(); showToast('Audio playback error', 'error'); };
currentAudio.play();
if (btn) { btn.classList.add('btn-reading'); btn.innerHTML = '<i class="fas fa-stop"></i> Stop'; }
showToast('Reading aloud (' + result.provider + ')', 'info');
})
.catch(function(err) {
stopReading();
// Fallback to browser TTS if server TTS fails
if ('speechSynthesis' in window) {
var utter = new SpeechSynthesisUtterance(text);
utter.rate = 0.9;
utter.onend = function() { stopReading(); };
currentlyReadingId = elementId;
if (btn) { btn.classList.add('btn-reading'); btn.innerHTML = '<i class="fas fa-stop"></i> Stop'; }
window.speechSynthesis.speak(utter);
showToast('TTS unavailable — using browser voice', 'info');
} else {
showToast('Read aloud failed: ' + err.message, 'error');
}
});
}
function stopReading() {
if ('speechSynthesis' in window) window.speechSynthesis.cancel();
if (currentAudio) { currentAudio.pause(); currentAudio = null; }
if (currentlyReadingId) {
var btn = findReadButton(currentlyReadingId);
if (btn) { btn.classList.remove('btn-reading'); btn.innerHTML = '<i class="fas fa-volume-high"></i> Read'; }
currentlyReadingId = null;
}
}
function findReadButton(elementId) {
var el = document.getElementById(elementId);
if (!el) return null;
var card = el.closest('.output-card') || el.closest('.card');
if (!card) return null;
var buttons = card.querySelectorAll('button');
for (var i = 0; i < buttons.length; i++) {
var btn = buttons[i];
// data-action="speak" buttons (current approach)
if (btn.getAttribute('data-action') === 'speak' && btn.getAttribute('data-target') === elementId) return btn;
// legacy onclick fallback
var oc = btn.getAttribute('onclick') || '';
if (oc.indexOf('speakText') !== -1 && oc.indexOf(elementId) !== -1) return btn;
}
return null;
}
// Timer
function createTimer(el) {
var s = 0, iv = null;
return {
start: function() { s = 0; this.resume(); },
resume: function() { this.update(); var self = this; if (!iv) iv = setInterval(function() { s++; self.update(); }, 1000); },
stop: function() { if (iv) { clearInterval(iv); iv = null; } return s; },
reset: function() { s = 0; this.update(); },
update: function() { el.textContent = String(Math.floor(s / 60)).padStart(2, '0') + ':' + String(s % 60).padStart(2, '0'); }
};
}
// Audio Recorder
function AudioRecorder() { this.mediaRecorder = null; this.chunks = []; this.stream = null; }
AudioRecorder.prototype.start = function() {
var self = this; self.chunks = [];
return navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, sampleRate: 16000, echoCancellation: true, noiseSuppression: true } })
.then(function(stream) {
self.stream = stream;
var mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
// 32kbps Opus is excellent for speech — small files, fast upload, great quality
self.mediaRecorder = new MediaRecorder(stream, { mimeType: mime, audioBitsPerSecond: 32000 });
self.mediaRecorder.ondataavailable = function(e) { if (e.data.size > 0) self.chunks.push(e.data); };
self.mediaRecorder.start(1000);
});
};
AudioRecorder.prototype.stop = function() {
var self = this;
return new Promise(function(resolve) {
if (!self.mediaRecorder || self.mediaRecorder.state === 'inactive') { resolve(null); return; }
self.mediaRecorder.onstop = function() {
var blob = new Blob(self.chunks, { type: self.mediaRecorder.mimeType });
if (self.stream) self.stream.getTracks().forEach(function(t) { t.stop(); });
resolve(blob);
};
self.mediaRecorder.stop();
});
};
// ── Native mobile helpers (Capacitor / Android bridge) ──
// These only activate when running inside the native app
window.nativeHaptic = function(style) {
try {
if (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.Haptics) {
window.Capacitor.Plugins.Haptics.impact({ style: style || 'medium' });
} else if (navigator.vibrate) {
navigator.vibrate(style === 'heavy' ? 100 : 50);
}
} catch(e) {}
};
// Start/stop Android foreground service for background recording
window.nativeStartRecordingService = function() {
try { if (window.NativeRecording) window.NativeRecording.startForegroundService(); } catch(e) {}
};
window.nativeStopRecordingService = function() {
try { if (window.NativeRecording) window.NativeRecording.stopForegroundService(); } catch(e) {}
};
// Keep screen awake during recording.
//
// Prefer the NativeRecording bridge (addJavascriptInterface, so it is present
// on the remote origin the launcher navigates to). The Capacitor KeepAwake
// plugin is kept as a fallback but is NOT installed in this project — relying
// on it alone meant this function silently did nothing and the screen slept
// mid-recording, killing the MediaRecorder.
window.nativeKeepAwake = function(on) {
try {
if (window.NativeRecording && typeof window.NativeRecording.keepAwake === 'function') {
window.NativeRecording.keepAwake(!!on);
return;
}
if (window.Capacitor && window.Capacitor.Plugins && window.Capacitor.Plugins.KeepAwake) {
if (on) window.Capacitor.Plugins.KeepAwake.keepAwake();
else window.Capacitor.Plugins.KeepAwake.allowSleep();
}
} catch(e) {}
};
// Detect if running in native app
window.isNativeApp = function() {
return !!(window.Capacitor && window.Capacitor.isNativePlatform && window.Capacitor.isNativePlatform());
};
// Check if server-side transcription (Whisper/AWS) is available
window._transcribeAvailable = null; // null = not checked yet, true/false after check
function checkTranscribeStatus() {
var token = window.AUTH_TOKEN || localStorage.getItem('ped_scribe_token') || '';
if (!token) return;
fetch('/api/transcribe/status', {
headers: { 'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json' }
})
.then(function(r) { return r.json(); })
.then(function(data) {
window._transcribeAvailable = !!data.available;
window._transcribeProvider = data.provider || 'none';
if (!data.available) {
console.log('[Transcribe] No server transcription configured — using browser speech recognition only');
}
})
.catch(function() { window._transcribeAvailable = false; });
}
function transcribeAudio(blob) {
return _serverTranscribe(blob);
}
function _serverTranscribe(blob) {
// If no server transcription is configured, skip upload entirely
if (window._transcribeAvailable === false) {
return Promise.resolve({ success: false, noProvider: true, error: 'No transcription API configured — using live transcript' });
}
var startTime = Date.now();
var formData = new FormData();
formData.append('audio', blob, 'audio.webm');
return fetch('/api/transcribe', {
method: 'POST',
headers: { 'Authorization': 'Bearer ' + (window.AUTH_TOKEN || localStorage.getItem('ped_scribe_token') || '') },
body: formData
}).then(function(r) { return r.json(); }).then(function(data) {
var elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
if (data.success && data.provider) {
showToast('Transcribed via ' + data.provider + ' (' + elapsed + 's)', 'info');
}
if (!data.success && blob.size > 0 && typeof saveAudioBackup === 'function') {
console.log('[AudioBackup] Transcription failed, saving backup...');
saveAudioBackup(blob, 'failed-transcription').then(function(id) {
console.log('[AudioBackup] Saved with id:', id);
showToast('Audio backed up for retry', 'info');
}).catch(function(e) { console.error('[AudioBackup] Save failed:', e); });
}
return data;
}).catch(function(err) {
if (blob.size > 0 && typeof saveAudioBackup === 'function') {
console.log('[AudioBackup] Transcription error, saving backup...');
saveAudioBackup(blob, 'failed-transcription').then(function(id) {
console.log('[AudioBackup] Saved with id:', id);
showToast('Audio backed up for retry', 'info');
}).catch(function(e) { console.error('[AudioBackup] Save failed:', e); });
}
return { success: false, error: err.message };
});
}
// Store original source material on an output element (call after generation)
function storeSourceContext(outputElementId, sourceText) {
var el = document.getElementById(outputElementId);
if (el && sourceText) el.dataset.sourceContext = sourceText;
}
// ── Billing code suggestions (called after note generation) ──
function suggestBillingCodes(outputElementId, noteText, noteType, patientAge, visitType) {
// Find or create the billing codes container near the output element
var outputEl = document.getElementById(outputElementId);
if (!outputEl || !noteText) return;
var card = outputEl.closest('.card, .output-card');
if (!card) return;
var containerId = outputElementId.replace('-text', '') + '-billing-codes';
var container = document.getElementById(containerId);
if (!container) {
// Create container dynamically if not in HTML
container = document.createElement('div');
container.id = containerId;
container.className = 'billing-codes-card';
// Insert after the output text element
outputEl.parentNode.insertBefore(container, outputEl.nextSibling);
}
container.className = 'billing-codes-card';
container.innerHTML = '<div style="font-size:12px;color:var(--g500);"><i class="fas fa-spinner fa-spin"></i> Analyzing billing codes...</div>';
fetch('/api/suggest-codes', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
noteText: noteText,
noteType: noteType || 'soap',
patientAge: patientAge || '',
visitType: visitType || 'outpatient'
})
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success || (!data.icd10.length && !data.cpt.length)) {
container.classList.add('hidden');
return;
}
var html = '<h4><i class="fas fa-file-invoice-dollar"></i> Suggested Billing Codes</h4>';
if (data.icd10 && data.icd10.length > 0) {
html += '<div class="billing-codes-section"><div class="billing-codes-label">ICD-10 Diagnoses</div><div>';
data.icd10.forEach(function(c) {
var name = c.name ? ' <span class="billing-code-name">' + escHtml(c.name) + '</span>' : '';
html += '<span class="billing-code-chip icd" title="Click to copy" data-code="' + escHtml(c.code) + '">' + escHtml(c.code) + name + '</span>';
});
html += '</div></div>';
}
if (data.cpt && data.cpt.length > 0) {
html += '<div class="billing-codes-section"><div class="billing-codes-label">CPT / E&M</div><div>';
data.cpt.forEach(function(c) {
var desc = c.desc ? ' <span class="billing-code-name">' + escHtml(c.desc) + '</span>' : '';
html += '<span class="billing-code-chip cpt" title="Click to copy" data-code="' + escHtml(c.code) + '">' + escHtml(c.code) + desc + '</span>';
});
html += '</div></div>';
}
if (data.emLevel) {
html += '<div class="billing-codes-section"><div class="billing-codes-label">E/M Assessment</div>';
html += '<span class="billing-code-chip em">Level ' + escHtml(data.emLevel.level) + '</span>';
html += '<span style="font-size:11px;color:var(--g500);margin-left:6px;">MDM: ' + escHtml(data.emLevel.complexity) + ' | ' + escHtml(data.emLevel.diagnosisCount) + ' dx | ' + escHtml(data.emLevel.rosCount) + ' ROS | ' + escHtml(data.emLevel.peCount) + ' PE</span>';
html += '</div>';
}
html += '<p style="font-size:10px;color:var(--g400);margin:8px 0 0;">Suggestions only. Always verify codes against your institution\'s coding guidelines.</p>';
container.innerHTML = html;
// Wire click-to-copy on chips
container.querySelectorAll('.billing-code-chip').forEach(function(chip) {
chip.addEventListener('click', function() {
var code = chip.dataset.code;
if (code && navigator.clipboard) {
navigator.clipboard.writeText(code);
showToast('Copied: ' + code, 'info');
}
});
});
})
.catch(function() {
container.classList.add('hidden');
});
}
function escHtml(s) { return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); }
// ── Don't-miss tooltip (called after sick visit / encounter HPI generates) ──
// Same insertion pattern as suggestBillingCodes — a side card next to the note
// output. Hard-capped at 5 items by the server prompt. Silent on empty / failure.
function suggestDontMiss(outputElementId, noteText, noteType, patientAge, chiefComplaint) {
var outputEl = document.getElementById(outputElementId);
if (!outputEl || !noteText) return;
var card = outputEl.closest('.card, .output-card');
if (!card) return;
var containerId = outputElementId.replace('-text', '') + '-dont-miss';
var container = document.getElementById(containerId);
if (!container) {
container = document.createElement('div');
container.id = containerId;
container.className = 'card dont-miss-card';
container.style.cssText = 'margin-top:10px;border-left:3px solid #f59e0b;';
outputEl.parentNode.insertBefore(container, outputEl.nextSibling);
}
container.innerHTML = '<div style="padding:10px 16px;font-size:12px;color:var(--g500);"><i class="fas fa-spinner fa-spin"></i> Reviewing note for high-yield items...</div>';
fetch('/api/dont-miss', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
noteText: noteText,
noteType: noteType || '',
patientAge: patientAge || '',
chiefComplaint: chiefComplaint || ''
})
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success || !data.points || data.points.length === 0) {
container.classList.add('hidden');
return;
}
var html = '<div class="card-header"><h3><i class="fas fa-triangle-exclamation" style="color:#f59e0b;"></i> Don\'t Miss</h3>' +
'<span style="font-size:11px;color:var(--g500);">High-yield items. Suggestions only.</span></div>' +
'<div style="padding:8px 16px;">';
data.points.forEach(function(p) {
var why = p.why ? '<div style="font-size:11px;color:var(--g500);margin-top:2px;">' + escHtml(p.why) + '</div>' : '';
html += '<div style="padding:6px 0;border-bottom:1px solid var(--g100);">' +
'<div style="font-size:13px;color:var(--g800);"><i class="fas fa-circle-exclamation" style="color:#f59e0b;font-size:11px;margin-right:6px;"></i>' + escHtml(p.point) + '</div>' +
why + '</div>';
});
html += '</div>';
container.classList.remove('hidden');
container.innerHTML = html;
})
.catch(function() {
container.classList.add('hidden');
});
}
// ── Patient education handout helper ────────────────────────
// Adds a reusable "Handout" action beside generated clinical notes. The actual
// handout is generated only when the physician clicks Generate in the panel.
function attachPatientEducation(outputElementId, opts) {
var outputEl = document.getElementById(outputElementId);
if (!outputEl) return;
opts = opts || {};
var card = outputEl.closest('.card, .output-card');
if (!card) return;
var actions = card.querySelector('.output-actions');
var prefix = outputElementId.replace(/[^a-zA-Z0-9_-]/g, '');
if (actions && !document.getElementById(prefix + '-patient-ed-btn')) {
var btn = document.createElement('button');
btn.id = prefix + '-patient-ed-btn';
btn.className = 'btn-sm btn-ghost';
btn.type = 'button';
btn.innerHTML = '<i class="fas fa-person-breastfeeding"></i> Handout';
btn.addEventListener('click', function() {
var panel = ensurePatientEducationPanel(outputElementId, opts);
panel.classList.remove('hidden');
panel.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
});
actions.appendChild(btn);
}
}
function ensurePatientEducationPanel(outputElementId, opts) {
var outputEl = document.getElementById(outputElementId);
var prefix = outputElementId.replace(/[^a-zA-Z0-9_-]/g, '');
var panelId = prefix + '-patient-ed';
var panel = document.getElementById(panelId);
if (panel) return panel;
panel = document.createElement('div');
panel.id = panelId;
panel.className = 'card patient-ed-card hidden';
panel.style.cssText = 'margin-top:10px;border-left:3px solid #0ea5e9;';
panel.innerHTML =
'<div class="card-header output-header">' +
'<h3><i class="fas fa-person-breastfeeding" style="color:#0ea5e9;"></i> Patient Education Handout</h3>' +
'<div class="output-actions">' +
'<button class="btn-sm btn-primary" id="' + prefix + '-patient-ed-generate" type="button"><i class="fas fa-wand-magic-sparkles"></i> Generate</button>' +
'</div>' +
'</div>' +
'<div style="padding:10px 16px;display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px;align-items:end;">' +
'<div class="demo-field"><label>Parent language</label><select id="' + prefix + '-patient-ed-language">' +
'<option>English</option><option>Spanish</option><option>French</option><option>Arabic</option><option>Haitian Creole</option><option>Chinese</option><option>Russian</option><option>Portuguese</option>' +
'</select></div>' +
'<div class="demo-field"><label>Diagnosis/context</label><input type="text" id="' + prefix + '-patient-ed-diagnosis" placeholder="Optional: diagnosis to emphasize"></div>' +
'<div class="demo-field"><label>Medications</label><input type="text" id="' + prefix + '-patient-ed-meds" placeholder="Optional: meds/doses from plan"></div>' +
'</div>' +
'<div id="' + prefix + '-patient-ed-text" class="output-text" contenteditable="true" style="margin:0 16px 12px;min-height:120px;" data-placeholder="Generated parent handout appears here..."></div>' +
'<div style="padding:0 16px 12px;display:flex;gap:8px;flex-wrap:wrap;align-items:center;">' +
'<button class="btn-sm btn-primary" data-action="copy" data-target="' + prefix + '-patient-ed-text"><i class="fas fa-copy"></i> Copy</button>' +
'<span style="font-size:11px;color:var(--g500);">Parent-facing draft. Verify before sharing.</span>' +
'</div>';
outputEl.parentNode.insertBefore(panel, outputEl.nextSibling);
var gen = panel.querySelector('#' + prefix + '-patient-ed-generate');
if (gen) gen.addEventListener('click', function() { generatePatientEducation(outputElementId, opts); });
return panel;
}
function generatePatientEducation(outputElementId, opts) {
var outputEl = document.getElementById(outputElementId);
if (!outputEl) return;
opts = opts || {};
var prefix = outputElementId.replace(/[^a-zA-Z0-9_-]/g, '');
var handoutEl = document.getElementById(prefix + '-patient-ed-text');
var langEl = document.getElementById(prefix + '-patient-ed-language');
var dxEl = document.getElementById(prefix + '-patient-ed-diagnosis');
var medsEl = document.getElementById(prefix + '-patient-ed-meds');
var noteText = (outputEl.innerText || outputEl.textContent || '').trim();
if (!noteText) { showToast('No note for handout', 'error'); return; }
if (handoutEl) handoutEl.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Generating parent handout...';
fetch('/api/patient-education', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
noteText: noteText,
diagnosis: dxEl ? dxEl.value : '',
medications: medsEl ? medsEl.value : '',
patientAge: opts.patientAge || '',
language: langEl ? langEl.value : 'English',
readingLevel: '6th grade plain language',
model: getSelectedModel()
})
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.success) {
if (handoutEl) handoutEl.textContent = '';
showToast(data.error || 'Handout generation failed', 'error');
return;
}
setOutputText(handoutEl, data.handout || '');
showToast('Patient handout generated', 'success');
})
.catch(function(err) {
if (handoutEl) handoutEl.textContent = '';
showToast(err.message || 'Handout generation failed', 'error');
});
}
function refineDocument(outputElementId, inputElementId) {
var doc = document.getElementById(outputElementId);
var input = document.getElementById(inputElementId);
if (!doc || !input) return;
var docText = doc.innerText.trim();
var instructions = input.value.trim();
if (!docText) { showToast('No document', 'error'); return; }
if (!instructions) { showToast('Enter instructions', 'error'); return; }
showBusy('Refining...');
var body = { currentDocument: docText, instructions: instructions, model: getSelectedModel() };
// Include original source material so AI can reference the full input
if (doc.dataset.sourceContext) body.sourceContext = doc.dataset.sourceContext;
fetch('/api/refine', {
method: 'POST', headers: getAuthHeaders(),
body: JSON.stringify(body)
})
.then(function(r) { return r.json(); })
.then(function(data) {
hideBusy();
if (data.success) { setOutputText(doc, data.refined); input.value = ''; showToast('Refined!', 'success'); }
else showToast(data.error || 'Failed', 'error');
})
.catch(function(err) { hideBusy(); showToast(err.message, 'error'); });
}
function shortenDocument(outputElementId) {
var doc = document.getElementById(outputElementId);
if (!doc) return;
var text = doc.innerText.trim();
if (!text) { showToast('No document', 'error'); return; }
showBusy('Shortening...');
fetch('/api/shorten', {
method: 'POST', headers: getAuthHeaders(),
body: JSON.stringify({ document: text, model: getSelectedModel() })
})
.then(function(r) { return r.json(); })
.then(function(data) {
hideBusy();
if (data.success) { setOutputText(doc, data.shortened); showToast('Shortened!', 'success'); }
else showToast(data.error || 'Failed', 'error');
})
.catch(function(err) { hideBusy(); showToast(err.message, 'error'); });
}
function exportToNextcloud(elementId, docType) {
var el = document.getElementById(elementId);
if (!el) return;
var text = el.innerText.trim();
if (!text) { showToast('Nothing to export', 'error'); return; }
fetch('/api/nextcloud/export', {
method: 'POST', headers: getAuthHeaders(),
body: JSON.stringify({ content: text, filename: docType + '-' + Date.now(), type: docType })
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) showToast(data.message, 'success');
else showToast(data.error || 'Export failed', 'error');
})
.catch(function() { showToast('Nextcloud not connected', 'error'); });
}
function createSpeechRecognition() {
if (window.WebSpeechRecognition && !window.WebSpeechRecognition.isEnabled()) return null;
if (!(window.SpeechRecognition || window.webkitSpeechRecognition)) return null;
var SR = window.SpeechRecognition || window.webkitSpeechRecognition;
var rec = new SR();
rec.continuous = true;
rec.interimResults = true;
rec.lang = 'en-US';
rec.maxAlternatives = 1;
return rec;
}
// Deduplicate speech recognition finals — Chrome can repeat text across restarts
function deduplicateFinal(newText, existingText) {
if (!newText || !existingText) return newText;
var trimmed = newText.trim();
if (!trimmed) return '';
// Check if the new text is already at the end of existing text
if (existingText.trimEnd().endsWith(trimmed)) return '';
// Check for partial overlap (last sentence repeated)
var words = trimmed.split(/\s+/);
if (words.length >= 3) {
var tail = existingText.trimEnd().split(/\s+/).slice(-words.length).join(' ');
if (tail === trimmed) return '';
// Check if first half of new text overlaps with end of existing
var half = Math.ceil(words.length / 2);
var firstHalf = words.slice(0, half).join(' ');
if (existingText.trimEnd().endsWith(firstHalf)) {
return words.slice(half).join(' ') + ' ';
}
}
return newText;
}
// PWA Service Worker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw.js?v=' + encodeURIComponent(window.PEDSCRIBE_COMPONENT_VERSION || 'dev')).catch(function() {});
}
console.log('✅ App.js loaded');