Four changes batched: 1. ED Encounters tab (new) — multi-stage emergency note with don't-miss tooltips and 2023 E/M MDM finalize. New route /api/ed-encounters (generate per-stage + finalize MDM), new ed-encounters.js owning all client logic, new ed-encounter.html component, new template_ed memory category. Persists draft to localStorage every keystroke and to saved_encounters on stage advance. encounters.js touched only to register the new tab in sessionStorage restore + tabMap (save and idempotency code untouched). 2. Notes model selector — /notes/from-voice now accepts a client-supplied model (validated by the existing callAI allow-list); falls back to the admin default. Added <select class="tab-model-select"> to notes.html so the existing app.js populator handles options + default. 3. Remove AI-learning-from-corrections — deleted correctionTracker.js, POST /memories/correction, the corrections branch in /memories/context, the settings UI section, the FAQ entry, and all dead trackAIOutput/saveCorrection guards in callers. Legacy correction_* DB rows are filtered (NOT LIKE) rather than dropped, so no destructive migration. 4. Fix notes AI framing — /notes/from-voice prompt no longer assumes "physician dictation". Plain notes (shopping lists, reminders, ideas) now match the dictation tone instead of being forced into clinical structure. All 46 tests pass.
890 lines
37 KiB
JavaScript
890 lines
37 KiB
JavaScript
// ============================================================
|
||
// NOTES — per-user personal scratchpad under Clinical Tools.
|
||
// Rich-text via the app-wide Tiptap bundle (window.Tiptap).
|
||
// CRUD against /api/notes (auth + server-side encryption).
|
||
//
|
||
// Three panes — list / reader / editor. After opening or saving
|
||
// a note, the reader shows a clean, read-only rendering; tap
|
||
// Edit to switch to the Tiptap editor. Autosaves during edit
|
||
// (1.2s quiet period) so you never lose progress if you close
|
||
// the tab or the browser crashes.
|
||
//
|
||
// On mobile (<900px) only one pane is visible at a time — the
|
||
// layout's data-view attribute controls which one. Desktop
|
||
// always shows the sidebar + the right pane together.
|
||
// ============================================================
|
||
|
||
(function() {
|
||
var _inited = false;
|
||
var _notes = []; // active notes (deleted_at IS NULL)
|
||
var _trash = []; // trashed notes (deleted_at IS NOT NULL)
|
||
var _pane = 'active'; // 'active' | 'trash' — drives the list view
|
||
var _activeId = null; // currently open note id (reader OR editor)
|
||
var _editor = null; // Tiptap instance
|
||
var _search = '';
|
||
|
||
// Autosave state
|
||
var _dirty = false;
|
||
var _autosaveTimer = null;
|
||
var _autosaveInFlight = false;
|
||
var _pendingAfterInflight = false;
|
||
var AUTOSAVE_DEBOUNCE = 1200;
|
||
|
||
// Voice recording state
|
||
var _recorder = null;
|
||
var _recTimer = null;
|
||
var _recPaused = false;
|
||
var _recActive = false;
|
||
|
||
var _statusTimer = null;
|
||
|
||
// Wait for the Notes tab to be activated — the component HTML is
|
||
// lazy-loaded, so elements don't exist until the user clicks the tab.
|
||
document.addEventListener('tabChanged', function(e) {
|
||
if (e.detail.tab === 'notes') {
|
||
if (_inited) { refreshList(); return; }
|
||
_inited = true;
|
||
init();
|
||
return;
|
||
}
|
||
// Leaving the Notes tab — cancel any in-flight recording so the
|
||
// mic doesn't stay live + the recorder doesn't keep buffering.
|
||
if (_recActive) stopRecording(true);
|
||
flushAutosave();
|
||
});
|
||
|
||
function $(id) { return document.getElementById(id); }
|
||
|
||
function init() {
|
||
var newBtn = $('btn-notes-new');
|
||
var searchEl = $('notes-search');
|
||
var titleEl = $('note-title');
|
||
var listEl = $('notes-list');
|
||
if (!newBtn || !listEl) return;
|
||
|
||
// List + sidebar
|
||
newBtn.addEventListener('click', function() { startCreate(); });
|
||
searchEl.addEventListener('input', function() {
|
||
_search = searchEl.value.trim().toLowerCase();
|
||
renderList();
|
||
});
|
||
listEl.addEventListener('click', function(e) {
|
||
// Trash-view actions get handled by their own classes; route them
|
||
// first so a click on Restore or × doesn't also open the note.
|
||
var restoreBtn = e.target.closest('.notes-restore-btn');
|
||
if (restoreBtn) {
|
||
e.stopPropagation();
|
||
restoreNote(parseInt(restoreBtn.dataset.id));
|
||
return;
|
||
}
|
||
var hardBtn = e.target.closest('.notes-hard-delete-btn');
|
||
if (hardBtn) {
|
||
e.stopPropagation();
|
||
hardDeleteNote(parseInt(hardBtn.dataset.id));
|
||
return;
|
||
}
|
||
var item = e.target.closest('.notes-list-item');
|
||
if (!item) return;
|
||
var id = parseInt(item.dataset.id);
|
||
if (!id) return;
|
||
if (_pane === 'active') openReader(id);
|
||
// In trash, clicking the row body is a no-op — explicit Restore /
|
||
// delete-forever buttons are the only actions, so a stray click
|
||
// can't accidentally open a stale draft.
|
||
});
|
||
|
||
// Active <-> Trash tab toggle
|
||
var tabActive = $('btn-notes-tab-active');
|
||
var tabTrash = $('btn-notes-tab-trash');
|
||
if (tabActive) tabActive.addEventListener('click', function() { switchPane('active'); });
|
||
if (tabTrash) tabTrash.addEventListener('click', function() { switchPane('trash'); });
|
||
|
||
var emptyTrashBtn = $('btn-notes-trash-empty');
|
||
if (emptyTrashBtn) emptyTrashBtn.addEventListener('click', emptyTrash);
|
||
|
||
// Empty-state CTA
|
||
var newFromEmpty = $('btn-notes-new-empty');
|
||
if (newFromEmpty) newFromEmpty.addEventListener('click', function() { startCreate(); });
|
||
|
||
// Reader pane
|
||
var editBtn = $('btn-note-edit');
|
||
var delFromRead = $('btn-note-delete-read');
|
||
var backFromRead = $('btn-notes-reader-back');
|
||
if (editBtn) editBtn.addEventListener('click', function() { if (_activeId != null) openEditor(_activeId); });
|
||
if (delFromRead) delFromRead.addEventListener('click', deleteActive);
|
||
if (backFromRead) backFromRead.addEventListener('click', closeToList);
|
||
|
||
// Editor pane
|
||
var saveBtn = $('btn-note-save');
|
||
var delBtn = $('btn-note-delete');
|
||
var backFromEdit = $('btn-notes-editor-back');
|
||
if (saveBtn) saveBtn.addEventListener('click', function() { saveNote({ source: 'manual', returnToReader: true }); });
|
||
if (delBtn) delBtn.addEventListener('click', deleteActive);
|
||
if (backFromEdit) backFromEdit.addEventListener('click', backFromEditor);
|
||
|
||
titleEl.addEventListener('input', function() {
|
||
_dirty = true; updateStatus('Saving…', 'saving'); scheduleAutosave();
|
||
});
|
||
|
||
// Voice recording controls. addEventListener passes the click Event
|
||
// as the first argument — wrap so the truthy Event doesn't get passed
|
||
// to stopRecording(silent) and trigger the silent-cancel branch
|
||
// (which shuts the mic without transcribing → "Stop does nothing").
|
||
var recStart = $('btn-note-rec-start');
|
||
var recPause = $('btn-note-rec-pause');
|
||
var recStop = $('btn-note-rec-stop');
|
||
if (recStart) recStart.addEventListener('click', function() { startRecording(); });
|
||
if (recPause) recPause.addEventListener('click', function() { togglePauseRecording(); });
|
||
if (recStop) recStop.addEventListener('click', function() { stopRecording(false); });
|
||
|
||
// Ctrl/Cmd+S still saves manually from the editor
|
||
document.addEventListener('keydown', function(e) {
|
||
var tab = $('notes-tab');
|
||
if (!tab || !tab.classList.contains('active')) return;
|
||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||
if (!$('notes-editor').classList.contains('hidden')) {
|
||
e.preventDefault();
|
||
saveNote({ source: 'shortcut', returnToReader: true });
|
||
}
|
||
}
|
||
});
|
||
|
||
// Persist dirty state on tab close / page unload — best-effort final flush.
|
||
// navigator.sendBeacon is POST-only so it can't drive a PUT update; fetch
|
||
// with keepalive:true is the right primitive here (works for both POST
|
||
// for new notes and PUT for existing).
|
||
window.addEventListener('beforeunload', function() {
|
||
if (!_dirty) return;
|
||
var title = getTitle();
|
||
if (!title) return;
|
||
var body = getBody();
|
||
var isNew = _activeId == null;
|
||
var url = isNew ? '/api/notes' : '/api/notes/' + _activeId;
|
||
var method = isNew ? 'POST' : 'PUT';
|
||
try {
|
||
fetch(url, {
|
||
method: method,
|
||
keepalive: true,
|
||
credentials: 'include',
|
||
headers: Object.assign({ 'Content-Type': 'application/json' }, getAuthHeaders()),
|
||
body: JSON.stringify({ title: title, body: body }),
|
||
});
|
||
} catch (e) {}
|
||
});
|
||
|
||
refreshList();
|
||
setView('list');
|
||
}
|
||
|
||
// ── View management ──────────────────────────────────────
|
||
// On mobile (<900px), only one pane visible at a time, driven
|
||
// by the layout's data-view attribute.
|
||
function setView(view) {
|
||
var layout = $('notes-layout');
|
||
if (layout) layout.setAttribute('data-view', view);
|
||
|
||
var reader = $('notes-reader');
|
||
var editor = $('notes-editor');
|
||
var empty = $('notes-empty-state');
|
||
|
||
if (reader) reader.classList.toggle('hidden', view !== 'reader');
|
||
if (editor) editor.classList.toggle('hidden', view !== 'editor');
|
||
// Empty state is only the "no note picked" desktop placeholder
|
||
if (empty) empty.classList.toggle('hidden', view !== 'empty' && view !== 'list');
|
||
}
|
||
|
||
function closeToList() {
|
||
// Any unsaved edits are already autosaved, so no discard prompt needed.
|
||
flushAutosave();
|
||
_activeId = null;
|
||
_dirty = false;
|
||
if (_editor) { _editor.destroy(); _editor = null; }
|
||
stopRecording(true); // silent cancel if in flight
|
||
setView('list');
|
||
renderList();
|
||
}
|
||
|
||
function backFromEditor() {
|
||
flushAutosave();
|
||
if (_activeId != null) openReader(_activeId);
|
||
else closeToList();
|
||
}
|
||
|
||
// ── Data fetching ─────────────────────────────────────────
|
||
// Always pulls both lists in parallel so the trash-count badge stays
|
||
// current regardless of which pane the user has open.
|
||
function refreshList() {
|
||
var listEl = $('notes-list');
|
||
if (!listEl) return;
|
||
listEl.innerHTML = '<div class="notes-empty">Loading…</div>';
|
||
Promise.all([
|
||
fetch('/api/notes', { headers: getAuthHeaders(), credentials: 'include' }).then(function(r) { return r.json(); }),
|
||
fetch('/api/notes/trash', { headers: getAuthHeaders(), credentials: 'include' }).then(function(r) { return r.json(); }),
|
||
])
|
||
.then(function(results) {
|
||
var active = results[0], trash = results[1];
|
||
if (active && active.success) _notes = active.notes || [];
|
||
if (trash && trash.success) _trash = trash.notes || [];
|
||
renderList();
|
||
updateTrashCount();
|
||
})
|
||
.catch(function(err) {
|
||
listEl.innerHTML = '<div class="notes-empty">' + esc(err.message || 'Load failed') + '</div>';
|
||
});
|
||
}
|
||
|
||
function switchPane(pane) {
|
||
if (pane === _pane) return;
|
||
_pane = pane;
|
||
var tabA = $('btn-notes-tab-active');
|
||
var tabT = $('btn-notes-tab-trash');
|
||
if (tabA) tabA.classList.toggle('active', pane === 'active');
|
||
if (tabT) tabT.classList.toggle('active', pane === 'trash');
|
||
var foot = $('notes-trash-foot');
|
||
if (foot) foot.classList.toggle('hidden', pane !== 'trash');
|
||
// Switching to trash clears any open editor — trashed notes are
|
||
// read-only previews, opening them is intentionally disabled.
|
||
if (pane === 'trash') closeToList();
|
||
renderList();
|
||
}
|
||
|
||
function updateTrashCount() {
|
||
var el = $('notes-trash-count');
|
||
if (!el) return;
|
||
el.textContent = _trash.length ? '(' + _trash.length + ')' : '';
|
||
}
|
||
|
||
function renderList() {
|
||
var listEl = $('notes-list');
|
||
if (!listEl) return;
|
||
var source = _pane === 'trash' ? _trash : _notes;
|
||
var filtered = source;
|
||
if (_search) {
|
||
filtered = source.filter(function(n) {
|
||
var hay = (n.title + ' ' + stripTags(n.body || '')).toLowerCase();
|
||
return hay.indexOf(_search) !== -1;
|
||
});
|
||
}
|
||
if (filtered.length === 0) {
|
||
listEl.innerHTML = '<div class="notes-empty">'
|
||
+ (_search ? 'No matches' : (_pane === 'trash' ? 'Trash is empty' : ''))
|
||
+ '</div>';
|
||
return;
|
||
}
|
||
listEl.innerHTML = filtered.map(function(n) {
|
||
var snippet = stripTags(n.body || '').substring(0, 110);
|
||
var when = formatWhen(_pane === 'trash' ? n.deleted_at : n.updated_at);
|
||
var active = (n.id === _activeId && _pane === 'active') ? ' active' : '';
|
||
var trashActions = _pane === 'trash'
|
||
? '<div class="notes-trash-actions">'
|
||
+ '<button type="button" class="notes-restore-btn" data-id="' + n.id + '" title="Restore">'
|
||
+ '<i class="fas fa-rotate-left"></i> Restore'
|
||
+ '</button>'
|
||
+ '<button type="button" class="notes-hard-delete-btn" data-id="' + n.id + '" title="Delete forever">'
|
||
+ '<i class="fas fa-times"></i>'
|
||
+ '</button>'
|
||
+ '</div>'
|
||
: '';
|
||
return '<div class="notes-list-row">'
|
||
+ '<button type="button" class="notes-list-item' + active + '" data-id="' + n.id + '">'
|
||
+ '<div class="notes-list-title">' + esc(n.title || 'Untitled') + '</div>'
|
||
+ '<div class="notes-list-snippet">' + esc(snippet) + '</div>'
|
||
+ '<div class="notes-list-when">'
|
||
+ (_pane === 'trash' ? 'Deleted ' : '') + esc(when)
|
||
+ '</div>'
|
||
+ '</button>'
|
||
+ trashActions
|
||
+ '</div>';
|
||
}).join('');
|
||
}
|
||
|
||
function restoreNote(id) {
|
||
fetch('/api/notes/' + id + '/restore', {
|
||
method: 'POST', credentials: 'include', headers: getAuthHeaders(),
|
||
})
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(data) {
|
||
if (!data.success) { if (typeof showToast === 'function') showToast(data.error || 'Restore failed', 'error'); return; }
|
||
if (typeof showToast === 'function') showToast('Note restored', 'success');
|
||
refreshList();
|
||
})
|
||
.catch(function(err) {
|
||
if (typeof showToast === 'function') showToast(err.message || 'Restore failed', 'error');
|
||
});
|
||
}
|
||
|
||
function hardDeleteNote(id) {
|
||
if (typeof showConfirm !== 'function') return;
|
||
showConfirm('Delete forever? This cannot be undone.', function() {
|
||
fetch('/api/notes/' + id + '?hard=1', {
|
||
method: 'DELETE', credentials: 'include', headers: getAuthHeaders(),
|
||
})
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(data) {
|
||
if (!data.success) { if (typeof showToast === 'function') showToast(data.error || 'Delete failed', 'error'); return; }
|
||
refreshList();
|
||
});
|
||
}, { danger: true, confirmText: 'Delete forever' });
|
||
}
|
||
|
||
function emptyTrash() {
|
||
if (typeof showConfirm !== 'function') return;
|
||
if (_trash.length === 0) return;
|
||
showConfirm('Empty the trash? This will permanently delete ' + _trash.length + ' note' + (_trash.length === 1 ? '' : 's') + '.',
|
||
function() {
|
||
fetch('/api/notes/trash/empty', {
|
||
method: 'POST', credentials: 'include', headers: getAuthHeaders(),
|
||
})
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(data) {
|
||
if (!data.success) { if (typeof showToast === 'function') showToast(data.error || 'Empty trash failed', 'error'); return; }
|
||
if (typeof showToast === 'function') showToast('Trash emptied', 'success');
|
||
refreshList();
|
||
});
|
||
},
|
||
{ danger: true, confirmText: 'Empty trash' });
|
||
}
|
||
|
||
// ── Reader mode (default after opening or saving) ─────────
|
||
function openReader(id) {
|
||
flushAutosave();
|
||
if (_recActive) stopRecording(true);
|
||
var note = _notes.find(function(n) { return n.id === id; });
|
||
if (!note) return;
|
||
_activeId = id;
|
||
_dirty = false;
|
||
|
||
// Tear down the editor if it was open
|
||
if (_editor) { _editor.destroy(); _editor = null; }
|
||
|
||
$('notes-reader-title').textContent = note.title || 'Untitled';
|
||
$('notes-reader-meta').textContent = 'Created ' + formatWhen(note.created_at) + ' · Updated ' + formatWhen(note.updated_at);
|
||
|
||
var body = $('notes-reader-body');
|
||
body.innerHTML = note.body ? sanitizeHtml(note.body) : '';
|
||
|
||
setView('reader');
|
||
renderList();
|
||
}
|
||
|
||
// ── Editor mode (create new / edit existing) ──────────────
|
||
function startCreate() {
|
||
flushAutosave();
|
||
if (_recActive) stopRecording(true);
|
||
_activeId = null;
|
||
_dirty = false;
|
||
$('note-title').value = '';
|
||
$('note-meta').textContent = '';
|
||
$('btn-note-delete').style.display = 'none';
|
||
mountTiptap($('note-body-editor'), '');
|
||
updateStatus('', '');
|
||
setRecUI('idle');
|
||
setView('editor');
|
||
setTimeout(function() { $('note-title').focus(); }, 20);
|
||
renderList();
|
||
}
|
||
|
||
function openEditor(id) {
|
||
var note = _notes.find(function(n) { return n.id === id; });
|
||
if (!note) return;
|
||
_activeId = id;
|
||
_dirty = false;
|
||
|
||
$('note-title').value = note.title || '';
|
||
$('note-meta').textContent = 'Created ' + formatWhen(note.created_at) + ' · Updated ' + formatWhen(note.updated_at);
|
||
$('btn-note-delete').style.display = '';
|
||
mountTiptap($('note-body-editor'), note.body || '');
|
||
updateStatus('', '');
|
||
setRecUI('idle');
|
||
setView('editor');
|
||
renderList();
|
||
}
|
||
|
||
function getTitle() { return ($('note-title').value || '').trim(); }
|
||
function getBody() {
|
||
if (!_editor) {
|
||
// Fallback textarea if Tiptap didn't load
|
||
var fb = document.querySelector('.notes-body-fallback');
|
||
return fb ? fb.value : '';
|
||
}
|
||
var html = _editor.getHTML();
|
||
return (html === '<p></p>' || html === '') ? '' : html;
|
||
}
|
||
|
||
// ── Save + autosave ──────────────────────────────────────
|
||
function scheduleAutosave() {
|
||
if (_autosaveTimer) clearTimeout(_autosaveTimer);
|
||
_autosaveTimer = setTimeout(function() {
|
||
_autosaveTimer = null;
|
||
saveNote({ source: 'auto' });
|
||
}, AUTOSAVE_DEBOUNCE);
|
||
}
|
||
|
||
function flushAutosave() {
|
||
if (_autosaveTimer) { clearTimeout(_autosaveTimer); _autosaveTimer = null; }
|
||
// Flush dirty state regardless of whether the note is new or existing.
|
||
// saveNote() handles both paths (POST for new, PUT for existing), so
|
||
// gating on _activeId here silently dropped the very first flush for
|
||
// a brand-new note — e.g. tap New note → type → tap Back.
|
||
if (_dirty) saveNote({ source: 'flush' });
|
||
}
|
||
|
||
function saveNote(opts) {
|
||
opts = opts || {};
|
||
var title = getTitle();
|
||
var body = getBody();
|
||
|
||
// Title is required — but for autosave, don't nag every keystroke.
|
||
if (!title) {
|
||
if (opts.source === 'manual' || opts.source === 'shortcut') {
|
||
updateStatus('Title required', 'err');
|
||
$('note-title').focus();
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (_autosaveInFlight) {
|
||
// Another save is already pending the network — mark so we
|
||
// fire one more right after it completes to capture the newest
|
||
// state. Prevents "the last keystroke didn't save" races.
|
||
_pendingAfterInflight = true;
|
||
return;
|
||
}
|
||
|
||
var isNew = _activeId == null;
|
||
var url = isNew ? '/api/notes' : '/api/notes/' + _activeId;
|
||
var method = isNew ? 'POST' : 'PUT';
|
||
|
||
_autosaveInFlight = true;
|
||
updateStatus('Saving…', 'saving');
|
||
|
||
fetch(url, {
|
||
method: method,
|
||
headers: Object.assign({ 'Content-Type': 'application/json' }, getAuthHeaders()),
|
||
credentials: 'include',
|
||
body: JSON.stringify({ title: title, body: body }),
|
||
})
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(data) {
|
||
_autosaveInFlight = false;
|
||
if (!data.success) { updateStatus(data.error || 'Save failed', 'err'); return; }
|
||
if (isNew && data.id) _activeId = data.id;
|
||
_dirty = false;
|
||
updateStatus('Saved', 'ok');
|
||
|
||
// Refresh the list so the new/updated note appears with correct
|
||
// timestamps + ordering.
|
||
return fetch('/api/notes', { headers: getAuthHeaders(), credentials: 'include' })
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(d) {
|
||
if (d.success) _notes = d.notes || [];
|
||
var note = _notes.find(function(n) { return n.id === _activeId; });
|
||
if (note) {
|
||
$('note-meta').textContent = 'Created ' + formatWhen(note.created_at) + ' · Updated ' + formatWhen(note.updated_at);
|
||
}
|
||
renderList();
|
||
})
|
||
.then(function() {
|
||
// If the user typed more while the save was in flight, fire once more.
|
||
if (_pendingAfterInflight) {
|
||
_pendingAfterInflight = false;
|
||
_dirty = true;
|
||
scheduleAutosave();
|
||
} else if (opts.returnToReader) {
|
||
// Manual save → show the reader view with the saved content.
|
||
openReader(_activeId);
|
||
}
|
||
});
|
||
})
|
||
.catch(function(err) {
|
||
_autosaveInFlight = false;
|
||
updateStatus(err.message || 'Save failed', 'err');
|
||
});
|
||
}
|
||
|
||
// ── Delete ──────────────────────────────────────────────
|
||
function deleteActive() {
|
||
if (_activeId == null) return;
|
||
var note = _notes.find(function(n) { return n.id === _activeId; });
|
||
var label = (note && note.title) ? note.title : 'this note';
|
||
|
||
// Soft-delete: row gets deleted_at timestamp and moves to Trash.
|
||
// The user can Restore from there or Empty trash to actually erase.
|
||
var doMove = function() {
|
||
flushAutosave();
|
||
fetch('/api/notes/' + _activeId, { method: 'DELETE', headers: getAuthHeaders(), credentials: 'include' })
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(data) {
|
||
if (!data.success) { updateStatus(data.error || 'Move to trash failed', 'err'); return; }
|
||
_notes = _notes.filter(function(n) { return n.id !== _activeId; });
|
||
_activeId = null;
|
||
_dirty = false;
|
||
if (_editor) { _editor.destroy(); _editor = null; }
|
||
closeToList();
|
||
if (typeof showToast === 'function') showToast('Moved to trash', 'success');
|
||
refreshList();
|
||
})
|
||
.catch(function(err) { updateStatus(err.message || 'Move to trash failed', 'err'); });
|
||
};
|
||
|
||
if (typeof showConfirm === 'function') {
|
||
showConfirm('Move "' + label + '" to trash?', doMove, { confirmText: 'Move to trash' });
|
||
}
|
||
}
|
||
|
||
// ── Voice recording → AI note ──────────────────────────────
|
||
function startRecording() {
|
||
if (_recActive) return;
|
||
// Ensure the editor is open — voice always targets the editor
|
||
if ($('notes-editor').classList.contains('hidden')) startCreate();
|
||
if (typeof AudioRecorder === 'undefined') { updateStatus('Recorder unavailable', 'err'); return; }
|
||
_recorder = new AudioRecorder();
|
||
_recorder.start().then(function() {
|
||
_recActive = true; _recPaused = false;
|
||
setRecUI('recording');
|
||
startRecTimer();
|
||
}).catch(function(err) {
|
||
updateStatus('Mic denied: ' + (err && err.message || ''), 'err');
|
||
});
|
||
}
|
||
|
||
function togglePauseRecording() {
|
||
if (!_recActive || !_recorder || !_recorder.mediaRecorder) return;
|
||
if (!_recPaused) {
|
||
try { _recorder.mediaRecorder.pause(); } catch (e) {}
|
||
_recPaused = true; stopRecTimer(); setRecUI('paused');
|
||
} else {
|
||
try { _recorder.mediaRecorder.resume(); } catch (e) {
|
||
// Browser doesn't support resume — start a fresh segment.
|
||
try {
|
||
if (_recorder.stream && _recorder.stream.active) {
|
||
var mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
|
||
_recorder.mediaRecorder = new MediaRecorder(_recorder.stream, { mimeType: mime, audioBitsPerSecond: 32000 });
|
||
_recorder.mediaRecorder.ondataavailable = function(ev) { if (ev.data.size > 0) _recorder.chunks.push(ev.data); };
|
||
_recorder.mediaRecorder.start(1000);
|
||
}
|
||
} catch (e2) {}
|
||
}
|
||
_recPaused = false; startRecTimer(); setRecUI('recording');
|
||
}
|
||
}
|
||
|
||
function stopRecording(silent) {
|
||
if (!_recActive || !_recorder) {
|
||
if (!silent) setRecUI('idle');
|
||
return;
|
||
}
|
||
_recActive = false; _recPaused = false;
|
||
stopRecTimer(true);
|
||
if (silent) {
|
||
// Cancel cleanly — close the stream, don't transcribe.
|
||
try { _recorder.mediaRecorder && _recorder.mediaRecorder.state !== 'inactive' && _recorder.mediaRecorder.stop(); } catch (e) {}
|
||
try { _recorder.stream && _recorder.stream.getTracks().forEach(function(t) { t.stop(); }); } catch (e) {}
|
||
setRecUI('idle');
|
||
return;
|
||
}
|
||
setRecUI('processing');
|
||
updateStatus('Transcribing…', 'saving');
|
||
|
||
_recorder.stop().then(function(blob) {
|
||
if (!blob || blob.size === 0) { updateStatus('Nothing recorded', 'err'); setRecUI('idle'); return; }
|
||
if (typeof transcribeAudio !== 'function') { updateStatus('Transcription unavailable', 'err'); setRecUI('idle'); return; }
|
||
return transcribeAudio(blob).then(function(resp) {
|
||
if (!resp || !resp.success || !resp.text) {
|
||
var msg = (resp && (resp.error || (resp.noProvider ? 'No STT provider configured' : null))) || 'Transcription failed';
|
||
updateStatus(msg, 'err'); setRecUI('idle'); return;
|
||
}
|
||
updateStatus('Generating note…', 'saving');
|
||
var modelEl = document.getElementById('notes-model-select');
|
||
var selectedModel = modelEl ? modelEl.value : '';
|
||
return fetch('/api/notes/from-voice', {
|
||
method: 'POST',
|
||
credentials: 'include',
|
||
headers: Object.assign({ 'Content-Type': 'application/json' }, getAuthHeaders()),
|
||
body: JSON.stringify({ transcript: resp.text, model: selectedModel || undefined }),
|
||
})
|
||
.then(function(r) { return r.json(); })
|
||
.then(function(data) {
|
||
setRecUI('idle');
|
||
if (!data.success) { updateStatus(data.error || 'Generation failed', 'err'); return; }
|
||
applyGeneratedNote(data.title || 'Voice note', data.body || '');
|
||
updateStatus('Generated — autosaving…', 'ok');
|
||
// Trigger the autosave so the AI-generated note is persisted
|
||
// even if the user walks away.
|
||
_dirty = true; scheduleAutosave();
|
||
});
|
||
});
|
||
}).catch(function(err) {
|
||
setRecUI('idle');
|
||
updateStatus(err.message || 'Recording failed', 'err');
|
||
});
|
||
}
|
||
|
||
function applyGeneratedNote(title, body) {
|
||
var titleEl = $('note-title');
|
||
var container = $('note-body-editor');
|
||
if (!titleEl || !container) {
|
||
console.error('[notes] applyGeneratedNote: editor not mounted; title/container missing');
|
||
return;
|
||
}
|
||
titleEl.value = title || 'Voice note';
|
||
|
||
// Prefer Tiptap's native commands.setContent over a remount when an
|
||
// editor already exists — remounting on every voice take left the
|
||
// toolbar reattaching, which has caused flicker + a brief window
|
||
// where the body looked empty.
|
||
if (_editor && typeof _editor.commands === 'object') {
|
||
try {
|
||
_editor.commands.setContent(body || '<p></p>', { emitUpdate: true });
|
||
_dirty = true;
|
||
return;
|
||
} catch (err) {
|
||
console.warn('[notes] Tiptap setContent failed, rebuilding:', err && err.message);
|
||
}
|
||
}
|
||
if (_editor) { try { _editor.destroy(); } catch (e) {} _editor = null; }
|
||
mountTiptap(container, body || '<p></p>');
|
||
_dirty = true;
|
||
}
|
||
|
||
function startRecTimer() {
|
||
var el = $('notes-rec-timer');
|
||
if (_recTimer || !el) return;
|
||
_recTimer = createTimerLite(el);
|
||
_recTimer.start();
|
||
}
|
||
function stopRecTimer(reset) {
|
||
if (_recTimer) _recTimer.stop();
|
||
if (reset && _recTimer) { _recTimer.reset(); _recTimer = null; }
|
||
}
|
||
function createTimerLite(el) {
|
||
var s = 0, iv = null;
|
||
function paint() { el.textContent = String(Math.floor(s/60)).padStart(2,'0') + ':' + String(s%60).padStart(2,'0'); }
|
||
return {
|
||
start: function() { paint(); if (!iv) iv = setInterval(function() { s++; paint(); }, 1000); },
|
||
stop: function() { if (iv) { clearInterval(iv); iv = null; } },
|
||
reset: function() { s = 0; paint(); },
|
||
};
|
||
}
|
||
|
||
function setRecUI(state) {
|
||
var start = $('btn-note-rec-start');
|
||
var pause = $('btn-note-rec-pause');
|
||
var stopBtn = $('btn-note-rec-stop');
|
||
var ind = $('notes-rec-indicator');
|
||
var stateEl = $('notes-rec-state');
|
||
var dot = ind ? ind.querySelector('.pulse-dot') : null;
|
||
if (!start) return;
|
||
|
||
var idle = (state === 'idle');
|
||
var recording = (state === 'recording');
|
||
var paused = (state === 'paused');
|
||
var processing = (state === 'processing');
|
||
|
||
start.classList.toggle('hidden', !idle);
|
||
pause.classList.toggle('hidden', !(recording || paused));
|
||
stopBtn.classList.toggle('hidden', !(recording || paused));
|
||
ind.classList.toggle('hidden', idle);
|
||
|
||
if (pause) pause.innerHTML = paused ? '<i class="fas fa-play"></i> Resume' : '<i class="fas fa-pause"></i> Pause';
|
||
if (stateEl) stateEl.textContent = processing ? 'Processing…' : (paused ? 'Paused' : 'Recording');
|
||
if (dot) dot.classList.toggle('paused', paused || processing);
|
||
|
||
start.disabled = !idle;
|
||
pause.disabled = processing;
|
||
stopBtn.disabled = processing;
|
||
}
|
||
|
||
// ── Tiptap ─────────────────────────────────────────────
|
||
function mountTiptap(container, initialHtml) {
|
||
var T = window.Tiptap || {};
|
||
if (!T.Editor) {
|
||
// Fallback when the Tiptap bundle didn't load — plain textarea.
|
||
container.innerHTML = '<textarea class="notes-body-fallback" placeholder="Write your note…">' + esc(stripTags(initialHtml)) + '</textarea>';
|
||
_editor = null;
|
||
var fb = container.querySelector('.notes-body-fallback');
|
||
if (fb) fb.addEventListener('input', function() { _dirty = true; updateStatus('Saving…', 'saving'); scheduleAutosave(); });
|
||
return;
|
||
}
|
||
container.innerHTML = toolbarHTML() + '<div class="tp-content"></div>';
|
||
_editor = new T.Editor({
|
||
element: container.querySelector('.tp-content'),
|
||
extensions: [
|
||
T.StarterKit,
|
||
T.Link.configure({ openOnClick: false, autolink: true }),
|
||
T.Underline
|
||
],
|
||
content: initialHtml || '',
|
||
autofocus: false,
|
||
onUpdate: function() {
|
||
_dirty = true;
|
||
updateStatus('Saving…', 'saving');
|
||
updateToolbarState(container);
|
||
scheduleAutosave();
|
||
},
|
||
onSelectionUpdate: function() { updateToolbarState(container); }
|
||
});
|
||
wireToolbar(container, _editor);
|
||
}
|
||
|
||
function toolbarHTML() {
|
||
var btns = ''
|
||
+ '<button type="button" class="tp-btn" data-cmd="bold" title="Bold"><i class="fas fa-bold"></i></button>'
|
||
+ '<button type="button" class="tp-btn" data-cmd="italic" title="Italic"><i class="fas fa-italic"></i></button>'
|
||
+ '<button type="button" class="tp-btn" data-cmd="underline" title="Underline"><i class="fas fa-underline"></i></button>'
|
||
+ '<button type="button" class="tp-btn" data-cmd="strike" title="Strike"><i class="fas fa-strikethrough"></i></button>'
|
||
+ '<span class="tp-sep"></span>'
|
||
+ '<button type="button" class="tp-btn" data-cmd="h2" title="Heading 2">H2</button>'
|
||
+ '<button type="button" class="tp-btn" data-cmd="h3" title="Heading 3">H3</button>'
|
||
+ '<span class="tp-sep"></span>'
|
||
+ '<button type="button" class="tp-btn" data-cmd="bulletList" title="Bullet list"><i class="fas fa-list-ul"></i></button>'
|
||
+ '<button type="button" class="tp-btn" data-cmd="orderedList" title="Numbered list"><i class="fas fa-list-ol"></i></button>'
|
||
+ '<button type="button" class="tp-btn" data-cmd="blockquote" title="Quote"><i class="fas fa-quote-left"></i></button>'
|
||
+ '<button type="button" class="tp-btn" data-cmd="codeBlock" title="Code"><i class="fas fa-code"></i></button>'
|
||
+ '<span class="tp-sep"></span>'
|
||
+ '<button type="button" class="tp-btn" data-cmd="link" title="Link"><i class="fas fa-link"></i></button>'
|
||
+ '<span class="tp-sep"></span>'
|
||
+ '<button type="button" class="tp-btn" data-cmd="clear" title="Clear formatting"><i class="fas fa-remove-format"></i></button>';
|
||
return ''
|
||
+ '<div class="tp-toolbar">' + btns + '</div>'
|
||
+ '<div class="tp-link-bar" style="display:none;">'
|
||
+ '<input type="url" class="tp-link-input" placeholder="https://">'
|
||
+ '<button type="button" class="tp-link-apply">Apply</button>'
|
||
+ '<button type="button" class="tp-link-remove">Remove</button>'
|
||
+ '<button type="button" class="tp-link-cancel">✕</button>'
|
||
+ '</div>';
|
||
}
|
||
|
||
function wireToolbar(wrap, ed) {
|
||
var toolbar = wrap.querySelector('.tp-toolbar');
|
||
var linkBar = wrap.querySelector('.tp-link-bar');
|
||
var linkInput = wrap.querySelector('.tp-link-input');
|
||
|
||
toolbar.addEventListener('mousedown', function(e) {
|
||
var btn = e.target.closest('.tp-btn[data-cmd]');
|
||
if (!btn) return;
|
||
e.preventDefault();
|
||
var cmd = btn.dataset.cmd;
|
||
switch (cmd) {
|
||
case 'bold': ed.chain().focus().toggleBold().run(); break;
|
||
case 'italic': ed.chain().focus().toggleItalic().run(); break;
|
||
case 'underline': ed.chain().focus().toggleUnderline().run(); break;
|
||
case 'strike': ed.chain().focus().toggleStrike().run(); break;
|
||
case 'h2': ed.chain().focus().toggleHeading({ level: 2 }).run(); break;
|
||
case 'h3': ed.chain().focus().toggleHeading({ level: 3 }).run(); break;
|
||
case 'bulletList': ed.chain().focus().toggleBulletList().run(); break;
|
||
case 'orderedList': ed.chain().focus().toggleOrderedList().run(); break;
|
||
case 'blockquote': ed.chain().focus().toggleBlockquote().run(); break;
|
||
case 'codeBlock': ed.chain().focus().toggleCodeBlock().run(); break;
|
||
case 'clear': ed.chain().focus().unsetAllMarks().clearNodes().run(); break;
|
||
case 'link':
|
||
if (linkBar.style.display === 'none') {
|
||
linkInput.value = ed.getAttributes('link').href || '';
|
||
linkBar.style.display = 'flex';
|
||
setTimeout(function() { linkInput.focus(); }, 0);
|
||
} else {
|
||
linkBar.style.display = 'none';
|
||
}
|
||
break;
|
||
}
|
||
updateToolbarState(wrap);
|
||
});
|
||
|
||
wrap.querySelector('.tp-link-apply').addEventListener('mousedown', function(e) {
|
||
e.preventDefault();
|
||
var url = linkInput.value.trim();
|
||
if (url) ed.chain().focus().extendMarkRange('link').setLink({ href: url }).run();
|
||
linkBar.style.display = 'none';
|
||
});
|
||
wrap.querySelector('.tp-link-remove').addEventListener('mousedown', function(e) {
|
||
e.preventDefault();
|
||
ed.chain().focus().unsetLink().run();
|
||
linkBar.style.display = 'none';
|
||
});
|
||
wrap.querySelector('.tp-link-cancel').addEventListener('mousedown', function(e) {
|
||
e.preventDefault();
|
||
linkBar.style.display = 'none';
|
||
});
|
||
linkInput.addEventListener('keydown', function(e) {
|
||
if (e.key === 'Enter') { e.preventDefault(); wrap.querySelector('.tp-link-apply').dispatchEvent(new MouseEvent('mousedown')); }
|
||
if (e.key === 'Escape') { linkBar.style.display = 'none'; }
|
||
});
|
||
}
|
||
|
||
function updateToolbarState(wrap) {
|
||
if (!_editor) return;
|
||
wrap.querySelectorAll('.tp-btn[data-cmd]').forEach(function(btn) {
|
||
var cmd = btn.dataset.cmd;
|
||
var active = false;
|
||
if (cmd === 'bold') active = _editor.isActive('bold');
|
||
else if (cmd === 'italic') active = _editor.isActive('italic');
|
||
else if (cmd === 'underline') active = _editor.isActive('underline');
|
||
else if (cmd === 'strike') active = _editor.isActive('strike');
|
||
else if (cmd === 'h2') active = _editor.isActive('heading', { level: 2 });
|
||
else if (cmd === 'h3') active = _editor.isActive('heading', { level: 3 });
|
||
else if (cmd === 'bulletList') active = _editor.isActive('bulletList');
|
||
else if (cmd === 'orderedList') active = _editor.isActive('orderedList');
|
||
else if (cmd === 'blockquote') active = _editor.isActive('blockquote');
|
||
else if (cmd === 'codeBlock') active = _editor.isActive('codeBlock');
|
||
else if (cmd === 'link') active = _editor.isActive('link');
|
||
btn.classList.toggle('active', active);
|
||
});
|
||
}
|
||
|
||
// ── Helpers ──────────────────────────────────────────────
|
||
function updateStatus(text, kind) {
|
||
var el = $('notes-status');
|
||
if (!el) return;
|
||
el.textContent = text || '';
|
||
el.className = 'notes-status' + (kind ? ' notes-status-' + kind : '');
|
||
if (_statusTimer) { clearTimeout(_statusTimer); _statusTimer = null; }
|
||
if (kind === 'ok') {
|
||
_statusTimer = setTimeout(function() { el.textContent = ''; el.className = 'notes-status'; }, 2000);
|
||
}
|
||
}
|
||
|
||
function esc(s) {
|
||
return (s == null ? '' : String(s))
|
||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||
.replace(/"/g, '"').replace(/'/g, ''');
|
||
}
|
||
function stripTags(html) {
|
||
if (!html) return '';
|
||
var d = document.createElement('div'); d.innerHTML = html;
|
||
return (d.textContent || d.innerText || '').replace(/\s+/g, ' ').trim();
|
||
}
|
||
// Sanitize via DOMPurify (loaded from cdnjs in index.html, also used by
|
||
// learningHub.js). DOMPurify's allowlist + attribute filter is the right
|
||
// primitive — homegrown regex/walker sanitizers historically have bypasses.
|
||
// If DOMPurify somehow fails to load, refuse to render HTML rather than
|
||
// falling back to a hand-rolled walker.
|
||
function sanitizeHtml(html) {
|
||
if (!window.DOMPurify || typeof window.DOMPurify.sanitize !== 'function') {
|
||
console.warn('[notes] DOMPurify unavailable — rendering as plain text.');
|
||
var d = document.createElement('div');
|
||
d.textContent = String(html == null ? '' : html);
|
||
return d.innerHTML;
|
||
}
|
||
return window.DOMPurify.sanitize(html, {
|
||
ALLOWED_TAGS: ['p','br','strong','em','b','i','u','s','h2','h3','h4',
|
||
'ul','ol','li','a','blockquote','code','pre','hr'],
|
||
ALLOWED_ATTR: ['href','target','rel'],
|
||
ADD_ATTR: ['target'],
|
||
FORBID_ATTR: ['style','onerror','onload','onclick','onmouseover'],
|
||
ALLOW_DATA_ATTR: false
|
||
});
|
||
}
|
||
function formatWhen(iso) {
|
||
if (!iso) return '';
|
||
var d = new Date(iso);
|
||
if (isNaN(d.getTime())) return '';
|
||
var now = new Date();
|
||
var sameDay = d.toDateString() === now.toDateString();
|
||
if (sameDay) return d.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' });
|
||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: d.getFullYear() === now.getFullYear() ? undefined : 'numeric' });
|
||
}
|
||
function getAuthHeaders() {
|
||
if (typeof window.getAuthHeaders === 'function') return window.getAuthHeaders();
|
||
return {};
|
||
}
|
||
})();
|