{
- graphRef.current = ref;
+
+ }>
+ {
+ graphRef.current = ref;
+ }}
+ graphData={{ nodes: graphNodes, links: graphLinks }}
+ width={width}
+ height={height}
+ backgroundColor={bgColor}
+ nodeCanvasObject={paintNode}
+ nodeCanvasObjectMode={() => "replace"}
+ linkColor={linkColor}
+ linkLineDash={linkLineDash}
+ linkWidth={(link: ForceGraphLink) =>
+ Math.min(0.5 + (link.weight - 1) * 0.3, 3)
+ }
+ linkDirectionalArrowLength={4}
+ linkDirectionalArrowRelPos={1}
+ linkOpacity={0.6}
+ onNodeClick={handleNodeClick}
+ onNodeRightClick={handleNodeRightClick}
+ onNodeDoubleClick={handleNodeDoubleClick}
+ nodeLabel={(node: ForceGraphNode) =>
+ node.label ?? node.slug ?? t("graph.untitled_page")
+ }
+ enableNodeDrag
+ cooldownTicks={200}
+ />
+
+
+ {/* Edge type legend — accessible via aria-label */}
+ "replace"}
- linkColor={linkColor}
- linkWidth={(link: ForceGraphLink) =>
- Math.min(0.5 + (link.weight - 1) * 0.3, 3)
- }
- linkDirectionalArrowLength={4}
- linkDirectionalArrowRelPos={1}
- linkOpacity={0.6}
- onNodeClick={handleNodeClick}
- onNodeRightClick={handleNodeRightClick}
- onNodeDoubleClick={handleNodeDoubleClick}
- nodeLabel={(node: ForceGraphNode) => node.label ?? "(untitled)"}
- enableNodeDrag
- cooldownTicks={200}
- />
-
+ >
+
+ {t("graph.legend_title")}
+
+
+
+
+ {t("graph.legend_wikilink")}
+
+
+
+ {t("graph.legend_parent_child")}
+
+
+
+
);
}
diff --git a/apps/client/src/features/acadenice/graph/components/graph-side-panel.tsx b/apps/client/src/features/acadenice/graph/components/graph-side-panel.tsx
index 82031981..7e538715 100644
--- a/apps/client/src/features/acadenice/graph/components/graph-side-panel.tsx
+++ b/apps/client/src/features/acadenice/graph/components/graph-side-panel.tsx
@@ -34,7 +34,8 @@ export function GraphSidePanel({ node, slugMap }: GraphSidePanelProps) {
function openPage() {
if (!node) return;
- const slugId = slugMap[node.id];
+ // Prefer slug from the node (populated server-side since R4.6).
+ const slugId = node.slug ?? slugMap[node.id];
if (slugId) navigate(`/p/${slugId}`);
close();
}
@@ -57,7 +58,7 @@ export function GraphSidePanel({ node, slugMap }: GraphSidePanelProps) {
- {node.label ?? t("graph.untitled_page")}
+ {node.label ?? node.slug ?? t("graph.untitled_page")}
}
onClick={openPage}
- disabled={!slugMap[node.id]}
+ disabled={!node.slug && !slugMap[node.id]}
fullWidth
>
{t("graph.open_page")}
diff --git a/apps/client/src/features/acadenice/graph/services/graph-client.ts b/apps/client/src/features/acadenice/graph/services/graph-client.ts
index 8ceb48f4..000c8bde 100644
--- a/apps/client/src/features/acadenice/graph/services/graph-client.ts
+++ b/apps/client/src/features/acadenice/graph/services/graph-client.ts
@@ -10,6 +10,8 @@ import api from "@/lib/api-client";
export interface GraphNode {
id: string;
label: string | null;
+ /** URL slug for /p/:slug navigation — null when the page has no slug yet */
+ slug: string | null;
type: "page";
spaceId: string;
spaceName: string | null;
@@ -25,7 +27,7 @@ export interface GraphEdge {
id: string;
source: string;
target: string;
- type: "wikilink" | "mention" | "database_embed";
+ type: "wikilink" | "mention" | "database_embed" | "parent_child";
weight: number;
}
diff --git a/apps/client/src/features/space/components/sidebar/space-sidebar.tsx b/apps/client/src/features/space/components/sidebar/space-sidebar.tsx
index ac5c6227..4e75b9d1 100644
--- a/apps/client/src/features/space/components/sidebar/space-sidebar.tsx
+++ b/apps/client/src/features/space/components/sidebar/space-sidebar.tsx
@@ -13,6 +13,7 @@ import {
IconEyeOff,
IconFileExport,
IconHome,
+ IconNetwork,
IconPlus,
IconSearch,
IconSettings,
@@ -349,6 +350,16 @@ function SpaceMenu({
{isWatching ? t("Stop watching space") : t("Watch space")}
+ {/* Acadenice R4.6 — space graph view */}
+ }
+ aria-label={t("graph.space_graph_menu_label")}
+ >
+ {t("graph.space_graph_menu_label")}
+
+
{canManagePages && (
<>
diff --git a/apps/client/src/pages/space/space-graph.tsx b/apps/client/src/pages/space/space-graph.tsx
new file mode 100644
index 00000000..23e8fd65
--- /dev/null
+++ b/apps/client/src/pages/space/space-graph.tsx
@@ -0,0 +1,49 @@
+/**
+ * Space-scoped graph page (R4.6).
+ *
+ * Route: /s/:spaceSlug/graph
+ *
+ * Renders the full GraphPage component but pre-filters to the current space.
+ * The spaceId is injected into the graph filters atom on mount so the backend
+ * only returns nodes belonging to this space.
+ */
+
+import { useEffect } from "react";
+import { useParams } from "react-router-dom";
+import { useSetAtom } from "jotai";
+import { Helmet } from "react-helmet-async";
+import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
+import { getAppName } from "@/lib/config.ts";
+import { graphFiltersAtom } from "@/features/acadenice/graph/hooks/use-graph-controls";
+import GraphPage from "@/features/acadenice/graph/pages/graph-page";
+import { useTranslation } from "react-i18next";
+
+export default function SpaceGraph() {
+ const { spaceSlug } = useParams<{ spaceSlug: string }>();
+ const { data: space } = useGetSpaceBySlugQuery(spaceSlug);
+ const setFilters = useSetAtom(graphFiltersAtom);
+ const { t } = useTranslation();
+
+ // Inject spaceId filter on mount; restore to null on unmount so the
+ // workspace-level graph is unaffected when navigating away.
+ useEffect(() => {
+ if (!space?.id) return;
+ setFilters((prev) => ({ ...prev, spaceId: space.id }));
+ return () => {
+ setFilters((prev) => ({ ...prev, spaceId: null }));
+ };
+ }, [space?.id, setFilters]);
+
+ const pageTitle = space
+ ? `${t("graph.space_graph_title", { spaceName: space.name })} - ${getAppName()}`
+ : `${t("graph.page_title")} - ${getAppName()}`;
+
+ return (
+ <>
+
+ {pageTitle}
+
+
+ >
+ );
+}
diff --git a/apps/server/src/core/acadenice/graph/controllers/graph.controller.ts b/apps/server/src/core/acadenice/graph/controllers/graph.controller.ts
index 55ce8ab8..8b666e93 100644
--- a/apps/server/src/core/acadenice/graph/controllers/graph.controller.ts
+++ b/apps/server/src/core/acadenice/graph/controllers/graph.controller.ts
@@ -45,12 +45,18 @@ export class GraphController {
const q = parsed.data;
+ // Always include parent_child so pages linked via sub-page hierarchy are
+ // visible even when the user has not created any wikilinks.
+ const types = q.types.includes('parent_child')
+ ? q.types
+ : [...q.types, 'parent_child' as const];
+
return this.graphService.buildGraph({
workspaceId: workspace.id,
spaceId: q.spaceId,
pageId: q.pageId,
depth: q.depth,
- types: q.types,
+ types,
includeOrphans: q.includeOrphans,
userId: user.id,
});
diff --git a/apps/server/src/core/acadenice/graph/dto/graph.dto.ts b/apps/server/src/core/acadenice/graph/dto/graph.dto.ts
index 7b56219a..a9f65466 100644
--- a/apps/server/src/core/acadenice/graph/dto/graph.dto.ts
+++ b/apps/server/src/core/acadenice/graph/dto/graph.dto.ts
@@ -11,7 +11,7 @@ import { z } from 'zod';
// Query parameter schema
// ---------------------------------------------------------------------------
-const LINK_TYPES = ['wikilink', 'mention', 'database_embed'] as const;
+const LINK_TYPES = ['wikilink', 'mention', 'database_embed', 'parent_child'] as const;
export type LinkType = (typeof LINK_TYPES)[number];
export const GraphQuerySchema = z.object({
@@ -31,12 +31,15 @@ export const GraphQuerySchema = z.object({
.string()
.optional()
.transform((val) => {
- if (!val) return [...LINK_TYPES];
+ // Default types exclude parent_child so the filter stays backward-compat.
+ // parent_child must be requested explicitly (or via the default all-types path).
+ const DEFAULT_QUERY_TYPES: LinkType[] = ['wikilink', 'mention', 'database_embed'];
+ if (!val) return DEFAULT_QUERY_TYPES;
const parts = val.split(',').map((s) => s.trim()) as LinkType[];
const valid = parts.filter((p): p is LinkType =>
(LINK_TYPES as readonly string[]).includes(p),
);
- return valid.length > 0 ? valid : [...LINK_TYPES];
+ return valid.length > 0 ? valid : DEFAULT_QUERY_TYPES;
}),
includeOrphans: z
.string()
@@ -55,6 +58,8 @@ export interface GraphNode {
id: string;
/** Page title (may be null for untitled pages) */
label: string | null;
+ /** URL slug for /p/:slug navigation — null when the page has no slug */
+ slug: string | null;
type: 'page';
spaceId: string;
spaceName: string | null;
diff --git a/apps/server/src/core/acadenice/graph/services/graph.service.ts b/apps/server/src/core/acadenice/graph/services/graph.service.ts
index dc9b923b..960b9a65 100644
--- a/apps/server/src/core/acadenice/graph/services/graph.service.ts
+++ b/apps/server/src/core/acadenice/graph/services/graph.service.ts
@@ -43,6 +43,7 @@ interface BacklinkAggRow {
interface PageMetaRow {
id: string;
title: string | null;
+ slug: string | null;
space_id: string;
space_name: string | null;
icon: string | null;
@@ -178,7 +179,24 @@ export class GraphService {
// Step 1: Load all permission-filtered edges for the workspace (optionally
// constrained to a space). This is a single aggregated SQL query.
- const rawEdges = await this.loadEdges(workspaceId, userId, spaceId, types);
+ // Wikilink/mention/database_embed edges come from acadenice_backlink.
+ // parent_child edges come from pages.parent_page_id (always included when
+ // the effective type list contains 'parent_child').
+ const backlinkTypes = types.filter(
+ (t): t is 'wikilink' | 'mention' | 'database_embed' => t !== 'parent_child',
+ );
+ const includeParentChild = types.includes('parent_child');
+
+ const [backlinkEdges, parentChildEdges] = await Promise.all([
+ backlinkTypes.length > 0
+ ? this.loadEdges(workspaceId, userId, spaceId, backlinkTypes)
+ : Promise.resolve([]),
+ includeParentChild
+ ? this.loadParentChildEdges(workspaceId, userId, spaceId)
+ : Promise.resolve([]),
+ ]);
+
+ const rawEdges: BacklinkAggRow[] = [...backlinkEdges, ...parentChildEdges];
// Step 2: If pageId is specified, run BFS to keep only reachable nodes.
let filteredEdges: BacklinkAggRow[];
@@ -291,6 +309,7 @@ export class GraphService {
nodes.push({
id,
label: meta.title,
+ slug: meta.slug,
type: 'page',
spaceId: meta.space_id,
spaceName: meta.space_name,
@@ -337,7 +356,7 @@ export class GraphService {
workspaceId: string,
userId: string,
spaceId: string | undefined,
- types: LinkType[],
+ types: Array<'wikilink' | 'mention' | 'database_embed'>,
): Promise {
if (types.length === 0) return [];
@@ -415,6 +434,7 @@ export class GraphService {
SELECT
p.id,
p.title,
+ p.slug,
p.space_id,
sp.name AS space_name,
p.icon
@@ -459,6 +479,7 @@ export class GraphService {
SELECT
p.id,
p.title,
+ p.slug,
p.space_id,
sp.name AS space_name,
p.icon
@@ -479,6 +500,7 @@ export class GraphService {
WHERE bl.source_page_id = p.id
OR bl.target_page_id = p.id
)
+ AND p.parent_page_id IS NULL
ORDER BY p.title ASC
`.execute(this.db);
@@ -489,6 +511,65 @@ export class GraphService {
}
}
+ /**
+ * Load parent-child edges from pages.parent_page_id.
+ *
+ * Each row where parent_page_id IS NOT NULL produces an edge from the parent
+ * page to the child page with type='parent_child' and weight=1.
+ * Permission filter: both parent and child must be readable by userId.
+ */
+ private async loadParentChildEdges(
+ workspaceId: string,
+ userId: string,
+ spaceId: string | undefined,
+ ): Promise {
+ try {
+ const spaceFilter = spaceId
+ ? sql`AND child_sp.id = ${spaceId}`
+ : sql``;
+
+ const rows = await sql`
+ SELECT
+ child.parent_page_id AS source_page_id,
+ child.id AS target_page_id,
+ 'parent_child'::text AS link_type,
+ 1::int AS weight
+ FROM pages child
+ -- Child page space
+ JOIN spaces child_sp ON child_sp.id = child.space_id
+ -- Parent page
+ JOIN pages parent ON parent.id = child.parent_page_id
+ -- Parent page space
+ JOIN spaces parent_sp ON parent_sp.id = parent.space_id
+ WHERE child_sp.workspace_id = ${workspaceId}
+ AND child.deleted_at IS NULL
+ AND parent.deleted_at IS NULL
+ -- Permission: child page readable
+ AND (
+ child_sp.visibility = 'public'
+ OR EXISTS (
+ SELECT 1 FROM space_members sm
+ WHERE sm.space_id = child_sp.id AND sm.user_id = ${userId}
+ )
+ )
+ -- Permission: parent page readable
+ AND (
+ parent_sp.visibility = 'public'
+ OR EXISTS (
+ SELECT 1 FROM space_members sm
+ WHERE sm.space_id = parent_sp.id AND sm.user_id = ${userId}
+ )
+ )
+ ${spaceFilter}
+ `.execute(this.db);
+
+ return rows.rows;
+ } catch (err) {
+ this.logger.error(`loadParentChildEdges failed: ${err?.['message']}`);
+ return [];
+ }
+ }
+
// -------------------------------------------------------------------------
// BFS (iterative, depth-limited)
// -------------------------------------------------------------------------
diff --git a/apps/server/src/core/acadenice/graph/spec/graph.controller.spec.ts b/apps/server/src/core/acadenice/graph/spec/graph.controller.spec.ts
index 33b6181d..906dc046 100644
--- a/apps/server/src/core/acadenice/graph/spec/graph.controller.spec.ts
+++ b/apps/server/src/core/acadenice/graph/spec/graph.controller.spec.ts
@@ -62,6 +62,7 @@ describe('GraphController', () => {
{
id: 'p1',
label: 'Page 1',
+ slug: 'page-1',
type: 'page',
spaceId: 'sp-1',
spaceName: 'Space',
@@ -197,13 +198,21 @@ describe('GraphController', () => {
);
});
- it('applies all three types by default when types param is absent', async () => {
+ it('always includes parent_child in the types passed to the service', async () => {
await controller.getGraph({}, mockUser, mockWorkspace);
expect(service.buildGraph).toHaveBeenCalledWith(
expect.objectContaining({
- types: expect.arrayContaining(['wikilink', 'mention', 'database_embed']),
+ types: expect.arrayContaining(['wikilink', 'mention', 'database_embed', 'parent_child']),
}),
);
});
+
+ it('does not duplicate parent_child when explicitly requested', async () => {
+ await controller.getGraph({ types: 'wikilink,parent_child' }, mockUser, mockWorkspace);
+
+ const callArgs = (service.buildGraph as jest.Mock).mock.calls[0][0];
+ const parentChildCount = callArgs.types.filter((t: string) => t === 'parent_child').length;
+ expect(parentChildCount).toBe(1);
+ });
});
diff --git a/apps/server/src/core/acadenice/graph/spec/graph.service.spec.ts b/apps/server/src/core/acadenice/graph/spec/graph.service.spec.ts
index 9702c2b4..0733f1f5 100644
--- a/apps/server/src/core/acadenice/graph/spec/graph.service.spec.ts
+++ b/apps/server/src/core/acadenice/graph/spec/graph.service.spec.ts
@@ -244,8 +244,8 @@ describe('GraphService', () => {
expect(result.nodes).toHaveLength(0);
expect(result.edges).toHaveLength(0);
- // loadEdges returns early for empty types without calling SQL
- expect(loadEdgesSpy).toHaveBeenCalled();
+ // With an empty types list, no backlink types remain so loadEdges is skipped entirely.
+ expect(loadEdgesSpy).not.toHaveBeenCalled();
});
// -------------------------------------------------------------------------
@@ -382,6 +382,7 @@ describe('GraphService', () => {
{
id: 'cached-p1',
label: 'Cached',
+ slug: null,
type: 'page',
spaceId: 'sp-1',
spaceName: 'S',
@@ -497,4 +498,129 @@ describe('GraphService', () => {
expect(result.nodes).toHaveLength(0);
expect(result.edges).toHaveLength(0);
});
+
+ // -------------------------------------------------------------------------
+ // R4.6 — parent_child edges
+ // -------------------------------------------------------------------------
+
+ it('parent_child edges appear when types includes parent_child', async () => {
+ // Backlink table is empty — only parent-child edges exist
+ jest.spyOn(service as any, 'loadEdges').mockResolvedValue([]);
+ jest.spyOn(service as any, 'loadParentChildEdges').mockResolvedValue([
+ { source_page_id: 'parent-1', target_page_id: 'child-1', link_type: 'parent_child', weight: 1 },
+ { source_page_id: 'parent-1', target_page_id: 'child-2', link_type: 'parent_child', weight: 1 },
+ ]);
+ jest.spyOn(service as any, 'loadPageMeta').mockResolvedValue([
+ pageMeta('parent-1', 'Parent'),
+ pageMeta('child-1', 'Child A'),
+ pageMeta('child-2', 'Child B'),
+ ]);
+
+ const result = await service.buildGraph({
+ ...baseOpts(),
+ types: ['parent_child'],
+ });
+
+ expect(result.nodes).toHaveLength(3);
+ expect(result.edges).toHaveLength(2);
+ expect(result.edges.every((e) => e.type === 'parent_child')).toBe(true);
+ });
+
+ it('parent_child edges carry correct source -> target direction', async () => {
+ jest.spyOn(service as any, 'loadEdges').mockResolvedValue([]);
+ jest.spyOn(service as any, 'loadParentChildEdges').mockResolvedValue([
+ { source_page_id: 'parent-1', target_page_id: 'child-1', link_type: 'parent_child', weight: 1 },
+ ]);
+ jest.spyOn(service as any, 'loadPageMeta').mockResolvedValue([
+ pageMeta('parent-1', 'Parent'),
+ pageMeta('child-1', 'Child'),
+ ]);
+
+ const result = await service.buildGraph({
+ ...baseOpts(),
+ types: ['parent_child'],
+ });
+
+ const edge = result.edges[0];
+ expect(edge.source).toBe('parent-1');
+ expect(edge.target).toBe('child-1');
+ expect(edge.id).toBe('parent-1:child-1:parent_child');
+ });
+
+ it('filters parent_child edges by spaceId', async () => {
+ const loadParentSpy = jest
+ .spyOn(service as any, 'loadParentChildEdges')
+ .mockResolvedValue([]);
+ jest.spyOn(service as any, 'loadEdges').mockResolvedValue([]);
+ jest.spyOn(service as any, 'loadPageMeta').mockResolvedValue([]);
+
+ await service.buildGraph({
+ ...baseOpts(),
+ spaceId: 'space-99',
+ types: ['parent_child'],
+ });
+
+ expect(loadParentSpy).toHaveBeenCalledWith('ws-1', 'user-1', 'space-99');
+ });
+
+ it('loadParentChildEdges is NOT called when parent_child is excluded from types', async () => {
+ const loadParentSpy = jest
+ .spyOn(service as any, 'loadParentChildEdges')
+ .mockResolvedValue([]);
+ jest.spyOn(service as any, 'loadEdges').mockResolvedValue([]);
+ jest.spyOn(service as any, 'loadPageMeta').mockResolvedValue([]);
+
+ await service.buildGraph({
+ ...baseOpts(),
+ types: ['wikilink', 'mention'],
+ });
+
+ expect(loadParentSpy).not.toHaveBeenCalled();
+ });
+
+ it('mixed wikilink + parent_child edges are merged and all returned', async () => {
+ jest.spyOn(service as any, 'loadEdges').mockResolvedValue([
+ row('p1', 'p2', 'wikilink'),
+ ]);
+ jest.spyOn(service as any, 'loadParentChildEdges').mockResolvedValue([
+ { source_page_id: 'p2', target_page_id: 'p3', link_type: 'parent_child', weight: 1 },
+ ]);
+ jest.spyOn(service as any, 'loadPageMeta').mockResolvedValue([
+ pageMeta('p1'),
+ pageMeta('p2'),
+ pageMeta('p3'),
+ ]);
+
+ const result = await service.buildGraph({
+ ...baseOpts(),
+ types: ['wikilink', 'parent_child'],
+ });
+
+ expect(result.edges).toHaveLength(2);
+ const types = result.edges.map((e) => e.type).sort();
+ expect(types).toEqual(['parent_child', 'wikilink']);
+ expect(result.nodes).toHaveLength(3);
+ });
+
+ it('nodes include slug field from page metadata', async () => {
+ jest.spyOn(service as any, 'loadEdges').mockResolvedValue([row('p1', 'p2')]);
+ jest.spyOn(service as any, 'loadParentChildEdges').mockResolvedValue([]);
+ jest.spyOn(service as any, 'loadPageMeta').mockImplementation(
+ async (..._args: any[]) => [
+ { id: 'p1', title: 'Page 1', slug: 'page-1-slug', space_id: 'sp-1', space_name: 'S', icon: null },
+ { id: 'p2', title: 'Page 2', slug: null, space_id: 'sp-1', space_name: 'S', icon: null },
+ ],
+ );
+
+ const result = await service.buildGraph({
+ ...baseOpts(),
+ types: ['wikilink'],
+ });
+
+ const p1 = result.nodes.find((n) => n.id === 'p1')!;
+ const p2 = result.nodes.find((n) => n.id === 'p2')!;
+ expect(p1.slug).toBe('page-1-slug');
+ expect(p2.slug).toBeNull();
+ });
});
+