This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/src/js/notes.js
2026-03-14 14:43:55 +00:00

977 lines
42 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ==========================================
// Chat Switchboard Notes Panel
// ==========================================
// Notes panel: editor, multi-select, folders, CRUD.
(function() {
'use strict';
var _editingNoteId = null;
var _notesSelectMode = false;
var _selectedNoteIds = new Set();
var _notesSort = 'updated_desc';
var _noteEditor = null; // CM6 noteEditor instance
// ── Notes ─────────────────────────────────────
async function openNotes() {
// Toggle: if notes is active, close it; otherwise open it
if (PanelRegistry.isOpen('notes')) {
PanelRegistry.close('notes');
return;
}
PanelRegistry.open('notes');
_exitSelectMode();
}
async function loadNotesList(folder, searchQuery) {
const list = document.getElementById('notesList');
list.innerHTML = '<div class="loading">Loading…</div>';
try {
let data;
if (searchQuery) {
data = await API.searchNotes(searchQuery);
const results = data.data || [];
if (results.length === 0) {
list.innerHTML = '<div class="empty-hint">No results found</div>';
return;
}
list.innerHTML = results.map(n => _noteListItem(n, true)).join('');
} else {
const folderVal = folder || document.getElementById('notesFolderFilter')?.value || '';
data = await API.listNotes(100, 0, folderVal, '', _notesSort);
const notes = data.data || [];
if (notes.length === 0) {
list.innerHTML = `<div class="empty-hint">${folderVal ? 'No notes in this folder' : 'No notes yet. Create one or ask the AI to save a note.'}</div>`;
return;
}
list.innerHTML = notes.map(n => _noteListItem(n, false)).join('');
}
} catch (e) {
list.innerHTML = `<div class="empty-hint">Failed to load: ${esc(e.message)}</div>`;
}
}
function _noteListItem(note, isSearch) {
const tags = (note.tags || []).map(t => `<span class="note-tag">${esc(t)}</span>`).join('');
const folder = note.folder_path && note.folder_path !== '/' ? `<span class="note-folder">${esc(note.folder_path)}</span>` : '';
const preview = isSearch && note.headline
? `<div class="note-headline">${_highlightHeadline(note.headline)}</div>`
: `<div class="note-preview">${esc((note.preview || note.content || '').slice(0, 120))}</div>`;
const time = _relativeTime(note.updated_at);
const checked = _selectedNoteIds.has(note.id) ? 'checked' : '';
return `
<div class="note-item ${_selectedNoteIds.has(note.id) ? 'selected' : ''}" data-note-id="${note.id}">
<div class="note-select-col" style="display:${_notesSelectMode ? '' : 'none'}">
<input type="checkbox" class="note-checkbox" data-id="${note.id}" ${checked}
data-action="_toggleNoteSelectCb" data-args='${JSON.stringify([note.id])}' data-pass-el">
</div>
<div class="note-content-col" data-action="_noteItemClick" data-args='${JSON.stringify([note.id])}'">
<div class="note-item-header">
<span class="note-item-title">${esc(note.title)}</span>
<span class="note-item-time">${time}</span>
</div>
${preview}
<div class="note-item-meta">${folder}${tags}</div>
</div>
</div>`;
}
function _highlightHeadline(headline) {
const escaped = esc(headline);
return escaped.replace(/\*\*(.+?)\*\*/g, '<mark>$1</mark>');
}
// ── Multi-select ────────────────
function _enterSelectMode() {
_notesSelectMode = true;
_selectedNoteIds.clear();
document.getElementById('notesSelectionBar').style.display = '';
document.querySelectorAll('.note-select-col').forEach(el => el.style.display = '');
_updateSelectedCount();
}
function _exitSelectMode() {
_notesSelectMode = false;
_selectedNoteIds.clear();
document.getElementById('notesSelectionBar').style.display = 'none';
document.querySelectorAll('.note-select-col').forEach(el => el.style.display = 'none');
document.querySelectorAll('.note-item.selected').forEach(el => el.classList.remove('selected'));
document.querySelectorAll('.note-checkbox').forEach(cb => cb.checked = false);
const sa = document.getElementById('notesSelectAll');
if (sa) sa.checked = false;
}
function _toggleNoteSelect(id, forceState) {
if (!_notesSelectMode) {
_enterSelectMode();
}
const has = _selectedNoteIds.has(id);
const newState = forceState !== undefined ? forceState : !has;
if (newState) _selectedNoteIds.add(id);
else _selectedNoteIds.delete(id);
const item = document.querySelector(`.note-item[data-note-id="${id}"]`);
if (item) {
item.classList.toggle('selected', newState);
const cb = item.querySelector('.note-checkbox');
if (cb) cb.checked = newState;
}
_updateSelectedCount();
}
function _toggleSelectAll(checked) {
document.querySelectorAll('.note-checkbox').forEach(cb => {
const id = cb.dataset.id;
if (checked) _selectedNoteIds.add(id);
else _selectedNoteIds.delete(id);
cb.checked = checked;
cb.closest('.note-item')?.classList.toggle('selected', checked);
});
_updateSelectedCount();
}
function _updateSelectedCount() {
const el = document.getElementById('notesSelectedCount');
if (el) el.textContent = _selectedNoteIds.size;
}
async function _bulkDeleteSelected() {
if (_selectedNoteIds.size === 0) return;
const count = _selectedNoteIds.size;
if (!await showConfirm(`Delete ${count} note${count > 1 ? 's' : ''}?`)) return;
try {
const resp = await API.bulkDeleteNotes([..._selectedNoteIds]);
UI.toast(`Deleted ${resp.deleted} note${resp.deleted !== 1 ? 's' : ''}`, 'success');
_exitSelectMode();
await loadNotesList();
await loadNoteFolders();
} catch (e) { UI.toast(e.message, 'error'); }
}
// ── Folders / Editor / CRUD ─────
async function loadNoteFolders() {
const sel = document.getElementById('notesFolderFilter');
if (!sel) return;
try {
const data = await API.listNoteFolders();
const folders = data.folders || [];
sel.innerHTML = '<option value="">All folders</option>';
folders.forEach(f => {
sel.innerHTML += `<option value="${esc(f.path)}">${esc(f.path)} (${f.count})</option>`;
});
} catch (e) { /* non-critical */ }
}
function showNotesList() {
document.getElementById('notesListView').style.display = '';
document.getElementById('notesEditorView').style.display = 'none';
const graphView = document.getElementById('notesGraphView');
if (graphView) graphView.style.display = 'none';
_editingNoteId = null;
_destroyNoteEditor();
// Reset list to loading state to prevent stale content flash
const list = document.getElementById('notesList');
if (list) list.innerHTML = '<div class="loading">Loading…</div>';
}
var _currentNote = null; // cached note for read mode
function copyNoteContent() {
if (!_currentNote) return;
const text = `# ${_currentNote.title || ''}\n\n${_currentNote.content || ''}`;
navigator.clipboard.writeText(text).then(() => UI.toast('Copied', 'success'));
}
async function openNoteEditor(noteId) {
document.getElementById('notesListView').style.display = 'none';
document.getElementById('notesEditorView').style.display = '';
if (noteId) {
_editingNoteId = noteId;
try {
_currentNote = await API.getNote(noteId);
_populateEditFields(_currentNote);
_showNoteReadMode();
} catch (e) {
UI.toast('Failed to load note: ' + e.message, 'error');
showNotesList();
}
} else {
// New note — straight to edit mode
_editingNoteId = null;
_currentNote = null;
_clearEditFields();
_showNoteEditMode();
document.getElementById('noteEditorTitle').focus();
}
}
function _populateEditFields(note) {
document.getElementById('noteEditorTitle').value = note.title || '';
document.getElementById('noteEditorFolder').value = note.folder_path || '';
document.getElementById('noteEditorTags').value = (note.tags || []).join(', ');
_setNoteEditorContent(note.content || '');
}
function _clearEditFields() {
document.getElementById('noteEditorTitle').value = '';
document.getElementById('noteEditorFolder').value = '';
document.getElementById('noteEditorTags').value = '';
_setNoteEditorContent('');
}
/** Get content from CM6 editor or fallback textarea */
function _getNoteEditorContent() {
if (_noteEditor) return _noteEditor.getValue();
const ta = document.querySelector('#noteEditorContentContainer textarea');
return ta ? ta.value : '';
}
/** Set content in CM6 editor or fallback textarea */
function _setNoteEditorContent(text) {
if (_noteEditor) {
_noteEditor.setValue(text);
return;
}
// Lazy-init CM6 note editor
const container = document.getElementById('noteEditorContentContainer');
if (!container) return;
if (window.CM?.noteEditor) {
container.innerHTML = ''; // clear any previous
_noteEditor = CM.noteEditor(container, {
value: text,
darkMode: document.documentElement.getAttribute('data-theme') !== 'light',
onChange: null, // could wire auto-save later
onLink: (title) => {
_navigateToLinkedNote(title);
},
linkCompleter: async (query) => {
if (!query || query.length < 1) return [];
try {
const resp = await API.searchNoteTitles(query, 8);
return (resp.data || []).map(n => ({ label: n.title, id: n.id }));
} catch { return []; }
},
});
} else {
// Fallback: plain textarea
if (!container.querySelector('textarea')) {
container.innerHTML = '<textarea id="noteEditorContent" rows="12" class="notes-content-input" placeholder="Write your note in markdown..."></textarea>';
}
const ta = container.querySelector('textarea');
if (ta) ta.value = text;
}
}
/** Navigate to a note by title (from wikilink click) */
async function _navigateToLinkedNote(title) {
try {
const resp = await API.searchNoteTitles(title, 1);
const match = (resp.data || []).find(n => n.title.toLowerCase() === title.toLowerCase());
if (match) {
await openNoteEditor(match.id);
} else {
// Offer to create
if (await showConfirm(`Note "${title}" not found. Create it?`)) {
const created = await API.createNote(title, `# ${title}\n\n`, '/', []);
await openNoteEditor(created.id);
}
}
} catch (e) { UI.toast('Failed to navigate: ' + e.message, 'error'); }
}
/** Destroy CM6 editor on panel close */
function _destroyNoteEditor() {
if (_noteEditor) {
_noteEditor.destroy();
_noteEditor = null;
}
}
function _showNoteReadMode() {
if (!_currentNote) return;
const n = _currentNote;
// Title
document.getElementById('noteReadTitle').textContent = n.title || '';
// Meta: folder + tags
const parts = [];
if (n.folder_path && n.folder_path !== '/') parts.push(`<span class="note-folder">${esc(n.folder_path)}</span>`);
(n.tags || []).forEach(t => parts.push(`<span class="note-tag">${esc(t)}</span>`));
document.getElementById('noteReadMeta').innerHTML = parts.join(' ');
// Rendered markdown content — reuse the chat formatter
const noteReadEl = document.getElementById('noteReadContent');
noteReadEl.innerHTML = formatMessage(n.content || '');
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(noteReadEl);
// Render [[wikilinks]] as clickable chips in read mode
_renderWikilinksInReadMode(noteReadEl);
// Show/hide delete
document.getElementById('noteDeleteBtn2').style.display = _editingNoteId ? '' : 'none';
// Toggle visibility
document.getElementById('noteReadMode').style.display = '';
document.getElementById('noteEditMode').style.display = 'none';
document.getElementById('noteEditBtn').style.display = '';
document.getElementById('notePreviewBtn').style.display = 'none';
// Load backlinks
if (_editingNoteId) _loadBacklinks(_editingNoteId);
// Show daily note navigation if applicable
_updateDailyNav(_currentNote);
}
function _showNoteEditMode() {
document.getElementById('noteReadMode').style.display = 'none';
document.getElementById('noteEditMode').style.display = '';
document.getElementById('noteDeleteBtn').style.display = _editingNoteId ? '' : 'none';
document.getElementById('noteCancelEditBtn').style.display = _editingNoteId ? '' : 'none';
document.getElementById('noteEditBtn').style.display = 'none';
document.getElementById('notePreviewBtn').style.display = '';
}
function _showNotePreview() {
// Live preview from editor without saving
const title = document.getElementById('noteEditorTitle').value.trim();
const content = _getNoteEditorContent();
const folderVal = document.getElementById('noteEditorFolder').value.trim();
const tagsStr = document.getElementById('noteEditorTags').value.trim();
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
document.getElementById('noteReadTitle').textContent = title || 'Untitled';
const parts = [];
if (folderVal) parts.push(`<span class="note-folder">${esc(folderVal)}</span>`);
tags.forEach(t => parts.push(`<span class="note-tag">${esc(t)}</span>`));
document.getElementById('noteReadMeta').innerHTML = parts.join(' ');
document.getElementById('noteReadContent').innerHTML = formatMessage(content || '');
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(document.getElementById('noteReadContent'));
_renderWikilinksInReadMode(document.getElementById('noteReadContent'));
document.getElementById('noteReadMode').style.display = '';
document.getElementById('noteEditMode').style.display = 'none';
document.getElementById('noteEditBtn').style.display = '';
document.getElementById('notePreviewBtn').style.display = 'none';
// Keep delete hidden in preview-from-edit
document.getElementById('noteDeleteBtn2').style.display = 'none';
}
async function saveNote() {
const title = document.getElementById('noteEditorTitle').value.trim();
const content = _getNoteEditorContent();
const folder = document.getElementById('noteEditorFolder').value.trim();
const tagsStr = document.getElementById('noteEditorTags').value.trim();
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
if (!title) { UI.toast('Title is required', 'warning'); return; }
if (!content) { UI.toast('Content is required', 'warning'); return; }
try {
if (_editingNoteId) {
const updated = await API.updateNote(_editingNoteId, { title, content, folder_path: folder, tags, mode: 'replace' });
_currentNote = updated;
UI.toast('Note updated', 'success');
_showNoteReadMode();
} else {
const created = await API.createNote(title, content, folder, tags);
_editingNoteId = created.id;
_currentNote = created;
UI.toast('Note created', 'success');
_showNoteReadMode();
}
// Invalidate graph cache
if (typeof invalidateNoteGraph === 'function') invalidateNoteGraph();
} catch (e) { UI.toast(e.message, 'error'); }
}
async function deleteNote() {
if (!_editingNoteId) return;
if (!await showConfirm('Delete this note?')) return;
try {
await API.deleteNote(_editingNoteId);
UI.toast('Note deleted', 'success');
if (typeof invalidateNoteGraph === 'function') invalidateNoteGraph();
showNotesList();
await loadNotesList();
await loadNoteFolders();
} catch (e) { UI.toast(e.message, 'error'); }
}
// ── Backlinks ───────────────────
async function _loadBacklinks(noteId) {
const container = document.getElementById('noteBacklinks');
const listEl = document.getElementById('noteBacklinksList');
const countEl = document.getElementById('noteBacklinksCount');
if (!container || !listEl || !countEl) return;
try {
const resp = await API.getNoteBacklinks(noteId);
const links = resp.data || [];
countEl.textContent = links.length;
container.style.display = links.length > 0 ? '' : 'none';
listEl.innerHTML = links.map(n => `
<div class="note-backlink-item" data-action="openNoteEditor" data-args='${JSON.stringify([n.id])}'">
<span class="note-backlink-title">${esc(n.title)}</span>
<span class="note-backlink-folder text-muted">${esc(n.folder_path)}</span>
</div>
`).join('');
} catch {
container.style.display = 'none';
}
}
function toggleBacklinks() {
const list = document.getElementById('noteBacklinksList');
if (list) list.style.display = list.style.display === 'none' ? '' : 'none';
}
// ── Wikilink Read-Mode Rendering ──
function _renderWikilinksInReadMode(containerEl) {
if (!containerEl) return;
const html = containerEl.innerHTML;
let transclusionId = 0;
// Replace [[Title|display]] and [[Title]] with clickable spans
// For ![[transclusion]], render a placeholder that gets filled async
const rendered = html.replace(
/(!?)\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]/g,
(match, bang, title, display) => {
const titleTrimmed = title.trim();
const displayText = display ? display.trim() : titleTrimmed;
if (bang === '!') {
const tid = `transclusion-${++transclusionId}`;
return `<div class="transclusion-embed" id="${tid}" data-title="${esc(titleTrimmed)}">
<div class="transclusion-header">
<span class="wikilink-chip wikilink-transclusion" data-action="_navigateToLinkedNote" data-args='${JSON.stringify([esc(titleTrimmed)])}'" title="Open: ${esc(titleTrimmed)}">↗ ${esc(displayText)}</span>
</div>
<div class="transclusion-content"><span class="text-muted">Loading…</span></div>
</div>`;
}
return `<span class="wikilink-chip" data-action="_navigateToLinkedNote" data-args='${JSON.stringify([esc(titleTrimmed)])}'" title="Link: ${esc(titleTrimmed)}">${esc(displayText)}</span>`;
}
);
containerEl.innerHTML = rendered;
// Async: resolve and embed transclusion content
_resolveTransclusions(containerEl);
}
/** Fetch content for each ![[embed]] placeholder — max depth 1 (no recursive transclusion) */
async function _resolveTransclusions(containerEl) {
const embeds = containerEl.querySelectorAll('.transclusion-embed');
if (!embeds.length) return;
// Cache to avoid duplicate fetches in same note
const cache = {};
for (const el of embeds) {
const title = el.dataset.title;
const contentEl = el.querySelector('.transclusion-content');
if (!title || !contentEl) continue;
try {
let note = cache[title.toLowerCase()];
if (!note) {
const resp = await API.searchNoteTitles(title, 1);
const match = (resp.data || []).find(n => n.title.toLowerCase() === title.toLowerCase());
if (!match) {
contentEl.innerHTML = '<span class="text-muted">Note not found</span>';
continue;
}
note = await API.getNote(match.id);
cache[title.toLowerCase()] = note;
}
// Render embedded content (no recursive transclusion — strip ![[]] )
const safeContent = (note.content || '').replace(/!\[\[[^\]]+\]\]/g, (m) => {
const inner = m.match(/!\[\[([^\]|]+)/)?.[1]?.trim() || '';
return `[Embedded: ${inner}]`;
});
contentEl.innerHTML = formatMessage(safeContent);
if (typeof runExtensionPostRender === 'function') runExtensionPostRender(contentEl);
} catch {
contentEl.innerHTML = '<span class="text-muted">Failed to load</span>';
}
}
}
// ── Daily Notes ─────────────────
async function openDailyNote() {
const today = new Date().toISOString().slice(0, 10);
const title = `Daily — ${today}`;
const folder = '/daily/';
try {
const resp = await API.searchNoteTitles(title, 1);
const existing = (resp.data || []).find(
n => n.title.toLowerCase() === title.toLowerCase()
);
if (existing) {
await openNoteEditor(existing.id);
} else {
const content = `# ${today}\n\n## Tasks\n\n- [ ] \n\n## Notes\n\n`;
const created = await API.createNote(title, content, folder, ['daily']);
await openNoteEditor(created.id);
}
} catch (e) { UI.toast('Failed to open daily note: ' + e.message, 'error'); }
}
/** Detect if current note is a daily note */
function _isDailyNote(note) {
return note && /^Daily — \d{4}-\d{2}-\d{2}$/.test(note.title);
}
/** Show prev/next nav for daily notes */
function _updateDailyNav(note) {
let nav = document.getElementById('noteDailyNav');
if (!_isDailyNote(note)) {
if (nav) nav.style.display = 'none';
return;
}
// Ensure nav element exists
if (!nav) {
const toolbar = document.querySelector('.notes-editor-toolbar');
if (!toolbar) return;
nav = document.createElement('div');
nav.id = 'noteDailyNav';
nav.className = 'note-daily-nav';
nav.innerHTML = `
<button class="btn-small" id="dailyPrevBtn" title="Previous day"> Prev</button>
<span class="note-daily-date" id="dailyDateLabel"></span>
<button class="btn-small" id="dailyNextBtn" title="Next day">Next </button>
`;
toolbar.after(nav);
document.getElementById('dailyPrevBtn')?.addEventListener('click', () => _navigateDaily(-1));
document.getElementById('dailyNextBtn')?.addEventListener('click', () => _navigateDaily(1));
}
nav.style.display = '';
const dateStr = note.title.replace('Daily — ', '');
document.getElementById('dailyDateLabel').textContent = dateStr;
// Disable "Next" if it's today or future
const today = new Date().toISOString().slice(0, 10);
document.getElementById('dailyNextBtn').disabled = dateStr >= today;
}
/** Navigate to prev/next daily note */
async function _navigateDaily(offset) {
if (!_currentNote || !_isDailyNote(_currentNote)) return;
const dateStr = _currentNote.title.replace('Daily — ', '');
const d = new Date(dateStr + 'T12:00:00'); // noon to avoid DST edge
d.setDate(d.getDate() + offset);
const newDateStr = d.toISOString().slice(0, 10);
const title = `Daily — ${newDateStr}`;
try {
const resp = await API.searchNoteTitles(title, 1);
const existing = (resp.data || []).find(
n => n.title.toLowerCase() === title.toLowerCase()
);
if (existing) {
await openNoteEditor(existing.id);
} else {
// Create the daily note for that day
const content = `# ${newDateStr}\n\n## Tasks\n\n- [ ] \n\n## Notes\n\n`;
const created = await API.createNote(title, content, '/daily/', ['daily']);
await openNoteEditor(created.id);
}
} catch (e) { UI.toast('Failed to navigate: ' + e.message, 'error'); }
}
// ── Save-to-Note (from chat messages) ──
async function saveMessageToNote(msgIndex) {
const chat = App.chats?.find(c => c.id === App.activeId);
const msg = chat?.messages?.[msgIndex];
if (!msg) return;
// Check for text selection within the message element
const sel = window.getSelection();
let content = msg.content;
// Strip thinking/reasoning blocks — only the response matters in notes
content = content.replace(/<(?:thinking|think)>[\s\S]*?<\/(?:thinking|think)>/gi, '').trim();
const msgId = msg.id || '';
const msgEl = msgId ? document.querySelector(`.message[data-msg-id="${msgId}"] .msg-text`) : null;
if (sel && sel.rangeCount > 0 && msgEl?.contains(sel.anchorNode)) {
const selected = sel.toString().trim();
if (selected) content = selected;
}
// Extract title from first line
const firstLine = content.split('\n')[0].replace(/^#+\s*/, '').trim();
const defaultTitle = firstLine.slice(0, 60) || 'Chat excerpt';
// Show save-to-note modal
_showSaveToNoteModal({
content,
sourceChannelId: App.activeId || '',
sourceMessageId: msg.id || '',
defaultTitle,
});
}
/** Modal for save-to-note with create/append options */
function _showSaveToNoteModal(opts) {
// Remove existing modal if any
document.getElementById('saveToNoteModal')?.remove();
const modal = document.createElement('div');
modal.id = 'saveToNoteModal';
modal.className = 'modal-overlay active';
modal.innerHTML = `
<div class="modal save-to-note-modal">
<h3>Save to Note</h3>
<div class="save-note-mode-toggle">
<label><input type="radio" name="saveNoteMode" value="create" checked> Create new note</label>
<label><input type="radio" name="saveNoteMode" value="append"> Append to existing</label>
</div>
<div id="saveNoteCreateFields">
<div class="form-group">
<input type="text" id="saveNoteTitle" placeholder="Title" value="${esc(opts.defaultTitle)}" class="notes-title-input">
</div>
<div class="form-row">
<div class="form-group"><input type="text" id="saveNoteFolder" placeholder="Folder (e.g. /work)" class="notes-folder-input"></div>
<div class="form-group"><input type="text" id="saveNoteTags" placeholder="Tags (comma-separated)" class="notes-tags-input"></div>
</div>
</div>
<div id="saveNoteAppendFields" style="display:none">
<div class="form-group">
<input type="text" id="saveNoteSearch" placeholder="Search notes to append to..." class="notes-title-input" autocomplete="off">
<div id="saveNoteSearchResults" class="save-note-search-results"></div>
</div>
</div>
<div class="form-group">
<label class="text-muted" style="font-size:12px">Preview (${opts.content.length} chars)</label>
<div class="save-note-preview msg-text">${formatMessage(opts.content.slice(0, 300))}${opts.content.length > 300 ? '…' : ''}</div>
</div>
<div class="modal-actions">
<button class="btn-small" id="saveNoteCancelBtn">Cancel</button>
<button class="btn-small btn-primary" id="saveNoteConfirmBtn">Create</button>
</div>
</div>
`;
document.body.appendChild(modal);
let selectedAppendNote = null;
// Toggle create vs append
modal.querySelectorAll('input[name="saveNoteMode"]').forEach(radio => {
radio.addEventListener('change', (e) => {
const isAppend = e.target.value === 'append';
document.getElementById('saveNoteCreateFields').style.display = isAppend ? 'none' : '';
document.getElementById('saveNoteAppendFields').style.display = isAppend ? '' : 'none';
document.getElementById('saveNoteConfirmBtn').textContent = isAppend ? 'Append' : 'Create';
selectedAppendNote = null;
document.getElementById('saveNoteSearchResults').innerHTML = '';
});
});
// Append search with debounce
let _searchTimer;
document.getElementById('saveNoteSearch')?.addEventListener('input', (e) => {
clearTimeout(_searchTimer);
const q = e.target.value.trim();
selectedAppendNote = null;
_searchTimer = setTimeout(async () => {
const resultsEl = document.getElementById('saveNoteSearchResults');
if (q.length < 2) { resultsEl.innerHTML = ''; return; }
try {
const resp = await API.searchNoteTitles(q, 8);
const notes = resp.data || [];
resultsEl.innerHTML = notes.map(n =>
`<div class="save-note-result" data-id="${n.id}" data-title="${esc(n.title)}">${esc(n.title)}</div>`
).join('') || '<div class="text-muted" style="padding:6px">No results</div>';
resultsEl.querySelectorAll('.save-note-result').forEach(el => {
el.addEventListener('click', () => {
resultsEl.querySelectorAll('.save-note-result').forEach(r => r.classList.remove('selected'));
el.classList.add('selected');
selectedAppendNote = { id: el.dataset.id, title: el.dataset.title };
document.getElementById('saveNoteSearch').value = el.dataset.title;
});
});
} catch { resultsEl.innerHTML = '<div class="text-muted" style="padding:6px">Search failed</div>'; }
}, 250);
});
// Cancel
document.getElementById('saveNoteCancelBtn').addEventListener('click', () => modal.remove());
modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
// Confirm
document.getElementById('saveNoteConfirmBtn').addEventListener('click', async () => {
const isAppend = modal.querySelector('input[name="saveNoteMode"]:checked')?.value === 'append';
try {
if (isAppend) {
if (!selectedAppendNote) { UI.toast('Select a note to append to', 'warning'); return; }
await API.updateNote(selectedAppendNote.id, {
content: '\n\n---\n\n' + opts.content,
mode: 'append',
});
UI.toast(`Appended to "${selectedAppendNote.title}"`, 'success');
} else {
const title = document.getElementById('saveNoteTitle').value.trim();
if (!title) { UI.toast('Title is required', 'warning'); return; }
const folder = document.getElementById('saveNoteFolder').value.trim();
const tagsStr = document.getElementById('saveNoteTags').value.trim();
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(Boolean) : [];
await API.createNote(title, opts.content, folder, tags, opts.sourceChannelId, opts.sourceMessageId);
UI.toast('Note created', 'success');
}
if (typeof invalidateNoteGraph === 'function') invalidateNoteGraph();
modal.remove();
} catch (e) { UI.toast(e.message, 'error'); }
});
// Focus title
document.getElementById('saveNoteTitle')?.focus();
document.getElementById('saveNoteTitle')?.select();
}
// ── Notes Listeners (extracted from initListeners) ──
function _registerNotesPanel() {
const body = document.getElementById('sidePanelBody');
if (!body || typeof PanelRegistry === 'undefined') return;
// ── Create the panel element dynamically ──
const el = document.createElement('div');
el.className = 'side-panel-page';
el.id = 'sidePanelNotes';
el.style.display = 'none';
el.innerHTML = `
<!-- List view -->
<div id="notesListView">
<div class="notes-toolbar">
<button id="notesNewBtn" class="btn-small" title="New note">+ New</button>
<button id="notesTodayBtn" class="btn-small" title="Open daily note">📅 Today</button>
<button id="notesGraphBtn" class="btn-small" title="Note graph">🕸 Graph</button>
<button id="notesSelectModeBtn" class="btn-small" title="Select mode">☑ Select</button>
</div>
<div class="notes-search-row">
<input type="text" id="notesSearchInput" class="notes-search-input" placeholder="Search notes…" autocomplete="off">
</div>
<div class="notes-filter-row">
<select id="notesFolderFilter" class="notes-filter-select"><option value="">All folders</option></select>
<select id="notesSortSelect" class="notes-filter-select">
<option value="updated_desc">Last updated</option>
<option value="created_desc">Created</option>
<option value="alpha">Alphabetical</option>
</select>
</div>
<div id="notesList" class="notes-list"></div>
<div id="notesSelectionBar" class="notes-selection-bar" style="display:none;">
<label class="notes-select-all"><input type="checkbox" id="notesSelectAll"> All</label>
<span id="notesSelectedCount">0</span> selected
<button id="notesDeleteSelectedBtn" class="btn-small btn-danger">Delete</button>
<button id="notesCancelSelectBtn" class="btn-small">Cancel</button>
</div>
</div>
<!-- Editor view -->
<div id="notesEditorView" style="display:none;">
<div class="notes-editor-header">
<button id="notesBackBtn" class="btn-small" title="Back to list">← Back</button>
<div style="flex:1"></div>
<button id="noteEditBtn" class="btn-small" style="display:none;">Edit</button>
<button id="notePreviewBtn" class="btn-small" style="display:none;">Preview</button>
<button id="noteCancelEditBtn" class="btn-small" style="display:none;">Cancel</button>
<button id="noteDeleteBtn" class="btn-small btn-danger" style="display:none;">Delete</button>
<button id="noteSaveBtn" class="btn-small btn-primary">Save</button>
</div>
<!-- Edit mode -->
<div id="noteEditMode" class="notes-editor">
<input type="text" id="noteEditorTitle" class="notes-title-input" placeholder="Note title">
<div class="notes-meta-row">
<input type="text" id="noteEditorFolder" class="notes-folder-input" placeholder="Folder (e.g. work/project)">
<input type="text" id="noteEditorTags" class="notes-tags-input" placeholder="Tags (comma-separated)">
</div>
<div id="noteEditorContentContainer" class="notes-content-container">
<textarea id="noteEditorContent" rows="12" class="notes-content-input" placeholder="Write your note in markdown…"></textarea>
</div>
</div>
<!-- Read mode -->
<div id="noteReadMode" style="display:none;">
<div class="notes-read-view">
<h3 id="noteReadTitle" class="note-read-title"></h3>
<div id="noteReadMeta" class="note-read-meta"></div>
<div id="noteReadContent" class="note-read-content msg-content"></div>
<button id="noteDeleteBtn2" class="btn-small btn-danger" style="display:none;margin-top:8px;">Delete</button>
</div>
</div>
<!-- Backlinks -->
<div id="noteBacklinks" class="note-backlinks" style="display:none;">
<div class="note-backlinks-header" data-action="toggleBacklinks">
Backlinks <span id="noteBacklinksCount" class="badge">0</span>
</div>
<div id="noteBacklinksList" class="note-backlinks-list"></div>
</div>
</div>
<!-- Graph view -->
<div id="notesGraphView" class="notes-graph-view" style="display:none;">
<div class="notes-graph-toolbar">
<button class="btn-small" id="notesGraphBackBtn" title="Back to list">← Back</button>
<span class="notes-graph-stats">
<span id="graphNodeCount">0</span> notes · <span id="graphEdgeCount">0</span> links
</span>
<div style="flex:1"></div>
<button class="btn-small" data-action="_graphToggleOrphans" title="Toggle orphan nodes">Orphans</button>
<button class="btn-small" data-action="_graphResetZoom" title="Reset zoom">Reset</button>
</div>
<canvas id="noteGraphCanvas" style="width:100%;display:block;"></canvas>
</div>`;
body.appendChild(el);
// ── Wire listeners ──
// Sidebar trigger (lives outside this panel)
document.getElementById('notesBtn')?.addEventListener('click', openNotes);
// Toolbar buttons
el.querySelector('#notesNewBtn').addEventListener('click', () => openNoteEditor(null));
el.querySelector('#notesGraphBtn').addEventListener('click', () => {
if (typeof openNoteGraph === 'function') openNoteGraph();
});
el.querySelector('#notesTodayBtn').addEventListener('click', openDailyNote);
el.querySelector('#notesGraphBackBtn').addEventListener('click', () => {
if (typeof closeNoteGraph === 'function') closeNoteGraph();
});
el.querySelector('#notesSelectModeBtn').addEventListener('click', () => {
if (_notesSelectMode) _exitSelectMode();
else _enterSelectMode();
});
// Editor header buttons
el.querySelector('#notesBackBtn').addEventListener('click', () => { showNotesList(); loadNotesList(); loadNoteFolders(); });
el.querySelector('#noteSaveBtn').addEventListener('click', saveNote);
el.querySelector('#noteDeleteBtn').addEventListener('click', deleteNote);
el.querySelector('#noteDeleteBtn2').addEventListener('click', deleteNote);
el.querySelector('#noteEditBtn').addEventListener('click', _showNoteEditMode);
el.querySelector('#notePreviewBtn').addEventListener('click', _showNotePreview);
el.querySelector('#noteCancelEditBtn').addEventListener('click', () => {
if (_currentNote) { _populateEditFields(_currentNote); _showNoteReadMode(); }
else showNotesList();
});
// Filters
el.querySelector('#notesFolderFilter').addEventListener('change', (e) => {
document.getElementById('notesSearchInput').value = '';
_exitSelectMode();
loadNotesList(e.target.value);
});
el.querySelector('#notesSortSelect').addEventListener('change', (e) => {
_notesSort = e.target.value;
_exitSelectMode();
loadNotesList();
});
// Multi-select controls
el.querySelector('#notesSelectAll').addEventListener('change', (e) => {
_toggleSelectAll(e.target.checked);
});
el.querySelector('#notesDeleteSelectedBtn').addEventListener('click', _bulkDeleteSelected);
el.querySelector('#notesCancelSelectBtn').addEventListener('click', _exitSelectMode);
// Search with debounce
let _notesSearchTimer;
el.querySelector('#notesSearchInput').addEventListener('input', (e) => {
clearTimeout(_notesSearchTimer);
const q = e.target.value.trim();
_notesSearchTimer = setTimeout(() => {
_exitSelectMode();
if (q.length >= 2) {
document.getElementById('notesFolderFilter').value = '';
loadNotesList(null, q);
} else if (q.length === 0) {
loadNotesList();
}
}, 300);
});
// ── Register with PanelRegistry ──
PanelRegistry.register('notes', {
element: el,
label: 'Notes',
onOpen() {
// Ensure notes are loaded whenever the panel opens
loadNotesList();
loadNoteFolders();
},
onClose() {
_destroyNoteEditor();
},
saveState() {
const listView = document.getElementById('notesListView');
const editorView = document.getElementById('notesEditorView');
const graphView = document.getElementById('notesGraphView');
let viewMode = 'list';
if (editorView && editorView.style.display !== 'none') viewMode = 'editor';
if (graphView && graphView.style.display !== 'none') viewMode = 'graph';
return {
scrollTop: listView?.scrollTop || 0,
editingNoteId: _editingNoteId,
viewMode,
};
},
restoreState(state) {
if (state.scrollTop) {
const listView = document.getElementById('notesListView');
if (listView) listView.scrollTop = state.scrollTop;
}
},
});
}
/** @deprecated — listeners are now wired in _registerNotesPanel() */
function _initNotesListeners() {
// No-op: kept for backward compatibility with app.js calling sequence.
// All wiring moved into _registerNotesPanel().
}
// ── Wrappers for compound onclick expressions ──
// Checkbox toggle: reads .checked from the clicked element
function _toggleNoteSelectCb(el, noteId) {
_toggleNoteSelect(noteId, el.checked);
}
// Note list item click: mode-dependent dispatch
function _noteItemClick(noteId) {
if (_notesSelectMode) {
_toggleNoteSelect(noteId);
} else {
openNoteEditor(noteId);
}
}
// ── Exports ─────────────────────────────────
sb.register('_currentNote', _currentNote);
sb.register('_editingNoteId', _editingNoteId);
sb.register('_initNotesListeners', _initNotesListeners);
sb.register('_registerNotesPanel', _registerNotesPanel);
sb.register('deleteNote', deleteNote);
sb.register('openNoteEditor', openNoteEditor);
sb.register('openNotes', openNotes);
sb.register('saveMessageToNote', saveMessageToNote);
sb.register('saveNote', saveNote);
sb.register('_toggleNoteSelect', _toggleNoteSelect);
sb.register('_navigateToLinkedNote', _navigateToLinkedNote);
sb.register('toggleBacklinks', toggleBacklinks);
sb.register('_toggleNoteSelectCb', _toggleNoteSelectCb);
sb.register('_noteItemClick', _noteItemClick);
})();