Force-directed graph view with tag nodes, project clustering, physics tuning

- New /graph route with D3 force simulation (GraphView.vue)
- Tag nodes as first-class graph nodes (string IDs "tag:name") — clicking
  navigates to /notes?tags=name; tags shown by default
- Invisible project hub nodes attract project members into clusters
- Physics panel with live sliders: repulsion, link distance, link strength,
  project pull, gravity (forceX/forceY, not forceCenter)
- Wikilink edges retain directed arrowheads; tag edges are thin, no arrowhead
- Graph nav link in AppHeader; `g` shortcut in App.vue
- Backend: build_note_graph() emits tag nodes + note→tag edges instead of
  O(n²) note→note shared-tag mesh

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-06 20:50:43 -05:00
parent b33aa25fb7
commit a8bb687349
7 changed files with 775 additions and 0 deletions
+2
View File
@@ -9,6 +9,7 @@
"preview": "vite preview"
},
"dependencies": {
"d3": "^7",
"@tiptap/extension-link": "^2.11.0",
"@tiptap/extension-placeholder": "^2.11.0",
"@tiptap/pm": "^2.11.0",
@@ -22,6 +23,7 @@
"vue-router": "^4.4.0"
},
"devDependencies": {
"@types/d3": "^7",
"@types/dompurify": "^3.0.0",
"@vitejs/plugin-vue": "^5.1.0",
"typescript": "~5.6.0",
+1
View File
@@ -79,6 +79,7 @@ function onGlobalKeydown(e: KeyboardEvent) {
case "t": router.push("/tasks"); break;
case "p": router.push("/projects"); break;
case "c": router.push("/chat"); break;
case "g": router.push("/graph"); break;
}
return;
}
+2
View File
@@ -88,6 +88,7 @@ onUnmounted(() => document.removeEventListener("click", handleClickOutside));
<router-link to="/projects" class="nav-link">Projects</router-link>
<router-link to="/tasks" class="nav-link">Tasks</router-link>
<router-link to="/chat" class="nav-link">Chat</router-link>
<router-link to="/graph" class="nav-link">Graph</router-link>
</div>
<!-- Right: status + utilities + gear + user -->
@@ -139,6 +140,7 @@ onUnmounted(() => document.removeEventListener("click", handleClickOutside));
<router-link to="/projects" class="nav-link">Projects</router-link>
<router-link to="/tasks" class="nav-link">Tasks</router-link>
<router-link to="/chat" class="nav-link">Chat</router-link>
<router-link to="/graph" class="nav-link">Graph</router-link>
<div class="mobile-divider"></div>
<router-link to="/settings" class="nav-link">Settings</router-link>
<router-link v-if="authStore.isAdmin" to="/admin/users" class="nav-link">Users</router-link>
+5
View File
@@ -60,6 +60,11 @@ const router = createRouter({
name: "note-edit",
component: () => import("@/views/NoteEditorView.vue"),
},
{
path: "/graph",
name: "graph",
component: () => import("@/views/GraphView.vue"),
},
{
path: "/projects",
name: "projects",
+672
View File
@@ -0,0 +1,672 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch, computed } from "vue";
import { useRouter } from "vue-router";
import { forceSimulation, forceLink, forceManyBody, forceX, forceY, forceCollide, SimulationNodeDatum, SimulationLinkDatum, Simulation } from "d3-force";
import { zoom, zoomIdentity } from "d3-zoom";
import { drag } from "d3-drag";
import { select } from "d3-selection";
import { apiGet } from "@/api/client";
const router = useRouter();
interface GraphNode extends SimulationNodeDatum {
id: number | string;
title: string;
type: "note" | "task" | "tag";
tags: string[];
project_id: number | null;
project_color: string | null;
// computed
degree?: number;
radius?: number;
}
interface GraphEdge {
source: number | string | GraphNode;
target: number | string | GraphNode;
type: "wikilink" | "tag";
}
interface Project {
id: number;
title: string;
color: string | null;
}
const svgRef = ref<SVGSVGElement | null>(null);
const containerRef = ref<HTMLElement | null>(null);
const loading = ref(false);
const error = ref<string | null>(null);
const nodes = ref<GraphNode[]>([]);
const edges = ref<GraphEdge[]>([]);
const projects = ref<Project[]>([]);
const projectFilter = ref<number | null>(null);
const showSharedTags = ref(true);
const hideOrphans = ref(false);
// Physics tuning knobs
const showPhysics = ref(false);
const repulsion = ref(110); // displayed as positive; applied as negative charge
const linkDist = ref(80);
const linkStrength = ref(0.50);
const hubPull = ref(0.05);
const gravity = ref(0.03); // forceX/Y strength — 0 = no pull, ~0.1 = strong
// tooltip state
const tooltip = ref<{ visible: boolean; x: number; y: number; node: GraphNode | null }>({
visible: false,
x: 0,
y: 0,
node: null,
});
let simulation: Simulation<GraphNode, undefined> | null = null;
let resizeObserver: ResizeObserver | null = null;
const statsText = computed(() => {
if (!nodes.value.length) return "";
const orphanCount = nodes.value.filter((n) => (n.degree ?? 0) === 0).length;
const visibleNodes = hideOrphans.value ? nodes.value.length - orphanCount : nodes.value.length;
return `${visibleNodes} nodes · ${edges.value.length} edges`;
});
async function fetchData() {
loading.value = true;
error.value = null;
try {
const params = new URLSearchParams();
if (projectFilter.value !== null) params.set("project_id", String(projectFilter.value));
if (showSharedTags.value) params.set("shared_tags", "true");
const qs = params.toString() ? "?" + params.toString() : "";
const data = await apiGet<{ nodes: GraphNode[]; edges: GraphEdge[] }>(`/api/notes/graph${qs}`);
nodes.value = data.nodes;
edges.value = data.edges;
} catch (e: any) {
error.value = e?.message ?? "Failed to load graph";
} finally {
loading.value = false;
}
}
async function fetchProjects() {
try {
const data = await apiGet<{ projects: Project[] }>("/api/projects");
projects.value = data.projects ?? [];
} catch {
// non-fatal
}
}
function initGraph() {
if (!svgRef.value || !containerRef.value) return;
const svg = select(svgRef.value);
svg.selectAll("*").remove();
const w = containerRef.value.clientWidth || 800;
const h = containerRef.value.clientHeight || 600;
// Compute degrees
const degreeMap = new Map<string, number>();
for (const n of nodes.value) degreeMap.set(String(n.id), 0);
for (const e of edges.value) {
const sid = String(typeof e.source === "object" ? (e.source as GraphNode).id : e.source);
const tid = String(typeof e.target === "object" ? (e.target as GraphNode).id : e.target);
degreeMap.set(sid, (degreeMap.get(sid) ?? 0) + 1);
degreeMap.set(tid, (degreeMap.get(tid) ?? 0) + 1);
}
const simNodes: GraphNode[] = nodes.value.map((n) => {
const deg = degreeMap.get(String(n.id)) ?? 0;
const isTag = n.type === "tag";
return {
...n,
degree: deg,
radius: isTag ? 7 : Math.max(6, Math.min(18, 6 + deg * 1.5)),
x: w / 2 + (Math.random() - 0.5) * 200,
y: h / 2 + (Math.random() - 0.5) * 200,
};
});
// Apply orphan filter (tag nodes always have edges so never filtered)
const visibleNodes = hideOrphans.value
? simNodes.filter((n) => (n.degree ?? 0) > 0)
: simNodes;
const visibleIds = new Set(visibleNodes.map((n) => String(n.id)));
const simEdges: SimulationLinkDatum<GraphNode>[] = edges.value
.filter((e) => {
const sid = String(typeof e.source === "object" ? (e.source as GraphNode).id : e.source);
const tid = String(typeof e.target === "object" ? (e.target as GraphNode).id : e.target);
return visibleIds.has(sid) && visibleIds.has(tid);
})
.map((e) => ({
source: String(typeof e.source === "object" ? (e.source as GraphNode).id : e.source),
target: String(typeof e.target === "object" ? (e.target as GraphNode).id : e.target),
type: (e as GraphEdge).type,
})) as SimulationLinkDatum<GraphNode>[] & { type?: string }[];
// Defs — arrowhead marker
const defs = svg.append("defs");
defs.append("marker")
.attr("id", "arrow")
.attr("viewBox", "0 -4 8 8")
.attr("refX", 8)
.attr("refY", 0)
.attr("markerWidth", 6)
.attr("markerHeight", 6)
.attr("orient", "auto")
.append("path")
.attr("d", "M0,-4L8,0L0,4")
.attr("fill", "var(--color-primary)")
.attr("opacity", "0.6");
// Zoom layer
const zoomG = svg.append("g").attr("class", "zoom-layer");
const zoomBehavior = zoom<SVGSVGElement, unknown>()
.scaleExtent([0.1, 8])
.on("zoom", (event) => {
zoomG.attr("transform", event.transform);
});
svg
.attr("width", w)
.attr("height", h)
.call(zoomBehavior)
.on("dblclick.zoom", null);
// Reset zoom to fit on initial load
svg.call(zoomBehavior.transform, zoomIdentity.translate(0, 0).scale(1));
// Edges
const linkSel = zoomG
.selectAll("line.graph-edge")
.data(simEdges)
.enter()
.append("line")
.attr("class", "graph-edge")
.attr("stroke", (d: any) =>
d.type === "wikilink" ? "var(--color-primary)" : "var(--color-text-muted)"
)
.attr("stroke-opacity", (d: any) => (d.type === "wikilink" ? 0.5 : 0.4))
.attr("stroke-dasharray", null)
.attr("stroke-width", (d: any) => (d.type === "wikilink" ? 1.5 : 1))
.attr("marker-end", (d: any) => (d.type === "wikilink" ? "url(#arrow)" : null));
// Node groups
const nodeSel = zoomG
.selectAll("g.graph-node")
.data(visibleNodes)
.enter()
.append("g")
.attr("class", "graph-node")
.style("cursor", "pointer")
.on("click", (_event: MouseEvent, d: GraphNode) => {
if (d.type === "tag") {
router.push(`/notes?tags=${encodeURIComponent(d.title)}`);
} else {
router.push(d.type === "task" ? `/tasks/${d.id}` : `/notes/${d.id}`);
}
})
.on("mouseover", (event: MouseEvent, d: GraphNode) => {
const rect = containerRef.value!.getBoundingClientRect();
tooltip.value = {
visible: true,
x: event.clientX - rect.left + 12,
y: event.clientY - rect.top - 8,
node: d,
};
})
.on("mousemove", (event: MouseEvent) => {
const rect = containerRef.value!.getBoundingClientRect();
tooltip.value.x = event.clientX - rect.left + 12;
tooltip.value.y = event.clientY - rect.top - 8;
})
.on("mouseout", () => {
tooltip.value.visible = false;
});
nodeSel
.append("circle")
.attr("r", (d: GraphNode) => d.radius ?? 8)
.attr("fill", (d: GraphNode) => {
if (d.type === "tag") return "color-mix(in srgb, var(--color-primary) 20%, transparent)";
return d.project_color ?? "var(--color-bg-secondary)";
})
.attr("stroke", (d: GraphNode) =>
d.type === "tag" ? "var(--color-primary)" : "var(--color-border)"
)
.attr("stroke-width", (d: GraphNode) => (d.type === "tag" ? 1.5 : 1.5))
.attr("stroke-dasharray", (d: GraphNode) => (d.type === "task" ? "3" : null))
.attr("opacity", (d: GraphNode) =>
!hideOrphans.value && (d.degree ?? 0) === 0 ? 0.35 : 1
);
nodeSel
.append("text")
.text((d: GraphNode) => {
const label = d.type === "tag" ? `#${d.title}` : d.title;
return label.length > 20 ? label.slice(0, 20) + "…" : label;
})
.attr("text-anchor", "middle")
.attr("dy", (d: GraphNode) => (d.radius ?? 8) + 12)
.attr("font-size", "10px")
.attr("fill", (d: GraphNode) =>
d.type === "tag" ? "var(--color-primary)" : "var(--color-text-secondary)"
)
.attr("pointer-events", "none");
// Drag
const dragBehavior = drag<SVGGElement, GraphNode>()
.on("start", (event, d) => {
if (!event.active) simulation?.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
})
.on("drag", (event, d) => {
d.fx = event.x;
d.fy = event.y;
})
.on("end", (event, d) => {
if (!event.active) simulation?.alphaTarget(0);
d.fx = null;
d.fy = null;
});
nodeSel.call(dragBehavior as any);
// Invisible project hub nodes — one per project, attract all member nodes inward
const projectHubs = new Map<number, GraphNode>();
for (const n of visibleNodes) {
if (n.project_id !== null && !projectHubs.has(n.project_id)) {
projectHubs.set(n.project_id, {
id: `hub:${n.project_id}`,
title: "",
type: "tag" as any, // unused — hubs are never rendered
tags: [],
project_id: n.project_id,
project_color: null,
degree: 0,
radius: 0,
x: w / 2 + (Math.random() - 0.5) * 100,
y: h / 2 + (Math.random() - 0.5) * 100,
});
}
}
const hubNodes = [...projectHubs.values()];
const allSimNodes = [...visibleNodes, ...hubNodes];
const hubEdges: SimulationLinkDatum<GraphNode>[] = [];
for (const n of visibleNodes) {
if (n.project_id !== null && projectHubs.has(n.project_id)) {
hubEdges.push({
source: String(n.id),
target: `hub:${n.project_id}`,
type: "hub",
} as any);
}
}
const allSimEdges = [...simEdges, ...hubEdges];
// Simulation
simulation = forceSimulation<GraphNode>(allSimNodes)
.force(
"link",
forceLink<GraphNode, SimulationLinkDatum<GraphNode>>(allSimEdges)
.id((d) => String(d.id))
.distance((d: any) => d.type === "hub" ? 55 : linkDist.value)
.strength((d: any) => d.type === "hub" ? hubPull.value : linkStrength.value)
)
.force("charge", forceManyBody<GraphNode>().strength((d: any) =>
String(d.id).startsWith("hub:") ? 0 : -repulsion.value
))
.force("gravityX", forceX<GraphNode>(w / 2).strength(gravity.value))
.force("gravityY", forceY<GraphNode>(h / 2).strength(gravity.value))
.force("collide", forceCollide<GraphNode>().radius((d) => (d.radius ?? 8) + 4))
.on("tick", () => {
linkSel
.attr("x1", (d: any) => (d.source as GraphNode).x ?? 0)
.attr("y1", (d: any) => (d.source as GraphNode).y ?? 0)
.attr("x2", (d: any) => {
const s = d.source as GraphNode;
const t = d.target as GraphNode;
const dx = (t.x ?? 0) - (s.x ?? 0);
const dy = (t.y ?? 0) - (s.y ?? 0);
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const r = (t.radius ?? 8) + 6;
return (t.x ?? 0) - (dx / dist) * r;
})
.attr("y2", (d: any) => {
const s = d.source as GraphNode;
const t = d.target as GraphNode;
const dx = (t.x ?? 0) - (s.x ?? 0);
const dy = (t.y ?? 0) - (s.y ?? 0);
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
const r = (t.radius ?? 8) + 6;
return (t.y ?? 0) - (dy / dist) * r;
});
nodeSel.attr("transform", (d: GraphNode) => `translate(${d.x ?? 0},${d.y ?? 0})`);
});
}
function applyPhysics() {
if (!simulation) return;
const r = repulsion.value;
(simulation.force("charge") as any)?.strength((d: any) =>
String(d.id).startsWith("hub:") ? 0 : -r
);
const lf = simulation.force("link") as any;
if (lf) {
lf.distance((d: any) => d.type === "hub" ? 55 : linkDist.value)
.strength((d: any) => d.type === "hub" ? hubPull.value : linkStrength.value);
}
(simulation.force("gravityX") as any)?.strength(gravity.value);
(simulation.force("gravityY") as any)?.strength(gravity.value);
simulation.alpha(0.3).restart();
}
watch([nodes, edges, hideOrphans], () => {
initGraph();
});
watch([repulsion, linkDist, linkStrength, hubPull, gravity], () => {
applyPhysics();
});
watch([projectFilter, showSharedTags], () => {
fetchData();
});
function handleResize() {
initGraph();
}
onMounted(async () => {
await Promise.all([fetchData(), fetchProjects()]);
initGraph();
resizeObserver = new ResizeObserver(handleResize);
if (containerRef.value) resizeObserver.observe(containerRef.value);
});
onUnmounted(() => {
simulation?.stop();
resizeObserver?.disconnect();
});
</script>
<template>
<main class="graph-page">
<div class="graph-controls">
<select
v-model="projectFilter"
class="graph-select"
aria-label="Filter by project"
>
<option :value="null">All projects</option>
<option v-for="p in projects" :key="p.id" :value="p.id">{{ p.title }}</option>
</select>
<label class="graph-toggle">
<input type="checkbox" v-model="showSharedTags" />
Show tags
</label>
<label class="graph-toggle">
<input type="checkbox" v-model="hideOrphans" />
Hide orphans
</label>
<button class="graph-physics-btn" :class="{ active: showPhysics }" @click="showPhysics = !showPhysics">
Physics
</button>
<span v-if="statsText" class="graph-stats">{{ statsText }}</span>
</div>
<div v-if="showPhysics" class="graph-physics-panel">
<label class="physics-knob">
<span class="knob-label">Repulsion <em>{{ repulsion }}</em></span>
<input type="range" v-model.number="repulsion" min="10" max="400" step="5" />
</label>
<label class="physics-knob">
<span class="knob-label">Link distance <em>{{ linkDist }}</em></span>
<input type="range" v-model.number="linkDist" min="20" max="200" step="5" />
</label>
<label class="physics-knob">
<span class="knob-label">Link strength <em>{{ linkStrength.toFixed(2) }}</em></span>
<input type="range" v-model.number="linkStrength" min="0.05" max="1" step="0.05" />
</label>
<label class="physics-knob">
<span class="knob-label">Project pull <em>{{ hubPull.toFixed(2) }}</em></span>
<input type="range" v-model.number="hubPull" min="0" max="1" step="0.05" />
</label>
<label class="physics-knob">
<span class="knob-label">Gravity <em>{{ gravity.toFixed(2) }}</em></span>
<input type="range" v-model.number="gravity" min="0" max="0.2" step="0.01" />
</label>
</div>
<div class="graph-wrap" ref="containerRef">
<svg ref="svgRef" class="graph-svg" />
<div v-if="loading" class="graph-overlay graph-loading">
<span class="spinner"></span>
</div>
<div v-else-if="error" class="graph-overlay graph-empty">
<p>{{ error }}</p>
</div>
<div v-else-if="!loading && nodes.length === 0" class="graph-overlay graph-empty">
<p>No notes to display. Create some notes to see the graph.</p>
</div>
<div
v-if="tooltip.visible && tooltip.node"
class="graph-tooltip"
:style="{ left: tooltip.x + 'px', top: tooltip.y + 'px' }"
>
<div class="tooltip-title">{{ tooltip.node.type === 'tag' ? '#' + tooltip.node.title : tooltip.node.title }}</div>
<div v-if="tooltip.node.tags.length" class="tooltip-tags">
<span v-for="tag in tooltip.node.tags" :key="tag" class="tag-chip">{{ tag }}</span>
</div>
<div class="tooltip-meta">{{ tooltip.node.type === 'tag' ? 'tag' : tooltip.node.type }}</div>
</div>
</div>
</main>
</template>
<style scoped>
.graph-page {
display: flex;
flex-direction: column;
height: calc(100vh - var(--header-height, 52px));
overflow: hidden;
}
.graph-controls {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.6rem 1rem;
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
flex-wrap: wrap;
}
.graph-select {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text);
font-size: 0.875rem;
padding: 0.3rem 0.6rem;
cursor: pointer;
font-family: inherit;
}
.graph-toggle {
display: flex;
align-items: center;
gap: 0.35rem;
font-size: 0.875rem;
color: var(--color-text-secondary);
cursor: pointer;
user-select: none;
}
.graph-toggle input {
cursor: pointer;
accent-color: var(--color-primary);
}
.graph-stats {
margin-left: auto;
font-size: 0.8rem;
color: var(--color-text-muted);
}
.graph-physics-btn {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text-secondary);
font-size: 0.875rem;
font-family: inherit;
padding: 0.3rem 0.7rem;
cursor: pointer;
transition: border-color 0.15s, color 0.15s;
}
.graph-physics-btn:hover {
border-color: var(--color-primary);
color: var(--color-text);
}
.graph-physics-btn.active {
border-color: var(--color-primary);
color: var(--color-primary);
}
.graph-physics-panel {
display: flex;
flex-wrap: wrap;
gap: 0.5rem 1.5rem;
padding: 0.6rem 1rem;
background: var(--color-bg-secondary);
border-bottom: 1px solid var(--color-border);
flex-shrink: 0;
}
.physics-knob {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-width: 140px;
flex: 1;
max-width: 220px;
}
.knob-label {
font-size: 0.75rem;
color: var(--color-text-secondary);
display: flex;
justify-content: space-between;
}
.knob-label em {
font-style: normal;
color: var(--color-text-muted);
font-variant-numeric: tabular-nums;
}
.physics-knob input[type="range"] {
width: 100%;
accent-color: var(--color-primary);
cursor: pointer;
}
.graph-wrap {
flex: 1;
position: relative;
overflow: hidden;
background: var(--color-bg);
}
.graph-svg {
width: 100%;
height: 100%;
display: block;
}
.graph-overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.graph-empty p {
color: var(--color-text-muted);
font-size: 0.95rem;
}
.spinner {
width: 28px;
height: 28px;
border: 3px solid var(--color-border);
border-top-color: var(--color-primary);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.graph-tooltip {
position: absolute;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: 0 4px 16px var(--color-shadow, rgba(0, 0, 0, 0.15));
padding: 0.5rem 0.75rem;
pointer-events: none;
z-index: 10;
max-width: 240px;
}
.tooltip-title {
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text);
margin-bottom: 0.25rem;
}
.tooltip-tags {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
margin-bottom: 0.25rem;
}
.tag-chip {
font-size: 0.7rem;
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
color: var(--color-primary);
border-radius: 999px;
padding: 0.1rem 0.4rem;
}
.tooltip-meta {
font-size: 0.75rem;
color: var(--color-text-muted);
text-transform: capitalize;
}
</style>
+13
View File
@@ -17,6 +17,7 @@ from fabledassistant.services.generation_buffer import (
)
from fabledassistant.services.generation_task import run_assist_generation
from fabledassistant.services.notes import (
build_note_graph,
convert_note_to_task,
convert_task_to_note,
create_note,
@@ -510,3 +511,15 @@ async def get_version_route(note_id: int, version_id: int):
if version is None:
return not_found("Version")
return jsonify(version.to_dict(include_body=True))
# ── Graph route ────────────────────────────────────────────────────────────────
@notes_bp.route("/graph", methods=["GET"])
@login_required
async def graph_route():
uid = get_current_user_id()
project_id = request.args.get("project_id", type=int)
shared_tags = request.args.get("shared_tags", "false").lower() == "true"
graph = await build_note_graph(uid, project_id=project_id, include_shared_tags=shared_tags)
return jsonify(graph)
+80
View File
@@ -1,4 +1,5 @@
import logging
import re
from datetime import date, datetime, timezone
from sqlalchemy import func, or_, select, text
@@ -356,3 +357,82 @@ async def get_backlinks(user_id: int, note_id: int) -> list[dict]:
backlinks.append({"type": link_type, "id": row[0], "title": row[1]})
return backlinks
_WIKILINK_RE = re.compile(r'\[\[([^\]|]+)(?:\|[^\]]+)?\]\]')
async def build_note_graph(
user_id: int,
project_id: int | None = None,
include_shared_tags: bool = False,
) -> dict:
notes, _ = await list_notes(user_id, project_id=project_id, limit=1000)
if not notes:
return {"nodes": [], "edges": []}
# Fetch project colours
project_ids = {n.project_id for n in notes if n.project_id is not None}
project_colors: dict[int, str] = {}
if project_ids:
from fabledassistant.models.project import Project
async with async_session() as session:
result = await session.execute(
select(Project.id, Project.color).where(Project.id.in_(project_ids))
)
for pid, color in result.fetchall():
project_colors[pid] = color or "#888888"
# Build title lookup (lowercase → id)
title_map: dict[str, int] = {}
for n in notes:
if n.title:
title_map[n.title.lower()] = n.id
# Build nodes
nodes = []
for n in notes:
nodes.append({
"id": n.id,
"title": n.title or "(untitled)",
"type": "task" if n.is_task else "note",
"tags": n.tags or [],
"project_id": n.project_id,
"project_color": project_colors.get(n.project_id) if n.project_id else None,
})
# Build wikilink edges
edge_set: set[tuple[int, int]] = set()
edges = []
for n in notes:
if not n.body:
continue
for match in _WIKILINK_RE.findall(n.body):
target_id = title_map.get(match.strip().lower())
if target_id is not None and target_id != n.id:
pair = (n.id, target_id)
if pair not in edge_set:
edge_set.add(pair)
edges.append({"source": n.id, "target": target_id, "type": "wikilink"})
# Build tag nodes + note→tag edges
if include_shared_tags:
tag_to_ids: dict[str, list[int]] = {}
for n in notes:
for tag in (n.tags or []):
tag_to_ids.setdefault(tag, []).append(n.id)
for tag, note_ids in tag_to_ids.items():
tag_node_id = f"tag:{tag}"
nodes.append({
"id": tag_node_id,
"title": tag,
"type": "tag",
"tags": [],
"project_id": None,
"project_color": None,
})
for nid in note_ids:
edges.append({"source": nid, "target": tag_node_id, "type": "tag"})
return {"nodes": nodes, "edges": edges}