feat(kernel): complete tree-first graph tasks 074-080

This commit is contained in:
lix-2026
2026-04-16 22:01:51 +08:00
parent 2ff10fa86c
commit b1d5d97142
65 changed files with 11579 additions and 4606 deletions
@@ -0,0 +1,387 @@
export type PageSubtreeInlineNode = {
type?: string;
text?: string;
href?: string;
content?: unknown;
styles?: Record<string, unknown>;
};
export type PageSubtreeBlock = {
id?: string;
type?: string;
props?: Record<string, unknown>;
content?: unknown;
children?: unknown;
};
export type PageSubtreeNodeType =
| "page"
| "section"
| "content_node"
| "reference_anchor"
| "mindmap";
export type PageSubtreeNode = {
id: string;
parentNodeId: string | null;
nodeType: PageSubtreeNodeType;
blockId: string | null;
anchorBlockId: string | null;
depth: number;
metadata: {
title: string | null;
textSnippet: string | null;
blockType: string | null;
headingLevel: number | null;
numbering: string | null;
childCount: number;
order: number;
path: string[];
};
};
export type PageOutlineEntry = {
id: string;
nodeId: string;
anchorBlockId: string | null;
title: string;
level: number;
numbering: string;
};
export type PageEvidenceItem = {
id: string;
nodeId: string;
blockId: string | null;
kind:
| "page"
| "heading"
| "paragraph"
| "list"
| "todo"
| "quote"
| "code"
| "media"
| "reference"
| "table"
| "mindmap"
| "text";
snippet: string;
};
export type PageSubtreeProjection = {
projectionId: string;
projection: "page_tree";
rootNodeId: string;
rootNode: PageSubtreeNode;
subtree: {
rootNodeId: string;
nodes: PageSubtreeNode[];
};
outline: PageOutlineEntry[];
evidence: PageEvidenceItem[];
stats: {
blockCount: number;
headingCount: number;
evidenceCount: number;
maxDepth: number;
};
};
const SNIPPET_MAX_LENGTH = 220;
const pickFirstText = (...values: unknown[]) => {
for (const value of values) {
if (typeof value === "string" && value.trim()) {
return value.trim();
}
}
return "";
};
const normalizeSnippet = (value: string) => value.replace(/\s+/g, " ").trim().slice(0, SNIPPET_MAX_LENGTH);
export const clampHeadingLevel = (value: unknown) => {
const level = Number(value);
if (Number.isNaN(level) || !Number.isFinite(level)) {
return 1;
}
return Math.min(5, Math.max(1, Math.trunc(level)));
};
export const extractPageBlocks = (content: unknown): PageSubtreeBlock[] => {
if (Array.isArray(content)) {
return content as PageSubtreeBlock[];
}
if (content && typeof content === "object") {
const blocks = (content as { blocks?: unknown }).blocks;
if (Array.isArray(blocks)) {
return blocks as PageSubtreeBlock[];
}
}
return [];
};
export const getPageBlockChildren = (value: unknown): PageSubtreeBlock[] => {
if (!Array.isArray(value)) {
return [];
}
return value as PageSubtreeBlock[];
};
export const getInlineText = (value: unknown): string => {
if (typeof value === "string") {
return value;
}
if (!Array.isArray(value)) {
return "";
}
return value
.map((node) => {
if (typeof node === "string") {
return node;
}
if (!node || typeof node !== "object") {
return "";
}
const typedNode = node as PageSubtreeInlineNode;
if (typedNode.type === "link") {
return getInlineText(typedNode.content);
}
return typeof typedNode.text === "string" ? typedNode.text : "";
})
.join("");
};
const getBlockSnippet = (block: PageSubtreeBlock): string => {
const props = block.props ?? {};
const inlineText = normalizeSnippet(getInlineText(block.content));
if (inlineText) {
return inlineText;
}
return normalizeSnippet(
pickFirstText(
props.title,
props.caption,
props.summary,
props.fileName,
props.name,
props.alt,
props.status,
),
);
};
const getBlockDisplayTitle = (block: PageSubtreeBlock, snippet: string) => {
const props = block.props ?? {};
switch (block.type) {
case "heading":
return snippet || "未命名标题";
case "pageReference":
return pickFirstText(props.title, snippet, "页面引用");
case "blockReference":
return pickFirstText(props.title, snippet, "块引用");
case "onlineTable":
return pickFirstText(props.title, snippet, "在线表格");
case "mindmap":
return pickFirstText(props.title, snippet, "思维导图");
case "media":
return pickFirstText(props.caption, props.fileName, snippet, "附件");
case "codeBlock":
return snippet || "代码块";
case "advancedTodo":
return snippet || "任务";
case "quote":
return snippet || "引用";
default:
return snippet || null;
}
};
const getNodeType = (block: PageSubtreeBlock): PageSubtreeNodeType => {
switch (block.type) {
case "heading":
return "section";
case "blockReference":
case "pageReference":
return "reference_anchor";
case "mindmap":
return "mindmap";
default:
return "content_node";
}
};
const getEvidenceKind = (block: PageSubtreeBlock): PageEvidenceItem["kind"] => {
switch (block.type) {
case "heading":
return "heading";
case "paragraph":
return "paragraph";
case "bulletListItem":
case "numberedListItem":
case "checkListItem":
return "list";
case "advancedTodo":
return "todo";
case "quote":
return "quote";
case "codeBlock":
return "code";
case "media":
return "media";
case "pageReference":
case "blockReference":
return "reference";
case "onlineTable":
return "table";
case "mindmap":
return "mindmap";
default:
return "text";
}
};
export function buildPageSubtreeProjection(input: {
documentId: string;
title: string | null;
content: unknown;
}): PageSubtreeProjection {
const documentId = String(input.documentId ?? "").trim();
const rootNodeId = documentId || "page:unknown";
const blocks = extractPageBlocks(input.content);
const rootTitle = pickFirstText(input.title, "无标题");
const rootNode: PageSubtreeNode = {
id: rootNodeId,
parentNodeId: null,
nodeType: "page",
blockId: null,
anchorBlockId: null,
depth: 0,
metadata: {
title: rootTitle,
textSnippet: null,
blockType: "page",
headingLevel: null,
numbering: null,
childCount: blocks.length,
order: 0,
path: [rootNodeId],
},
};
const nodes: PageSubtreeNode[] = [rootNode];
const outline: PageOutlineEntry[] = [];
const evidence: PageEvidenceItem[] = [];
const headingCounters = [0, 0, 0, 0, 0];
const headingStack: Array<{ level: number; nodeId: string }> = [];
let order = 0;
let maxDepth = 0;
const walk = (items: PageSubtreeBlock[], parentBlockNodeId: string | null, depth: number, path: number[]) => {
items.forEach((block, index) => {
const blockId = typeof block.id === "string" && block.id.trim() ? block.id.trim() : null;
const nodeId = blockId ? `block:${blockId}` : `block:auto:${[...path, index].join(".")}`;
const snippet = getBlockSnippet(block);
const headingLevel = block.type === "heading" ? clampHeadingLevel(block.props?.level) : null;
let parentNodeId = parentBlockNodeId ?? headingStack.at(-1)?.nodeId ?? rootNodeId;
let numbering: string | null = null;
if (headingLevel != null) {
while (headingStack.length > 0 && headingStack[headingStack.length - 1]!.level >= headingLevel) {
headingStack.pop();
}
parentNodeId = headingStack.at(-1)?.nodeId ?? parentBlockNodeId ?? rootNodeId;
headingCounters[headingLevel - 1] += 1;
for (let counterIndex = headingLevel; counterIndex < headingCounters.length; counterIndex += 1) {
headingCounters[counterIndex] = 0;
}
numbering = headingCounters
.slice(0, headingLevel)
.filter((value) => value > 0)
.join(".");
}
order += 1;
const children = getPageBlockChildren(block.children);
const node: PageSubtreeNode = {
id: nodeId,
parentNodeId,
nodeType: getNodeType(block),
blockId,
anchorBlockId: blockId,
depth: depth + 1,
metadata: {
title: getBlockDisplayTitle(block, snippet),
textSnippet: snippet || null,
blockType: typeof block.type === "string" ? block.type : null,
headingLevel,
numbering,
childCount: children.length,
order,
path: [rootNodeId, ...path.map(String), String(index)],
},
};
nodes.push(node);
maxDepth = Math.max(maxDepth, node.depth);
if (headingLevel != null) {
outline.push({
id: blockId ?? node.id,
nodeId: node.id,
anchorBlockId: blockId,
title: node.metadata.title ?? "未命名标题",
level: headingLevel,
numbering: numbering ?? "",
});
headingStack.push({ level: headingLevel, nodeId: node.id });
}
if (snippet) {
evidence.push({
id: `evidence:${node.id}`,
nodeId: node.id,
blockId,
kind: getEvidenceKind(block),
snippet,
});
}
if (children.length > 0) {
walk(children, node.id, depth + 1, [...path, index]);
}
});
};
walk(blocks, null, 0, []);
if (rootTitle) {
evidence.unshift({
id: `evidence:${rootNodeId}`,
nodeId: rootNodeId,
blockId: null,
kind: "page",
snippet: rootTitle,
});
}
return {
projectionId: `page_subtree:${rootNodeId}`,
projection: "page_tree",
rootNodeId,
rootNode,
subtree: {
rootNodeId,
nodes,
},
outline,
evidence,
stats: {
blockCount: Math.max(0, nodes.length - 1),
headingCount: outline.length,
evidenceCount: evidence.length,
maxDepth,
},
};
}
+3 -3
View File
@@ -1,6 +1,6 @@
"use client";
import type { DocumentNode } from "@/lib/documents";
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
import type { MediaAsset } from "@/types/media";
import type { FileTreeRow } from "./types";
import { makeAssetFolderRowId, makeAssetRowId, makeDocRowId, makeIndexRowId } from "./types";
@@ -12,7 +12,7 @@ export function buildVisibleRows({
assetChildrenByAssetId,
expandedAssetFolderIds,
}: {
nodes: DocumentNode[];
nodes: SidebarTreeNode[];
expanded: Set<string>;
assetsByDoc: Record<string, MediaAsset[]>;
assetChildrenByAssetId?: Record<string, MediaAsset[]>;
@@ -21,7 +21,7 @@ export function buildVisibleRows({
const rows: FileTreeRow[] = [];
const visitedDocIds = new Set<string>();
const walk = (node: DocumentNode, depth: number) => {
const walk = (node: SidebarTreeNode, depth: number) => {
// 防御性处理:上游数据异常时(例如同一 docId 在树中重复出现),避免生成重复 rowId 导致 React key 冲突。
// 同时也能避免潜在的“循环引用/重复引用”导致的递归问题。
if (visitedDocIds.has(node.id)) return;
+3 -3
View File
@@ -1,6 +1,6 @@
"use client";
import type { DocumentNode } from "@/lib/documents";
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
import type { MediaAsset } from "@/types/media";
export type FileTreeRowKind = "doc" | "index" | "asset" | "asset-folder";
@@ -60,7 +60,7 @@ export type FileTreeRow =
depth: number;
docId: string;
parentDocId: string | null;
node: DocumentNode;
node: SidebarTreeNode;
hasChildren: boolean;
isExpanded: boolean;
}
@@ -70,7 +70,7 @@ export type FileTreeRow =
depth: number;
docId: string;
parentDocId: string;
node: DocumentNode;
node: SidebarTreeNode;
}
| {
kind: "asset-folder";
+213
View File
@@ -0,0 +1,213 @@
import type { DocumentRecord } from "@/lib/documents";
export type KernelSidebarProjectionEdge = {
id: string;
edgeType: "parent_of";
workspaceId: string | null;
fromNodeId: string;
toNodeId: string;
};
export type KernelSidebarProjectionItem = {
nodeId: string;
parentNodeId: string | null;
nodeType: "page";
title: string | null;
depth: number;
position: number | null;
childCount: number;
expandedByDefault: boolean;
};
export type KernelSidebarProjection = {
projectionId: string;
projection: "sidebar_tree";
rootNodeId: string | null;
items: KernelSidebarProjectionItem[];
edges: KernelSidebarProjectionEdge[];
};
export type SidebarTreeNode = DocumentRecord & {
children: SidebarTreeNode[];
kernel?: {
nodeType: "page";
depth: number;
position: number | null;
childCount: number;
expandedByDefault: boolean;
};
};
function sortRecords(records: DocumentRecord[]) {
return [...records].sort((a, b) => {
const orderA = a.sort_order ?? Number.MAX_SAFE_INTEGER;
const orderB = b.sort_order ?? Number.MAX_SAFE_INTEGER;
if (orderA !== orderB) {
return orderA - orderB;
}
return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
});
}
function dedupeRecords(records: DocumentRecord[]) {
const seen = new Set<string>();
const unique: DocumentRecord[] = [];
for (let index = records.length - 1; index >= 0; index -= 1) {
const record = records[index]!;
if (seen.has(record.id)) {
continue;
}
seen.add(record.id);
unique.push(record);
}
unique.reverse();
return unique;
}
function buildChildrenByParent(records: DocumentRecord[]) {
const childrenByParentId = new Map<string | null, DocumentRecord[]>();
const recordIds = new Set(records.map((record) => record.id));
for (const record of records) {
const parentId =
record.parent_id && recordIds.has(record.parent_id) ? record.parent_id : null;
const bucket = childrenByParentId.get(parentId) ?? [];
bucket.push(record);
childrenByParentId.set(parentId, bucket);
}
for (const [parentId, bucket] of childrenByParentId.entries()) {
childrenByParentId.set(parentId, sortRecords(bucket));
}
return childrenByParentId;
}
export function buildKernelSidebarProjection(
records: DocumentRecord[],
): KernelSidebarProjection {
const uniqueRecords = dedupeRecords(records);
const recordById = new Map(uniqueRecords.map((record) => [record.id, record]));
const childrenByParentId = buildChildrenByParent(uniqueRecords);
const items: KernelSidebarProjectionItem[] = [];
const edges: KernelSidebarProjectionEdge[] = [];
const visited = new Set<string>();
const walk = (parentId: string | null, depth: number) => {
const children = childrenByParentId.get(parentId) ?? [];
for (const child of children) {
if (visited.has(child.id)) {
continue;
}
visited.add(child.id);
const childNodes = childrenByParentId.get(child.id) ?? [];
items.push({
nodeId: child.id,
parentNodeId: parentId,
nodeType: "page",
title: child.title ?? "无标题",
depth,
position: child.sort_order ?? null,
childCount: childNodes.length,
expandedByDefault: true,
});
if (parentId && recordById.has(parentId)) {
edges.push({
id: `edge:${parentId}:${child.id}:parent_of`,
edgeType: "parent_of",
workspaceId: child.workspace_id ?? null,
fromNodeId: parentId,
toNodeId: child.id,
});
}
walk(child.id, depth + 1);
}
};
walk(null, 0);
return {
projectionId: "kernel_projection:sidebar_tree:workspace_root",
projection: "sidebar_tree",
rootNodeId: null,
items,
edges,
};
}
export function buildSidebarTreeFromKernelProjection(input: {
records: DocumentRecord[];
projection: KernelSidebarProjection;
}): SidebarTreeNode[] {
const recordById = new Map(input.records.map((record) => [record.id, record]));
const nodeMap = new Map<string, SidebarTreeNode>();
const itemById = new Map(input.projection.items.map((item) => [item.nodeId, item]));
for (const item of input.projection.items) {
const record = recordById.get(item.nodeId);
nodeMap.set(item.nodeId, {
access_scope: record?.access_scope ?? "private",
id: item.nodeId,
workspace_id: record?.workspace_id ?? "",
title: record?.title ?? item.title ?? "无标题",
parent_id: record?.parent_id ?? item.parentNodeId,
sort_order: record?.sort_order ?? item.position,
is_starred: record?.is_starred ?? false,
is_template: record?.is_template ?? false,
created_at: record?.created_at ?? "",
updated_at: record?.updated_at ?? null,
children: [],
kernel: {
nodeType: item.nodeType,
depth: item.depth,
position: item.position,
childCount: item.childCount,
expandedByDefault: item.expandedByDefault,
},
});
}
const roots: SidebarTreeNode[] = [];
for (const item of input.projection.items) {
const node = nodeMap.get(item.nodeId);
if (!node) continue;
const parentId = item.parentNodeId;
if (parentId && nodeMap.has(parentId)) {
nodeMap.get(parentId)!.children.push(node);
continue;
}
roots.push(node);
}
const sortTree = (nodes: SidebarTreeNode[]) => {
nodes.sort((a, b) => {
const orderA = a.kernel?.position ?? a.sort_order ?? Number.MAX_SAFE_INTEGER;
const orderB = b.kernel?.position ?? b.sort_order ?? Number.MAX_SAFE_INTEGER;
if (orderA !== orderB) {
return orderA - orderB;
}
return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
});
nodes.forEach((node) => sortTree(node.children));
};
sortTree(roots);
// 防御性处理:如果 projection 丢了节点,但 records 里还在,补到根节点,避免页面从主导航消失。
const missingRoots = input.records
.filter((record) => !itemById.has(record.id))
.map((record) => ({
...record,
children: [],
kernel: {
nodeType: "page" as const,
depth: 0,
position: record.sort_order ?? null,
childCount: 0,
expandedByDefault: true,
},
}));
if (missingRoots.length > 0) {
roots.push(...missingRoots);
sortTree(roots);
}
return roots;
}
@@ -0,0 +1,150 @@
export type MindMapData = {
data: Record<string, unknown>;
children?: unknown[];
};
export type MindmapRouteMeta = {
requestId?: string;
traceId?: string;
workspaceId?: string | null;
documentId?: string;
pageId?: string;
mindmapId?: string;
attachmentId?: string;
updatedAt?: string | null;
};
export type MindmapProjectionNode = {
uid: string;
text: string;
depth: number;
childCount: number;
};
export type MindmapProjection = {
projectionId: string;
projection: "mindmap_subtree";
documentId: string;
mindmapId: string;
rootNodeId: string | null;
title: string;
nodeCount: number;
nodes: MindmapProjectionNode[];
data: MindMapData;
meta: MindmapRouteMeta | null;
};
export const defaultMindmapData: MindMapData = {
data: { text: "中心主题" },
children: [],
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
// simple-mind-map 在文本字段缺失时会直接崩溃,这里统一做兜底归一化。
export const normalizeMindmapData = (input: unknown): unknown => {
if (!input || typeof input !== "object") return defaultMindmapData;
const root = (input as { root?: unknown }).root ?? input;
const walk = (node: unknown) => {
if (!isRecord(node)) return;
if (!isRecord(node.data)) {
node.data = {};
}
const rawText = node.data.text;
node.data.text = typeof rawText === "string" ? rawText : String(rawText ?? "");
const gen = node.data.generalization;
const fixGen = (value: unknown) => {
if (!isRecord(value)) return;
const text = value.text;
value.text = typeof text === "string" ? text : String(text ?? "");
};
if (Array.isArray(gen)) gen.forEach(fixGen);
else fixGen(gen);
if (Array.isArray(node.children)) {
node.children.forEach(walk);
}
};
walk(root);
return input;
};
// 持久化/初始化统一使用根节点对象,避免 wrapper 误传给 simple-mind-map。
export const canonicalizeMindmapData = (input: unknown): MindMapData => {
const normalized = (normalizeMindmapData(input) ?? defaultMindmapData) as Record<string, unknown>;
const root =
normalized && typeof normalized === "object" && "root" in normalized
? normalized.root
: normalized;
return (normalizeMindmapData(root) ?? defaultMindmapData) as MindMapData;
};
export const extractMindmapTitle = (input: unknown): string => {
const canonical = canonicalizeMindmapData(input);
const text = canonical?.data?.text;
if (typeof text === "string" && text.trim()) {
return text.trim();
}
return "未命名导图";
};
export const summarizeMindmapProjectionNodes = (input: unknown): MindmapProjectionNode[] => {
const root = canonicalizeMindmapData(input);
const queue: Array<{ node: MindMapData; depth: number }> = [{ node: root, depth: 0 }];
const nodes: MindmapProjectionNode[] = [];
while (queue.length > 0) {
const current = queue.shift();
if (!current) break;
const uidRaw = current.node?.data?.uid;
const textRaw = current.node?.data?.text;
const children = Array.isArray(current.node?.children) ? current.node.children : [];
nodes.push({
uid:
typeof uidRaw === "string" && uidRaw.trim()
? uidRaw
: `depth:${current.depth}:index:${nodes.length}`,
text: typeof textRaw === "string" && textRaw.trim() ? textRaw.trim() : "未命名节点",
depth: current.depth,
childCount: children.length,
});
children.forEach((child) => {
queue.push({
node: canonicalizeMindmapData(child),
depth: current.depth + 1,
});
});
}
return nodes;
};
export const buildMindmapProjection = (input: {
documentId: string;
mindmapId: string;
data: unknown;
meta?: unknown;
}): MindmapProjection => {
const data = canonicalizeMindmapData(input.data);
const nodes = summarizeMindmapProjectionNodes(data);
const meta = isRecord(input.meta) ? (input.meta as MindmapRouteMeta) : null;
const rootNodeId = nodes[0]?.uid ?? null;
return {
projectionId: `mindmap_projection:${input.documentId}:${input.mindmapId}`,
projection: "mindmap_subtree",
documentId: input.documentId,
mindmapId: input.mindmapId,
rootNodeId,
title: extractMindmapTitle(data),
nodeCount: nodes.length,
nodes,
data,
meta,
};
};
@@ -44,6 +44,13 @@ type SearchDocumentsRustResult = {
hasOcr: boolean;
publicPath: string;
score: number;
nodeId?: string | null;
subtreeRootId?: string | null;
evidence?: Array<{
kind: string;
nodeId?: string | null;
snippet: string;
}>;
}>;
enqueueAssetIds?: string[];
};
@@ -145,6 +152,15 @@ function mapRecentRowsToResults(input: {
hasOcr: false,
publicPath: `/documents/${item.id}`,
score: 0,
nodeId: item.id,
subtreeRootId: item.id,
evidence: [
{
kind: "recent",
nodeId: item.id,
snippet: item.title ?? "最近访问页面",
},
],
}));
}
@@ -161,6 +177,18 @@ function mapRustResultsToResponse(
hasOcr: Boolean(item.hasOcr),
publicPath: item.publicPath,
score: item.score,
nodeId: item.nodeId ?? item.id,
subtreeRootId: item.subtreeRootId ?? item.id,
evidence:
Array.isArray(item.evidence) && item.evidence.length > 0
? item.evidence
: [
{
kind: item.matchField,
nodeId: item.nodeId ?? item.id,
snippet: item.snippet || item.title || "命中文档",
},
],
}));
}
@@ -136,6 +136,8 @@ describe("buildSidebarInitialData", () => {
});
expect(payload.activeWorkspaceId).toBe("ws_1");
expect(payload.kernelSidebarProjection?.projection).toBe("sidebar_tree");
expect(payload.kernelSidebarTree?.map((item) => item.id)).toEqual(["doc_1"]);
expect(payload.mindmapDocs).toEqual(["doc_1"]);
expect(payload.mindmapAssetChildren).toEqual({
mind_1: ["img_a", "img_b"],
@@ -210,6 +212,24 @@ describe("buildSidebarInitialData", () => {
updated_at: null,
},
],
kernel_sidebar_projection: {
projectionId: "kernel_projection:sidebar_tree:workspace_root",
projection: "sidebar_tree",
rootNodeId: null,
items: [
{
nodeId: "doc_1",
parentNodeId: null,
nodeType: "page",
title: "页面 1",
depth: 0,
position: 1,
childCount: 0,
expandedByDefault: true,
},
],
edges: [],
},
trashed_documents: [],
media_assets: [],
trashed_media_assets: [],
@@ -247,6 +267,46 @@ describe("buildSidebarInitialData", () => {
updated_at: null,
},
],
kernelSidebarProjection: {
projectionId: "kernel_projection:sidebar_tree:workspace_root",
projection: "sidebar_tree",
rootNodeId: null,
items: [
{
nodeId: "doc_1",
parentNodeId: null,
nodeType: "page",
title: "页面 1",
depth: 0,
position: 1,
childCount: 0,
expandedByDefault: true,
},
],
edges: [],
},
kernelSidebarTree: [
{
access_scope: "private",
id: "doc_1",
workspace_id: "ws_1",
title: "页面 1",
parent_id: null,
sort_order: 1,
is_starred: false,
is_template: false,
created_at: "2026-04-14T00:00:00Z",
updated_at: null,
children: [],
kernel: {
nodeType: "page",
depth: 0,
position: 1,
childCount: 0,
expandedByDefault: true,
},
},
],
trashedDocuments: [],
trashedMediaAssets: [],
trashedMindmapAssets: [],
+15
View File
@@ -1,5 +1,10 @@
import type { SidebarInitialData } from "@/components/sidebar/types";
import type { DocumentRecord } from "@/lib/documents";
import {
buildKernelSidebarProjection,
buildSidebarTreeFromKernelProjection,
type KernelSidebarProjection,
} from "@/lib/kernel-sidebar";
import type { WorkspaceSummary } from "@/lib/workspaces";
import type { MediaAsset } from "@/types/media";
@@ -46,6 +51,7 @@ export type SidebarDatasetListQueryResult = {
active_workspace_id: string;
workspaces: WorkspaceSummary[];
documents: DocumentRecord[];
kernel_sidebar_projection?: KernelSidebarProjection | null;
trashed_documents: SidebarInitialData["trashedDocuments"];
media_assets: MediaAsset[];
trashed_media_assets: MediaAsset[];
@@ -213,11 +219,13 @@ export function buildSidebarDatasetListQueryResult(
input: SidebarDatasetInput,
): SidebarDatasetListQueryResult {
const derived = deriveSidebarDataset(input);
const kernelSidebarProjection = buildKernelSidebarProjection(input.documents);
return {
active_workspace_id: input.activeWorkspaceId,
workspaces: [...input.workspaces],
documents: [...input.documents],
kernel_sidebar_projection: kernelSidebarProjection,
trashed_documents: [...input.trashedDocuments],
media_assets: [...(input.mediaAssets ?? [])],
trashed_media_assets: [...(input.trashedMediaAssets ?? [])],
@@ -233,10 +241,17 @@ export function buildSidebarDatasetListQueryResult(
export function mapSidebarDatasetListQueryResultToInitialData(
result: SidebarDatasetListQueryResult,
): SidebarInitialData {
const kernelSidebarProjection =
result.kernel_sidebar_projection ?? buildKernelSidebarProjection(result.documents);
return {
activeWorkspaceId: result.active_workspace_id,
workspaces: [...result.workspaces],
documents: [...result.documents],
kernelSidebarProjection,
kernelSidebarTree: buildSidebarTreeFromKernelProjection({
records: result.documents,
projection: kernelSidebarProjection,
}),
trashedDocuments: [...result.trashed_documents],
trashedMediaAssets: [...result.trashed_media_assets],
trashedMindmapAssets: [...result.trashed_mindmap_assets],
+18 -11
View File
@@ -1,8 +1,12 @@
import type { DocumentRecord, DocumentNode } from "@/lib/documents";
import type { DocumentRecord } from "@/lib/documents";
import type { SidebarSectionId, TrashRecord } from "@/components/sidebar/types";
import type { Database } from "@/types/supabase";
import type { MediaAsset } from "@/types/media";
import { buildDocumentTree } from "@/lib/documents";
import {
buildKernelSidebarProjection,
buildSidebarTreeFromKernelProjection,
type SidebarTreeNode,
} from "@/lib/kernel-sidebar";
type TypedClient = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -138,10 +142,10 @@ export async function fetchSidebarDataset(
};
}
type NodePredicate = (node: DocumentNode) => boolean;
type NodePredicate = (node: SidebarTreeNode) => boolean;
function projectTree(nodes: DocumentNode[], predicate: NodePredicate): DocumentNode[] {
const result: DocumentNode[] = [];
function projectTree(nodes: SidebarTreeNode[], predicate: NodePredicate): SidebarTreeNode[] {
const result: SidebarTreeNode[] = [];
nodes.forEach((node) => {
const projectedChildren = projectTree(node.children, predicate);
if (predicate(node)) {
@@ -157,12 +161,12 @@ function projectTree(nodes: DocumentNode[], predicate: NodePredicate): DocumentN
}
export function flattenDocumentTree(
nodes: DocumentNode[],
nodes: SidebarTreeNode[],
expanded: Set<string>,
depth = 0,
parentId: string | null = null,
): Array<{ node: DocumentNode; depth: number; parentId: string | null }> {
const list: Array<{ node: DocumentNode; depth: number; parentId: string | null }> = [];
): Array<{ node: SidebarTreeNode; depth: number; parentId: string | null }> {
const list: Array<{ node: SidebarTreeNode; depth: number; parentId: string | null }> = [];
nodes.forEach((node) => {
list.push({ node, depth, parentId });
if (node.children.length > 0 && expanded.has(node.id)) {
@@ -184,16 +188,19 @@ type SidebarSectionSnapshot = {
id: SidebarSectionId;
title: string;
icon?: string;
nodes: DocumentNode[];
nodes: SidebarTreeNode[];
};
export function buildSidebarSections(records: DocumentRecord[]): SidebarSectionSnapshot[] {
const tree = buildDocumentTree(records);
const tree = buildSidebarTreeFromKernelProjection({
records,
projection: buildKernelSidebarProjection(records),
});
return buildSidebarSectionsFromTree(tree);
}
export function buildSidebarSectionsFromTree(
tree: DocumentNode[],
tree: SidebarTreeNode[],
): SidebarSectionSnapshot[] {
const sections: Array<{ id: SidebarSectionId; predicate: NodePredicate }> = [
{ id: "starred", predicate: (node) => Boolean(node.is_starred) },