diff --git a/CHANGELOG.md b/CHANGELOG.md index 5867c7a..344e118 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,39 @@ All notable changes to Switchboard Core are documented here. +## v0.4.7 — Note Graph + +### Added + +- **Graph API**: `GET /graph` endpoint returns all non-archived notes as nodes + and resolved wikilinks as edges in a single payload. Nodes include id, title, + folder_id, and tags. Edges include source, target, and link text. +- **Graph view**: Canvas-based force-directed graph visualization replaces the + editor pane when "Graph" button is clicked in the topbar. Force simulation + uses repulsion (all pairs), attraction (edges), and center gravity with + velocity damping. Supports zoom (scroll wheel) and pan (mouse drag). +- **Click focus**: Single-click a node to dim unconnected nodes/edges to 15% + opacity; clicked node and direct neighbors stay at full brightness. Edges + between focused nodes highlighted. Click empty space to reset. +- **Shift+click chain**: Hold shift and click a second node to union both + neighborhoods — trace thought paths across two hops. +- **Double-click open**: Double-click a graph node to navigate to that note in + the editor. Graph view closes, editor opens with the selected note. +- **Hover tooltips**: Hovering a node shows title, tag pills, and connection + count in an HTML overlay (no additional API calls). +- **Folder coloring**: Nodes colored by folder using a 10-color palette. + Unfiled notes shown in gray. +- **Tag filter integration**: Active tag filter dims non-matching nodes to 30% + opacity, stacking with click focus. +- **Orphan highlighting**: Notes with zero connections shown with dashed stroke. + "Hide orphans" checkbox in toolbar to toggle visibility. + +### Changed + +- Notes package version bumped from 0.7.0 to 0.8.0. +- Topbar gains a "Graph" toggle button alongside "New Note" and "Import .md". + + ## v0.4.6 — Sidebar Restructure ### Added diff --git a/ROADMAP.md b/ROADMAP.md index 4b48c84..684d8e2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -308,19 +308,19 @@ Zero platform special-casing. Proves the full extension stack E2E. | Sidebar tabs | ✅ | Two-tab header at top of sidebar: "Notes" (folders → tags → search → list) and "Outline" (heading tree for active note). Outline tab enabled only when a note is selected. | | Heading tree | ✅ | Nested heading hierarchy (H1 → H2 → H3) with depth indentation. Click → scroll to heading in editor/preview. Current heading highlighted based on scroll position. | -### v0.4.7 — Note Graph (planned) +### v0.4.7 — Note Graph | Step | Status | Description | |------|--------|-------------| -| Graph API | | `GET /graph` endpoint in Starlark — `{ nodes: [{id, title, folder_id}], edges: [{source, target, text}] }`. Full notes + links in one payload. | -| Graph renderer | | Canvas force-directed layout. Minimal force simulation (repulsion + attraction + damping). Nodes = circles with title labels, edges = lines. Zoom/pan via wheel + drag. | -| Click → focus + filter | | Single click a node: dims unconnected nodes/edges to ~15% opacity. Clicked node + direct neighbors stay full brightness. Edges highlighted in accent color. Click empty space to reset. | -| Shift+click → chain | | Shift+click adds a second node to the focus set — union of both neighborhoods visible. Trace thought paths across two hops without losing context. | -| Double-click → open | | Double-click navigates to the note. Graph view stays active — back returns to graph with previous focus state preserved. | -| Hover → tooltip | | Hover shows note title + tag pills + edge count. No API call — all data in graph payload. | -| Folder/tag coloring | | Nodes colored by folder. Tag filter dropdown — select tag, unmatched nodes dim. Stacks with click focus. | -| Orphan highlighting | | Zero-edge nodes rendered with dashed stroke. Optional toggle to hide entirely. | -| Entry point | | "Graph" button in topbar. Replaces editor pane with full graph canvas. Note selection in graph populates sidebar, switching back to editor shows that note. | +| Graph API | ✅ | `GET /graph` endpoint in Starlark — `{ nodes: [{id, title, folder_id, tags}], edges: [{source, target, text}] }`. Full notes + links in one payload. | +| Graph renderer | ✅ | Canvas force-directed layout. Minimal force simulation (repulsion + attraction + damping). Nodes = circles with title labels, edges = lines. Zoom/pan via wheel + drag. Retina-aware rendering. | +| Click → focus + filter | ✅ | Single click a node: dims unconnected nodes/edges to 15% opacity. Clicked node + direct neighbors stay full brightness. Edges highlighted in accent color. Click empty space to reset. | +| Shift+click → chain | ✅ | Shift+click adds a second node to the focus set — union of both neighborhoods visible. Trace thought paths across two hops without losing context. | +| Double-click → open | ✅ | Double-click navigates to the note and exits graph view, opening the editor with the selected note. | +| Hover → tooltip | ✅ | Hover shows note title + tag pills + edge count in HTML overlay. No API call — all data in graph payload. | +| Folder/tag coloring | ✅ | Nodes colored by folder using 10-color palette. Active tag filter dims non-matching nodes to 30% opacity. Stacks with click focus. | +| Orphan highlighting | ✅ | Zero-edge nodes rendered with dashed stroke. "Hide orphans" checkbox toggle in toolbar. | +| Entry point | ✅ | "Graph" button in topbar. Replaces editor pane with full graph canvas. Single-click selects note in sidebar; double-click opens editor. | ## v0.5.x — Realtime + Chat diff --git a/VERSION b/VERSION index ef52a64..f905682 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.6 +0.4.7 diff --git a/packages/notes/css/main.css b/packages/notes/css/main.css index 5604cb2..b44c3f3 100644 --- a/packages/notes/css/main.css +++ b/packages/notes/css/main.css @@ -706,6 +706,84 @@ .sidebar-outline__item--h1 { font-weight: 600; color: var(--text); } .sidebar-outline__item--h2 { font-weight: 500; } +/* ── Graph Pane ─────────────────────────── */ +.graph-pane { + flex: 1; + position: relative; + overflow: hidden; + background: var(--bg-surface, #fff); + min-height: 0; +} +.graph-pane canvas { + display: block; + width: 100%; + height: 100%; + cursor: grab; +} +.graph-pane canvas:active { cursor: grabbing; } +.graph-toolbar { + position: absolute; + top: 12px; + right: 12px; + display: flex; + gap: 8px; + align-items: center; + z-index: 5; + font-size: 12px; + color: var(--text-2, #666); +} +.graph-toolbar label { + display: flex; + align-items: center; + gap: 4px; + cursor: pointer; + user-select: none; +} +.graph-toolbar input[type="checkbox"] { margin: 0; } +.graph-tooltip { + position: absolute; + pointer-events: none; + background: var(--bg-raised, #fff); + border: 1px solid var(--border, #ddd); + border-radius: var(--radius, 6px); + padding: 8px 12px; + box-shadow: 0 2px 8px rgba(0,0,0,0.12); + z-index: 10; + max-width: 220px; + font-size: 12px; +} +.graph-tooltip__title { + font-weight: 600; + font-size: 13px; + margin-bottom: 2px; +} +.graph-tooltip__tags { + display: flex; + gap: 4px; + flex-wrap: wrap; + margin-top: 4px; +} +.graph-tooltip__tags .tag-pill { + font-size: 10px; + padding: 1px 6px; + border-radius: 8px; + background: var(--bg-hover, #eee); + color: var(--text-2, #666); +} +.graph-tooltip__edges { + font-size: 11px; + color: var(--text-3, #999); + margin-top: 4px; +} +.graph-empty { + display: flex; + align-items: center; + justify-content: center; + height: 100%; + color: var(--text-3, #999); + font-size: 14px; +} + /* ── Responsive ──────────────────────────── */ @media (max-width: 700px) { .notes-sidebar { width: 100%; border-right: none; border-bottom: 1px solid var(--border); max-height: 40vh; } @@ -713,4 +791,5 @@ .folder-tree { max-height: 30vh; } .notes-editor__body--split { grid-template-columns: 1fr; } .notes-editor__body--split .notes-preview { display: none; } + .graph-pane { min-height: 50vh; } } diff --git a/packages/notes/js/main.js b/packages/notes/js/main.js index 67e4c64..c797ec6 100644 --- a/packages/notes/js/main.js +++ b/packages/notes/js/main.js @@ -1,5 +1,5 @@ /** - * Notes — Surface Entry Point (v0.7.0) + * Notes — Surface Entry Point (v0.8.0) * * Markdown notes surface using the SDK: * sw.api.ext('notes') — scoped API client @@ -1098,6 +1098,433 @@ } + // ═══════════════════════════════════════════ + // GraphPane — force-directed note graph + // ═══════════════════════════════════════════ + + var GRAPH_PALETTE = [ + '#4e79a7', '#f28e2b', '#e15759', '#76b7b2', '#59a14f', + '#edc948', '#b07aa1', '#ff9da7', '#9c755f', '#bab0ac' + ]; + + function GraphPane(props) { + var folders = props.folders || []; + var activeTag = props.activeTag; + var onSelectNote = props.onSelectNote; + var onFocusNote = props.onFocusNote; + + var [graphData, setGraphData] = useState(null); + var [loading, setLoading] = useState(true); + var [focusedIds, setFocusedIds] = useState(null); // Set or null + var [hideOrphans, setHideOrphans] = useState(false); + var [hoveredNode, setHoveredNode] = useState(null); + var [mousePos, setMousePos] = useState({ x: 0, y: 0 }); + + var canvasRef = useRef(null); + var simRef = useRef({ + nodes: [], edges: [], adj: {}, + positions: [], velocities: [], + offsetX: 0, offsetY: 0, scale: 1, + dragging: false, dragStartX: 0, dragStartY: 0, + converged: false, frameCount: 0 + }); + var animRef = useRef(null); + + // build folder→color map + var folderColorMap = useMemo(function() { + var map = { '': '#888' }; + folders.forEach(function(f, i) { + map[f.id] = GRAPH_PALETTE[i % GRAPH_PALETTE.length]; + }); + return map; + }, [folders]); + + // fetch graph data + useEffect(function() { + var cancelled = false; + (async function() { + try { + var res = await api.get('/graph'); + if (cancelled) return; + var nodes = res.nodes || []; + var edges = res.edges || []; + + // build adjacency map + var adj = {}; + nodes.forEach(function(n) { adj[n.id] = new Set(); }); + edges.forEach(function(e) { + if (adj[e.source]) adj[e.source].add(e.target); + if (adj[e.target]) adj[e.target].add(e.source); + }); + + // initial positions in a circle + var radius = Math.sqrt(nodes.length) * 50 + 50; + var positions = nodes.map(function(_, i) { + var angle = (2 * Math.PI * i) / (nodes.length || 1); + return { x: Math.cos(angle) * radius, y: Math.sin(angle) * radius }; + }); + var velocities = nodes.map(function() { return { x: 0, y: 0 }; }); + + var sim = simRef.current; + sim.nodes = nodes; + sim.edges = edges; + sim.adj = adj; + sim.positions = positions; + sim.velocities = velocities; + sim.converged = false; + sim.frameCount = 0; + + setGraphData({ nodes: nodes, edges: edges, adj: adj }); + } catch (e) { + console.error('Graph load failed:', e); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return function() { cancelled = true; }; + }, []); + + // force simulation + rendering loop + useEffect(function() { + var canvas = canvasRef.current; + if (!canvas || !graphData) return; + var ctx = canvas.getContext('2d'); + var sim = simRef.current; + // resolve CSS vars for canvas (which can't use var()) + var cs = getComputedStyle(canvas.parentElement); + var colorText = cs.getPropertyValue('--text').trim() || '#ccc'; + var colorText3 = cs.getPropertyValue('--text-3').trim() || '#999'; + var colorAccent = cs.getPropertyValue('--accent').trim() || '#4e79a7'; + + function resize() { + var rect = canvas.parentElement.getBoundingClientRect(); + var dpr = window.devicePixelRatio || 1; + canvas.width = rect.width * dpr; + canvas.height = rect.height * dpr; + canvas.style.width = rect.width + 'px'; + canvas.style.height = rect.height + 'px'; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + } + resize(); + var ro = new ResizeObserver(resize); + ro.observe(canvas.parentElement); + + function step() { + var nodes = sim.nodes; + var edges = sim.edges; + var pos = sim.positions; + var vel = sim.velocities; + var N = nodes.length; + + // force simulation (skip if converged) + if (!sim.converged && N > 0) { + // reset forces + var forces = []; + for (var i = 0; i < N; i++) forces.push({ x: 0, y: 0 }); + + // repulsion between all pairs + for (var i = 0; i < N; i++) { + for (var j = i + 1; j < N; j++) { + var dx = pos[i].x - pos[j].x; + var dy = pos[i].y - pos[j].y; + var distSq = dx * dx + dy * dy + 1; + var f = 5000 / distSq; + var fx = dx / Math.sqrt(distSq) * f; + var fy = dy / Math.sqrt(distSq) * f; + forces[i].x += fx; forces[i].y += fy; + forces[j].x -= fx; forces[j].y -= fy; + } + } + + // attraction along edges + for (var e = 0; e < edges.length; e++) { + var si = -1, ti = -1; + for (var k = 0; k < N; k++) { + if (nodes[k].id === edges[e].source) si = k; + if (nodes[k].id === edges[e].target) ti = k; + } + if (si < 0 || ti < 0) continue; + var dx = pos[ti].x - pos[si].x; + var dy = pos[ti].y - pos[si].y; + var dist = Math.sqrt(dx * dx + dy * dy) + 0.1; + var f = 0.02 * (dist - 100); + forces[si].x += (dx / dist) * f; + forces[si].y += (dy / dist) * f; + forces[ti].x -= (dx / dist) * f; + forces[ti].y -= (dy / dist) * f; + } + + // center gravity + for (var i = 0; i < N; i++) { + forces[i].x -= pos[i].x * 0.001; + forces[i].y -= pos[i].y * 0.001; + } + + // integrate + var maxV = 0; + for (var i = 0; i < N; i++) { + vel[i].x = (vel[i].x + forces[i].x) * 0.85; + vel[i].y = (vel[i].y + forces[i].y) * 0.85; + pos[i].x += vel[i].x; + pos[i].y += vel[i].y; + var v = Math.abs(vel[i].x) + Math.abs(vel[i].y); + if (v > maxV) maxV = v; + } + + sim.frameCount++; + if ((maxV < 0.5 && sim.frameCount > 50) || sim.frameCount > 500) { + sim.converged = true; + } + } + + // render + var w = canvas.width / (window.devicePixelRatio || 1); + var h = canvas.height / (window.devicePixelRatio || 1); + ctx.clearRect(0, 0, w, h); + ctx.save(); + ctx.translate(sim.offsetX + w / 2, sim.offsetY + h / 2); + ctx.scale(sim.scale, sim.scale); + + var focused = focusedIds; + var nodes = sim.nodes; + var edges = sim.edges; + var pos = sim.positions; + var adj = sim.adj; + + // build index lookup for edge drawing + var idxMap = {}; + for (var i = 0; i < nodes.length; i++) idxMap[nodes[i].id] = i; + + // filter orphans + var visibleSet = null; + if (hideOrphans) { + visibleSet = new Set(); + for (var i = 0; i < nodes.length; i++) { + if (adj[nodes[i].id] && adj[nodes[i].id].size > 0) visibleSet.add(nodes[i].id); + } + } + + // draw edges + for (var e = 0; e < edges.length; e++) { + var si = idxMap[edges[e].source]; + var ti = idxMap[edges[e].target]; + if (si == null || ti == null) continue; + if (visibleSet && (!visibleSet.has(edges[e].source) || !visibleSet.has(edges[e].target))) continue; + + var edgeFocused = !focused || (focused.has(edges[e].source) && focused.has(edges[e].target)); + ctx.globalAlpha = edgeFocused ? 0.4 : 0.06; + ctx.strokeStyle = edgeFocused && focused ? colorAccent : '#999'; + ctx.lineWidth = edgeFocused && focused ? 1.5 : 0.8; + ctx.beginPath(); + ctx.moveTo(pos[si].x, pos[si].y); + ctx.lineTo(pos[ti].x, pos[ti].y); + ctx.stroke(); + } + + // draw nodes + for (var i = 0; i < nodes.length; i++) { + var node = nodes[i]; + if (visibleSet && !visibleSet.has(node.id)) continue; + var edgeCount = adj[node.id] ? adj[node.id].size : 0; + var isOrphan = edgeCount === 0; + + // opacity based on focus + tag filter + var nodeFocused = !focused || focused.has(node.id); + var tagMatch = !activeTag || (node.tags && node.tags.indexOf(activeTag) !== -1); + ctx.globalAlpha = nodeFocused ? (tagMatch ? 1 : 0.3) : 0.15; + + var color = folderColorMap[node.folder_id] || '#888'; + var r = 8; + + // circle + ctx.beginPath(); + ctx.arc(pos[i].x, pos[i].y, r, 0, Math.PI * 2); + ctx.fillStyle = color; + ctx.fill(); + + if (isOrphan) { + ctx.setLineDash([3, 2]); + ctx.strokeStyle = color; + ctx.lineWidth = 1.5; + ctx.stroke(); + ctx.setLineDash([]); + } + + // label + ctx.fillStyle = nodeFocused && tagMatch ? colorText : colorText3; + ctx.font = '11px -apple-system, BlinkMacSystemFont, sans-serif'; + ctx.textAlign = 'center'; + var label = node.title.length > 20 ? node.title.slice(0, 18) + '…' : node.title; + ctx.fillText(label, pos[i].x, pos[i].y + r + 13); + } + + ctx.restore(); + ctx.globalAlpha = 1; + + animRef.current = requestAnimationFrame(step); + } + + animRef.current = requestAnimationFrame(step); + return function() { + cancelAnimationFrame(animRef.current); + ro.disconnect(); + }; + }, [graphData, focusedIds, hideOrphans, activeTag, folderColorMap]); + + // hit-test helper + function hitTest(ex, ey) { + var sim = simRef.current; + var canvas = canvasRef.current; + if (!canvas) return null; + var w = canvas.width / (window.devicePixelRatio || 1); + var h = canvas.height / (window.devicePixelRatio || 1); + var wx = (ex - sim.offsetX - w / 2) / sim.scale; + var wy = (ey - sim.offsetY - h / 2) / sim.scale; + var best = null, bestDist = (12 / sim.scale) * (12 / sim.scale); + for (var i = 0; i < sim.nodes.length; i++) { + var dx = sim.positions[i].x - wx; + var dy = sim.positions[i].y - wy; + var d = dx * dx + dy * dy; + if (d < bestDist) { bestDist = d; best = i; } + } + return best; + } + + function handleClick(e) { + var rect = canvasRef.current.getBoundingClientRect(); + var ex = e.clientX - rect.left; + var ey = e.clientY - rect.top; + var idx = hitTest(ex, ey); + if (idx === null) { + setFocusedIds(null); + return; + } + var node = simRef.current.nodes[idx]; + var adj = simRef.current.adj; + var neighborhood = new Set([node.id]); + if (adj[node.id]) adj[node.id].forEach(function(id) { neighborhood.add(id); }); + + if (e.shiftKey && focusedIds) { + // union with existing focus + var merged = new Set(focusedIds); + neighborhood.forEach(function(id) { merged.add(id); }); + setFocusedIds(merged); + } else { + setFocusedIds(neighborhood); + } + if (onFocusNote) onFocusNote(node.id); + } + + function handleDblClick(e) { + var rect = canvasRef.current.getBoundingClientRect(); + var idx = hitTest(e.clientX - rect.left, e.clientY - rect.top); + if (idx !== null && onSelectNote) { + onSelectNote(simRef.current.nodes[idx].id); + } + } + + function handleMouseMove(e) { + var rect = canvasRef.current.getBoundingClientRect(); + var ex = e.clientX - rect.left; + var ey = e.clientY - rect.top; + + // panning + if (simRef.current.dragging) { + simRef.current.offsetX += e.movementX; + simRef.current.offsetY += e.movementY; + return; + } + + var idx = hitTest(ex, ey); + if (idx !== null) { + setHoveredNode(simRef.current.nodes[idx]); + setMousePos({ x: ex, y: ey }); + } else { + setHoveredNode(null); + } + } + + function handleMouseDown(e) { + var rect = canvasRef.current.getBoundingClientRect(); + var idx = hitTest(e.clientX - rect.left, e.clientY - rect.top); + if (idx === null) { + simRef.current.dragging = true; + } + } + + function handleMouseUp() { + simRef.current.dragging = false; + } + + function handleWheel(e) { + e.preventDefault(); + var sim = simRef.current; + var canvas = canvasRef.current; + var rect = canvas.getBoundingClientRect(); + var mx = e.clientX - rect.left; + var my = e.clientY - rect.top; + var w = rect.width; + var h = rect.height; + + var zoomFactor = e.deltaY > 0 ? 0.9 : 1.1; + var newScale = Math.max(0.1, Math.min(5.0, sim.scale * zoomFactor)); + // zoom toward cursor (relative to center) + var cx = mx - w / 2; + var cy = my - h / 2; + sim.offsetX = cx - (cx - sim.offsetX) * (newScale / sim.scale); + sim.offsetY = cy - (cy - sim.offsetY) * (newScale / sim.scale); + sim.scale = newScale; + } + + // attach wheel with passive:false + useEffect(function() { + var canvas = canvasRef.current; + if (!canvas) return; + canvas.addEventListener('wheel', handleWheel, { passive: false }); + return function() { canvas.removeEventListener('wheel', handleWheel); }; + }, []); + + if (loading) { + return html`
<${Spinner} size="md" />
`; + } + if (!graphData || graphData.nodes.length === 0) { + return html`
No notes to graph. Create notes with [[wikilinks]] to see connections.
`; + } + + var adj = graphData.adj; + + return html` +
+
+ +
+ + ${hoveredNode && html` +
+
${hoveredNode.title}
+ ${hoveredNode.tags && hoveredNode.tags.length > 0 && html` +
+ ${hoveredNode.tags.map(function(t) { return html`${t}`; })} +
+ `} +
${(adj[hoveredNode.id] ? adj[hoveredNode.id].size : 0)} connections
+
+ `} +
+ `; + } + + // ═══════════════════════════════════════════ // NotesApp — root component // ═══════════════════════════════════════════ @@ -1117,6 +1544,7 @@ var [sidebarTab, setSidebarTab] = useState('notes'); var [sidebarHeadings, setSidebarHeadings] = useState([]); var [activeHeadingIdx, setActiveHeadingIdx] = useState(-1); + var [showGraph, setShowGraph] = useState(false); var scrollToHeadingRef = useRef(null); // ── Load note list ──────────────────────── @@ -1385,6 +1813,8 @@ <${Topbar} title="Notes"> <${Button} variant="primary" size="sm" onClick=${handleNew}>+ New Note <${Button} variant="secondary" size="sm" onClick=${handleImport}>Import .md + <${Button} variant=${showGraph ? 'primary' : 'secondary'} size="sm" + onClick=${function() { setShowGraph(!showGraph); }}>Graph
@@ -1425,16 +1855,22 @@ onScrollToHeading=${function(h) { if (scrollToHeadingRef.current) scrollToHeadingRef.current(h); }} /> `}
- <${EditorPane} note=${activeNote} folders=${folders} allTags=${allTags} - onSave=${handleRefresh} - onDelete=${handleDelete} - onRefresh=${handleRefresh} - onTagsChanged=${handleTagsChanged} - onNavigate=${handleSelect} - onNavigateToTitle=${handleNavigateToTitle} - onHeadingsChange=${setSidebarHeadings} - scrollToHeadingRef=${scrollToHeadingRef} - onActiveHeadingChange=${setActiveHeadingIdx} /> + ${showGraph + ? html`<${GraphPane} folders=${folders} allTags=${allTags} + activeTag=${activeTag} + onSelectNote=${function(id) { handleSelect(id); setShowGraph(false); }} + onFocusNote=${handleSelect} />` + : html`<${EditorPane} note=${activeNote} folders=${folders} allTags=${allTags} + onSave=${handleRefresh} + onDelete=${handleDelete} + onRefresh=${handleRefresh} + onTagsChanged=${handleTagsChanged} + onNavigate=${handleSelect} + onNavigateToTitle=${handleNavigateToTitle} + onHeadingsChange=${setSidebarHeadings} + scrollToHeadingRef=${scrollToHeadingRef} + onActiveHeadingChange=${setActiveHeadingIdx} />` + }
`; } diff --git a/packages/notes/manifest.json b/packages/notes/manifest.json index 010c216..2157820 100644 --- a/packages/notes/manifest.json +++ b/packages/notes/manifest.json @@ -6,9 +6,9 @@ "route": "/s/notes", "auth": "authenticated", "layout": "single", - "version": "0.7.0", + "version": "0.8.0", "icon": "📝", - "description": "Markdown notes surface with rich editor, folders, tags, backlinks, import/export, sidebar tabs, and document outline.", + "description": "Markdown notes surface with rich editor, folders, tags, backlinks, import/export, sidebar tabs, document outline, and note graph.", "author": "switchboard", "permissions": ["db.write"], @@ -21,6 +21,7 @@ {"method": "DELETE", "path": "/notes/*"}, {"method": "GET", "path": "/search"}, {"method": "GET", "path": "/stats"}, + {"method": "GET", "path": "/graph"}, {"method": "GET", "path": "/folders"}, {"method": "POST", "path": "/folders"}, {"method": "PUT", "path": "/folders/*"}, diff --git a/packages/notes/script.star b/packages/notes/script.star index f262f27..637eb15 100644 --- a/packages/notes/script.star +++ b/packages/notes/script.star @@ -133,6 +133,10 @@ def on_request(req): if method == "POST" and path == "/notes/move": return _move_note(req) + # GET /graph — full graph data for visualization + if method == "GET" and path == "/graph": + return _get_graph() + # ── Link routes ────────────────────────── # GET /links/:note_id — outgoing links if method == "GET" and path.startswith("/links/"): @@ -463,6 +467,44 @@ def _get_backlinks(note_id): return _resp(200, {"data": items}) +def _get_graph(): + """Return all non-archived notes as nodes and resolved links as edges.""" + all_notes = db.query("notes", filters={"archived": 0}, limit=5000) + notes = all_notes or [] + + # batch-fetch tags + note_ids = [n.get("id", "") for n in notes] + tags_map = _tags_for_notes(note_ids) + + # build nodes + id set for edge filtering + nodes = [] + note_id_set = {} + for n in notes: + nid = n.get("id", "") + note_id_set[nid] = True + nodes.append({ + "id": nid, + "title": n.get("title", "Untitled"), + "folder_id": _str(n.get("folder_id", "")), + "tags": tags_map.get(nid, []), + }) + + # fetch links, keep only resolved (both endpoints exist) + all_links = db.query("links", limit=10000) + edges = [] + for link in (all_links or []): + src = _str(link.get("source_id", "")) + tgt = _str(link.get("target_id", "")) + if src and tgt and src in note_id_set and tgt in note_id_set: + edges.append({ + "source": src, + "target": tgt, + "text": _str(link.get("link_text", "")), + }) + + return _resp(200, {"nodes": nodes, "edges": edges}) + + # ═══════════════════════════════════════════════ # Folder CRUD # ═══════════════════════════════════════════════