diff --git a/CHANGELOG.md b/CHANGELOG.md index a7d7e2c..7525c25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,92 @@ All notable changes to Chat Switchboard. +## [0.17.3] — 2026-02-28 + +### Added +- **Wikilink bi-directional linking.** Notes support `[[Title]]` and + `[[Title|display text]]` syntax. Links are extracted on save via regex, + resolved to target note IDs by case-insensitive title match, and stored + in a `note_links` junction table. Dangling links (references to notes + that don't exist yet) are preserved and automatically resolved when a + matching note is created later. +- **Transclusion embeds.** `![[Title]]` syntax renders embedded note content + inline in read mode. Content is fetched asynchronously with a recursion + guard (max depth 1 — nested transclusions render as plain text references). + Transclusion links are visually distinct in both the editor (border-left, + italic) and the graph (dashed edges). +- **Backlinks panel.** Read mode shows a collapsible "Linked mentions" panel + below the note content listing all notes that link to the current note. + Each backlink is clickable to navigate directly. Count badge updates on + every note open. +- **Knowledge graph visualization.** Canvas-based force-directed graph with + no external dependencies (~480 lines). Physics: O(n²) Coulomb repulsion, + Hooke spring attraction on edges, center gravity. Interaction: pan, zoom + (0.15–4.0x toward cursor), drag nodes, hover highlights node + neighbors. + Click opens note editor. Nodes sized by √(link_count), colored by folder + path (10-color palette). Ghost nodes for unresolved `[[links]]` with + toggle button. Energy-based pause (stops rAF when total kinetic energy + falls below threshold). ResizeObserver for responsive canvas sizing. +- **CM6 note editor.** New `CM.noteEditor()` factory function with live + markdown preview (heading sizes for h1–h3, blockquote styling, fenced code + block decorations), `[[wikilink]]` autocomplete triggered by `[[` with + async title search, and clickable wikilink chip rendering. Falls back to + plain ` +
@@ -997,6 +1019,49 @@
+ + + +
@@ -1031,6 +1096,7 @@ + diff --git a/src/js/api.js b/src/js/api.js index 851c6fb..88e7770 100644 --- a/src/js/api.js +++ b/src/js/api.js @@ -489,10 +489,12 @@ const API = { if (sort) url += `&sort=${encodeURIComponent(sort)}`; return this._get(url); }, - createNote(title, content, folderPath, tags) { + createNote(title, content, folderPath, tags, sourceChannelId, sourceMessageId) { const body = { title, content }; if (folderPath) body.folder_path = folderPath; if (tags?.length) body.tags = tags; + if (sourceChannelId) body.source_channel_id = sourceChannelId; + if (sourceMessageId) body.source_message_id = sourceMessageId; return this._post('/api/v1/notes', body); }, getNote(id) { return this._get(`/api/v1/notes/${id}`); }, @@ -500,7 +502,10 @@ const API = { deleteNote(id) { return this._del(`/api/v1/notes/${id}`); }, bulkDeleteNotes(ids) { return this._post('/api/v1/notes/bulk-delete', { ids }); }, searchNotes(query, limit = 20) { return this._get(`/api/v1/notes/search?q=${encodeURIComponent(query)}&limit=${limit}`); }, + searchNoteTitles(query, limit = 10) { return this._get(`/api/v1/notes/search-titles?q=${encodeURIComponent(query)}&limit=${limit}`); }, listNoteFolders() { return this._get('/api/v1/notes/folders'); }, + getNoteBacklinks(id) { return this._get(`/api/v1/notes/${id}/backlinks`); }, + getNoteGraph() { return this._get('/api/v1/notes/graph'); }, // ── Attachments ───────────────────────── diff --git a/src/js/note-graph.js b/src/js/note-graph.js new file mode 100644 index 0000000..5f7fd72 --- /dev/null +++ b/src/js/note-graph.js @@ -0,0 +1,490 @@ +// ========================================== +// Chat Switchboard — Note Graph (Canvas) +// ========================================== +// Force-directed graph visualization of note +// connections. Lazy-loaded — initializes only +// when the graph view is opened. +// ========================================== + +var _graphData = null; // cached { nodes, edges, unresolved } +var _graphState = null; // { panX, panY, zoom, animId, canvas, ctx } +var _graphDirty = true; // invalidation flag + +// ── Force Simulation Parameters ───────────── + +const SIM = { + repulsion: 800, + attraction: 0.006, + edgeLength: 130, + damping: 0.88, + centerGravity: 0.012, + maxVelocity: 8, + ticksPerFrame: 3, + coolThreshold: 0.5, // total KE below this → stop sim +}; + +const BASE_RADIUS = 6; + +// ── Color Palette for Folders ─────────────── + +const FOLDER_COLORS = [ + '#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', + '#8b5cf6', '#ef4444', '#06b6d4', '#84cc16', '#f97316', +]; + +var _folderColorMap = {}; + +function folderColor(path) { + if (!path || path === '/') return '#6b7280'; + if (!_folderColorMap[path]) { + const idx = Object.keys(_folderColorMap).length % FOLDER_COLORS.length; + _folderColorMap[path] = FOLDER_COLORS[idx]; + } + return _folderColorMap[path]; +} + +// ── Graph Initialization ──────────────────── + +async function openNoteGraph() { + const listView = document.getElementById('notesListView'); + const editorView = document.getElementById('notesEditorView'); + const graphView = document.getElementById('notesGraphView'); + if (!graphView) return; + + listView.style.display = 'none'; + editorView.style.display = 'none'; + graphView.style.display = ''; + + const canvas = document.getElementById('noteGraphCanvas'); + if (!canvas) return; + + // Size canvas to panel + const rect = canvas.parentElement.getBoundingClientRect(); + canvas.width = rect.width; + canvas.height = rect.height - 40; // toolbar height + + const ctx = canvas.getContext('2d'); + + _graphState = { + panX: 0, panY: 0, zoom: 1, + animId: null, canvas, ctx, + hoveredNode: null, dragNode: null, + isDragging: false, lastMouse: null, + showOrphans: true, + }; + + // Load data + try { + _graphData = await API.getNoteGraph(); + _prepareGraphNodes(canvas); + _updateGraphStats(); + _startSimulation(); + _attachGraphListeners(canvas); + } catch (e) { + ctx.fillStyle = 'var(--text, #ccc)'; + ctx.font = '14px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('Failed to load graph: ' + e.message, canvas.width / 2, canvas.height / 2); + } +} + +function closeNoteGraph() { + if (_graphState?.animId) { + cancelAnimationFrame(_graphState.animId); + _graphState.animId = null; + } + const graphView = document.getElementById('notesGraphView'); + if (graphView) graphView.style.display = 'none'; + document.getElementById('notesListView').style.display = ''; +} + +// ── Prepare Nodes with Physics State ──────── + +function _prepareGraphNodes(canvas) { + if (!_graphData) return; + + const cx = canvas.width / 2; + const cy = canvas.height / 2; + + // Build lookup for edge source/target + const nodeMap = {}; + for (const n of _graphData.nodes) { + n.x = cx + (Math.random() - 0.5) * canvas.width * 0.5; + n.y = cy + (Math.random() - 0.5) * canvas.height * 0.5; + n.vx = 0; n.vy = 0; + n.ax = 0; n.ay = 0; + n.pinned = false; + n.hovered = false; + nodeMap[n.id] = n; + } + + // Resolve edge references + for (const e of _graphData.edges) { + e.sourceNode = nodeMap[e.source]; + e.targetNode = nodeMap[e.target]; + } + + // Optionally include ghost nodes for unresolved links + _graphData._ghostNodes = []; + if (_graphData.unresolved) { + const ghostMap = {}; + for (const d of _graphData.unresolved) { + const key = d.title.toLowerCase(); + if (!ghostMap[key]) { + const ghost = { + id: '__ghost_' + key, title: d.title, + folder_path: '', tags: [], link_count: 0, + x: cx + (Math.random() - 0.5) * canvas.width * 0.4, + y: cy + (Math.random() - 0.5) * canvas.height * 0.4, + vx: 0, vy: 0, ax: 0, ay: 0, + pinned: false, hovered: false, isGhost: true, + }; + ghostMap[key] = ghost; + _graphData._ghostNodes.push(ghost); + } + } + } +} + +function _updateGraphStats() { + const nc = document.getElementById('graphNodeCount'); + const ec = document.getElementById('graphEdgeCount'); + if (nc) nc.textContent = _graphData?.nodes?.length || 0; + if (ec) ec.textContent = _graphData?.edges?.length || 0; +} + +// ── Force Simulation Loop ─────────────────── + +function _startSimulation() { + if (!_graphData || !_graphState) return; + + function frame() { + for (let t = 0; t < SIM.ticksPerFrame; t++) { + _tick(); + } + _render(); + + // Check convergence + let totalKE = 0; + for (const n of _allNodes()) { + totalKE += n.vx * n.vx + n.vy * n.vy; + } + if (totalKE > SIM.coolThreshold || _graphState.dragNode) { + _graphState.animId = requestAnimationFrame(frame); + } else { + _graphState.animId = null; + // One final render + _render(); + } + } + + if (_graphState.animId) cancelAnimationFrame(_graphState.animId); + _graphState.animId = requestAnimationFrame(frame); +} + +function _allNodes() { + if (!_graphData) return []; + const nodes = [..._graphData.nodes]; + if (_graphState?.showOrphans !== false && _graphData._ghostNodes) { + nodes.push(..._graphData._ghostNodes); + } + return nodes; +} + +function _tick() { + const nodes = _allNodes(); + const edges = _graphData?.edges || []; + const canvas = _graphState?.canvas; + if (!canvas || nodes.length === 0) return; + + // Reset accelerations + for (const n of nodes) { n.ax = 0; n.ay = 0; } + + // 1. Repulsion (all pairs) + for (let i = 0; i < nodes.length; i++) { + for (let j = i + 1; j < nodes.length; j++) { + const dx = nodes[j].x - nodes[i].x; + const dy = nodes[j].y - nodes[i].y; + const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1); + const f = SIM.repulsion / (dist * dist); + const fx = (dx / dist) * f; + const fy = (dy / dist) * f; + nodes[i].ax -= fx; nodes[i].ay -= fy; + nodes[j].ax += fx; nodes[j].ay += fy; + } + } + + // 2. Attraction (edges) + for (const e of edges) { + const s = e.sourceNode, t = e.targetNode; + if (!s || !t) continue; + const dx = t.x - s.x, dy = t.y - s.y; + const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1); + const f = SIM.attraction * (dist - SIM.edgeLength); + const fx = (dx / dist) * f; + const fy = (dy / dist) * f; + s.ax += fx; s.ay += fy; + t.ax -= fx; t.ay -= fy; + } + + // 3. Center gravity + integration + const cx = canvas.width / 2, cy = canvas.height / 2; + for (const n of nodes) { + if (n.pinned) continue; + n.ax += (cx - n.x) * SIM.centerGravity; + n.ay += (cy - n.y) * SIM.centerGravity; + n.vx = _clamp((n.vx + n.ax) * SIM.damping, -SIM.maxVelocity, SIM.maxVelocity); + n.vy = _clamp((n.vy + n.ay) * SIM.damping, -SIM.maxVelocity, SIM.maxVelocity); + n.x += n.vx; + n.y += n.vy; + } +} + +function _clamp(v, min, max) { return Math.max(min, Math.min(max, v)); } + +// ── Canvas Rendering ──────────────────────── + +function _render() { + const { ctx, canvas, panX, panY, zoom, hoveredNode, showOrphans } = _graphState; + if (!ctx || !_graphData) return; + + const nodes = _allNodes(); + const edges = _graphData.edges || []; + + ctx.clearRect(0, 0, canvas.width, canvas.height); + ctx.save(); + ctx.translate(panX, panY); + ctx.scale(zoom, zoom); + + // Edges + for (const e of edges) { + const s = e.sourceNode, t = e.targetNode; + if (!s || !t) continue; + + const isHighlighted = hoveredNode && + (s.id === hoveredNode.id || t.id === hoveredNode.id); + + ctx.beginPath(); + ctx.moveTo(s.x, s.y); + ctx.lineTo(t.x, t.y); + + if (e.is_transclusion) { + ctx.setLineDash([4, 4]); + ctx.strokeStyle = isHighlighted + ? 'rgba(99, 102, 241, 0.8)' : 'rgba(99, 102, 241, 0.3)'; + } else { + ctx.setLineDash([]); + ctx.strokeStyle = isHighlighted + ? 'rgba(160, 160, 160, 0.8)' : 'rgba(120, 120, 120, 0.25)'; + } + ctx.lineWidth = isHighlighted ? 2 : 1; + ctx.stroke(); + } + ctx.setLineDash([]); + + // Nodes + for (const n of nodes) { + if (n.isGhost && !showOrphans) continue; + + const r = n.isGhost + ? BASE_RADIUS * 0.6 + : BASE_RADIUS + Math.sqrt(n.link_count || 0) * 2; + + const isHovered = hoveredNode && n.id === hoveredNode.id; + const isNeighbor = hoveredNode && _isNeighbor(hoveredNode, n); + const isDimmed = hoveredNode && !isHovered && !isNeighbor; + + ctx.beginPath(); + ctx.arc(n.x, n.y, r, 0, Math.PI * 2); + + if (n.isGhost) { + ctx.fillStyle = isDimmed ? 'rgba(200, 50, 50, 0.1)' : 'rgba(200, 50, 50, 0.4)'; + ctx.setLineDash([2, 2]); + ctx.strokeStyle = 'rgba(200, 50, 50, 0.5)'; + } else if (isHovered) { + ctx.fillStyle = '#6366f1'; + } else { + const color = folderColor(n.folder_path); + ctx.fillStyle = isDimmed ? _withAlpha(color, 0.15) : _withAlpha(color, 0.7); + ctx.strokeStyle = isDimmed ? 'rgba(255,255,255,0.05)' : 'rgba(255,255,255,0.15)'; + } + + ctx.fill(); + if (!n.isGhost) { + ctx.setLineDash([]); + ctx.lineWidth = isHovered ? 2 : 1; + ctx.stroke(); + } else { + ctx.stroke(); + ctx.setLineDash([]); + } + + // Labels + if (zoom > 0.5 && (isHovered || isNeighbor || !hoveredNode)) { + const fontSize = Math.max(10, 11 / zoom); + ctx.font = `${n.isGhost ? 'italic ' : ''}${fontSize}px var(--font, system-ui, sans-serif)`; + ctx.fillStyle = isDimmed ? 'rgba(200,200,200,0.2)' : 'rgba(220,220,220,0.9)'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + + const label = n.title.length > 30 ? n.title.slice(0, 28) + '…' : n.title; + ctx.fillText(label, n.x, n.y + r + 4); + } + } + + ctx.restore(); +} + +function _isNeighbor(hovered, candidate) { + if (!_graphData) return false; + for (const e of _graphData.edges) { + if ((e.source === hovered.id && e.target === candidate.id) || + (e.target === hovered.id && e.source === candidate.id)) { + return true; + } + } + return false; +} + +function _withAlpha(hex, alpha) { + const r = parseInt(hex.slice(1, 3), 16); + const g = parseInt(hex.slice(3, 5), 16); + const b = parseInt(hex.slice(5, 7), 16); + return `rgba(${r},${g},${b},${alpha})`; +} + +// ── Interaction: Pan, Zoom, Drag, Click ───── + +function _attachGraphListeners(canvas) { + // Mouse → canvas coordinates (accounting for pan/zoom) + function toGraph(e) { + const rect = canvas.getBoundingClientRect(); + const x = (e.clientX - rect.left - _graphState.panX) / _graphState.zoom; + const y = (e.clientY - rect.top - _graphState.panY) / _graphState.zoom; + return { x, y }; + } + + function hitTest(gx, gy) { + const nodes = _allNodes(); + for (let i = nodes.length - 1; i >= 0; i--) { + const n = nodes[i]; + const r = n.isGhost ? BASE_RADIUS * 0.6 : BASE_RADIUS + Math.sqrt(n.link_count || 0) * 2; + const dx = n.x - gx, dy = n.y - gy; + if (dx * dx + dy * dy <= (r + 4) * (r + 4)) return n; + } + return null; + } + + // Mousedown + canvas.addEventListener('mousedown', (e) => { + const g = toGraph(e); + const node = hitTest(g.x, g.y); + if (node && !node.isGhost) { + _graphState.dragNode = node; + node.pinned = true; + _graphState.isDragging = false; + } + _graphState.lastMouse = { x: e.clientX, y: e.clientY }; + }); + + // Mousemove + canvas.addEventListener('mousemove', (e) => { + const g = toGraph(e); + + if (_graphState.dragNode) { + _graphState.isDragging = true; + _graphState.dragNode.x = g.x; + _graphState.dragNode.y = g.y; + _graphState.dragNode.vx = 0; + _graphState.dragNode.vy = 0; + if (!_graphState.animId) _startSimulation(); + } else if (_graphState.lastMouse && e.buttons === 1) { + // Pan + _graphState.panX += e.clientX - _graphState.lastMouse.x; + _graphState.panY += e.clientY - _graphState.lastMouse.y; + _graphState.lastMouse = { x: e.clientX, y: e.clientY }; + _render(); + } else { + // Hover + const node = hitTest(g.x, g.y); + if (node !== _graphState.hoveredNode) { + _graphState.hoveredNode = node; + canvas.style.cursor = node ? 'pointer' : 'default'; + _render(); + } + } + }); + + // Mouseup + canvas.addEventListener('mouseup', (e) => { + if (_graphState.dragNode) { + _graphState.dragNode.pinned = false; + + // Click (not drag) → open note + if (!_graphState.isDragging && !_graphState.dragNode.isGhost) { + openNoteEditor(_graphState.dragNode.id); + } + _graphState.dragNode = null; + } + _graphState.lastMouse = null; + _graphState.isDragging = false; + }); + + // Wheel → zoom + canvas.addEventListener('wheel', (e) => { + e.preventDefault(); + const factor = e.deltaY > 0 ? 0.92 : 1.08; + const newZoom = _clamp(_graphState.zoom * factor, 0.15, 4.0); + + // Zoom toward cursor + const rect = canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + + _graphState.panX = mx - (mx - _graphState.panX) * (newZoom / _graphState.zoom); + _graphState.panY = my - (my - _graphState.panY) * (newZoom / _graphState.zoom); + _graphState.zoom = newZoom; + + _render(); + }, { passive: false }); + + // Resize observer — debounced to prevent notification loop + let _roFrame = null; + const ro = new ResizeObserver(() => { + if (_roFrame) return; // already scheduled + _roFrame = requestAnimationFrame(() => { + _roFrame = null; + const rect = canvas.parentElement.getBoundingClientRect(); + const w = Math.round(rect.width); + const h = Math.round(rect.height - 40); + if (canvas.width !== w || canvas.height !== h) { + canvas.width = w; + canvas.height = h; + _render(); + } + }); + }); + ro.observe(canvas.parentElement); +} + +function _graphResetZoom() { + if (!_graphState) return; + _graphState.panX = 0; + _graphState.panY = 0; + _graphState.zoom = 1; + _render(); +} + +function _graphToggleOrphans() { + if (!_graphState) return; + _graphState.showOrphans = !_graphState.showOrphans; + _render(); +} + +// ── Invalidation ──────────────────────────── + +function invalidateNoteGraph() { + _graphData = null; + _graphDirty = true; + _folderColorMap = {}; +} diff --git a/src/js/notes.js b/src/js/notes.js index 4cb456b..a3eae24 100644 --- a/src/js/notes.js +++ b/src/js/notes.js @@ -7,6 +7,7 @@ var _editingNoteId = null; var _notesSelectMode = false; var _selectedNoteIds = new Set(); var _notesSort = 'updated_desc'; +var _noteEditor = null; // CM6 noteEditor instance // ── Notes ───────────────────────────────────── @@ -172,7 +173,13 @@ async function loadNoteFolders() { 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 = '
Loading…
'; } var _currentNote = null; // cached note for read mode @@ -211,14 +218,83 @@ function _populateEditFields(note) { document.getElementById('noteEditorTitle').value = note.title || ''; document.getElementById('noteEditorFolder').value = note.folder_path || ''; document.getElementById('noteEditorTags').value = (note.tags || []).join(', '); - document.getElementById('noteEditorContent').value = note.content || ''; + _setNoteEditorContent(note.content || ''); } function _clearEditFields() { document.getElementById('noteEditorTitle').value = ''; document.getElementById('noteEditorFolder').value = ''; document.getElementById('noteEditorTags').value = ''; - document.getElementById('noteEditorContent').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 = ''; + } + 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() { @@ -239,6 +315,9 @@ function _showNoteReadMode() { 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'; @@ -247,6 +326,12 @@ function _showNoteReadMode() { 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() { @@ -259,9 +344,9 @@ function _showNoteEditMode() { } function _showNotePreview() { - // Live preview from textarea without saving + // Live preview from editor without saving const title = document.getElementById('noteEditorTitle').value.trim(); - const content = document.getElementById('noteEditorContent').value; + 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) : []; @@ -273,6 +358,7 @@ function _showNotePreview() { 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'; @@ -284,7 +370,7 @@ function _showNotePreview() { async function saveNote() { const title = document.getElementById('noteEditorTitle').value.trim(); - const content = document.getElementById('noteEditorContent').value; + 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) : []; @@ -305,6 +391,8 @@ async function saveNote() { UI.toast('Note created', 'success'); _showNoteReadMode(); } + // Invalidate graph cache + if (typeof invalidateNoteGraph === 'function') invalidateNoteGraph(); } catch (e) { UI.toast(e.message, 'error'); } } @@ -314,17 +402,356 @@ async function deleteNote() { 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 => ` + + `).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 `
+
+ ↗ ${esc(displayText)} +
+
Loading…
+
`; + } + return `${esc(displayText)}`; + } + ); + 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 = 'Note not found'; + 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 = 'Failed to load'; + } + } +} + +// ── 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 = ` + + + + `; + 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.currentChatId); + const msg = chat?.messages?.[msgIndex]; + if (!msg) return; + + // Check for text selection within the message element + const sel = window.getSelection(); + let content = msg.content; + 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.currentChatId || '', + 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'; + modal.innerHTML = ` + + `; + 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 => + `
${esc(n.title)}
` + ).join('') || '
No results
'; + 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 = '
Search failed
'; } + }, 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 _initNotesListeners() { document.getElementById('notesBtn')?.addEventListener('click', openNotes); document.getElementById('notesNewBtn')?.addEventListener('click', () => openNoteEditor(null)); + document.getElementById('notesGraphBtn')?.addEventListener('click', () => { + if (typeof openNoteGraph === 'function') openNoteGraph(); + }); + document.getElementById('notesTodayBtn')?.addEventListener('click', openDailyNote); document.getElementById('notesSelectModeBtn')?.addEventListener('click', () => { if (_notesSelectMode) _exitSelectMode(); else _enterSelectMode(); @@ -334,7 +761,6 @@ function _initNotesListeners() { document.getElementById('noteDeleteBtn')?.addEventListener('click', deleteNote); document.getElementById('noteDeleteBtn2')?.addEventListener('click', deleteNote); document.getElementById('noteEditBtn')?.addEventListener('click', _showNoteEditMode); - document.getElementById('noteEditBtn2')?.addEventListener('click', _showNoteEditMode); document.getElementById('notePreviewBtn')?.addEventListener('click', _showNotePreview); document.getElementById('noteCancelEditBtn')?.addEventListener('click', () => { if (_currentNote) { _populateEditFields(_currentNote); _showNoteReadMode(); } diff --git a/src/js/ui-core.js b/src/js/ui-core.js index 2497893..b93331a 100644 --- a/src/js/ui-core.js +++ b/src/js/ui-core.js @@ -418,6 +418,7 @@ const UI = { ${editBtn} ${regenBtn} +
${!isUser ? _renderToolCallsHTML(msg.tool_calls) : ''}