- JWT_SECRET fails fast at startup in production
- CORS fails closed if APP_URL + CORS_ORIGINS are both missing
- Explicit HSTS (1y, includeSubDomains, preload)
- Rate limit sensitive auth endpoints (change-password, 2FA)
- /api/health now returns {ok:true}; details gated behind admin auth
- Login enumeration removed — generic 401 + dummy bcrypt on miss
- ReDoS guard: 20KB input cap on /suggest-codes
- showToast uses textContent, no innerHTML
- clearSession() clears service worker caches on logout
- OIDC state is now HMAC-signed and stateless (survives restart)
- SSRF guard on admin-set OIDC issuer (blocks private IPs, requires HTTPS)
Adds docs/mobile-build.md covering APK build, release, git push,
keystore, and troubleshooting for both PedScribe and PedsHub apps.
990 lines
40 KiB
JavaScript
990 lines
40 KiB
JavaScript
// ============================================================
|
|
// 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 = '<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();
|
|
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 += '<p style="color:var(--red);font-size:12px;margin:8px 0 0;">Not supported in this browser. Use Chrome or Edge.</p>';
|
|
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 <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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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)
|
|
(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 = '<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 (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 = '<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 ' + data.emLevel.level + '</span>';
|
|
html += '<span style="font-size:11px;color:var(--g500);margin-left:6px;">MDM: ' + data.emLevel.complexity + ' | ' + data.emLevel.diagnosisCount + ' dx | ' + data.emLevel.rosCount + ' ROS | ' + 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, '&').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');
|