graph: interactive liveliness (drag/pan/zoom, unlinked toggle, label colors)
Backend /api/graph now returns ALL non-trashed notes as nodes, each with its first label's color; edges unchanged. GraphView reworked: drag a node to reposition it (pins to cursor + reheats the sim), pan the background, wheel- zoom toward the cursor, a "Show unlinked notes" toggle (connected-only by default), a Reset view button, and nodes filled by label color. Click (a press without a drag) still opens the note; re-fetches on view open. 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:
@@ -57,6 +57,21 @@ export const LABEL_CHIP_CLASSES: Record<NoteColor, string> = {
|
|||||||
gray: "bg-neutral-200 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-200",
|
gray: "bg-neutral-200 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-200",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Solid fills for graph nodes (SVG needs concrete colors, not Tailwind bg classes).
|
||||||
|
// Mid-tone hues read on both the light and dark graph background.
|
||||||
|
export const NOTE_NODE_FILL: Record<NoteColor, string> = {
|
||||||
|
default: "#9ca3af",
|
||||||
|
red: "#ef4444",
|
||||||
|
orange: "#f97316",
|
||||||
|
yellow: "#f59e0b",
|
||||||
|
green: "#22c55e",
|
||||||
|
teal: "#14b8a6",
|
||||||
|
blue: "#3b82f6",
|
||||||
|
purple: "#a855f7",
|
||||||
|
pink: "#ec4899",
|
||||||
|
gray: "#6b7280",
|
||||||
|
};
|
||||||
|
|
||||||
export const NOTE_COLOR_LABELS: Record<NoteColor, string> = {
|
export const NOTE_COLOR_LABELS: Record<NoteColor, string> = {
|
||||||
default: "Default",
|
default: "Default",
|
||||||
red: "Red",
|
red: "Red",
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||||
import { api } from "../api/client";
|
import { api } from "../api/client";
|
||||||
import { useNotesStore, type Note } from "../stores/notes";
|
import { useNotesStore, type Note } from "../stores/notes";
|
||||||
|
import { NOTE_NODE_FILL, type NoteColor } from "../notes/colors";
|
||||||
import NoteEditor from "../components/NoteEditor.vue";
|
import NoteEditor from "../components/NoteEditor.vue";
|
||||||
|
|
||||||
interface GNode {
|
interface GNode {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
|
color: string;
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
vx: number;
|
vx: number;
|
||||||
@@ -21,26 +23,69 @@ const WIDTH = 1000;
|
|||||||
const HEIGHT = 700;
|
const HEIGHT = 700;
|
||||||
|
|
||||||
const notes = useNotesStore();
|
const notes = useNotesStore();
|
||||||
const nodes = ref<GNode[]>([]);
|
const allNodes = ref<GNode[]>([]);
|
||||||
const edges = ref<GEdge[]>([]);
|
const edges = ref<GEdge[]>([]);
|
||||||
const loading = ref(true);
|
const loading = ref(true);
|
||||||
const editing = ref<Note | null>(null);
|
const editing = ref<Note | null>(null);
|
||||||
|
const showAll = ref(false);
|
||||||
|
|
||||||
|
const svgRef = ref<SVGSVGElement | null>(null);
|
||||||
|
const gRef = ref<SVGGElement | null>(null);
|
||||||
|
|
||||||
|
// Pan/zoom applied to the inner <g>.
|
||||||
|
const scale = ref(1);
|
||||||
|
const tx = ref(0);
|
||||||
|
const ty = ref(0);
|
||||||
|
|
||||||
let frame = 0;
|
let frame = 0;
|
||||||
let raf = 0;
|
let raf = 0;
|
||||||
|
|
||||||
|
const connectedIds = computed(() => {
|
||||||
|
const s = new Set<string>();
|
||||||
|
for (const e of edges.value) {
|
||||||
|
s.add(e.source);
|
||||||
|
s.add(e.target);
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Connected notes only by default; the toggle adds unlinked ones.
|
||||||
|
const activeNodes = computed(() =>
|
||||||
|
showAll.value ? allNodes.value : allNodes.value.filter((n) => connectedIds.value.has(n.id)),
|
||||||
|
);
|
||||||
|
const activeIds = computed(() => new Set(activeNodes.value.map((n) => n.id)));
|
||||||
|
|
||||||
|
const edgeLines = computed(() => {
|
||||||
|
const byId = new Map(allNodes.value.map((n) => [n.id, n]));
|
||||||
|
const out: { x1: number; y1: number; x2: number; y2: number }[] = [];
|
||||||
|
for (const e of edges.value) {
|
||||||
|
if (!activeIds.value.has(e.source) || !activeIds.value.has(e.target)) continue;
|
||||||
|
const s = byId.get(e.source);
|
||||||
|
const t = byId.get(e.target);
|
||||||
|
if (s && t) out.push({ x1: s.x, y1: s.y, x2: t.x, y2: t.y });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
});
|
||||||
|
|
||||||
|
function fill(color: string): string {
|
||||||
|
return NOTE_NODE_FILL[color as NoteColor] ?? NOTE_NODE_FILL.default;
|
||||||
|
}
|
||||||
|
|
||||||
async function loadGraph() {
|
async function loadGraph() {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const res = await api.get<{ nodes: { id: string; title: string }[]; edges: GEdge[] }>("/api/graph");
|
const res = await api.get<{ nodes: { id: string; title: string; color: string }[]; edges: GEdge[] }>(
|
||||||
|
"/api/graph",
|
||||||
|
);
|
||||||
const cx = WIDTH / 2;
|
const cx = WIDTH / 2;
|
||||||
const cy = HEIGHT / 2;
|
const cy = HEIGHT / 2;
|
||||||
const count = Math.max(res.nodes.length, 1);
|
const count = Math.max(res.nodes.length, 1);
|
||||||
nodes.value = res.nodes.map((n, i) => {
|
allNodes.value = res.nodes.map((n, i) => {
|
||||||
const angle = (i / count) * Math.PI * 2;
|
const angle = (i / count) * Math.PI * 2;
|
||||||
return {
|
return {
|
||||||
id: n.id,
|
id: n.id,
|
||||||
title: n.title,
|
title: n.title,
|
||||||
|
color: n.color,
|
||||||
x: cx + Math.cos(angle) * 220 + (Math.random() - 0.5) * 40,
|
x: cx + Math.cos(angle) * 220 + (Math.random() - 0.5) * 40,
|
||||||
y: cy + Math.sin(angle) * 220 + (Math.random() - 0.5) * 40,
|
y: cy + Math.sin(angle) * 220 + (Math.random() - 0.5) * 40,
|
||||||
vx: 0,
|
vx: 0,
|
||||||
@@ -51,13 +96,18 @@ async function loadGraph() {
|
|||||||
} finally {
|
} finally {
|
||||||
loading.value = false;
|
loading.value = false;
|
||||||
}
|
}
|
||||||
|
reheat();
|
||||||
|
}
|
||||||
|
|
||||||
|
function reheat() {
|
||||||
frame = 0;
|
frame = 0;
|
||||||
cancelAnimationFrame(raf);
|
cancelAnimationFrame(raf);
|
||||||
if (nodes.value.length) simulate();
|
raf = 0;
|
||||||
|
if (activeNodes.value.length) simulate();
|
||||||
}
|
}
|
||||||
|
|
||||||
function simulate() {
|
function simulate() {
|
||||||
const list = nodes.value;
|
const list = activeNodes.value;
|
||||||
const cx = WIDTH / 2;
|
const cx = WIDTH / 2;
|
||||||
const cy = HEIGHT / 2;
|
const cy = HEIGHT / 2;
|
||||||
const byId = new Map(list.map((n) => [n.id, n]));
|
const byId = new Map(list.map((n) => [n.id, n]));
|
||||||
@@ -102,6 +152,11 @@ function simulate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const n of list) {
|
for (const n of list) {
|
||||||
|
if (n === dragNode) {
|
||||||
|
n.vx = 0;
|
||||||
|
n.vy = 0;
|
||||||
|
continue; // pinned to the cursor while dragging
|
||||||
|
}
|
||||||
n.vx += (cx - n.x) * 0.002;
|
n.vx += (cx - n.x) * 0.002;
|
||||||
n.vy += (cy - n.y) * 0.002;
|
n.vy += (cy - n.y) * 0.002;
|
||||||
n.vx *= 0.85;
|
n.vx *= 0.85;
|
||||||
@@ -111,19 +166,87 @@ function simulate() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
frame++;
|
frame++;
|
||||||
if (frame < 400) raf = requestAnimationFrame(simulate);
|
// Keep running while cooling, or indefinitely while a node is being dragged.
|
||||||
|
raf = frame < 400 || dragNode ? requestAnimationFrame(simulate) : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
const edgeLines = computed(() => {
|
// --- pointer interaction: drag a node, pan the background, wheel-zoom ---
|
||||||
const byId = new Map(nodes.value.map((n) => [n.id, n]));
|
let dragNode: GNode | null = null;
|
||||||
const out: { x1: number; y1: number; x2: number; y2: number }[] = [];
|
let dragMoved = false;
|
||||||
for (const e of edges.value) {
|
let downPos = { x: 0, y: 0 };
|
||||||
const s = byId.get(e.source);
|
let panning = false;
|
||||||
const t = byId.get(e.target);
|
let panLast = { x: 0, y: 0 };
|
||||||
if (s && t) out.push({ x1: s.x, y1: s.y, x2: t.x, y2: t.y });
|
|
||||||
|
function toLocal(el: SVGGraphicsElement | null, e: MouseEvent) {
|
||||||
|
const ctm = el?.getScreenCTM();
|
||||||
|
if (!ctm) return { x: 0, y: 0 };
|
||||||
|
const p = new DOMPoint(e.clientX, e.clientY).matrixTransform(ctm.inverse());
|
||||||
|
return { x: p.x, y: p.y };
|
||||||
|
}
|
||||||
|
|
||||||
|
function onNodeDown(n: GNode, e: MouseEvent) {
|
||||||
|
e.stopPropagation();
|
||||||
|
dragNode = n;
|
||||||
|
dragMoved = false;
|
||||||
|
downPos = { x: e.clientX, y: e.clientY };
|
||||||
|
reheat();
|
||||||
|
window.addEventListener("mousemove", onMove);
|
||||||
|
window.addEventListener("mouseup", onUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onBgDown(e: MouseEvent) {
|
||||||
|
panning = true;
|
||||||
|
panLast = toLocal(svgRef.value, e);
|
||||||
|
window.addEventListener("mousemove", onMove);
|
||||||
|
window.addEventListener("mouseup", onUp);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onMove(e: MouseEvent) {
|
||||||
|
if (dragNode) {
|
||||||
|
if (Math.hypot(e.clientX - downPos.x, e.clientY - downPos.y) > 3) dragMoved = true;
|
||||||
|
const p = toLocal(gRef.value, e);
|
||||||
|
dragNode.x = p.x;
|
||||||
|
dragNode.y = p.y;
|
||||||
|
} else if (panning) {
|
||||||
|
const p = toLocal(svgRef.value, e);
|
||||||
|
tx.value += p.x - panLast.x;
|
||||||
|
ty.value += p.y - panLast.y;
|
||||||
|
panLast = p;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUp() {
|
||||||
|
window.removeEventListener("mousemove", onMove);
|
||||||
|
window.removeEventListener("mouseup", onUp);
|
||||||
|
const node = dragNode;
|
||||||
|
const moved = dragMoved;
|
||||||
|
dragNode = null;
|
||||||
|
panning = false;
|
||||||
|
// A press without a drag is a click → open the note.
|
||||||
|
if (node && !moved) void openNode(node);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onWheel(e: WheelEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
const vb = toLocal(svgRef.value, e);
|
||||||
|
const gx = (vb.x - tx.value) / scale.value;
|
||||||
|
const gy = (vb.y - ty.value) / scale.value;
|
||||||
|
const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
|
||||||
|
scale.value = Math.min(Math.max(scale.value * factor, 0.3), 3);
|
||||||
|
tx.value = vb.x - gx * scale.value;
|
||||||
|
ty.value = vb.y - gy * scale.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetView() {
|
||||||
|
scale.value = 1;
|
||||||
|
tx.value = 0;
|
||||||
|
ty.value = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAll() {
|
||||||
|
showAll.value = !showAll.value;
|
||||||
|
reheat();
|
||||||
}
|
}
|
||||||
return out;
|
|
||||||
});
|
|
||||||
|
|
||||||
async function openNode(n: GNode) {
|
async function openNode(n: GNode) {
|
||||||
const found = notes.items.find((x) => x.id === n.id);
|
const found = notes.items.find((x) => x.id === n.id);
|
||||||
@@ -138,20 +261,49 @@ async function onNavigate(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(loadGraph);
|
onMounted(loadGraph);
|
||||||
onBeforeUnmount(() => cancelAnimationFrame(raf));
|
onBeforeUnmount(() => {
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
window.removeEventListener("mousemove", onMove);
|
||||||
|
window.removeEventListener("mouseup", onUp);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex h-full flex-col p-4">
|
<div class="flex h-full flex-col p-4">
|
||||||
<h1 class="mb-3 text-lg font-semibold">Graph</h1>
|
<div class="mb-3 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h1 class="text-lg font-semibold">Graph</h1>
|
||||||
|
<div class="flex items-center gap-3 text-sm">
|
||||||
|
<label class="flex cursor-pointer items-center gap-1.5 text-neutral-600 dark:text-neutral-300">
|
||||||
|
<input type="checkbox" class="accent-brand" :checked="showAll" @change="toggleAll" />
|
||||||
|
Show unlinked notes
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-md border border-neutral-300 px-2 py-1 text-xs hover:bg-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:hover:bg-neutral-800"
|
||||||
|
@click="resetView"
|
||||||
|
>
|
||||||
|
Reset view
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="loading" class="py-24 text-center text-sm text-neutral-400">Loading graph…</div>
|
<div v-if="loading" class="py-24 text-center text-sm text-neutral-400">Loading graph…</div>
|
||||||
|
|
||||||
<div v-else-if="nodes.length === 0" class="py-24 text-center">
|
<div v-else-if="allNodes.length === 0" class="py-24 text-center">
|
||||||
<h2 class="text-lg font-semibold text-neutral-700 dark:text-neutral-200">No connections yet</h2>
|
<h2 class="text-lg font-semibold text-neutral-700 dark:text-neutral-200">No notes yet</h2>
|
||||||
<p class="mt-1 text-sm text-neutral-400">
|
<p class="mt-1 text-sm text-neutral-400">
|
||||||
Link notes with <span class="font-mono text-brand-700 dark:text-brand">[[Note title]]</span> to see them
|
Create notes and link them with
|
||||||
connected here.
|
<span class="font-mono text-brand-700 dark:text-brand">[[Note title]]</span> to see them here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="activeNodes.length === 0" class="py-24 text-center">
|
||||||
|
<h2 class="text-lg font-semibold text-neutral-700 dark:text-neutral-200">No links yet</h2>
|
||||||
|
<p class="mt-1 text-sm text-neutral-400">
|
||||||
|
Link notes with <span class="font-mono text-brand-700 dark:text-brand">[[Note title]]</span>, or
|
||||||
|
<button type="button" class="text-brand-700 underline dark:text-brand" @click="toggleAll">
|
||||||
|
show all notes
|
||||||
|
</button>.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -159,7 +311,15 @@ onBeforeUnmount(() => cancelAnimationFrame(raf));
|
|||||||
v-else
|
v-else
|
||||||
class="min-h-[500px] flex-1 overflow-hidden rounded-xl border border-neutral-200 bg-neutral-50 dark:border-neutral-800 dark:bg-neutral-950"
|
class="min-h-[500px] flex-1 overflow-hidden rounded-xl border border-neutral-200 bg-neutral-50 dark:border-neutral-800 dark:bg-neutral-950"
|
||||||
>
|
>
|
||||||
<svg :viewBox="`0 0 ${WIDTH} ${HEIGHT}`" class="h-full w-full" preserveAspectRatio="xMidYMid meet">
|
<svg
|
||||||
|
ref="svgRef"
|
||||||
|
:viewBox="`0 0 ${WIDTH} ${HEIGHT}`"
|
||||||
|
class="h-full w-full cursor-grab select-none touch-none"
|
||||||
|
preserveAspectRatio="xMidYMid meet"
|
||||||
|
@mousedown="onBgDown"
|
||||||
|
@wheel="onWheel"
|
||||||
|
>
|
||||||
|
<g ref="gRef" :transform="`translate(${tx} ${ty}) scale(${scale})`">
|
||||||
<line
|
<line
|
||||||
v-for="(l, i) in edgeLines"
|
v-for="(l, i) in edgeLines"
|
||||||
:key="`e${i}`"
|
:key="`e${i}`"
|
||||||
@@ -170,8 +330,15 @@ onBeforeUnmount(() => cancelAnimationFrame(raf));
|
|||||||
class="stroke-neutral-300 dark:stroke-neutral-700"
|
class="stroke-neutral-300 dark:stroke-neutral-700"
|
||||||
stroke-width="1.5"
|
stroke-width="1.5"
|
||||||
/>
|
/>
|
||||||
<g v-for="n in nodes" :key="n.id" class="cursor-pointer" @click="openNode(n)">
|
<g v-for="n in activeNodes" :key="n.id" class="cursor-pointer" @mousedown="onNodeDown(n, $event)">
|
||||||
<circle :cx="n.x" :cy="n.y" r="8" class="fill-brand" />
|
<circle
|
||||||
|
:cx="n.x"
|
||||||
|
:cy="n.y"
|
||||||
|
r="8"
|
||||||
|
:fill="fill(n.color)"
|
||||||
|
class="stroke-neutral-50 dark:stroke-neutral-950"
|
||||||
|
stroke-width="1.5"
|
||||||
|
/>
|
||||||
<text
|
<text
|
||||||
:x="n.x"
|
:x="n.x"
|
||||||
:y="n.y - 13"
|
:y="n.y - 13"
|
||||||
@@ -181,6 +348,7 @@ onBeforeUnmount(() => cancelAnimationFrame(raf));
|
|||||||
{{ n.title }}
|
{{ n.title }}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
</g>
|
||||||
|
</g>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+28
-11
@@ -6,6 +6,7 @@ from sqlalchemy.orm import aliased
|
|||||||
|
|
||||||
from .auth import login_required
|
from .auth import login_required
|
||||||
from .db import session_scope
|
from .db import session_scope
|
||||||
|
from .models.label import Label, NoteLabel
|
||||||
from .models.note import Note
|
from .models.note import Note
|
||||||
from .models.note_link import NoteLink
|
from .models.note_link import NoteLink
|
||||||
|
|
||||||
@@ -15,11 +16,13 @@ bp = Blueprint("graph", __name__, url_prefix="/api/graph")
|
|||||||
@bp.get("")
|
@bp.get("")
|
||||||
@login_required
|
@login_required
|
||||||
async def get_graph():
|
async def get_graph():
|
||||||
"""Wiki-link graph: nodes are the owner's connected notes, edges are resolved
|
"""Wiki-link graph. Nodes are ALL of the owner's non-trashed notes (the frontend
|
||||||
[[links]] (note_links.target_norm matched to a note's normalized title)."""
|
toggles whether to show unlinked ones); each carries its first label's color for
|
||||||
|
clustering. Edges are resolved [[links]] (note_links.target_norm matched to a
|
||||||
|
note's normalized title)."""
|
||||||
source = aliased(Note)
|
source = aliased(Note)
|
||||||
target = aliased(Note)
|
target = aliased(Note)
|
||||||
stmt = (
|
edge_stmt = (
|
||||||
select(source.id, target.id)
|
select(source.id, target.id)
|
||||||
.select_from(NoteLink)
|
.select_from(NoteLink)
|
||||||
.join(source, source.id == NoteLink.source_id)
|
.join(source, source.id == NoteLink.source_id)
|
||||||
@@ -33,9 +36,8 @@ async def get_graph():
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
async with session_scope() as db:
|
async with session_scope() as db:
|
||||||
rows = (await db.execute(stmt)).all()
|
rows = (await db.execute(edge_stmt)).all()
|
||||||
edges = []
|
edges = []
|
||||||
node_ids: set = set()
|
|
||||||
seen: set = set()
|
seen: set = set()
|
||||||
for src_id, tgt_id in rows:
|
for src_id, tgt_id in rows:
|
||||||
key = (src_id, tgt_id)
|
key = (src_id, tgt_id)
|
||||||
@@ -43,10 +45,25 @@ async def get_graph():
|
|||||||
continue
|
continue
|
||||||
seen.add(key)
|
seen.add(key)
|
||||||
edges.append({"source": str(src_id), "target": str(tgt_id)})
|
edges.append({"source": str(src_id), "target": str(tgt_id)})
|
||||||
node_ids.add(src_id)
|
|
||||||
node_ids.add(tgt_id)
|
# First label color per note (labels ordered by name) → node color.
|
||||||
nodes = []
|
color_rows = (
|
||||||
if node_ids:
|
await db.execute(
|
||||||
note_rows = (await db.scalars(select(Note).where(Note.id.in_(node_ids)))).all()
|
select(NoteLabel.note_id, Label.color)
|
||||||
nodes = [{"id": str(n.id), "title": n.title or "Untitled"} for n in note_rows]
|
.join(Label, Label.id == NoteLabel.label_id)
|
||||||
|
.where(Label.owner_id == g.user_id)
|
||||||
|
.order_by(Label.name)
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
first_color: dict = {}
|
||||||
|
for note_id, color in color_rows:
|
||||||
|
first_color.setdefault(note_id, color)
|
||||||
|
|
||||||
|
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.title or "Untitled", "color": first_color.get(n.id, "default")}
|
||||||
|
for n in note_rows
|
||||||
|
]
|
||||||
return jsonify({"nodes": nodes, "edges": edges})
|
return jsonify({"nodes": nodes, "edges": edges})
|
||||||
|
|||||||
Reference in New Issue
Block a user