diff --git a/frontend/package.json b/frontend/package.json index 34f3a94..9da5f6f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/App.vue b/frontend/src/App.vue index b9b9cfe..093b35a 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -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; } diff --git a/frontend/src/components/AppHeader.vue b/frontend/src/components/AppHeader.vue index bed1775..2f8fc03 100644 --- a/frontend/src/components/AppHeader.vue +++ b/frontend/src/components/AppHeader.vue @@ -88,6 +88,7 @@ onUnmounted(() => document.removeEventListener("click", handleClickOutside)); Projects Tasks Chat + Graph @@ -139,6 +140,7 @@ onUnmounted(() => document.removeEventListener("click", handleClickOutside)); Projects Tasks Chat + Graph
Settings Users diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 80ec252..4b672ed 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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", diff --git a/frontend/src/views/GraphView.vue b/frontend/src/views/GraphView.vue new file mode 100644 index 0000000..e5226f7 --- /dev/null +++ b/frontend/src/views/GraphView.vue @@ -0,0 +1,672 @@ + + + + + diff --git a/src/fabledassistant/routes/notes.py b/src/fabledassistant/routes/notes.py index 926da78..77b30e6 100644 --- a/src/fabledassistant/routes/notes.py +++ b/src/fabledassistant/routes/notes.py @@ -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) diff --git a/src/fabledassistant/services/notes.py b/src/fabledassistant/services/notes.py index 3c91f4b..8221112 100644 --- a/src/fabledassistant/services/notes.py +++ b/src/fabledassistant/services/notes.py @@ -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}