M3 graph view: /api/graph + force-directed SVG
- 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:
@@ -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
|
||||
|
||||
@@ -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})
|
||||
Reference in New Issue
Block a user