Feat v0.4.7 note graph (#29)
All checks were successful
CI/CD / detect-changes (push) Successful in 4s
CI/CD / test-frontend (push) Successful in 5s
CI/CD / test-go-pg (push) Successful in 2m33s
CI/CD / test-sqlite (push) Successful in 2m46s
CI/CD / build-and-deploy (push) Successful in 1m40s

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #29.
This commit is contained in:
2026-03-30 09:17:36 +00:00
committed by xcaliber
parent 50d991001d
commit eb9a2d7d27
7 changed files with 615 additions and 24 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -1 +1 @@
0.4.6
0.4.7

View File

@@ -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; }
}

View File

@@ -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`<div class="graph-pane"><div class="graph-empty"><${Spinner} size="md" /></div></div>`;
}
if (!graphData || graphData.nodes.length === 0) {
return html`<div class="graph-pane"><div class="graph-empty">No notes to graph. Create notes with [[wikilinks]] to see connections.</div></div>`;
}
var adj = graphData.adj;
return html`
<div class="graph-pane">
<div class="graph-toolbar">
<label>
<input type="checkbox" checked=${hideOrphans}
onChange=${function(e) { setHideOrphans(e.target.checked); }} />
Hide orphans
</label>
</div>
<canvas ref=${canvasRef}
onClick=${handleClick}
onDblClick=${handleDblClick}
onMouseMove=${handleMouseMove}
onMouseDown=${handleMouseDown}
onMouseUp=${handleMouseUp}
onMouseLeave=${handleMouseUp} />
${hoveredNode && html`
<div class="graph-tooltip" style=${{ left: (mousePos.x + 14) + 'px', top: (mousePos.y - 10) + 'px' }}>
<div class="graph-tooltip__title">${hoveredNode.title}</div>
${hoveredNode.tags && hoveredNode.tags.length > 0 && html`
<div class="graph-tooltip__tags">
${hoveredNode.tags.map(function(t) { return html`<span class="tag-pill" key=${t}>${t}</span>`; })}
</div>
`}
<div class="graph-tooltip__edges">${(adj[hoveredNode.id] ? adj[hoveredNode.id].size : 0)} connections</div>
</div>
`}
</div>
`;
}
// ═══════════════════════════════════════════
// 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<//>
<//>
<div class="notes-app">
<div class="notes-sidebar">
@@ -1425,16 +1855,22 @@
onScrollToHeading=${function(h) { if (scrollToHeadingRef.current) scrollToHeadingRef.current(h); }} />
`}
</div>
<${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} />`
}
</div>
`;
}

View File

@@ -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/*"},

View File

@@ -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
# ═══════════════════════════════════════════════