Add labels as first-class nodes in the graph so tags act as visual clustering hubs, and let unlinked notes float in the space by default — per operator request (the graph is a light auxiliary lens, not a focal surface). Backend (graph.py): - Emit a label-hub node (id "label:<uuid>", kind "label", #name, label color) for every label attached to a live note. - Emit note -> label membership edges (kind "label") alongside the existing wiki-link edges (now kind "link"). Frontend (GraphView.vue): - Render hubs as larger ringed nodes; membership edges dashed with a slightly longer spring rest so notes ring their hub. - Default showAll (unlinked notes float) to true; add a Show labels toggle (default on) that hides hubs + membership edges. - Click a label hub -> that label's board lens (/label/<id>); note clicks still open the editor. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FRgehjoz7Yv8LkUfADxACm
106 lines
3.9 KiB
Python
106 lines
3.9 KiB
Python
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.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:<uuid>") — 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)
|
|
edge_stmt = (
|
|
select(source.id, target.id)
|
|
.select_from(NoteLink)
|
|
.join(source, source.id == NoteLink.source_id)
|
|
.join(target, 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})
|