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:
lix-2026
2026-05-09 19:05:06 +08:00
parent 71146de5f0
commit d148e1ccc2
20 changed files with 1635 additions and 374 deletions
@@ -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),
}));
}