4-10 树域 Rust 家族化
This commit is contained in:
@@ -20,7 +20,7 @@ describe("tree-command-client", () => {
|
||||
it("通过统一 client 发送 tree/document command 并返回结果", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ ok: true, success: true, items: [] }),
|
||||
json: async () => ({ ok: true, success: true, items: [], id: "doc_created", documentId: "doc_created" }),
|
||||
} as Response);
|
||||
|
||||
await createDocumentCommand(null);
|
||||
@@ -41,9 +41,9 @@ describe("tree-command-client", () => {
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(10);
|
||||
expect(fetchMock.mock.calls.map((call) => String(call[0]))).toEqual([
|
||||
"/api/documents/create",
|
||||
"/api/documents/title",
|
||||
"/api/documents/move",
|
||||
"/api/tree/commands",
|
||||
"/api/tree/commands",
|
||||
"/api/tree/commands",
|
||||
"/api/documents/delete",
|
||||
"/api/documents/restore",
|
||||
"/api/documents/purge",
|
||||
@@ -52,6 +52,24 @@ describe("tree-command-client", () => {
|
||||
"/api/documents/title",
|
||||
"/api/documents/options",
|
||||
]);
|
||||
|
||||
expect(JSON.parse(String(fetchMock.mock.calls[0]?.[1]?.body))).toEqual({
|
||||
action: "create",
|
||||
parentId: null,
|
||||
});
|
||||
expect(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body))).toEqual({
|
||||
action: "rename",
|
||||
documentId: "doc_1",
|
||||
workspaceId: null,
|
||||
title: "新标题",
|
||||
});
|
||||
expect(JSON.parse(String(fetchMock.mock.calls[2]?.[1]?.body))).toEqual({
|
||||
action: "move",
|
||||
documentId: "doc_1",
|
||||
parentId: null,
|
||||
sortOrder: 0,
|
||||
workspaceId: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("在后端返回错误时抛出统一异常", async () => {
|
||||
|
||||
@@ -96,6 +96,8 @@ type MoveDocumentInput = {
|
||||
workspaceId?: string | null;
|
||||
};
|
||||
|
||||
type TreeCommandAction = "create" | "rename" | "move";
|
||||
|
||||
type DeleteDocumentInput = {
|
||||
documentId: string;
|
||||
workspaceId?: string | null;
|
||||
@@ -148,8 +150,56 @@ async function postDocumentCommand<TResult>(path: string, payload: unknown, fall
|
||||
return body as TResult;
|
||||
}
|
||||
|
||||
type TreeCommandResponse = {
|
||||
requestId?: string;
|
||||
traceId?: string;
|
||||
result?: {
|
||||
action?: TreeCommandAction;
|
||||
workspaceId?: string | null;
|
||||
documentId?: string;
|
||||
parentId?: string | null;
|
||||
title?: string | null;
|
||||
sortOrder?: number | null;
|
||||
updatedAt?: string | null;
|
||||
execution?: {
|
||||
access_scope?: "private" | "shared" | "public";
|
||||
is_template?: boolean;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
} | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
async function postTreeCommand<TResult>(payload: unknown, fallbackMessage: string): Promise<TResult> {
|
||||
return postDocumentCommand<TResult>("/api/tree/commands", payload, fallbackMessage);
|
||||
}
|
||||
|
||||
export async function createDocumentCommand(parentId: string | null): Promise<DocumentCreateCommandResult> {
|
||||
return postDocumentCommand<DocumentCreateCommandResult>("/api/documents/create", { parentId }, "新建页面失败,请稍后再试");
|
||||
const response = await postTreeCommand<TreeCommandResponse>(
|
||||
{
|
||||
action: "create",
|
||||
parentId,
|
||||
},
|
||||
"新建页面失败,请稍后再试",
|
||||
);
|
||||
const result = response.result;
|
||||
|
||||
return {
|
||||
id: result?.documentId ?? "",
|
||||
title: result?.title ?? "无标题",
|
||||
parent_id: result?.parentId ?? parentId,
|
||||
sort_order: result?.sortOrder ?? null,
|
||||
workspace_id: result?.workspaceId ?? undefined,
|
||||
access_scope: result?.execution?.access_scope ?? "private",
|
||||
is_template: result?.execution?.is_template ?? false,
|
||||
created_at: result?.execution?.created_at ?? null,
|
||||
updated_at: result?.execution?.updated_at ?? result?.updatedAt ?? null,
|
||||
meta: {
|
||||
requestId: response.requestId,
|
||||
traceId: response.traceId,
|
||||
commandName: TREE_COMMAND_PROTOCOL.create.preferredCommandName,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function createChildDocumentCommand(
|
||||
@@ -163,15 +213,24 @@ export async function createChildDocumentCommand(
|
||||
}
|
||||
|
||||
export async function renameDocumentCommand(input: RenameDocumentInput): Promise<{ ok: true; meta?: DocumentCommandMeta }> {
|
||||
return postDocumentCommand<{ ok: true; meta?: DocumentCommandMeta }>(
|
||||
"/api/documents/title",
|
||||
const response = await postTreeCommand<TreeCommandResponse>(
|
||||
{
|
||||
action: "rename",
|
||||
documentId: input.documentId,
|
||||
workspaceId: input.workspaceId ?? null,
|
||||
title: input.title,
|
||||
},
|
||||
"重命名失败,请稍后再试",
|
||||
);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
meta: {
|
||||
requestId: response.requestId,
|
||||
traceId: response.traceId,
|
||||
commandName: TREE_COMMAND_PROTOCOL.rename.preferredCommandName,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function updatePageTitleCommand(
|
||||
@@ -192,16 +251,25 @@ export async function updatePageOptionsCommand(
|
||||
}
|
||||
|
||||
export async function moveDocumentCommand(input: MoveDocumentInput): Promise<{ ok: true; meta?: DocumentCommandMeta }> {
|
||||
return postDocumentCommand<{ ok: true; meta?: DocumentCommandMeta }>(
|
||||
"/api/documents/move",
|
||||
const response = await postTreeCommand<TreeCommandResponse>(
|
||||
{
|
||||
action: "move",
|
||||
documentId: input.documentId,
|
||||
parentId: input.parentId ?? null,
|
||||
position: input.position,
|
||||
sortOrder: Number.isFinite(input.position) ? Math.floor(input.position) : 0,
|
||||
workspaceId: input.workspaceId ?? null,
|
||||
},
|
||||
"移动失败,请稍后再试",
|
||||
);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
meta: {
|
||||
requestId: response.requestId,
|
||||
traceId: response.traceId,
|
||||
commandName: TREE_COMMAND_PROTOCOL.move.preferredCommandName,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteDocumentCommand(
|
||||
|
||||
@@ -117,6 +117,172 @@ describe("buildVisibleRows", () => {
|
||||
expect(rows.filter((r) => r.rowId === "doc:a").length).toBe(1);
|
||||
expect(new Set(rows.map((r) => r.rowId)).size).toBe(rows.length);
|
||||
});
|
||||
|
||||
it("优先消费 Rust file_tree projection 并保留 index 与资源文件夹语义", () => {
|
||||
const rows = buildVisibleRows({
|
||||
fileTreeItems: [
|
||||
{
|
||||
rowId: "doc:page_root",
|
||||
rowKind: "document",
|
||||
nodeId: "page_root",
|
||||
parentNodeId: null,
|
||||
nodeType: "page",
|
||||
projectionKind: "file_tree",
|
||||
title: "根页面",
|
||||
depth: 0,
|
||||
position: 0,
|
||||
childCount: 4,
|
||||
expandable: true,
|
||||
expandedByDefault: true,
|
||||
capabilities: ["expand", "open", "select"],
|
||||
resourceMeta: {
|
||||
resourceKind: "document",
|
||||
documentId: "page_root",
|
||||
workspaceId: "ws_1",
|
||||
iconHint: "page",
|
||||
},
|
||||
iconHint: "page",
|
||||
},
|
||||
{
|
||||
rowId: "index:page_root",
|
||||
rowKind: "index",
|
||||
nodeId: "index:page_root",
|
||||
parentNodeId: "page_root",
|
||||
nodeType: "index",
|
||||
projectionKind: "file_tree",
|
||||
title: "index.md",
|
||||
depth: 1,
|
||||
position: 0,
|
||||
childCount: 0,
|
||||
expandable: false,
|
||||
expandedByDefault: false,
|
||||
capabilities: ["open", "select"],
|
||||
resourceMeta: {
|
||||
resourceKind: "index",
|
||||
documentId: "page_root",
|
||||
workspaceId: "ws_1",
|
||||
iconHint: "index",
|
||||
},
|
||||
iconHint: "index",
|
||||
},
|
||||
{
|
||||
rowId: "asset-folder:mind_1",
|
||||
rowKind: "asset_folder",
|
||||
nodeId: "asset-folder:mind_1",
|
||||
parentNodeId: "page_root",
|
||||
nodeType: "mindmap",
|
||||
projectionKind: "file_tree",
|
||||
title: "头脑风暴",
|
||||
depth: 1,
|
||||
position: 1,
|
||||
childCount: 1,
|
||||
expandable: true,
|
||||
expandedByDefault: false,
|
||||
capabilities: ["expand", "open-asset", "select"],
|
||||
resourceMeta: {
|
||||
resourceKind: "mindmap",
|
||||
documentId: "page_root",
|
||||
assetId: "mind_1",
|
||||
workspaceId: "ws_1",
|
||||
assetKind: "mindmap",
|
||||
iconHint: "mindmap",
|
||||
},
|
||||
iconHint: "mindmap",
|
||||
},
|
||||
{
|
||||
rowId: "asset:asset_child_1",
|
||||
rowKind: "asset",
|
||||
nodeId: "asset:asset_child_1",
|
||||
parentNodeId: "asset-folder:mind_1",
|
||||
nodeType: "asset",
|
||||
projectionKind: "file_tree",
|
||||
title: "节点图片.png",
|
||||
depth: 2,
|
||||
position: 0,
|
||||
childCount: 0,
|
||||
expandable: false,
|
||||
expandedByDefault: false,
|
||||
capabilities: ["open-asset", "select"],
|
||||
resourceMeta: {
|
||||
resourceKind: "asset",
|
||||
documentId: "page_root",
|
||||
assetId: "asset_child_1",
|
||||
workspaceId: "ws_1",
|
||||
assetKind: "image",
|
||||
iconHint: "image",
|
||||
},
|
||||
iconHint: "image",
|
||||
},
|
||||
{
|
||||
rowId: "asset:table_1",
|
||||
rowKind: "asset",
|
||||
nodeId: "asset:table_1",
|
||||
parentNodeId: "page_root",
|
||||
nodeType: "table",
|
||||
projectionKind: "file_tree",
|
||||
title: "预算.luckysheet",
|
||||
depth: 1,
|
||||
position: 2,
|
||||
childCount: 0,
|
||||
expandable: false,
|
||||
expandedByDefault: false,
|
||||
capabilities: ["open-asset", "select"],
|
||||
resourceMeta: {
|
||||
resourceKind: "table",
|
||||
documentId: "page_root",
|
||||
assetId: "table_1",
|
||||
workspaceId: "ws_1",
|
||||
assetKind: "table",
|
||||
iconHint: "table",
|
||||
},
|
||||
iconHint: "table",
|
||||
},
|
||||
],
|
||||
expanded: new Set(["page_root"]),
|
||||
expandedAssetFolderIds: new Set(["mind_1"]),
|
||||
});
|
||||
|
||||
expect(rows.map((row) => `${row.kind}:${row.depth}:${row.rowId}`)).toEqual([
|
||||
"doc:0:doc:page_root",
|
||||
"index:1:index:page_root",
|
||||
"asset-folder:1:asset-folder:mind_1",
|
||||
"asset:2:asset:asset_child_1",
|
||||
"asset:1:asset:table_1",
|
||||
]);
|
||||
expect(rows[0]).toMatchObject({
|
||||
kind: "doc",
|
||||
docId: "page_root",
|
||||
hasChildren: true,
|
||||
isExpanded: true,
|
||||
});
|
||||
expect(rows[2]).toMatchObject({
|
||||
kind: "asset-folder",
|
||||
docId: "page_root",
|
||||
asset: {
|
||||
id: "mind_1",
|
||||
asset_type: "mindmap",
|
||||
file_name: "头脑风暴",
|
||||
},
|
||||
hasChildren: true,
|
||||
isExpanded: true,
|
||||
});
|
||||
expect(rows[3]).toMatchObject({
|
||||
kind: "asset",
|
||||
asset: {
|
||||
id: "asset_child_1",
|
||||
asset_type: "image",
|
||||
file_name: "节点图片.png",
|
||||
},
|
||||
});
|
||||
expect(rows[4]).toMatchObject({
|
||||
kind: "asset",
|
||||
asset: {
|
||||
id: "table_1",
|
||||
asset_type: "luckysheet",
|
||||
file_name: "预算.luckysheet",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseFileTreeRowId", () => {
|
||||
|
||||
@@ -1,28 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import type { KernelFileTreeProjectionItem } from "@/lib/kernel-file-tree";
|
||||
import {
|
||||
getDocIdFromFileTreeItem,
|
||||
resolveFileTreeRowAsset,
|
||||
resolveFileTreeRowNode,
|
||||
} from "@/lib/kernel-file-tree";
|
||||
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
||||
import type { PageTreeProjectionItem } from "@/lib/tree-projection";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
import type { FileTreeRow } from "./types";
|
||||
import { makeAssetFolderRowId, makeAssetRowId, makeDocRowId, makeIndexRowId } from "./types";
|
||||
|
||||
function buildRowsFromKernelFileTreeProjection(input: {
|
||||
fileTreeItems: KernelFileTreeProjectionItem[];
|
||||
expanded: Set<string>;
|
||||
expandedAssetFolderIds?: Set<string>;
|
||||
nodeById?: Map<string, SidebarTreeNode>;
|
||||
assetById?: Map<string, MediaAsset>;
|
||||
}): FileTreeRow[] {
|
||||
const rows: FileTreeRow[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
input.fileTreeItems.forEach((item) => {
|
||||
if (!item?.rowId || seen.has(item.rowId)) {
|
||||
return;
|
||||
}
|
||||
seen.add(item.rowId);
|
||||
const docId = getDocIdFromFileTreeItem(item);
|
||||
|
||||
switch (item.rowKind) {
|
||||
case "document": {
|
||||
const node = resolveFileTreeRowNode(item, input.nodeById);
|
||||
rows.push({
|
||||
kind: "doc",
|
||||
rowId: makeDocRowId(docId),
|
||||
depth: item.depth,
|
||||
docId,
|
||||
parentDocId: item.parentNodeId,
|
||||
node,
|
||||
hasChildren: item.childCount > 0,
|
||||
isExpanded: input.expanded.has(docId),
|
||||
});
|
||||
return;
|
||||
}
|
||||
case "index": {
|
||||
const node = resolveFileTreeRowNode(item, input.nodeById);
|
||||
rows.push({
|
||||
kind: "index",
|
||||
rowId: makeIndexRowId(docId),
|
||||
depth: item.depth,
|
||||
docId,
|
||||
parentDocId: docId,
|
||||
node,
|
||||
});
|
||||
return;
|
||||
}
|
||||
case "asset_folder": {
|
||||
const asset = resolveFileTreeRowAsset(item, input.assetById);
|
||||
rows.push({
|
||||
kind: "asset-folder",
|
||||
rowId: makeAssetFolderRowId(asset.id),
|
||||
depth: item.depth,
|
||||
docId,
|
||||
parentDocId: docId,
|
||||
asset,
|
||||
hasChildren: item.childCount > 0,
|
||||
isExpanded: input.expandedAssetFolderIds?.has(asset.id) ?? false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
case "asset": {
|
||||
const asset = resolveFileTreeRowAsset(item, input.assetById);
|
||||
rows.push({
|
||||
kind: "asset",
|
||||
rowId: makeAssetRowId(asset.id),
|
||||
depth: item.depth,
|
||||
docId,
|
||||
parentDocId: docId,
|
||||
asset,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
export function buildVisibleRows({
|
||||
fileTreeItems,
|
||||
pageRows,
|
||||
expanded,
|
||||
assetsByDoc,
|
||||
assetChildrenByAssetId,
|
||||
expandedAssetFolderIds,
|
||||
nodeById,
|
||||
assetById,
|
||||
}: {
|
||||
fileTreeItems?: KernelFileTreeProjectionItem[];
|
||||
// 只消费统一 page_tree projection;结构真相不再由文件树自行定义。
|
||||
pageRows: PageTreeProjectionItem[];
|
||||
pageRows?: PageTreeProjectionItem[];
|
||||
expanded: Set<string>;
|
||||
assetsByDoc: Record<string, MediaAsset[]>;
|
||||
assetsByDoc?: Record<string, MediaAsset[]>;
|
||||
assetChildrenByAssetId?: Record<string, MediaAsset[]>;
|
||||
expandedAssetFolderIds?: Set<string>;
|
||||
nodeById?: Map<string, SidebarTreeNode>;
|
||||
assetById?: Map<string, MediaAsset>;
|
||||
}): FileTreeRow[] {
|
||||
if (fileTreeItems && fileTreeItems.length > 0) {
|
||||
return buildRowsFromKernelFileTreeProjection({
|
||||
fileTreeItems,
|
||||
expanded,
|
||||
expandedAssetFolderIds,
|
||||
nodeById,
|
||||
assetById,
|
||||
});
|
||||
}
|
||||
|
||||
const safePageRows = pageRows ?? [];
|
||||
const safeAssetsByDoc = assetsByDoc ?? {};
|
||||
const rows: FileTreeRow[] = [];
|
||||
|
||||
pageRows.forEach((item) => {
|
||||
const assets = assetsByDoc[item.nodeId] ?? [];
|
||||
safePageRows.forEach((item) => {
|
||||
const assets = safeAssetsByDoc[item.nodeId] ?? [];
|
||||
const hasChildren = item.childCount > 0 || assets.length > 0;
|
||||
const isExpanded = expanded.has(item.nodeId);
|
||||
rows.push({
|
||||
|
||||
@@ -0,0 +1,578 @@
|
||||
import type { DocumentRecord } from "@/lib/documents";
|
||||
import type { SidebarTreeNode } from "@/lib/kernel-sidebar";
|
||||
import type {
|
||||
TreeProjectionAssetKind,
|
||||
TreeProjectionCapability,
|
||||
TreeProjectionItemBase,
|
||||
TreeProjectionNodeType,
|
||||
TreeProjectionResourceKind,
|
||||
} from "@/lib/tree-protocol";
|
||||
import type { MediaAsset } from "@/types/media";
|
||||
|
||||
export type KernelFileTreeProjectionRowKind =
|
||||
| "document"
|
||||
| "index"
|
||||
| "asset"
|
||||
| "asset_folder";
|
||||
|
||||
export type KernelFileTreeProjectionItem = TreeProjectionItemBase & {
|
||||
projectionKind: "file_tree";
|
||||
rowId: string;
|
||||
rowKind: KernelFileTreeProjectionRowKind;
|
||||
};
|
||||
|
||||
export type KernelFileTreeProjectionEdge = {
|
||||
id: string;
|
||||
edgeType: "parent_of";
|
||||
workspaceId: string | null;
|
||||
fromNodeId: string;
|
||||
toNodeId: string;
|
||||
};
|
||||
|
||||
export type KernelFileTreeProjection = {
|
||||
projectionId: string;
|
||||
projection: "file_tree";
|
||||
rootNodeId: string | null;
|
||||
items: KernelFileTreeProjectionItem[];
|
||||
edges: KernelFileTreeProjectionEdge[];
|
||||
};
|
||||
|
||||
type BuildKernelFileTreeProjectionInput = {
|
||||
documents: DocumentRecord[];
|
||||
mediaAssets?: MediaAsset[] | null;
|
||||
mindmapAssets?: MediaAsset[] | null;
|
||||
tableAssets?: MediaAsset[] | null;
|
||||
mindmapAssetChildren?: Record<string, string[]> | null;
|
||||
rootNodeId?: string | null;
|
||||
};
|
||||
|
||||
type NormalizedFileTreeAsset = {
|
||||
id: string;
|
||||
documentId: string;
|
||||
workspaceId: string | null;
|
||||
title: string;
|
||||
resourceKind: TreeProjectionResourceKind;
|
||||
assetKind: TreeProjectionAssetKind;
|
||||
iconHint: string;
|
||||
nodeType: TreeProjectionNodeType;
|
||||
};
|
||||
|
||||
function readRowId(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
}
|
||||
|
||||
function toMillis(value: string | null | undefined): number {
|
||||
if (!value) return 0;
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function sortDocuments(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 toMillis(a.created_at) - toMillis(b.created_at);
|
||||
});
|
||||
}
|
||||
|
||||
function dedupeDocuments(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 buildDocumentChildren(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, sortDocuments(bucket));
|
||||
}
|
||||
|
||||
return childrenByParentId;
|
||||
}
|
||||
|
||||
function makeProjectionEdge(
|
||||
fromNodeId: string,
|
||||
toNodeId: string,
|
||||
workspaceId: string | null,
|
||||
): KernelFileTreeProjectionEdge {
|
||||
return {
|
||||
id: `edge:${fromNodeId}:${toNodeId}:parent_of`,
|
||||
edgeType: "parent_of",
|
||||
workspaceId,
|
||||
fromNodeId,
|
||||
toNodeId,
|
||||
};
|
||||
}
|
||||
|
||||
function buildDocumentCapabilities(childCount: number): TreeProjectionCapability[] {
|
||||
const capabilities: TreeProjectionCapability[] = [
|
||||
"open",
|
||||
"drag",
|
||||
"drop",
|
||||
"select",
|
||||
"create-child",
|
||||
"rename",
|
||||
"archive",
|
||||
"restore",
|
||||
"context-menu",
|
||||
"reorder",
|
||||
];
|
||||
if (childCount > 0) {
|
||||
capabilities.unshift("expand");
|
||||
}
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
function buildAssetFolderCapabilities(childCount: number): TreeProjectionCapability[] {
|
||||
const capabilities: TreeProjectionCapability[] = [
|
||||
"open-asset",
|
||||
"select",
|
||||
"context-menu",
|
||||
];
|
||||
if (childCount > 0) {
|
||||
capabilities.unshift("expand");
|
||||
}
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
function buildLeafCapabilities(openAsset: boolean): TreeProjectionCapability[] {
|
||||
const capabilities: TreeProjectionCapability[] = ["select", "context-menu"];
|
||||
capabilities.unshift(openAsset ? "open-asset" : "open");
|
||||
return capabilities;
|
||||
}
|
||||
|
||||
function classifyGenericAssetKind(asset: MediaAsset): TreeProjectionAssetKind {
|
||||
const assetType = String(asset.asset_type ?? "").trim().toLowerCase();
|
||||
const name = String(asset.file_name ?? "").trim().toLowerCase();
|
||||
const mimeType = String(asset.mime_type ?? "").trim().toLowerCase();
|
||||
const ext = name.includes(".") ? name.split(".").pop() ?? "" : "";
|
||||
|
||||
if (assetType === "mindmap") return "mindmap";
|
||||
if (assetType === "luckysheet") return "table";
|
||||
if (ext === "pdf" || mimeType.includes("pdf")) return "pdf";
|
||||
if (ext === "epub" || mimeType.includes("epub")) return "book";
|
||||
if (mimeType.startsWith("image/")) return "image";
|
||||
if (mimeType.startsWith("video/")) return "video";
|
||||
if (mimeType.startsWith("audio/")) return "audio";
|
||||
return "file";
|
||||
}
|
||||
|
||||
function classifyResourceKind(assetKind: TreeProjectionAssetKind): TreeProjectionResourceKind {
|
||||
switch (assetKind) {
|
||||
case "mindmap":
|
||||
return "mindmap";
|
||||
case "table":
|
||||
return "table";
|
||||
case "book":
|
||||
return "book";
|
||||
case "pdf":
|
||||
return "pdf";
|
||||
default:
|
||||
return "asset";
|
||||
}
|
||||
}
|
||||
|
||||
function classifyNodeType(assetKind: TreeProjectionAssetKind): TreeProjectionNodeType {
|
||||
switch (assetKind) {
|
||||
case "mindmap":
|
||||
return "mindmap";
|
||||
case "table":
|
||||
return "table";
|
||||
case "book":
|
||||
return "book";
|
||||
case "pdf":
|
||||
return "pdf";
|
||||
default:
|
||||
return "asset";
|
||||
}
|
||||
}
|
||||
|
||||
function classifyIconHint(assetKind: TreeProjectionAssetKind): string {
|
||||
switch (assetKind) {
|
||||
case "mindmap":
|
||||
return "mindmap";
|
||||
case "table":
|
||||
return "table";
|
||||
case "book":
|
||||
return "book";
|
||||
case "pdf":
|
||||
return "pdf";
|
||||
case "image":
|
||||
return "image";
|
||||
case "video":
|
||||
return "video";
|
||||
case "audio":
|
||||
return "audio";
|
||||
default:
|
||||
return "file";
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeAsset(asset: MediaAsset): NormalizedFileTreeAsset {
|
||||
const assetKind = classifyGenericAssetKind(asset);
|
||||
return {
|
||||
id: asset.id,
|
||||
documentId: asset.document_id,
|
||||
workspaceId: asset.workspace_id ?? null,
|
||||
title: asset.file_name?.trim() || "附件",
|
||||
resourceKind: classifyResourceKind(assetKind),
|
||||
assetKind,
|
||||
iconHint: classifyIconHint(assetKind),
|
||||
nodeType: classifyNodeType(assetKind),
|
||||
};
|
||||
}
|
||||
|
||||
function buildAssetCollections(input: BuildKernelFileTreeProjectionInput) {
|
||||
const assetsByDoc = new Map<string, NormalizedFileTreeAsset[]>();
|
||||
const assetById = new Map<string, NormalizedFileTreeAsset>();
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const asset of [
|
||||
...(input.mediaAssets ?? []),
|
||||
...(input.mindmapAssets ?? []),
|
||||
...(input.tableAssets ?? []),
|
||||
]) {
|
||||
if (!asset?.id || seen.has(asset.id)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(asset.id);
|
||||
const normalized = normalizeAsset(asset);
|
||||
const bucket = assetsByDoc.get(normalized.documentId) ?? [];
|
||||
bucket.push(normalized);
|
||||
assetsByDoc.set(normalized.documentId, bucket);
|
||||
assetById.set(normalized.id, normalized);
|
||||
}
|
||||
|
||||
const childAssetIdsByParentId = new Map<string, string[]>();
|
||||
Object.entries(input.mindmapAssetChildren ?? {}).forEach(([parentAssetId, childIds]) => {
|
||||
childAssetIdsByParentId.set(
|
||||
parentAssetId,
|
||||
(childIds ?? []).filter((childId) => typeof childId === "string" && childId.trim().length > 0),
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
assetsByDoc,
|
||||
assetById,
|
||||
childAssetIdsByParentId,
|
||||
};
|
||||
}
|
||||
|
||||
function makeFallbackNode(item: KernelFileTreeProjectionItem): SidebarTreeNode {
|
||||
const documentId =
|
||||
item.resourceMeta.documentId ??
|
||||
(item.rowKind === "document" ? item.nodeId : item.parentNodeId ?? item.nodeId);
|
||||
return {
|
||||
access_scope: "private",
|
||||
id: documentId,
|
||||
workspace_id: item.resourceMeta.workspaceId ?? "",
|
||||
title: item.title,
|
||||
parent_id: item.rowKind === "document" ? item.parentNodeId : documentId,
|
||||
sort_order: item.position,
|
||||
is_starred: false,
|
||||
is_template: false,
|
||||
created_at: "",
|
||||
updated_at: null,
|
||||
children: [],
|
||||
kernel: {
|
||||
nodeType: "page",
|
||||
depth: item.depth,
|
||||
position: item.position,
|
||||
childCount: item.childCount,
|
||||
expandedByDefault: item.expandedByDefault,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeFallbackAsset(item: KernelFileTreeProjectionItem): MediaAsset {
|
||||
const assetKind = item.resourceMeta.assetKind ?? "unknown";
|
||||
const resourceKind = item.resourceMeta.resourceKind;
|
||||
const assetType =
|
||||
assetKind === "mindmap"
|
||||
? "mindmap"
|
||||
: assetKind === "table" || resourceKind === "table"
|
||||
? "luckysheet"
|
||||
: assetKind;
|
||||
return {
|
||||
id: item.resourceMeta.assetId ?? item.nodeId,
|
||||
workspace_id: item.resourceMeta.workspaceId ?? "",
|
||||
document_id: item.resourceMeta.documentId ?? "",
|
||||
asset_type: assetType,
|
||||
file_url: null,
|
||||
thumbnail_url: null,
|
||||
bucket: null,
|
||||
storage_path: null,
|
||||
file_name: item.title,
|
||||
file_size: null,
|
||||
mime_type: null,
|
||||
ocr_payload: undefined,
|
||||
ocr_strategy: null,
|
||||
ocr_text: null,
|
||||
ocr_status: null,
|
||||
signed_url: null,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
};
|
||||
}
|
||||
|
||||
export function getDocIdFromFileTreeItem(item: KernelFileTreeProjectionItem): string {
|
||||
if (item.resourceMeta.documentId) {
|
||||
return item.resourceMeta.documentId;
|
||||
}
|
||||
if (item.rowKind === "document") {
|
||||
return item.nodeId;
|
||||
}
|
||||
const rowId = readRowId(item.rowId);
|
||||
if (rowId?.startsWith("index:")) {
|
||||
return rowId.slice("index:".length);
|
||||
}
|
||||
return item.parentNodeId ?? item.nodeId;
|
||||
}
|
||||
|
||||
export function resolveFileTreeRowNode(
|
||||
item: KernelFileTreeProjectionItem,
|
||||
nodeById?: Map<string, SidebarTreeNode>,
|
||||
): SidebarTreeNode {
|
||||
const documentId = getDocIdFromFileTreeItem(item);
|
||||
return nodeById?.get(documentId) ?? makeFallbackNode(item);
|
||||
}
|
||||
|
||||
export function resolveFileTreeRowAsset(
|
||||
item: KernelFileTreeProjectionItem,
|
||||
assetById?: Map<string, MediaAsset>,
|
||||
): MediaAsset {
|
||||
const assetId = item.resourceMeta.assetId ?? item.nodeId;
|
||||
return assetById?.get(assetId) ?? makeFallbackAsset(item);
|
||||
}
|
||||
|
||||
export function isKernelFileTreeProjection(value: unknown): value is KernelFileTreeProjection {
|
||||
return (
|
||||
Boolean(value) &&
|
||||
typeof value === "object" &&
|
||||
(value as KernelFileTreeProjection).projection === "file_tree" &&
|
||||
Array.isArray((value as KernelFileTreeProjection).items) &&
|
||||
Array.isArray((value as KernelFileTreeProjection).edges)
|
||||
);
|
||||
}
|
||||
|
||||
export function buildKernelFileTreeProjection(
|
||||
input: BuildKernelFileTreeProjectionInput,
|
||||
): KernelFileTreeProjection {
|
||||
const uniqueDocuments = dedupeDocuments(input.documents);
|
||||
const documentById = new Map(uniqueDocuments.map((document) => [document.id, document]));
|
||||
const childrenByParentId = buildDocumentChildren(uniqueDocuments);
|
||||
const { assetsByDoc, assetById, childAssetIdsByParentId } = buildAssetCollections(input);
|
||||
const rootNodeId = input.rootNodeId?.trim() || null;
|
||||
const roots =
|
||||
rootNodeId && documentById.has(rootNodeId)
|
||||
? [rootNodeId]
|
||||
: sortDocuments(
|
||||
uniqueDocuments.filter((document) => {
|
||||
const parentId = document.parent_id?.trim() || null;
|
||||
return !parentId || !documentById.has(parentId);
|
||||
}),
|
||||
).map((document) => document.id);
|
||||
|
||||
const items: KernelFileTreeProjectionItem[] = [];
|
||||
const edges: KernelFileTreeProjectionEdge[] = [];
|
||||
|
||||
const walk = (documentId: string, depth: number) => {
|
||||
const document = documentById.get(documentId);
|
||||
if (!document) {
|
||||
return;
|
||||
}
|
||||
|
||||
const childDocuments = childrenByParentId.get(documentId) ?? [];
|
||||
const documentAssets = assetsByDoc.get(documentId) ?? [];
|
||||
const nestedChildIds = new Set<string>();
|
||||
documentAssets.forEach((asset) => {
|
||||
const childIds = childAssetIdsByParentId.get(asset.id) ?? [];
|
||||
childIds.forEach((childId) => nestedChildIds.add(childId));
|
||||
});
|
||||
const directAssets = documentAssets.filter((asset) => !nestedChildIds.has(asset.id));
|
||||
const childCount = 1 + directAssets.length + childDocuments.length;
|
||||
const parentNodeId = depth === 0 ? null : document.parent_id ?? null;
|
||||
const workspaceId = document.workspace_id ?? null;
|
||||
|
||||
items.push({
|
||||
rowId: `doc:${document.id}`,
|
||||
rowKind: "document",
|
||||
nodeId: document.id,
|
||||
parentNodeId,
|
||||
nodeType: "page",
|
||||
projectionKind: "file_tree",
|
||||
title: document.title ?? "无标题",
|
||||
depth,
|
||||
position: document.sort_order ?? null,
|
||||
childCount,
|
||||
expandable: childCount > 0,
|
||||
expandedByDefault: true,
|
||||
capabilities: buildDocumentCapabilities(childCount),
|
||||
resourceMeta: {
|
||||
resourceKind: "document",
|
||||
documentId: document.id,
|
||||
workspaceId,
|
||||
iconHint: "page",
|
||||
},
|
||||
iconHint: "page",
|
||||
});
|
||||
|
||||
const indexNodeId = `index:${document.id}`;
|
||||
items.push({
|
||||
rowId: indexNodeId,
|
||||
rowKind: "index",
|
||||
nodeId: indexNodeId,
|
||||
parentNodeId: document.id,
|
||||
nodeType: "index",
|
||||
projectionKind: "file_tree",
|
||||
title: "index.md",
|
||||
depth: depth + 1,
|
||||
position: 0,
|
||||
childCount: 0,
|
||||
expandable: false,
|
||||
expandedByDefault: false,
|
||||
capabilities: buildLeafCapabilities(false),
|
||||
resourceMeta: {
|
||||
resourceKind: "index",
|
||||
documentId: document.id,
|
||||
workspaceId,
|
||||
iconHint: "index",
|
||||
},
|
||||
iconHint: "index",
|
||||
});
|
||||
edges.push(makeProjectionEdge(document.id, indexNodeId, workspaceId));
|
||||
|
||||
let assetPosition = 1;
|
||||
directAssets.forEach((asset) => {
|
||||
const childIds = childAssetIdsByParentId.get(asset.id) ?? [];
|
||||
if (childIds.length > 0) {
|
||||
const folderNodeId = `asset-folder:${asset.id}`;
|
||||
items.push({
|
||||
rowId: folderNodeId,
|
||||
rowKind: "asset_folder",
|
||||
nodeId: folderNodeId,
|
||||
parentNodeId: document.id,
|
||||
nodeType: "mindmap",
|
||||
projectionKind: "file_tree",
|
||||
title: asset.title.replace(/\.json$/i, ""),
|
||||
depth: depth + 1,
|
||||
position: assetPosition,
|
||||
childCount: childIds.length,
|
||||
expandable: true,
|
||||
expandedByDefault: false,
|
||||
capabilities: buildAssetFolderCapabilities(childIds.length),
|
||||
resourceMeta: {
|
||||
resourceKind: asset.resourceKind,
|
||||
documentId: asset.documentId,
|
||||
assetId: asset.id,
|
||||
workspaceId: asset.workspaceId,
|
||||
assetKind: asset.assetKind,
|
||||
iconHint: "mindmap",
|
||||
},
|
||||
iconHint: "mindmap",
|
||||
});
|
||||
edges.push(makeProjectionEdge(document.id, folderNodeId, workspaceId));
|
||||
|
||||
childIds.forEach((childAssetId, index) => {
|
||||
const childAsset = assetById.get(childAssetId);
|
||||
if (!childAsset) {
|
||||
return;
|
||||
}
|
||||
const childNodeId = `asset:${childAsset.id}`;
|
||||
items.push({
|
||||
rowId: childNodeId,
|
||||
rowKind: "asset",
|
||||
nodeId: childNodeId,
|
||||
parentNodeId: folderNodeId,
|
||||
nodeType: childAsset.nodeType,
|
||||
projectionKind: "file_tree",
|
||||
title: childAsset.title,
|
||||
depth: depth + 2,
|
||||
position: index,
|
||||
childCount: 0,
|
||||
expandable: false,
|
||||
expandedByDefault: false,
|
||||
capabilities: buildLeafCapabilities(true),
|
||||
resourceMeta: {
|
||||
resourceKind: childAsset.resourceKind,
|
||||
documentId: childAsset.documentId,
|
||||
assetId: childAsset.id,
|
||||
workspaceId: childAsset.workspaceId,
|
||||
assetKind: childAsset.assetKind,
|
||||
iconHint: childAsset.iconHint,
|
||||
},
|
||||
iconHint: childAsset.iconHint,
|
||||
});
|
||||
edges.push(makeProjectionEdge(folderNodeId, childNodeId, workspaceId));
|
||||
});
|
||||
|
||||
assetPosition += 1;
|
||||
return;
|
||||
}
|
||||
|
||||
const assetNodeId = `asset:${asset.id}`;
|
||||
items.push({
|
||||
rowId: assetNodeId,
|
||||
rowKind: "asset",
|
||||
nodeId: assetNodeId,
|
||||
parentNodeId: document.id,
|
||||
nodeType: asset.nodeType,
|
||||
projectionKind: "file_tree",
|
||||
title: asset.title,
|
||||
depth: depth + 1,
|
||||
position: assetPosition,
|
||||
childCount: 0,
|
||||
expandable: false,
|
||||
expandedByDefault: false,
|
||||
capabilities: buildLeafCapabilities(true),
|
||||
resourceMeta: {
|
||||
resourceKind: asset.resourceKind,
|
||||
documentId: asset.documentId,
|
||||
assetId: asset.id,
|
||||
workspaceId: asset.workspaceId,
|
||||
assetKind: asset.assetKind,
|
||||
iconHint: asset.iconHint,
|
||||
},
|
||||
iconHint: asset.iconHint,
|
||||
});
|
||||
edges.push(makeProjectionEdge(document.id, assetNodeId, workspaceId));
|
||||
assetPosition += 1;
|
||||
});
|
||||
|
||||
childDocuments.forEach((childDocument) => {
|
||||
walk(childDocument.id, depth + 1);
|
||||
});
|
||||
};
|
||||
|
||||
roots.forEach((documentId) => walk(documentId, 0));
|
||||
|
||||
return {
|
||||
projectionId: `kernel_projection:file_tree:${rootNodeId ?? "root"}`,
|
||||
projection: "file_tree",
|
||||
rootNodeId,
|
||||
items,
|
||||
edges,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import "server-only";
|
||||
|
||||
const DEFAULT_MNOTE_WEB_INTERNAL_URL = "http://127.0.0.1:3104";
|
||||
const DEFAULT_MNOTE_WEB_INTERNAL_URL_CANDIDATES = [
|
||||
DEFAULT_MNOTE_WEB_INTERNAL_URL,
|
||||
"http://localhost:3104",
|
||||
];
|
||||
const MNOTE_WEB_PROBE_PATH = "/health";
|
||||
const RESOLVE_CACHE_TTL_MS = 30_000;
|
||||
|
||||
let cachedMnoteWebInternalUrl = "";
|
||||
let cachedMnoteWebInternalUrlAt = 0;
|
||||
let pendingMnoteWebInternalUrl: Promise<string> | null = null;
|
||||
|
||||
const normalizeMnoteWebInternalUrl = (raw?: string | null) => {
|
||||
const value = String(raw || "").trim().replace(/\/+$/, "");
|
||||
if (!value) return "";
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
return "";
|
||||
}
|
||||
return url.toString().replace(/\/+$/, "");
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const getMnoteWebInternalUrlCandidates = () => {
|
||||
const candidates: string[] = [];
|
||||
const push = (value?: string | null) => {
|
||||
const normalized = normalizeMnoteWebInternalUrl(value);
|
||||
if (!normalized) return;
|
||||
if (!candidates.includes(normalized)) {
|
||||
candidates.push(normalized);
|
||||
}
|
||||
};
|
||||
|
||||
push(process.env.MNOTE_WEB_INTERNAL_URL);
|
||||
|
||||
for (const raw of String(process.env.MNOTE_WEB_INTERNAL_URL_CANDIDATES || "").split(",")) {
|
||||
push(raw);
|
||||
}
|
||||
|
||||
for (const candidate of DEFAULT_MNOTE_WEB_INTERNAL_URL_CANDIDATES) {
|
||||
push(candidate);
|
||||
}
|
||||
|
||||
return candidates.length > 0 ? candidates : [DEFAULT_MNOTE_WEB_INTERNAL_URL];
|
||||
};
|
||||
|
||||
const probeMnoteWebInternalUrl = async (candidate: string) => {
|
||||
try {
|
||||
const url = new URL(MNOTE_WEB_PROBE_PATH, `${candidate}/`);
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
redirect: "follow",
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(2_500),
|
||||
});
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const resolveMnoteWebInternalUrl = async () => {
|
||||
const now = Date.now();
|
||||
if (cachedMnoteWebInternalUrl && now - cachedMnoteWebInternalUrlAt < RESOLVE_CACHE_TTL_MS) {
|
||||
return cachedMnoteWebInternalUrl;
|
||||
}
|
||||
|
||||
if (pendingMnoteWebInternalUrl) {
|
||||
return pendingMnoteWebInternalUrl;
|
||||
}
|
||||
|
||||
pendingMnoteWebInternalUrl = (async () => {
|
||||
const candidates = getMnoteWebInternalUrlCandidates();
|
||||
for (const candidate of candidates) {
|
||||
if (await probeMnoteWebInternalUrl(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return candidates[0] || DEFAULT_MNOTE_WEB_INTERNAL_URL;
|
||||
})();
|
||||
|
||||
try {
|
||||
const resolved = await pendingMnoteWebInternalUrl;
|
||||
cachedMnoteWebInternalUrl = resolved;
|
||||
cachedMnoteWebInternalUrlAt = Date.now();
|
||||
return resolved;
|
||||
} finally {
|
||||
pendingMnoteWebInternalUrl = null;
|
||||
}
|
||||
};
|
||||
@@ -11,12 +11,41 @@ describe("runtime-config public projection", () => {
|
||||
expect("mnoteWebTreeShellEnabled" in (runtime as Record<string, unknown>)).toBe(false);
|
||||
});
|
||||
|
||||
it("允许通过新 runtime 配置显式选择树 renderer family", () => {
|
||||
window.__MNOTE_RUNTIME_CONFIG__ = {
|
||||
treeRendererFamily: "rust_family",
|
||||
};
|
||||
|
||||
const runtime = getMnoteRuntimeConfig();
|
||||
|
||||
expect(runtime.treeRendererFamily).toBe("rust_family");
|
||||
});
|
||||
|
||||
it("树 renderer family 别名应归一到 rust_family", () => {
|
||||
window.__MNOTE_RUNTIME_CONFIG__ = {
|
||||
treeRendererFamily: "tree_shell" as "rust_family",
|
||||
};
|
||||
|
||||
const runtime = getMnoteRuntimeConfig();
|
||||
|
||||
expect(runtime.treeRendererFamily).toBe("rust_family");
|
||||
});
|
||||
|
||||
it("树 renderer family 缺省时应回落到 react", () => {
|
||||
const runtime = getMnoteRuntimeConfig();
|
||||
|
||||
expect(runtime.treeRendererFamily).toBe("react");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
delete process.env.NEXT_PUBLIC_MNOTE_WEB_BASE_URL;
|
||||
delete process.env.MNOTE_WEB_BASE_URL;
|
||||
delete process.env.NEXT_PUBLIC_MNOTE_WEB_TREE_SHELL_ENABLED;
|
||||
delete process.env.MNOTE_WEB_TREE_SHELL_ENABLED;
|
||||
delete process.env.NEXT_PUBLIC_TREE_RENDERER_FAMILY;
|
||||
delete process.env.TREE_RENDERER_FAMILY;
|
||||
delete window.__MNOTE_RUNTIME_CONFIG__;
|
||||
});
|
||||
|
||||
it("即使保留 legacy env 也不应回注 mnote-web runtime", () => {
|
||||
|
||||
@@ -35,6 +35,11 @@ export type MnoteRuntimeConfig = {
|
||||
* 说明:开启后,未显式传入 query host 的文档页会优先回退到 blocknote。
|
||||
*/
|
||||
documentEditorBlocknoteKillSwitch?: boolean;
|
||||
/**
|
||||
* 树域 renderer family 选择。
|
||||
* 说明:默认仍为 react;`rust_family` 只作为渐进切流开关,不代表已完全切主路径。
|
||||
*/
|
||||
treeRendererFamily?: "react" | "rust_family";
|
||||
/**
|
||||
* 是否为桌面端(Electron)运行。
|
||||
*/
|
||||
@@ -109,6 +114,25 @@ const parseDocumentEditorHost = (
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const parseTreeRendererFamily = (
|
||||
value: unknown,
|
||||
): MnoteRuntimeConfig["treeRendererFamily"] | undefined => {
|
||||
if (typeof value !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return undefined;
|
||||
}
|
||||
if (normalized === "rust_family" || normalized === "rust" || normalized === "tree_shell") {
|
||||
return "rust_family";
|
||||
}
|
||||
if (normalized === "react") {
|
||||
return "react";
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
function getServerNodeBuiltin<T>(moduleName: string): T | null {
|
||||
if (typeof window !== "undefined") {
|
||||
return null;
|
||||
@@ -167,6 +191,17 @@ const readFromEnv = (): MnoteRuntimeConfig => ({
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(parseTreeRendererFamily(
|
||||
process.env.NEXT_PUBLIC_TREE_RENDERER_FAMILY ??
|
||||
process.env.TREE_RENDERER_FAMILY,
|
||||
) !== undefined
|
||||
? {
|
||||
treeRendererFamily: parseTreeRendererFamily(
|
||||
process.env.NEXT_PUBLIC_TREE_RENDERER_FAMILY ??
|
||||
process.env.TREE_RENDERER_FAMILY,
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
onlyofficeBaseUrl: process.env.NEXT_PUBLIC_ONLYOFFICE_BASE_URL,
|
||||
onlyofficeStorageHostOverride: process.env.NEXT_PUBLIC_ONLYOFFICE_STORAGE_HOST_OVERRIDE,
|
||||
onlyofficeProxyOrigin: process.env.NEXT_PUBLIC_ONLYOFFICE_PROXY_ORIGIN,
|
||||
@@ -256,12 +291,15 @@ const normalizeRuntimeConfig = (cfg: MnoteRuntimeConfig): MnoteRuntimeConfig =>
|
||||
parseDocumentEditorHost(cfg.documentEditorHost) ?? "leptos_tiptap_island";
|
||||
const documentEditorBlocknoteKillSwitch =
|
||||
parseRuntimeBoolean(cfg.documentEditorBlocknoteKillSwitch) ?? false;
|
||||
const treeRendererFamily =
|
||||
parseTreeRendererFamily(cfg.treeRendererFamily) ?? "react";
|
||||
|
||||
return {
|
||||
...cfg,
|
||||
isDesktop,
|
||||
documentEditorHost,
|
||||
documentEditorBlocknoteKillSwitch,
|
||||
treeRendererFamily,
|
||||
onlyofficeBaseUrl,
|
||||
onlyofficeStorageHostOverride,
|
||||
onlyofficeProxyOrigin,
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { ConvexHttpClient } from "convex/browser";
|
||||
import type { SidebarDatasetListQueryResult } from "@/lib/sidebar-data";
|
||||
import type { KernelFileTreeProjection } from "@/lib/kernel-file-tree";
|
||||
import {
|
||||
buildDocumentBridgeContextWithActor,
|
||||
buildDocumentQueryEnvelope,
|
||||
type BridgeActor,
|
||||
} from "@/lib/documents/bridge";
|
||||
import { executeRustBridgeQuery } from "@/lib/documents/rust-runtime";
|
||||
|
||||
export async function resolveKernelFileTreeProjection(input: {
|
||||
client: ConvexHttpClient;
|
||||
request: Request;
|
||||
workspaceId: string;
|
||||
actor: BridgeActor;
|
||||
dataset: SidebarDatasetListQueryResult;
|
||||
rootNodeId?: string | null;
|
||||
depth?: number | null;
|
||||
}): Promise<KernelFileTreeProjection> {
|
||||
const context = buildDocumentBridgeContextWithActor({
|
||||
request: input.request,
|
||||
actor: input.actor,
|
||||
workspaceId: input.workspaceId,
|
||||
});
|
||||
|
||||
return executeRustBridgeQuery<KernelFileTreeProjection>({
|
||||
context,
|
||||
envelope: buildDocumentQueryEnvelope({
|
||||
name: "kernel.project_view",
|
||||
payload: {
|
||||
projection: "file_tree",
|
||||
workspaceId: input.workspaceId,
|
||||
rootNodeId: input.rootNodeId ?? null,
|
||||
depth: input.depth ?? null,
|
||||
includeEdges: true,
|
||||
includeContent: false,
|
||||
nodeTypes: ["page"],
|
||||
},
|
||||
}),
|
||||
data: input.dataset as unknown as Record<string, unknown>,
|
||||
});
|
||||
}
|
||||
|
||||
export function attachKernelFileTreeProjection<T extends SidebarDatasetListQueryResult>(input: {
|
||||
dataset: T;
|
||||
projection: KernelFileTreeProjection;
|
||||
}): T {
|
||||
return {
|
||||
...input.dataset,
|
||||
kernel_file_tree_projection: input.projection,
|
||||
};
|
||||
}
|
||||
@@ -9,9 +9,15 @@ import {
|
||||
type SidebarDatasetListQueryResult,
|
||||
} from "@/lib/sidebar-data";
|
||||
import type { WorkspaceSummary } from "@/lib/workspaces";
|
||||
import {
|
||||
attachKernelFileTreeProjection,
|
||||
resolveKernelFileTreeProjection,
|
||||
} from "@/lib/server/kernel-file-tree";
|
||||
import type { AuthContext } from "@/lib/auth/types";
|
||||
|
||||
type LoadSidebarDataFromConvexInput = {
|
||||
client: ConvexHttpClient;
|
||||
auth: AuthContext;
|
||||
fallbackName: string;
|
||||
requestedWorkspaceId?: string | null;
|
||||
};
|
||||
@@ -64,14 +70,29 @@ export async function loadSidebarDataFromConvex(
|
||||
const sidebarDataset = (await input.client.query(sidebarDatasetListQuery, {
|
||||
workspaceId: targetWorkspaceId,
|
||||
})) as SidebarDatasetListQueryResult;
|
||||
const normalizedDocuments = (sidebarDataset.documents ?? []) as DocumentRecord[];
|
||||
const syntheticRequest = new Request(`http://127.0.0.1:3000/api/sidebar?workspaceId=${targetWorkspaceId}`);
|
||||
const sidebarDatasetWithFileTree = attachKernelFileTreeProjection({
|
||||
dataset: sidebarDataset,
|
||||
projection: await resolveKernelFileTreeProjection({
|
||||
client: input.client,
|
||||
request: syntheticRequest,
|
||||
workspaceId: targetWorkspaceId,
|
||||
actor: {
|
||||
actorType: "user",
|
||||
actorId: input.auth.userId,
|
||||
sessionId: null,
|
||||
},
|
||||
dataset: sidebarDataset,
|
||||
}),
|
||||
});
|
||||
const normalizedDocuments = (sidebarDatasetWithFileTree.documents ?? []) as DocumentRecord[];
|
||||
|
||||
return {
|
||||
workspaces: sidebarDataset.workspaces ?? workspaces,
|
||||
workspaces: sidebarDatasetWithFileTree.workspaces ?? workspaces,
|
||||
activeWorkspaceId,
|
||||
targetWorkspaceId,
|
||||
sidebarDataset,
|
||||
sidebarInitialData: mapSidebarDatasetListQueryResultToInitialData(sidebarDataset),
|
||||
sidebarDataset: sidebarDatasetWithFileTree,
|
||||
sidebarInitialData: mapSidebarDatasetListQueryResultToInitialData(sidebarDatasetWithFileTree),
|
||||
documents: normalizedDocuments,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -137,6 +137,7 @@ describe("buildSidebarInitialData", () => {
|
||||
|
||||
expect(payload.activeWorkspaceId).toBe("ws_1");
|
||||
expect(payload.kernelSidebarProjection?.projection).toBe("sidebar_tree");
|
||||
expect(payload.kernelFileTreeProjection?.projection).toBe("file_tree");
|
||||
expect(payload.kernelSidebarTree?.map((item) => item.id)).toEqual(["doc_1"]);
|
||||
expect(payload.mindmapDocs).toEqual(["doc_1"]);
|
||||
expect(payload.mindmapAssetChildren).toEqual({
|
||||
@@ -251,6 +252,78 @@ describe("buildSidebarInitialData", () => {
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
kernel_file_tree_projection: {
|
||||
projectionId: "kernel_projection:file_tree:root",
|
||||
projection: "file_tree",
|
||||
rootNodeId: null,
|
||||
items: [
|
||||
{
|
||||
rowId: "doc:doc_1",
|
||||
rowKind: "document",
|
||||
nodeId: "doc_1",
|
||||
parentNodeId: null,
|
||||
nodeType: "page",
|
||||
projectionKind: "file_tree",
|
||||
title: "页面 1",
|
||||
depth: 0,
|
||||
position: 1,
|
||||
childCount: 1,
|
||||
expandable: true,
|
||||
expandedByDefault: true,
|
||||
capabilities: [
|
||||
"expand",
|
||||
"open",
|
||||
"drag",
|
||||
"drop",
|
||||
"select",
|
||||
"create-child",
|
||||
"rename",
|
||||
"archive",
|
||||
"restore",
|
||||
"context-menu",
|
||||
"reorder",
|
||||
],
|
||||
resourceMeta: {
|
||||
resourceKind: "document",
|
||||
documentId: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
iconHint: "page",
|
||||
},
|
||||
iconHint: "page",
|
||||
},
|
||||
{
|
||||
rowId: "index:doc_1",
|
||||
rowKind: "index",
|
||||
nodeId: "index:doc_1",
|
||||
parentNodeId: "doc_1",
|
||||
nodeType: "index",
|
||||
projectionKind: "file_tree",
|
||||
title: "index.md",
|
||||
depth: 1,
|
||||
position: 0,
|
||||
childCount: 0,
|
||||
expandable: false,
|
||||
expandedByDefault: false,
|
||||
capabilities: ["open", "select", "context-menu"],
|
||||
resourceMeta: {
|
||||
resourceKind: "index",
|
||||
documentId: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
iconHint: "index",
|
||||
},
|
||||
iconHint: "index",
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: "edge:doc_1:index:doc_1:parent_of",
|
||||
edgeType: "parent_of",
|
||||
workspaceId: "ws_1",
|
||||
fromNodeId: "doc_1",
|
||||
toNodeId: "index:doc_1",
|
||||
},
|
||||
],
|
||||
},
|
||||
trashed_documents: [],
|
||||
media_assets: [],
|
||||
trashed_media_assets: [],
|
||||
@@ -349,6 +422,78 @@ describe("buildSidebarInitialData", () => {
|
||||
},
|
||||
},
|
||||
],
|
||||
kernelFileTreeProjection: {
|
||||
projectionId: "kernel_projection:file_tree:root",
|
||||
projection: "file_tree",
|
||||
rootNodeId: null,
|
||||
items: [
|
||||
{
|
||||
rowId: "doc:doc_1",
|
||||
rowKind: "document",
|
||||
nodeId: "doc_1",
|
||||
parentNodeId: null,
|
||||
nodeType: "page",
|
||||
projectionKind: "file_tree",
|
||||
title: "页面 1",
|
||||
depth: 0,
|
||||
position: 1,
|
||||
childCount: 1,
|
||||
expandable: true,
|
||||
expandedByDefault: true,
|
||||
capabilities: [
|
||||
"expand",
|
||||
"open",
|
||||
"drag",
|
||||
"drop",
|
||||
"select",
|
||||
"create-child",
|
||||
"rename",
|
||||
"archive",
|
||||
"restore",
|
||||
"context-menu",
|
||||
"reorder",
|
||||
],
|
||||
resourceMeta: {
|
||||
resourceKind: "document",
|
||||
documentId: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
iconHint: "page",
|
||||
},
|
||||
iconHint: "page",
|
||||
},
|
||||
{
|
||||
rowId: "index:doc_1",
|
||||
rowKind: "index",
|
||||
nodeId: "index:doc_1",
|
||||
parentNodeId: "doc_1",
|
||||
nodeType: "index",
|
||||
projectionKind: "file_tree",
|
||||
title: "index.md",
|
||||
depth: 1,
|
||||
position: 0,
|
||||
childCount: 0,
|
||||
expandable: false,
|
||||
expandedByDefault: false,
|
||||
capabilities: ["open", "select", "context-menu"],
|
||||
resourceMeta: {
|
||||
resourceKind: "index",
|
||||
documentId: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
iconHint: "index",
|
||||
},
|
||||
iconHint: "index",
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{
|
||||
id: "edge:doc_1:index:doc_1:parent_of",
|
||||
edgeType: "parent_of",
|
||||
workspaceId: "ws_1",
|
||||
fromNodeId: "doc_1",
|
||||
toNodeId: "index:doc_1",
|
||||
},
|
||||
],
|
||||
},
|
||||
trashedDocuments: [],
|
||||
trashedMediaAssets: [],
|
||||
trashedMindmapAssets: [],
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import type { SidebarInitialData } from "@/components/sidebar/types";
|
||||
import type { DocumentRecord } from "@/lib/documents";
|
||||
import {
|
||||
buildKernelFileTreeProjection,
|
||||
isKernelFileTreeProjection,
|
||||
type KernelFileTreeProjection,
|
||||
} from "@/lib/kernel-file-tree";
|
||||
import {
|
||||
buildKernelSidebarProjection as buildProjectionContract,
|
||||
buildSidebarTreeFromKernelProjection,
|
||||
@@ -51,6 +56,8 @@ export type SidebarDatasetListQueryResult = {
|
||||
active_workspace_id: string;
|
||||
workspaces: WorkspaceSummary[];
|
||||
documents: DocumentRecord[];
|
||||
kernel_file_tree_projection?: KernelFileTreeProjection;
|
||||
kernelFileTreeProjection?: KernelFileTreeProjection;
|
||||
kernel_sidebar_projection?: KernelSidebarProjection;
|
||||
kernelSidebarProjection?: KernelSidebarProjection;
|
||||
trashed_documents: SidebarInitialData["trashedDocuments"];
|
||||
@@ -72,6 +79,14 @@ const EMPTY_KERNEL_SIDEBAR_PROJECTION: KernelSidebarProjection = {
|
||||
edges: [],
|
||||
};
|
||||
|
||||
const EMPTY_KERNEL_FILE_TREE_PROJECTION: KernelFileTreeProjection = {
|
||||
projectionId: "kernel_projection:file_tree:missing",
|
||||
projection: "file_tree",
|
||||
rootNodeId: null,
|
||||
items: [],
|
||||
edges: [],
|
||||
};
|
||||
|
||||
function isKernelSidebarProjection(value: unknown): value is KernelSidebarProjection {
|
||||
return (
|
||||
Boolean(value) &&
|
||||
@@ -105,6 +120,34 @@ function readKernelSidebarProjection(
|
||||
return EMPTY_KERNEL_SIDEBAR_PROJECTION;
|
||||
}
|
||||
|
||||
function readKernelFileTreeProjection(
|
||||
result: SidebarDatasetListQueryResult,
|
||||
): KernelFileTreeProjection {
|
||||
const snakeCaseProjection =
|
||||
"kernel_file_tree_projection" in result ? result.kernel_file_tree_projection : undefined;
|
||||
if (isKernelFileTreeProjection(snakeCaseProjection)) {
|
||||
return snakeCaseProjection;
|
||||
}
|
||||
|
||||
const camelCaseProjection =
|
||||
"kernelFileTreeProjection" in result ? result.kernelFileTreeProjection : undefined;
|
||||
if (isKernelFileTreeProjection(camelCaseProjection)) {
|
||||
return camelCaseProjection;
|
||||
}
|
||||
|
||||
if (Array.isArray(result.documents)) {
|
||||
return buildKernelFileTreeProjection({
|
||||
documents: result.documents,
|
||||
mediaAssets: result.media_assets,
|
||||
mindmapAssets: result.mindmap_assets,
|
||||
tableAssets: result.table_assets,
|
||||
mindmapAssetChildren: result.mindmap_asset_children,
|
||||
});
|
||||
}
|
||||
|
||||
return EMPTY_KERNEL_FILE_TREE_PROJECTION;
|
||||
}
|
||||
|
||||
function normalizeStringArray(values: Iterable<string>): string[] {
|
||||
return Array.from(new Set(values)).filter((value) => value.trim().length > 0);
|
||||
}
|
||||
@@ -262,11 +305,19 @@ export function buildSidebarDatasetListQueryResult(
|
||||
): SidebarDatasetListQueryResult {
|
||||
const derived = deriveSidebarDataset(input);
|
||||
const kernelSidebarProjection = buildProjectionContract(input.documents);
|
||||
const kernelFileTreeProjection = buildKernelFileTreeProjection({
|
||||
documents: input.documents,
|
||||
mediaAssets: input.mediaAssets,
|
||||
mindmapAssets: derived.mindmapAssets,
|
||||
tableAssets: derived.tableAssets,
|
||||
mindmapAssetChildren: derived.mindmapAssetChildren,
|
||||
});
|
||||
|
||||
return {
|
||||
active_workspace_id: input.activeWorkspaceId,
|
||||
workspaces: [...input.workspaces],
|
||||
documents: [...input.documents],
|
||||
kernel_file_tree_projection: kernelFileTreeProjection,
|
||||
kernel_sidebar_projection: kernelSidebarProjection,
|
||||
trashed_documents: [...input.trashedDocuments],
|
||||
media_assets: [...(input.mediaAssets ?? [])],
|
||||
@@ -284,6 +335,7 @@ export function mapSidebarDatasetListQueryResultToInitialData(
|
||||
result: SidebarDatasetListQueryResult,
|
||||
): SidebarInitialData {
|
||||
const kernelSidebarProjection = readKernelSidebarProjection(result);
|
||||
const kernelFileTreeProjection = readKernelFileTreeProjection(result);
|
||||
|
||||
return {
|
||||
activeWorkspaceId: result.active_workspace_id,
|
||||
@@ -294,6 +346,7 @@ export function mapSidebarDatasetListQueryResultToInitialData(
|
||||
records: result.documents,
|
||||
projection: kernelSidebarProjection,
|
||||
}),
|
||||
kernelFileTreeProjection,
|
||||
trashedDocuments: [...result.trashed_documents],
|
||||
trashedMediaAssets: [...result.trashed_media_assets],
|
||||
trashedMindmapAssets: [...result.trashed_mindmap_assets],
|
||||
|
||||
@@ -31,6 +31,58 @@ const baseSidebarData: SidebarInitialData = {
|
||||
updated_at: null,
|
||||
},
|
||||
],
|
||||
kernelFileTreeProjection: {
|
||||
projectionId: "kernel_projection:file_tree:root",
|
||||
projection: "file_tree",
|
||||
rootNodeId: null,
|
||||
items: [
|
||||
{
|
||||
rowId: "doc:root",
|
||||
rowKind: "document",
|
||||
nodeId: "root",
|
||||
parentNodeId: null,
|
||||
nodeType: "page",
|
||||
projectionKind: "file_tree",
|
||||
title: "Root",
|
||||
depth: 0,
|
||||
position: 0,
|
||||
childCount: 2,
|
||||
expandable: true,
|
||||
expandedByDefault: true,
|
||||
capabilities: ["expand", "open", "select"],
|
||||
resourceMeta: {
|
||||
resourceKind: "document",
|
||||
documentId: "root",
|
||||
workspaceId: "ws_1",
|
||||
iconHint: "page",
|
||||
},
|
||||
iconHint: "page",
|
||||
},
|
||||
{
|
||||
rowId: "index:root",
|
||||
rowKind: "index",
|
||||
nodeId: "index:root",
|
||||
parentNodeId: "root",
|
||||
nodeType: "index",
|
||||
projectionKind: "file_tree",
|
||||
title: "index.md",
|
||||
depth: 1,
|
||||
position: 0,
|
||||
childCount: 0,
|
||||
expandable: false,
|
||||
expandedByDefault: false,
|
||||
capabilities: ["open", "select"],
|
||||
resourceMeta: {
|
||||
resourceKind: "index",
|
||||
documentId: "root",
|
||||
workspaceId: "ws_1",
|
||||
iconHint: "index",
|
||||
},
|
||||
iconHint: "index",
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
},
|
||||
kernelSidebarProjection: {
|
||||
projectionId: "kernel_projection:sidebar_tree:workspace_root",
|
||||
projection: "sidebar_tree",
|
||||
@@ -172,6 +224,13 @@ describe("tree-stream/tree-delta", () => {
|
||||
items: [],
|
||||
edges: [],
|
||||
},
|
||||
kernelFileTreeProjection: {
|
||||
projectionId: "kernel_projection:file_tree:root",
|
||||
projection: "file_tree",
|
||||
rootNodeId: null,
|
||||
items: [],
|
||||
edges: [],
|
||||
},
|
||||
kernelSidebarTree: [],
|
||||
trashedDocuments: [],
|
||||
trashedMediaAssets: [],
|
||||
|
||||
@@ -28,6 +28,11 @@ function cloneSidebarData(data: SidebarInitialData): SidebarInitialData {
|
||||
...data,
|
||||
workspaces: [...data.workspaces],
|
||||
documents: [...data.documents],
|
||||
kernelFileTreeProjection: {
|
||||
...data.kernelFileTreeProjection,
|
||||
items: [...data.kernelFileTreeProjection.items],
|
||||
edges: [...data.kernelFileTreeProjection.edges],
|
||||
},
|
||||
kernelSidebarProjection: {
|
||||
...data.kernelSidebarProjection,
|
||||
items: [...data.kernelSidebarProjection.items],
|
||||
|
||||
@@ -51,6 +51,13 @@ describe("tree-stream/protocol", () => {
|
||||
active_workspace_id: "ws_1",
|
||||
workspaces: [],
|
||||
documents: [],
|
||||
kernel_file_tree_projection: {
|
||||
projectionId: "kernel_projection:file_tree:root",
|
||||
projection: "file_tree",
|
||||
rootNodeId: null,
|
||||
items: [],
|
||||
edges: [],
|
||||
},
|
||||
kernel_sidebar_projection: {
|
||||
projectionId: "kernel_projection:sidebar_tree:workspace_root",
|
||||
projection: "sidebar_tree",
|
||||
@@ -101,6 +108,13 @@ describe("tree-stream/protocol", () => {
|
||||
active_workspace_id: "ws_1",
|
||||
workspaces: [],
|
||||
documents: [],
|
||||
kernel_file_tree_projection: {
|
||||
projectionId: "kernel_projection:file_tree:root",
|
||||
projection: "file_tree",
|
||||
rootNodeId: null,
|
||||
items: [],
|
||||
edges: [],
|
||||
},
|
||||
kernel_sidebar_projection: {
|
||||
projectionId: "kernel_projection:sidebar_tree:workspace_root",
|
||||
projection: "sidebar_tree",
|
||||
@@ -121,6 +135,9 @@ describe("tree-stream/protocol", () => {
|
||||
|
||||
expect(snapshot).toMatchObject({
|
||||
activeWorkspaceId: "ws_1",
|
||||
kernelFileTreeProjection: {
|
||||
projection: "file_tree",
|
||||
},
|
||||
kernelSidebarProjection: {
|
||||
projection: "sidebar_tree",
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user