943 lines
41 KiB
JavaScript
943 lines
41 KiB
JavaScript
// ==========================================
|
||
// Chat Switchboard – Notes Panel
|
||
// ==========================================
|
||
// Notes panel: editor, multi-select, folders, CRUD.
|
||
|
||
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}
|
||
onclick="event.stopPropagation(); _toggleNoteSelect('${note.id}', this.checked)">
|
||
</div>
|
||
<div class="note-content-col" onclick="${_notesSelectMode ? `_toggleNoteSelect('${note.id}')` : `openNoteEditor('${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" onclick="openNoteEditor('${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" onclick="_navigateToLinkedNote('${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" onclick="_navigateToLinkedNote('${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" onclick="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" onclick="_graphToggleOrphans()" title="Toggle orphan nodes">Orphans</button>
|
||
<button class="btn-small" onclick="_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().
|
||
}
|