graph: interactive liveliness (drag/pan/zoom, unlinked toggle, label colors)
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 35s

Backend /api/graph now returns ALL non-trashed notes as nodes, each with its
first label's color; edges unchanged. GraphView reworked: drag a node to
reposition it (pins to cursor + reheats the sim), pan the background, wheel-
zoom toward the cursor, a "Show unlinked notes" toggle (connected-only by
default), a Reset view button, and nodes filled by label color. Click (a
press without a drag) still opens the note; re-fetches on view open.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
This commit is contained in:
2026-07-20 22:40:28 -04:00
co-authored by Claude Opus 4.8
parent 27b09e18e3
commit 894dd4ac42
3 changed files with 253 additions and 53 deletions
+28 -11
View File
@@ -6,6 +6,7 @@ from sqlalchemy.orm import aliased
from .auth import login_required
from .db import session_scope
from .models.label import Label, NoteLabel
from .models.note import Note
from .models.note_link import NoteLink
@@ -15,11 +16,13 @@ bp = Blueprint("graph", __name__, url_prefix="/api/graph")
@bp.get("")
@login_required
async def get_graph():
"""Wiki-link graph: nodes are the owner's connected notes, edges are resolved
[[links]] (note_links.target_norm matched to a note's normalized title)."""
"""Wiki-link graph. Nodes are ALL of the owner's non-trashed notes (the frontend
toggles whether to show unlinked ones); each carries its first label's color for
clustering. Edges are resolved [[links]] (note_links.target_norm matched to a
note's normalized title)."""
source = aliased(Note)
target = aliased(Note)
stmt = (
edge_stmt = (
select(source.id, target.id)
.select_from(NoteLink)
.join(source, source.id == NoteLink.source_id)
@@ -33,9 +36,8 @@ async def get_graph():
)
)
async with session_scope() as db:
rows = (await db.execute(stmt)).all()
rows = (await db.execute(edge_stmt)).all()
edges = []
node_ids: set = set()
seen: set = set()
for src_id, tgt_id in rows:
key = (src_id, tgt_id)
@@ -43,10 +45,25 @@ async def get_graph():
continue
seen.add(key)
edges.append({"source": str(src_id), "target": str(tgt_id)})
node_ids.add(src_id)
node_ids.add(tgt_id)
nodes = []
if node_ids:
note_rows = (await db.scalars(select(Note).where(Note.id.in_(node_ids)))).all()
nodes = [{"id": str(n.id), "title": n.title or "Untitled"} for n in note_rows]
# First label color per note (labels ordered by name) → node color.
color_rows = (
await db.execute(
select(NoteLabel.note_id, Label.color)
.join(Label, Label.id == NoteLabel.label_id)
.where(Label.owner_id == g.user_id)
.order_by(Label.name)
)
).all()
first_color: dict = {}
for note_id, color in color_rows:
first_color.setdefault(note_id, color)
note_rows = (
await db.scalars(select(Note).where(Note.owner_id == g.user_id, Note.deleted_at.is_(None)))
).all()
nodes = [
{"id": str(n.id), "title": n.title or "Untitled", "color": first_color.get(n.id, "default")}
for n in note_rows
]
return jsonify({"nodes": nodes, "edges": edges})