diff --git a/frontend/src/components/AppShell.vue b/frontend/src/components/AppShell.vue index e040704..04acf1e 100644 --- a/frontend/src/components/AppShell.vue +++ b/frontend/src/components/AppShell.vue @@ -97,6 +97,9 @@ async function signOut() { Notes + + Graph +
Labels diff --git a/frontend/src/components/Icon.vue b/frontend/src/components/Icon.vue index 70778fa..bcad007 100644 --- a/frontend/src/components/Icon.vue +++ b/frontend/src/components/Icon.vue @@ -16,6 +16,7 @@ const paths: Record = { check: '', checkbox: '', image: '', + graph: '', }; diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 82f68f0..2933cb3 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -16,6 +16,7 @@ const router = createRouter({ { path: "trash", name: "trash", component: () => import("../views/BoardView.vue") }, { path: "label/:id", name: "label", component: () => import("../views/BoardView.vue") }, { path: "search", name: "search", component: () => import("../views/SearchView.vue") }, + { path: "graph", name: "graph", component: () => import("../views/GraphView.vue") }, ], }, { diff --git a/frontend/src/views/GraphView.vue b/frontend/src/views/GraphView.vue new file mode 100644 index 0000000..ffa6b6d --- /dev/null +++ b/frontend/src/views/GraphView.vue @@ -0,0 +1,191 @@ + + + diff --git a/src/thoughtsync/app.py b/src/thoughtsync/app.py index b48baf8..da5218d 100644 --- a/src/thoughtsync/app.py +++ b/src/thoughtsync/app.py @@ -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 diff --git a/src/thoughtsync/graph.py b/src/thoughtsync/graph.py new file mode 100644 index 0000000..bbe4f53 --- /dev/null +++ b/src/thoughtsync/graph.py @@ -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}) diff --git a/tests/test_notes.py b/tests/test_notes.py index bd07487..12591ae 100644 --- a/tests/test_notes.py +++ b/tests/test_notes.py @@ -90,3 +90,9 @@ async def test_titles_requires_auth(app): client = app.test_client() resp = await client.get("/api/notes/titles") assert resp.status_code == 401 + + +async def test_graph_requires_auth(app): + client = app.test_client() + resp = await client.get("/api/graph") + assert resp.status_code == 401