M3 graph view: /api/graph + force-directed SVG
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 5s
CI & Build / Python tests (push) Successful in 8s
CI & Build / Build & push image (push) Successful in 28s

- graph blueprint: GET /api/graph resolves note_links to target notes by
  normalized title (owner-scoped, non-trashed, self-excluded) → {nodes, edges}
  of connected notes.
- GraphView: hand-rolled force simulation (repulsion + edge springs + centering,
  cooling over ~400 frames), SVG nodes/edges, click a node to open it (reuses the
  editor with link navigation). Sidebar Graph entry + route; empty state.

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 08:05:45 -04:00
co-authored by Claude Opus 4.8
parent 2d72dcc7cb
commit ad006ccb58
7 changed files with 256 additions and 0 deletions
+2
View File
@@ -10,6 +10,7 @@ from . import __version__
from .auth import bp as auth_bp
from .config import Config
from .db import session_scope
from .graph import bp as graph_bp
from .labels import bp as labels_bp
from .notes import bp as notes_bp
from .settings import get_public_config, get_setting, load_or_create_secret_key
@@ -33,6 +34,7 @@ def create_app() -> Quart:
app.register_blueprint(auth_bp)
app.register_blueprint(notes_bp)
app.register_blueprint(labels_bp)
app.register_blueprint(graph_bp)
app.register_blueprint(settings_bp)
@app.before_serving
+52
View File
@@ -0,0 +1,52 @@
from __future__ import annotations
from quart import Blueprint, g, jsonify
from sqlalchemy import func, select
from sqlalchemy.orm import aliased
from .auth import login_required
from .db import session_scope
from .models.note import Note
from .models.note_link import NoteLink
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)."""
source = aliased(Note)
target = aliased(Note)
stmt = (
select(source.id, target.id)
.select_from(NoteLink)
.join(source, source.id == NoteLink.source_id)
.join(target, func.lower(func.trim(target.title)) == NoteLink.target_norm)
.where(
source.owner_id == g.user_id,
source.deleted_at.is_(None),
target.owner_id == g.user_id,
target.deleted_at.is_(None),
source.id != target.id,
)
)
async with session_scope() as db:
rows = (await db.execute(stmt)).all()
edges = []
node_ids: set = set()
seen: set = set()
for src_id, tgt_id in rows:
key = (src_id, tgt_id)
if key in seen:
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]
return jsonify({"nodes": nodes, "edges": edges})