// ============================================================ // APP.JS — Core utilities, tabs, model selector, helpers // ============================================================ // ── Client-side error logging ────────────────────────────── (function() { 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/) --- 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') .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 = '
Failed to load. Please refresh.
'; 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(); initBrowserWhisperSettings(); } 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); } }); // ── Browser Whisper settings UI ─────────────────────────── function initBrowserWhisperSettings() { var chk = document.getElementById('browser-whisper-enabled'); var sel = document.getElementById('browser-whisper-model'); var pre = document.getElementById('btn-whisper-preload'); var stat = document.getElementById('browser-whisper-status'); var prog = document.getElementById('browser-whisper-progress'); var pt = document.getElementById('browser-whisper-progress-text'); var sec = document.getElementById('browser-whisper-section'); if (!chk) return; var supported = typeof BrowserWhisper !== 'undefined' && BrowserWhisper.isSupported(); if (!supported) { if (sec) sec.innerHTML += '

Not supported in this browser. Use Chrome or Edge.

'; if (chk) chk.disabled = true; return; } // Restore saved state chk.checked = BrowserWhisper.isEnabled(); sel.value = BrowserWhisper.getModel(); stat.textContent = chk.checked ? 'On — audio stays on device' : 'Off'; chk.addEventListener('change', function() { BrowserWhisper.setEnabled(chk.checked); stat.textContent = chk.checked ? 'On — audio stays on device' : 'Off'; if (chk.checked) { BrowserWhisper.preload(function(file, pct) { if (!prog || !pt) return; if (pct >= 100) { prog.style.display = 'none'; return; } prog.style.display = 'block'; pt.textContent = file + (pct > 0 ? ' ' + pct + '%' : ''); }); } }); sel.addEventListener('change', function() { BrowserWhisper.setModel(sel.value); }); if (pre) { pre.addEventListener('click', function(e) { console.log('[BrowserWhisper] Pre-download button clicked!'); e.preventDefault(); if (!prog || !pt) { console.error('[BrowserWhisper] Progress elements not found'); showToast('UI elements missing - check page load', 'error'); return; } if (!BrowserWhisper || !BrowserWhisper.isSupported()) { console.error('[BrowserWhisper] Not supported'); showToast('Browser Whisper not supported in this browser', 'error'); return; } console.log('[BrowserWhisper] Starting preload...'); prog.style.display = 'block'; pt.textContent = 'Initializing...'; BrowserWhisper.setEnabled(true); chk.checked = true; stat.textContent = 'On — audio stays on device'; // Set timeout in case it gets stuck var timeout = setTimeout(function() { console.warn('[BrowserWhisper] 30s elapsed - still downloading, check Network tab'); showToast('Download in progress - check browser console', 'info'); }, 30000); try { BrowserWhisper.preload(function(file, pct) { console.log('[BrowserWhisper] Progress:', file, pct + '%'); if (pct >= 100) { clearTimeout(timeout); prog.style.display = 'none'; showToast('Whisper model ready!', 'success'); return; } prog.style.display = 'block'; pt.textContent = file + (pct > 0 ? ' ' + pct + '%' : ''); }); } catch (err) { clearTimeout(timeout); console.error('[BrowserWhisper] Preload error:', err); prog.style.display = 'none'; pt.textContent = ''; // Show CSP/network warning var cspWarning = document.getElementById('browser-whisper-csp-warning'); if (cspWarning) cspWarning.style.display = 'block'; showToast('Download blocked by network/firewall. Server transcription will be used.', 'warning'); } }); } } // --- MODEL SELECTOR --- var modelSelect = document.getElementById('global-model-select'); var costBadge = document.getElementById('model-cost-badge'); window._currentModels = []; window._currentProvider = 'openrouter'; 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); }); } // Determine default model (admin override or first model) var defaultModelId = data.defaultModel || (window._currentModels.length > 0 ? window._currentModels[0].id : ''); if (modelSelect && window._currentModels.length > 0) { window._buildModelOptions(modelSelect); // Select the admin-configured default if (defaultModelId) modelSelect.value = defaultModelId; if (costBadge) costBadge.textContent = ''; } // Populate all per-tab model selectors already in DOM document.querySelectorAll('.tab-model-select').forEach(function(sel) { window._buildModelOptions(sel); if (defaultModelId) sel.value = defaultModelId; }); }) .catch(function(err) { console.warn('Models load failed:', err); }); if (modelSelect) { modelSelect.addEventListener('change', function() { var m = window._currentModels.find(function(x) { return x.id === modelSelect.value; }); showToast('Model: ' + modelSelect.value.split('/').pop(), 'info'); }); } console.log('✅ App.js DOM ready'); }); // end DOMContentLoaded // ============================================================ // GLOBAL FUNCTIONS (must be outside DOMContentLoaded) // ============================================================ // ── Set formatted text on output/contenteditable elements ── // Converts \n to
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
var safe = String(text || '').replace(/&/g, '&').replace(//g, '>'); el.innerHTML = safe.replace(/\n/g, '
'); } // ── 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) (function() { 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; } var sel = document.getElementById('global-model-select'); return sel ? sel.value : 'google/gemini-2.5-flash'; } 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 = ' 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 = ' 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 = ' 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 = ' 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 (Capacitor KeepAwake or InsomniaCap) window.nativeKeepAwake = function(on) { try { 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) { // Browser Whisper — local, zero network, HIPAA-safe if (typeof BrowserWhisper !== 'undefined' && BrowserWhisper.isEnabled()) { var startTime = Date.now(); return BrowserWhisper.transcribe(blob) .then(function(text) { var elapsed = ((Date.now() - startTime) / 1000).toFixed(1); showToast('Transcribed locally (' + elapsed + 's)', 'success'); window._lastAudioBackupId = null; return { success: true, text: text, provider: 'browser-whisper' }; }) .catch(function(err) { console.warn('[BrowserWhisper] Failed:', err.message, '— falling back to server'); if (typeof saveAudioBackup === 'function') saveAudioBackup(blob, 'browser-whisper-failed').catch(function() {}); return _serverTranscribe(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 = '
Analyzing billing codes...
'; 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 = '

Suggested Billing Codes

'; if (data.icd10 && data.icd10.length > 0) { html += '
ICD-10 Diagnoses
'; data.icd10.forEach(function(c) { var name = c.name ? ' ' + escHtml(c.name) + '' : ''; html += '' + escHtml(c.code) + name + ''; }); html += '
'; } if (data.cpt && data.cpt.length > 0) { html += '
CPT / E&M
'; data.cpt.forEach(function(c) { var desc = c.desc ? ' ' + escHtml(c.desc) + '' : ''; html += '' + escHtml(c.code) + desc + ''; }); html += '
'; } if (data.emLevel) { html += '
E/M Assessment
'; html += 'Level ' + data.emLevel.level + ''; html += 'MDM: ' + data.emLevel.complexity + ' | ' + data.emLevel.diagnosisCount + ' dx | ' + data.emLevel.rosCount + ' ROS | ' + data.emLevel.peCount + ' PE'; html += '
'; } html += '

Suggestions only. Always verify codes against your institution\'s coding guidelines.

'; 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, '&').replace(//g, '>').replace(/"/g, '"'); } 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.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').catch(function() {}); } console.log('✅ App.js loaded');