Soft-delete for notes — Daniel asked for "deleted notes go to trash"
so a slip of the finger doesn't lose work.
Schema: migrations/1777090000000_notes-trash.js adds a deleted_at
timestamptz column to personal_notes (NULL = active) plus an index
on (user_id, deleted_at).
Server (src/routes/notes.js):
GET /api/notes now filters deleted_at IS NULL
GET /api/notes/trash new — list trashed items, newest-
deleted first
DELETE /api/notes/:id now soft-deletes (sets deleted_at)
DELETE /api/notes/:id?hard=1 hard-delete, only allowed on items
already in trash (UI bug can't
erase an active note)
POST /api/notes/:id/restore pull a note out of trash
POST /api/notes/trash/empty hard-delete every trashed note for
the user
Frontend (public/components/notes.html + public/js/notes.js +
public/css/styles.css):
• Sidebar gets two tabs — "Notes" / "Trash (n)" with live count
• Trash tab shows deleted-at timestamps, Restore + delete-forever
per row, Empty-trash button at the bottom
• Active list and trash count refresh in parallel after every
save / delete / restore
• Delete button in the editor now says "Move to trash" and uses
the showConfirm helper (no native dialogs)
Sanitizer swap (public/js/notes.js):
Replaced the homegrown allowlist walker with DOMPurify (already
loaded from cdnjs in index.html, used by learningHub.js too).
Custom HTML sanitizers historically have bypasses; DOMPurify is
the right primitive.
Tests (test/notes-sanitize.test.js — node:test + jsdom + dompurify
as new dev deps):
9 contract tests covering script-tag stripping, inline event
handlers, img onerror, iframe/object, style attributes, every
preserved tag in the allowlist, javascript: URI rejection,
null/undefined input, and nested-script-inside-paragraph. Total
test count: 37 → 46 passing.
SW cache bumped to pedscribe-v12-notes5.
867 lines
36 KiB
JavaScript
867 lines
36 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
|
||
var recStart = $('btn-note-rec-start');
|
||
var recPause = $('btn-note-rec-pause');
|
||
var recStop = $('btn-note-rec-stop');
|
||
if (recStart) recStart.addEventListener('click', startRecording);
|
||
if (recPause) recPause.addEventListener('click', togglePauseRecording);
|
||
if (recStop) recStop.addEventListener('click', stopRecording);
|
||
|
||
// 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');
|
||
return fetch('/api/notes/from-voice', {
|
||
method: 'POST',
|
||
credentials: 'include',
|
||
headers: Object.assign({ 'Content-Type': 'application/json' }, getAuthHeaders()),
|
||
body: JSON.stringify({ transcript: resp.text }),
|
||
})
|
||
.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) {
|
||
$('note-title').value = title;
|
||
var container = $('note-body-editor');
|
||
if (!container) return;
|
||
if (_editor) { _editor.destroy(); _editor = null; }
|
||
mountTiptap(container, body);
|
||
_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 {};
|
||
}
|
||
})();
|