from __future__ import annotations from quart import Blueprint, g, jsonify from sqlalchemy import and_, func, or_, select 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 bp = Blueprint("graph", __name__, url_prefix="/api/graph") @bp.get("") @login_required async def get_graph(): """Spatial view of the owner's non-trashed notes. Nodes are two kinds: - notes (kind="note") — every non-trashed note; each carries its first label's color for tinting. - labels (kind="label", id "label:") — every label actually attached to a live note, acting as a clustering HUB so tagged notes gravitate together even without wiki-links between them. Edges are two kinds: - wiki-links (kind="link") — resolved [[links]] (note_links.target_norm matched to a note's normalized display_title). - membership (kind="label") — each note → each of its label hubs. The frontend toggles labels + unlinked notes; the graph is a light auxiliary lens, not a focal surface. """ source = aliased(Note) target = aliased(Note) # A link joins on the note it was BOUND to, and only falls back to matching by # name where it was never bound — a forward link written before its target # existed. Name-matching alone is what used to make a rename break the graph. edge_stmt = ( select(source.id, target.id) .select_from(NoteLink) .join(source, source.id == NoteLink.source_id) .join( target, or_( target.id == NoteLink.target_id, and_( NoteLink.target_id.is_(None), func.lower(func.trim(target.display_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(edge_stmt)).all() edges = [] 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), "kind": "link"}) # Note ↔ label membership: one row per (note, label) for the owner's # non-trashed notes. Drives both the note-color tint (first label by name) # and the label-hub nodes + membership edges. label_rows = ( await db.execute( select(NoteLabel.note_id, Label.id, Label.name, Label.color) .join(Label, Label.id == NoteLabel.label_id) .join(Note, Note.id == NoteLabel.note_id) .where( Label.owner_id == g.user_id, Note.owner_id == g.user_id, Note.deleted_at.is_(None), ) .order_by(Label.name) ) ).all() first_color: dict = {} label_nodes: dict = {} for note_id, label_id, label_name, label_color in label_rows: first_color.setdefault(note_id, label_color) hub_id = f"label:{label_id}" if hub_id not in label_nodes: label_nodes[hub_id] = { "id": hub_id, "title": f"#{label_name}", "color": label_color or "default", "kind": "label", "label_id": str(label_id), } edges.append({"source": str(note_id), "target": hub_id, "kind": "label"}) 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.display_title or "Untitled", "color": first_color.get(n.id, "default"), "kind": "note", } for n in note_rows ] nodes.extend(label_nodes.values()) return jsonify({"nodes": nodes, "edges": edges})