feat: 收口本地文件夹入口并推进 page aggregate rust-first
- 为 Rust Web 主入口补齐本地文件夹/云空间切换、最近目录与路径回填体验\n- 对齐 local markdown media 与 inline marks 的 Rust shell / TipTap converter 语义\n- 让 documents/page 优先消费 Rust page aggregate snapshot,并保留 TS fallback\n- 补强 tree live、local markdown 与主入口 smoke,并同步设计稿状态
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/convex/enabled", () => ({
|
||||
isConvexEnabled: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth/authContext", () => ({
|
||||
HttpError: class HttpError extends Error {
|
||||
status: number;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
},
|
||||
requireAuthContext: vi.fn(async () => ({
|
||||
userId: "user_1",
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/convex/route", () => ({
|
||||
getAuthedConvexClient: vi.fn(async () => ({
|
||||
auth: { userId: "user_1" },
|
||||
client: {
|
||||
query: vi.fn(async () => ({
|
||||
id: "doc_1",
|
||||
workspace_id: "ws_1",
|
||||
title: "Next fallback 页面",
|
||||
updated_at: null,
|
||||
can_edit: true,
|
||||
})),
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/rust-runtime", () => ({
|
||||
resolveRustBridgeQueryPlan: vi.fn(async () => ({
|
||||
kind: "query",
|
||||
queryName: "documents.content.get",
|
||||
functionName: "documents:getContent",
|
||||
workspaceId: "ws_1",
|
||||
requestId: "req_next",
|
||||
traceId: "trace_next",
|
||||
actorId: "user_1",
|
||||
payloadJson: "{}",
|
||||
argsJson: { id: "doc_1" },
|
||||
})),
|
||||
executeRustBridgeQueryTransport: vi.fn(async () => ({
|
||||
content: [{ id: "block_1", type: "paragraph", content: [] }],
|
||||
revision: 1,
|
||||
conflict_detection_key: "doc_1:1",
|
||||
pageSubtree: null,
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/mnote-web/internal-url", () => ({
|
||||
resolveMnoteWebInternalUrl: vi.fn(async () => "http://127.0.0.1:3104"),
|
||||
}));
|
||||
|
||||
import { GET } from "@/app/api/documents/page/route";
|
||||
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||||
import { resolveMnoteWebInternalUrl } from "@/lib/mnote-web/internal-url";
|
||||
|
||||
function rustPageAggregateSnapshot() {
|
||||
return {
|
||||
schema: "mnote.page_aggregate.v1" as const,
|
||||
projectionVersion: 1,
|
||||
source: "KernelProjection",
|
||||
identity: { documentId: "doc_1", workspaceId: "ws_1" },
|
||||
head: {
|
||||
title: "Rust 聚合页面",
|
||||
updatedAt: null,
|
||||
permissions: {
|
||||
readOnly: false,
|
||||
disableDownload: false,
|
||||
disableCopy: false,
|
||||
},
|
||||
},
|
||||
layout: { pageOptions: { wideLayout: false } },
|
||||
body: { content: [], revision: 7, conflictDetectionKey: "doc_1:7" },
|
||||
tree: { pageSubtree: null },
|
||||
stats: { wordCount: 0, characterCount: 0, blockCount: 0, todoTotal: 0, todoDone: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("documents/page route", () => {
|
||||
it("优先返回 Rust page aggregate snapshot,而不是重新组装 meta + content", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
schema: "mnote.page_aggregate.v1",
|
||||
result: rustPageAggregateSnapshot(),
|
||||
requestId: "req_rust",
|
||||
traceId: "trace_rust",
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
|
||||
const response = await GET(
|
||||
new Request("http://localhost/api/documents/page?documentId=doc_1&workspaceId=ws_1", {
|
||||
headers: {
|
||||
"authorization": "Bearer route-token",
|
||||
"cookie": "convex-auth=test-cookie",
|
||||
"x-request-id": "req_route",
|
||||
"x-trace-id": "trace_route",
|
||||
"x-session-id": "sess_route",
|
||||
"x-source-channel": "next-route",
|
||||
"x-source-client": "vitest",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const payload = await response.json() as {
|
||||
page: { schema?: string; identity: { documentId: string }; head: { title: string } };
|
||||
meta: { requestId: string; traceId: string; queryName: string };
|
||||
};
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(resolveMnoteWebInternalUrl).toHaveBeenCalled();
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:3104/api/page-aggregate/doc_1?workspaceId=ws_1",
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
cache: "no-store",
|
||||
signal: expect.any(AbortSignal),
|
||||
headers: expect.any(Headers),
|
||||
}),
|
||||
);
|
||||
const fetchHeaders = fetchMock.mock.calls[0]?.[1]?.headers as Headers;
|
||||
expect(fetchHeaders.get("accept")).toBe("application/json");
|
||||
expect(fetchHeaders.get("authorization")).toBe("Bearer route-token");
|
||||
expect(fetchHeaders.get("cookie")).toBe("convex-auth=test-cookie");
|
||||
expect(fetchHeaders.get("x-request-id")).toBe("req_route");
|
||||
expect(fetchHeaders.get("x-mnote-request-id")).toBe("req_route");
|
||||
expect(fetchHeaders.get("x-trace-id")).toBe("trace_route");
|
||||
expect(fetchHeaders.get("x-mnote-trace-id")).toBe("trace_route");
|
||||
expect(fetchHeaders.get("x-session-id")).toBe("sess_route");
|
||||
expect(fetchHeaders.get("x-mnote-session-id")).toBe("sess_route");
|
||||
expect(fetchHeaders.get("x-source-channel")).toBe("next-route");
|
||||
expect(fetchHeaders.get("x-mnote-source-channel")).toBe("next-route");
|
||||
expect(fetchHeaders.get("x-source-client")).toBe("vitest");
|
||||
expect(fetchHeaders.get("x-mnote-source-client")).toBe("vitest");
|
||||
expect(getAuthedConvexClient).not.toHaveBeenCalled();
|
||||
expect(payload.page.schema).toBe("mnote.page_aggregate.v1");
|
||||
expect(payload.page.identity.documentId).toBe("doc_1");
|
||||
expect(payload.page.head.title).toBe("Rust 聚合页面");
|
||||
expect(payload.meta).toEqual({
|
||||
requestId: "req_rust",
|
||||
traceId: "trace_rust",
|
||||
queryName: "documents.page.get",
|
||||
});
|
||||
});
|
||||
|
||||
it("Rust snapshot 返回畸形 projection 时保留 TS builder fallback", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
ok: true,
|
||||
schema: "mnote.page_aggregate.v1",
|
||||
result: {
|
||||
schema: "mnote.page_aggregate.v1",
|
||||
projectionVersion: 1,
|
||||
source: "KernelProjection",
|
||||
identity: { documentId: "doc_1", workspaceId: "ws_1" },
|
||||
head: { title: "错误页面", updatedAt: null },
|
||||
layout: {},
|
||||
body: { content: [], revision: "bad_revision", conflictDetectionKey: 7 },
|
||||
tree: { pageSubtree: null },
|
||||
stats: null,
|
||||
},
|
||||
requestId: "req_bad",
|
||||
traceId: "trace_bad",
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
|
||||
const response = await GET(
|
||||
new Request("http://localhost/api/documents/page?documentId=doc_1&workspaceId=ws_1", {
|
||||
headers: {
|
||||
"x-request-id": "req_next",
|
||||
"x-trace-id": "trace_next",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const payload = await response.json() as {
|
||||
page: { schema?: string; head: { title: string }; body: { revision: number | null } };
|
||||
meta: { requestId: string; traceId: string; queryName: string };
|
||||
};
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(getAuthedConvexClient).toHaveBeenCalled();
|
||||
expect(payload.page.schema).toBeUndefined();
|
||||
expect(payload.page.head.title).toBe("Next fallback 页面");
|
||||
expect(payload.page.body.revision).toBe(1);
|
||||
expect(payload.meta).toEqual({
|
||||
requestId: "req_next",
|
||||
traceId: "trace_next",
|
||||
queryName: "documents.page.get",
|
||||
});
|
||||
});
|
||||
|
||||
it("Rust internal base 不可信时直接 fallback,且不会外发凭据", async () => {
|
||||
vi.mocked(resolveMnoteWebInternalUrl).mockResolvedValueOnce("https://example.com");
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch");
|
||||
|
||||
const response = await GET(
|
||||
new Request("http://localhost/api/documents/page?documentId=doc_1&workspaceId=ws_1", {
|
||||
headers: {
|
||||
"authorization": "Bearer route-token",
|
||||
"cookie": "convex-auth=test-cookie",
|
||||
"x-request-id": "req_untrusted",
|
||||
"x-trace-id": "trace_untrusted",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const payload = await response.json() as {
|
||||
page: { schema?: string; head: { title: string } };
|
||||
meta: { requestId: string; traceId: string; queryName: string };
|
||||
};
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(getAuthedConvexClient).toHaveBeenCalled();
|
||||
expect(payload.page.schema).toBeUndefined();
|
||||
expect(payload.page.head.title).toBe("Next fallback 页面");
|
||||
expect(payload.meta).toEqual({
|
||||
requestId: "req_untrusted",
|
||||
traceId: "trace_untrusted",
|
||||
queryName: "documents.page.get",
|
||||
});
|
||||
});
|
||||
|
||||
it("Rust snapshot fetch 抛错时保留 TS builder fallback", async () => {
|
||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("timeout"));
|
||||
|
||||
const response = await GET(
|
||||
new Request("http://localhost/api/documents/page?documentId=doc_1&workspaceId=ws_1", {
|
||||
headers: {
|
||||
"x-request-id": "req_timeout",
|
||||
"x-trace-id": "trace_timeout",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const payload = await response.json() as {
|
||||
page: { schema?: string; head: { title: string } };
|
||||
meta: { requestId: string; traceId: string; queryName: string };
|
||||
};
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(getAuthedConvexClient).toHaveBeenCalled();
|
||||
expect(payload.page.schema).toBeUndefined();
|
||||
expect(payload.page.head.title).toBe("Next fallback 页面");
|
||||
expect(payload.meta).toEqual({
|
||||
requestId: "req_timeout",
|
||||
traceId: "trace_timeout",
|
||||
queryName: "documents.page.get",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/convex/enabled", () => ({
|
||||
isConvexEnabled: vi.fn(() => true),
|
||||
@@ -28,10 +28,8 @@ vi.mock("@/lib/api-utils", () => ({
|
||||
|
||||
vi.mock("@/lib/documents/page-command-adapter", () => ({
|
||||
executeDocumentCreateChildBridgeCommand: vi.fn(async () => new Response(JSON.stringify({ ok: true }))),
|
||||
executeDocumentEmbedBridgeCommand: vi.fn(async () => new Response(JSON.stringify({ ok: true }))),
|
||||
executeDocumentTemplateBridgeCommand: vi.fn(async () => new Response(JSON.stringify({ ok: true }))),
|
||||
executeDocumentEmptyTrashBridgeCommand: vi.fn(async () => new Response(JSON.stringify({ ok: true }))),
|
||||
executeDocumentPurgeBridgeCommand: vi.fn(async () => new Response(JSON.stringify({ ok: true }))),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/documents/page-write-command-adapter", () => ({
|
||||
@@ -72,18 +70,11 @@ vi.mock("@/lib/documents/page-aggregate-loader", () => ({
|
||||
}));
|
||||
|
||||
import { POST as postCreateChild } from "@/app/api/documents/create-child/route";
|
||||
import { POST as postDelete } from "@/app/api/documents/delete/route";
|
||||
import { POST as postEmbed } from "@/app/api/documents/embed/route";
|
||||
import { POST as postTemplate } from "@/app/api/documents/template/route";
|
||||
import { POST as postEmptyTrash } from "@/app/api/documents/empty-trash/route";
|
||||
import { POST as postPurge } from "@/app/api/documents/purge/route";
|
||||
import { POST as postRestore } from "@/app/api/documents/restore/route";
|
||||
import { POST as postTitle } from "@/app/api/documents/title/route";
|
||||
import { POST as postOptions } from "@/app/api/documents/options/route";
|
||||
import { POST as postSave } from "@/app/api/documents/save/route";
|
||||
import { POST as postCopyTree } from "@/app/api/documents/copy-tree/route";
|
||||
import { POST as postCreate } from "@/app/api/documents/create/route";
|
||||
import { POST as postMove } from "@/app/api/documents/move/route";
|
||||
import { GET as getPage } from "@/app/api/documents/page/route";
|
||||
import {
|
||||
executeDocumentCreateChildBridgeCommand,
|
||||
@@ -93,107 +84,12 @@ import {
|
||||
import { executePageWriteBridgeCommand } from "@/lib/documents/page-write-command-adapter";
|
||||
import { loadPageAggregate } from "@/lib/documents/page-aggregate-loader";
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("documents route adapters", () => {
|
||||
it("create route 作为 compat 壳委托 tree commands 主路径", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
requestId: "req_tree_create_1",
|
||||
traceId: "trace_tree_create_1",
|
||||
result: {
|
||||
action: "create",
|
||||
workspaceId: "ws_1",
|
||||
documentId: "doc_new",
|
||||
parentId: null,
|
||||
title: "无标题",
|
||||
sortOrder: 0,
|
||||
updatedAt: "2026-04-23T00:00:00Z",
|
||||
execution: {
|
||||
access_scope: "private",
|
||||
is_template: false,
|
||||
created_at: "2026-04-23T00:00:00Z",
|
||||
updated_at: "2026-04-23T00:00:00Z",
|
||||
},
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
|
||||
const response = await postCreate(new Request("http://localhost/api/documents/create", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ parentId: null }),
|
||||
}));
|
||||
const payload = await response.json() as {
|
||||
id: string;
|
||||
workspace_id: string;
|
||||
meta: { commandName: string };
|
||||
};
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://localhost/api/tree/commands",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.any(Headers),
|
||||
body: JSON.stringify({
|
||||
action: "create",
|
||||
parentId: null,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(payload.id).toBe("doc_new");
|
||||
expect(payload.workspace_id).toBe("ws_1");
|
||||
expect(payload.meta.commandName).toBe("tree.node.create");
|
||||
});
|
||||
|
||||
it("move route 作为 compat 壳委托 tree commands 主路径", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
requestId: "req_tree_move_1",
|
||||
traceId: "trace_tree_move_1",
|
||||
result: {
|
||||
action: "move",
|
||||
workspaceId: "ws_1",
|
||||
documentId: "doc_1",
|
||||
parentId: "parent_1",
|
||||
sortOrder: 2,
|
||||
updatedAt: "2026-04-23T00:00:00Z",
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
|
||||
const response = await postMove(new Request("http://localhost/api/documents/move", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ documentId: "doc_1", parentId: "parent_1", position: 2.7, workspaceId: "ws_1" }),
|
||||
}));
|
||||
const payload = await response.json() as {
|
||||
ok: boolean;
|
||||
meta: { commandName: string };
|
||||
};
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://localhost/api/tree/commands",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.any(Headers),
|
||||
body: JSON.stringify({
|
||||
action: "move",
|
||||
documentId: "doc_1",
|
||||
parentId: "parent_1",
|
||||
sortOrder: 2,
|
||||
workspaceId: "ws_1",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(payload.ok).toBe(true);
|
||||
expect(payload.meta.commandName).toBe("tree.subtree.move");
|
||||
});
|
||||
|
||||
it("title route 在树重命名兼容请求下委托 tree commands 主路径", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(
|
||||
@@ -260,117 +156,6 @@ describe("documents route adapters", () => {
|
||||
expect(executeDocumentCreateChildBridgeCommand).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("delete route 作为 compat 壳委托 tree commands 主路径", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
requestId: "req_tree_archive_1",
|
||||
traceId: "trace_tree_archive_1",
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
|
||||
const response = await postDelete(new Request("http://localhost/api/documents/delete", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ documentId: "doc_1", workspaceId: "ws_1" }),
|
||||
}));
|
||||
const payload = await response.json() as {
|
||||
success: boolean;
|
||||
meta: { commandName: string };
|
||||
};
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://localhost/api/tree/commands",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.any(Headers),
|
||||
body: JSON.stringify({
|
||||
action: "archive",
|
||||
documentId: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.meta.commandName).toBe("tree.node.archive");
|
||||
});
|
||||
|
||||
it("restore route 作为 compat 壳委托 tree commands 主路径", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
requestId: "req_tree_restore_1",
|
||||
traceId: "trace_tree_restore_1",
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
|
||||
const response = await postRestore(new Request("http://localhost/api/documents/restore", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ documentId: "doc_1", workspaceId: "ws_1" }),
|
||||
}));
|
||||
const payload = await response.json() as {
|
||||
success: boolean;
|
||||
meta: { commandName: string };
|
||||
};
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://localhost/api/tree/commands",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.any(Headers),
|
||||
body: JSON.stringify({
|
||||
action: "restore",
|
||||
documentId: "doc_1",
|
||||
workspaceId: "ws_1",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.meta.commandName).toBe("tree.node.restore");
|
||||
});
|
||||
|
||||
it("embed route 作为 compat 壳委托 tree commands 主路径", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
requestId: "req_tree_embed_1",
|
||||
traceId: "trace_tree_embed_1",
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
|
||||
const response = await postEmbed(new Request("http://localhost/api/documents/embed", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ sourceId: "doc_1", targetId: "doc_2" }),
|
||||
}));
|
||||
const payload = await response.json() as {
|
||||
ok: boolean;
|
||||
meta: { commandName: string };
|
||||
};
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://localhost/api/tree/commands",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.any(Headers),
|
||||
body: JSON.stringify({
|
||||
action: "embed",
|
||||
sourceId: "doc_1",
|
||||
targetId: "doc_2",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(payload.ok).toBe(true);
|
||||
expect(payload.meta.commandName).toBe("tree.node.embed");
|
||||
});
|
||||
|
||||
it("template route delegates to unified adapter", async () => {
|
||||
await postTemplate(new Request("http://localhost/api/documents/template", {
|
||||
method: "POST",
|
||||
@@ -387,87 +172,6 @@ describe("documents route adapters", () => {
|
||||
expect(executeDocumentEmptyTrashBridgeCommand).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("purge route 作为 compat 壳委托 tree commands 主路径", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
requestId: "req_tree_purge_1",
|
||||
traceId: "trace_tree_purge_1",
|
||||
result: {
|
||||
purged: true,
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
|
||||
const response = await postPurge(new Request("http://localhost/api/documents/purge", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ documentId: "doc_1" }),
|
||||
}));
|
||||
const payload = await response.json() as {
|
||||
success: boolean;
|
||||
purged: boolean;
|
||||
meta: { commandName: string };
|
||||
};
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://localhost/api/tree/commands",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.any(Headers),
|
||||
body: JSON.stringify({
|
||||
action: "purge",
|
||||
documentId: "doc_1",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(payload.success).toBe(true);
|
||||
expect(payload.purged).toBe(true);
|
||||
expect(payload.meta.commandName).toBe("tree.node.purge");
|
||||
});
|
||||
|
||||
it("copy-tree route 作为 compat 壳委托 tree commands 主路径", async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
requestId: "req_tree_copy_1",
|
||||
traceId: "trace_tree_copy_1",
|
||||
result: {
|
||||
items: [{ oldId: "doc_1", newId: "doc_2" }],
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
|
||||
const response = await postCopyTree(new Request("http://localhost/api/documents/copy-tree", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ targetParentId: null, items: [{ documentId: "doc_1", recursive: true }] }),
|
||||
}));
|
||||
const payload = await response.json() as {
|
||||
items: Array<{ oldId: string; newId: string }>;
|
||||
meta: { commandName: string };
|
||||
};
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://localhost/api/tree/commands",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.any(Headers),
|
||||
body: JSON.stringify({
|
||||
action: "copy",
|
||||
targetParentId: null,
|
||||
items: [{ documentId: "doc_1", recursive: true }],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(payload.items).toEqual([{ oldId: "doc_1", newId: "doc_2" }]);
|
||||
expect(payload.meta.commandName).toBe("tree.subtree.copy");
|
||||
});
|
||||
|
||||
it("page route delegates to unified aggregate loader", async () => {
|
||||
const response = await getPage(
|
||||
new Request("http://localhost/api/documents/page?documentId=doc_1&workspaceId=ws_1"),
|
||||
|
||||
@@ -6,11 +6,11 @@ import {
|
||||
buildDocumentQueryEnvelope,
|
||||
type BridgeContext,
|
||||
} from "@/lib/documents/bridge";
|
||||
import { resolveMnoteWebInternalUrl } from "@/lib/mnote-web/internal-url";
|
||||
import type { PageAggregateProjection } from "@/lib/documents/page-aggregate";
|
||||
import {
|
||||
buildPageAggregateFromDocumentPayloads,
|
||||
type DocumentContentPayload,
|
||||
type DocumentMetaPayload,
|
||||
} from "@/lib/documents/page-aggregate-builder";
|
||||
import {
|
||||
executeRustBridgeQueryTransport,
|
||||
@@ -21,13 +21,20 @@ const FORWARDED_REQUEST_HEADERS = [
|
||||
"cookie",
|
||||
"authorization",
|
||||
"x-request-id",
|
||||
"x-mnote-request-id",
|
||||
"x-trace-id",
|
||||
"x-mnote-trace-id",
|
||||
"x-session-id",
|
||||
"x-mnote-session-id",
|
||||
"x-source-channel",
|
||||
"x-mnote-source-channel",
|
||||
"x-source-client",
|
||||
"x-mnote-source-client",
|
||||
"user-agent",
|
||||
] as const;
|
||||
|
||||
const RUST_PAGE_AGGREGATE_TIMEOUT_MS = 3_000;
|
||||
|
||||
export type LoadedPageAggregate = {
|
||||
page: PageAggregateProjection;
|
||||
bridge: {
|
||||
@@ -44,14 +51,187 @@ function copyForwardHeaderIfPresent(target: Headers, source: Headers, name: stri
|
||||
}
|
||||
}
|
||||
|
||||
function readHeaderAlias(source: Headers, ...names: string[]) {
|
||||
for (const name of names) {
|
||||
const value = source.get(name);
|
||||
if (value) return value;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function copyNormalizedHeader(target: Headers, source: Headers, targetName: string, ...sourceNames: string[]) {
|
||||
const value = readHeaderAlias(source, ...sourceNames);
|
||||
if (value) {
|
||||
target.set(targetName, value);
|
||||
}
|
||||
}
|
||||
|
||||
function buildForwardHeaders(source: Headers) {
|
||||
const requestHeaders = new Headers({
|
||||
accept: "application/json",
|
||||
});
|
||||
copyForwardHeaderIfPresent(requestHeaders, source, "cookie");
|
||||
copyForwardHeaderIfPresent(requestHeaders, source, "authorization");
|
||||
copyNormalizedHeader(requestHeaders, source, "x-request-id", "x-request-id", "x-mnote-request-id");
|
||||
copyNormalizedHeader(requestHeaders, source, "x-mnote-request-id", "x-mnote-request-id", "x-request-id");
|
||||
copyNormalizedHeader(requestHeaders, source, "x-trace-id", "x-trace-id", "x-mnote-trace-id");
|
||||
copyNormalizedHeader(requestHeaders, source, "x-mnote-trace-id", "x-mnote-trace-id", "x-trace-id");
|
||||
copyNormalizedHeader(requestHeaders, source, "x-session-id", "x-session-id", "x-mnote-session-id");
|
||||
copyNormalizedHeader(requestHeaders, source, "x-mnote-session-id", "x-mnote-session-id", "x-session-id");
|
||||
copyNormalizedHeader(requestHeaders, source, "x-source-channel", "x-source-channel", "x-mnote-source-channel");
|
||||
copyNormalizedHeader(requestHeaders, source, "x-mnote-source-channel", "x-mnote-source-channel", "x-source-channel");
|
||||
copyNormalizedHeader(requestHeaders, source, "x-source-client", "x-source-client", "x-mnote-source-client");
|
||||
copyNormalizedHeader(requestHeaders, source, "x-mnote-source-client", "x-mnote-source-client", "x-source-client");
|
||||
copyForwardHeaderIfPresent(requestHeaders, source, "user-agent");
|
||||
return requestHeaders;
|
||||
}
|
||||
|
||||
type RustPageAggregateSnapshotResponse = {
|
||||
ok?: boolean;
|
||||
schema?: string;
|
||||
result?: unknown;
|
||||
requestId?: string | null;
|
||||
traceId?: string | null;
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object";
|
||||
}
|
||||
|
||||
function isBoolean(value: unknown) {
|
||||
return typeof value === "boolean";
|
||||
}
|
||||
|
||||
function isNullableNumber(value: unknown) {
|
||||
return value === null || typeof value === "number";
|
||||
}
|
||||
|
||||
function isNullableString(value: unknown) {
|
||||
return value === null || typeof value === "string";
|
||||
}
|
||||
|
||||
function isTrustedMnoteWebInternalUrl(raw: string) {
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
const hostname = url.hostname.replace(/^\[(.*)\]$/, "$1").trim().toLowerCase();
|
||||
return (
|
||||
hostname === "localhost" ||
|
||||
hostname === "127.0.0.1" ||
|
||||
hostname === "::1" ||
|
||||
hostname === "0.0.0.0" ||
|
||||
hostname === "::"
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isRustPageAggregateProjection(
|
||||
value: unknown,
|
||||
input: { documentId: string; workspaceId?: string | null },
|
||||
): value is PageAggregateProjection {
|
||||
if (!isRecord(value)) return false;
|
||||
const record = value as Record<string, unknown>;
|
||||
const identity = isRecord(record.identity) ? record.identity : null;
|
||||
const head = isRecord(record.head) ? record.head : null;
|
||||
const permissions = head && isRecord(head.permissions) ? head.permissions : null;
|
||||
const layout = isRecord(record.layout) ? record.layout : null;
|
||||
const pageOptions = layout && isRecord(layout.pageOptions) ? layout.pageOptions : null;
|
||||
const body = isRecord(record.body) ? record.body : null;
|
||||
const tree = isRecord(record.tree) ? record.tree : null;
|
||||
const stats = isRecord(record.stats) ? record.stats : null;
|
||||
const requestedWorkspaceId = input.workspaceId?.trim() || null;
|
||||
return (
|
||||
record.schema === "mnote.page_aggregate.v1" &&
|
||||
typeof record.projectionVersion === "number" &&
|
||||
typeof record.source === "string" &&
|
||||
Boolean(identity) &&
|
||||
identity.documentId === input.documentId &&
|
||||
typeof identity.workspaceId === "string" &&
|
||||
(!requestedWorkspaceId || identity.workspaceId === requestedWorkspaceId) &&
|
||||
Boolean(head) &&
|
||||
typeof head.title === "string" &&
|
||||
isNullableString(head.updatedAt) &&
|
||||
Boolean(permissions) &&
|
||||
isBoolean(permissions.readOnly) &&
|
||||
isBoolean(permissions.disableDownload) &&
|
||||
isBoolean(permissions.disableCopy) &&
|
||||
Boolean(layout) &&
|
||||
Boolean(pageOptions) &&
|
||||
Boolean(body) &&
|
||||
"content" in body &&
|
||||
isNullableNumber(body.revision) &&
|
||||
isNullableString(body.conflictDetectionKey) &&
|
||||
Boolean(tree) &&
|
||||
Object.prototype.hasOwnProperty.call(tree, "pageSubtree") &&
|
||||
Boolean(stats) &&
|
||||
typeof stats.wordCount === "number" &&
|
||||
typeof stats.characterCount === "number" &&
|
||||
typeof stats.blockCount === "number" &&
|
||||
typeof stats.todoTotal === "number" &&
|
||||
typeof stats.todoDone === "number"
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadPageAggregateFromRustSnapshot(input: {
|
||||
request: Request;
|
||||
documentId: string;
|
||||
workspaceId?: string | null;
|
||||
}): Promise<LoadedPageAggregate | null> {
|
||||
const internalBaseUrl = await resolveMnoteWebInternalUrl();
|
||||
if (!isTrustedMnoteWebInternalUrl(internalBaseUrl)) {
|
||||
return null;
|
||||
}
|
||||
const url = new URL(`/api/page-aggregate/${encodeURIComponent(input.documentId)}`, `${internalBaseUrl}/`);
|
||||
const workspaceId = input.workspaceId?.trim();
|
||||
if (workspaceId) {
|
||||
url.searchParams.set("workspaceId", workspaceId);
|
||||
}
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url.toString(), {
|
||||
method: "GET",
|
||||
headers: buildForwardHeaders(input.request.headers),
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(RUST_PAGE_AGGREGATE_TIMEOUT_MS),
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = await response.json().catch(() => null) as RustPageAggregateSnapshotResponse | null;
|
||||
const page = payload?.result;
|
||||
if (
|
||||
payload?.schema !== "mnote.page_aggregate.v1" ||
|
||||
!isRustPageAggregateProjection(page, input)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
page,
|
||||
bridge: {
|
||||
requestId: typeof payload.requestId === "string" && payload.requestId.trim() ? payload.requestId : "unknown",
|
||||
traceId: typeof payload.traceId === "string" && payload.traceId.trim() ? payload.traceId : "unknown",
|
||||
queryName: "documents.page.get",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function buildServerBridgeRequest(pathname: string): Promise<Request> {
|
||||
const headerList = await headers();
|
||||
const requestHeaders = new Headers();
|
||||
FORWARDED_REQUEST_HEADERS.forEach((name) => {
|
||||
copyForwardHeaderIfPresent(requestHeaders, headerList, name);
|
||||
});
|
||||
const internalBaseUrl = await resolveMnoteWebInternalUrl();
|
||||
|
||||
return new Request(`http://mnote.local${pathname}`, {
|
||||
return new Request(new URL(pathname, `${internalBaseUrl}/`).toString(), {
|
||||
method: "GET",
|
||||
headers: requestHeaders,
|
||||
});
|
||||
@@ -86,6 +266,11 @@ export async function loadPageAggregate(input: {
|
||||
documentId: string;
|
||||
workspaceId?: string | null;
|
||||
}): Promise<LoadedPageAggregate | null> {
|
||||
const rustSnapshot = await loadPageAggregateFromRustSnapshot(input);
|
||||
if (rustSnapshot) {
|
||||
return rustSnapshot;
|
||||
}
|
||||
|
||||
const { client } = await getAuthedConvexClient();
|
||||
const meta = await client.query(api.documents.getMeta, {
|
||||
id: input.documentId,
|
||||
|
||||
@@ -33,6 +33,9 @@ export interface PageAggregateTree {
|
||||
}
|
||||
|
||||
export interface PageAggregateProjection {
|
||||
schema?: "mnote.page_aggregate.v1";
|
||||
projectionVersion?: number;
|
||||
source?: "KernelProjection" | "CompatMetaContentJoin" | "Fixture" | string;
|
||||
identity: PageAggregateIdentity;
|
||||
head: PageAggregateHead;
|
||||
layout: PageAggregateLayout;
|
||||
|
||||
@@ -72,4 +72,151 @@ describe("tiptap-content-converter", () => {
|
||||
expect(roundTrip.blocks[4].props?.checked).toBe(true);
|
||||
expect(roundTrip.blocks[6].props?.language).toBe("ts");
|
||||
});
|
||||
|
||||
it("将 legacy media block 转成带链接的可见段落文本", () => {
|
||||
expect(
|
||||
tiptapDocFromBlocks([
|
||||
{
|
||||
id: "m1",
|
||||
type: "media",
|
||||
props: { name: "Spec", sourcePath: "assets/spec.pdf" },
|
||||
content: [],
|
||||
},
|
||||
] as never),
|
||||
).toEqual({
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
attrs: { blockId: "m1" },
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Spec",
|
||||
marks: [{ type: "link", attrs: { href: "assets/spec.pdf" } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("legacy media block 在 sourcePath 为空时使用 url 作为链接", () => {
|
||||
expect(
|
||||
tiptapDocFromBlocks([
|
||||
{
|
||||
id: "m-url",
|
||||
type: "media",
|
||||
props: { name: "Spec", sourcePath: "", url: "assets/from-url.pdf" },
|
||||
content: [],
|
||||
},
|
||||
] as never),
|
||||
).toEqual({
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
attrs: { blockId: "m-url" },
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Spec",
|
||||
marks: [{ type: "link", attrs: { href: "assets/from-url.pdf" } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("legacy media block 在 name 为空时使用 fileName 作为可见文本", () => {
|
||||
expect(
|
||||
tiptapDocFromBlocks([
|
||||
{
|
||||
id: "m-file-name",
|
||||
type: "media",
|
||||
props: { name: "", fileName: "Fallback Name", sourcePath: "assets/spec.pdf" },
|
||||
content: [],
|
||||
},
|
||||
] as never),
|
||||
).toEqual({
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
attrs: { blockId: "m-file-name" },
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Fallback Name",
|
||||
marks: [{ type: "link", attrs: { href: "assets/spec.pdf" } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("legacy media block 会继续 fallback 到 src 和 title", () => {
|
||||
expect(
|
||||
tiptapDocFromBlocks([
|
||||
{
|
||||
id: "m-src-title",
|
||||
type: "media",
|
||||
props: {
|
||||
sourcePath: "",
|
||||
url: "",
|
||||
src: "assets/from-src.pdf",
|
||||
name: "",
|
||||
fileName: "",
|
||||
title: "Fallback Title",
|
||||
},
|
||||
content: [],
|
||||
},
|
||||
] as never),
|
||||
).toEqual({
|
||||
type: "doc",
|
||||
content: [
|
||||
{
|
||||
type: "paragraph",
|
||||
attrs: { blockId: "m-src-title" },
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Fallback Title",
|
||||
marks: [{ type: "link", attrs: { href: "assets/from-src.pdf" } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("legacy media block 的链接 mark 在 TipTap 往返和 legacy 输出中不丢失", () => {
|
||||
const tiptapDoc = tiptapDocFromBlocks([
|
||||
{
|
||||
id: "m-round-trip",
|
||||
type: "media",
|
||||
props: { name: "Spec", sourcePath: "assets/spec.pdf" },
|
||||
content: [],
|
||||
},
|
||||
] as never);
|
||||
|
||||
expect(tiptapDocFromEditorBlockDocument(editorBlockDocumentFromTiptapDoc(tiptapDoc))).toEqual(tiptapDoc);
|
||||
expect(blocksFromTiptapDoc(tiptapDoc)).toEqual([
|
||||
{
|
||||
id: "m-round-trip",
|
||||
type: "paragraph",
|
||||
props: undefined,
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Spec",
|
||||
styles: { link: "assets/spec.pdf" },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -22,6 +22,8 @@ export type TiptapDoc = {
|
||||
|
||||
export type EditorTextMark = "bold" | "italic" | "underline" | "strike" | "code";
|
||||
|
||||
export type EditorInlineMark = EditorTextMark | TiptapMark;
|
||||
|
||||
export type EditorBlockType =
|
||||
| "paragraph"
|
||||
| "heading"
|
||||
@@ -34,7 +36,7 @@ export type EditorBlockType =
|
||||
export type EditorContentNode = {
|
||||
type: "text";
|
||||
text: string;
|
||||
marks?: EditorTextMark[];
|
||||
marks?: EditorInlineMark[];
|
||||
};
|
||||
|
||||
export type EditorBlock = {
|
||||
@@ -88,16 +90,62 @@ function coerceTextMark(value: unknown): EditorTextMark | null {
|
||||
return MARK_NAME_MAP[normalized] ?? null;
|
||||
}
|
||||
|
||||
function readMarks(marks: unknown): EditorTextMark[] {
|
||||
function uniqueInlineMarks(marks: EditorInlineMark[]): EditorInlineMark[] {
|
||||
const seen = new Set<string>();
|
||||
return marks.filter((mark) => {
|
||||
const key = typeof mark === "string" ? mark : `${mark.type}:${JSON.stringify(mark.attrs ?? {})}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function readMarks(marks: unknown): EditorInlineMark[] {
|
||||
if (!Array.isArray(marks)) return [];
|
||||
const normalized = marks
|
||||
.map((mark) => {
|
||||
if (typeof mark === "string") return coerceTextMark(mark);
|
||||
if (mark && typeof mark === "object") return coerceTextMark((mark as { type?: unknown }).type);
|
||||
return null;
|
||||
})
|
||||
.filter((mark): mark is EditorTextMark => Boolean(mark));
|
||||
return Array.from(new Set(normalized));
|
||||
return uniqueInlineMarks(marks.flatMap((mark): EditorInlineMark[] => {
|
||||
if (typeof mark === "string") {
|
||||
const textMark = coerceTextMark(mark);
|
||||
return textMark ? [textMark] : [];
|
||||
}
|
||||
if (!mark || typeof mark !== "object") return [];
|
||||
const record = mark as { type?: unknown; attrs?: unknown };
|
||||
const textMark = coerceTextMark(record.type);
|
||||
if (textMark) return [textMark];
|
||||
const type = typeof record.type === "string" ? record.type.trim() : "";
|
||||
if (type !== "link") return [];
|
||||
const attrs = record.attrs && typeof record.attrs === "object" ? record.attrs as Record<string, unknown> : {};
|
||||
const href = typeof attrs.href === "string" ? attrs.href.trim() : "";
|
||||
return href ? [{ type: "link", attrs: { href } }] : [];
|
||||
}));
|
||||
}
|
||||
|
||||
function readStyles(styles: unknown): EditorInlineMark[] {
|
||||
if (!styles || typeof styles !== "object") return [];
|
||||
const record = styles as Record<string, unknown>;
|
||||
const marks: EditorInlineMark[] = [];
|
||||
if (record.bold) marks.push("bold");
|
||||
if (record.italic) marks.push("italic");
|
||||
if (record.underline) marks.push("underline");
|
||||
if (record.strike || record.strikethrough) marks.push("strike");
|
||||
if (record.code || record.inlineCode) marks.push("code");
|
||||
const href = typeof record.link === "string" && record.link.trim()
|
||||
? record.link.trim()
|
||||
: typeof record.href === "string" && record.href.trim()
|
||||
? record.href.trim()
|
||||
: "";
|
||||
if (href) marks.push({ type: "link", attrs: { href } });
|
||||
return marks;
|
||||
}
|
||||
|
||||
function readInlineMarks(marks: unknown, styles: unknown): EditorInlineMark[] {
|
||||
return uniqueInlineMarks([...readMarks(marks), ...readStyles(styles)]);
|
||||
}
|
||||
|
||||
function toTiptapMark(mark: EditorInlineMark): TiptapMark | null {
|
||||
if (typeof mark === "string") return { type: mark };
|
||||
const type = typeof mark.type === "string" ? mark.type.trim() : "";
|
||||
if (!type) return null;
|
||||
return mark.attrs ? { type, attrs: mark.attrs } : { type };
|
||||
}
|
||||
|
||||
function toText(value: unknown): string {
|
||||
@@ -110,6 +158,14 @@ function toText(value: unknown): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
function firstNonEmptyText(...values: unknown[]): string {
|
||||
for (const value of values) {
|
||||
const text = toText(value).trim();
|
||||
if (text) return text;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function clampHeadingLevel(value: unknown): number {
|
||||
const level = Number(value);
|
||||
return Number.isInteger(level) && level >= 1 && level <= 6 ? level : 1;
|
||||
@@ -124,16 +180,16 @@ function normalizeEditorContentNodes(input: unknown): EditorContentNode[] {
|
||||
if (Array.isArray(input)) {
|
||||
const nodes = input.flatMap((item) => {
|
||||
if (!item || typeof item !== "object") return [];
|
||||
const record = item as { type?: unknown; text?: unknown; marks?: unknown; payload?: unknown };
|
||||
const record = item as { type?: unknown; text?: unknown; marks?: unknown; styles?: unknown; payload?: unknown };
|
||||
if (record.type === "text") {
|
||||
const text = toText(record.text);
|
||||
return text ? [{ type: "text" as const, text, marks: readMarks(record.marks) }] : [];
|
||||
return text ? [{ type: "text" as const, text, marks: readInlineMarks(record.marks, record.styles) }] : [];
|
||||
}
|
||||
if (record.payload && typeof record.payload === "object") {
|
||||
const payload = record.payload as { type?: unknown; text?: unknown; marks?: unknown };
|
||||
const payload = record.payload as { type?: unknown; text?: unknown; marks?: unknown; styles?: unknown };
|
||||
if (payload.type === "text") {
|
||||
const text = toText(payload.text);
|
||||
return text ? [{ type: "text" as const, text, marks: readMarks(payload.marks) }] : [];
|
||||
return text ? [{ type: "text" as const, text, marks: readInlineMarks(payload.marks, payload.styles) }] : [];
|
||||
}
|
||||
}
|
||||
const text = toText(item);
|
||||
@@ -190,6 +246,25 @@ function normalizeLegacyBlock(block: LegacyBlockLike, index: number): EditorBloc
|
||||
contentNodes,
|
||||
childBlockIds: [],
|
||||
};
|
||||
case "media": {
|
||||
const sourcePath = firstNonEmptyText(props.sourcePath, props.url, props.src);
|
||||
const name = firstNonEmptyText(props.name, props.fileName, props.title, sourcePath);
|
||||
return {
|
||||
blockId,
|
||||
blockType: "paragraph",
|
||||
props: {},
|
||||
contentNodes: name
|
||||
? [
|
||||
{
|
||||
type: "text",
|
||||
text: name,
|
||||
marks: sourcePath ? [{ type: "link", attrs: { href: sourcePath } }] : [],
|
||||
},
|
||||
]
|
||||
: contentNodes,
|
||||
childBlockIds: [],
|
||||
};
|
||||
}
|
||||
case "paragraph":
|
||||
case "text":
|
||||
default:
|
||||
@@ -241,8 +316,8 @@ function textNodesToInline(contentNodes: EditorContentNode[] | undefined): Tipta
|
||||
const text = toText(node.text);
|
||||
if (!text) return [];
|
||||
const marks = (node.marks ?? [])
|
||||
.map((mark) => ({ type: mark }))
|
||||
.filter((mark) => Boolean(mark.type));
|
||||
.map(toTiptapMark)
|
||||
.filter((mark): mark is TiptapMark => Boolean(mark));
|
||||
return [{ type: "text", text, marks: marks.length > 0 ? marks : undefined }];
|
||||
});
|
||||
}
|
||||
@@ -476,6 +551,36 @@ function orderedBlocksForLegacy(document: EditorBlockDocument): EditorBlock[] {
|
||||
return ordered;
|
||||
}
|
||||
|
||||
function legacyStylesFromMarks(marks: EditorInlineMark[] | undefined): Record<string, boolean | string> | undefined {
|
||||
const styles: Record<string, boolean | string> = {};
|
||||
for (const mark of marks ?? []) {
|
||||
if (typeof mark === "string") {
|
||||
styles[mark] = true;
|
||||
continue;
|
||||
}
|
||||
if (mark.type === "link") {
|
||||
const href = typeof mark.attrs?.href === "string" ? mark.attrs.href.trim() : "";
|
||||
if (href) styles.link = href;
|
||||
}
|
||||
}
|
||||
return Object.keys(styles).length ? styles : undefined;
|
||||
}
|
||||
|
||||
function legacyContentFromNodes(contentNodes: EditorContentNode[] | undefined): Json {
|
||||
const nodes = contentNodes ?? [];
|
||||
if (!nodes.some((node) => (node.marks ?? []).length > 0)) {
|
||||
return nodes.map((node) => node.text).join("");
|
||||
}
|
||||
return nodes.map((node) => {
|
||||
const styles = legacyStylesFromMarks(node.marks);
|
||||
return {
|
||||
type: "text",
|
||||
text: node.text,
|
||||
...(styles ? { styles } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function legacyBlocksFromEditorBlockDocument(document: EditorBlockDocument): Json {
|
||||
return orderedBlocksForLegacy(document).map((block) => ({
|
||||
id: block.blockId,
|
||||
@@ -488,7 +593,7 @@ export function legacyBlocksFromEditorBlockDocument(document: EditorBlockDocumen
|
||||
: block.blockType === "code_block"
|
||||
? { language: block.props?.language ?? null }
|
||||
: undefined,
|
||||
content: (block.contentNodes ?? []).map((node) => node.text).join(""),
|
||||
content: legacyContentFromNodes(block.contentNodes),
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user