feat(kernel): complete tree-first graph tasks 074-080
This commit is contained in:
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user