334 lines
11 KiB
TypeScript
334 lines
11 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|||
|
|
import { api } from "@/lib/convex/api";
|
||
|
|
import { getAuthedConvexClient } from "@/lib/convex/route";
|
||
|
|
import {
|
||
|
|
buildDocumentBridgeContext,
|
||
|
|
buildDocumentCommandEnvelope,
|
||
|
|
buildDocumentQueryEnvelope,
|
||
|
|
documentBridgeErrorResponse,
|
||
|
|
type BridgeContext,
|
||
|
|
} from "@/lib/documents/bridge";
|
||
|
|
import {
|
||
|
|
recordBridgeCommandArtifacts,
|
||
|
|
recordBridgeCommandFailureArtifacts,
|
||
|
|
} from "@/lib/documents/bridge-log";
|
||
|
|
import {
|
||
|
|
executeRustBridgeMutationTransport,
|
||
|
|
executeRustBridgeQueryTransport,
|
||
|
|
resolveRustBridgeCommandPlan,
|
||
|
|
resolveRustBridgeQueryPlan,
|
||
|
|
} from "@/lib/documents/rust-runtime";
|
||
|
|
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
|
||
|
|
|
||
|
|
type BlockLike = {
|
||
|
|
id: string;
|
||
|
|
type?: string;
|
||
|
|
props?: Record<string, unknown>;
|
||
|
|
content?: unknown;
|
||
|
|
children?: BlockLike[];
|
||
|
|
};
|
||
|
|
|
||
|
|
function cloneBlock(block: BlockLike): BlockLike {
|
||
|
|
return {
|
||
|
|
...block,
|
||
|
|
props: block.props ? { ...block.props } : undefined,
|
||
|
|
content: Array.isArray(block.content) ? [...block.content] : block.content,
|
||
|
|
children: Array.isArray(block.children) ? block.children.map((item) => cloneBlock(item)) : block.children,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function findBlockInTree(
|
||
|
|
blocks: BlockLike[],
|
||
|
|
blockId: string,
|
||
|
|
): { block: BlockLike; parent: BlockLike | null; index: number } | null {
|
||
|
|
const stack: Array<{ list: BlockLike[]; parent: BlockLike | null }> = [{ list: blocks, parent: null }];
|
||
|
|
while (stack.length > 0) {
|
||
|
|
const current = stack.pop();
|
||
|
|
if (!current) continue;
|
||
|
|
for (let i = 0; i < current.list.length; i += 1) {
|
||
|
|
const block = current.list[i]!;
|
||
|
|
if (block.id === blockId) {
|
||
|
|
return { block, parent: current.parent, index: i };
|
||
|
|
}
|
||
|
|
if (Array.isArray(block.children) && block.children.length > 0) {
|
||
|
|
stack.push({ list: block.children, parent: block });
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
function removeBlockSubtree(blocks: BlockLike[], blockId: string) {
|
||
|
|
const nextTop = blocks.map((item) => cloneBlock(item));
|
||
|
|
const hit = findBlockInTree(nextTop, blockId);
|
||
|
|
if (!hit) {
|
||
|
|
return { removed: null as BlockLike | null, nextBlocks: nextTop };
|
||
|
|
}
|
||
|
|
if (hit.parent) {
|
||
|
|
const nextChildren = Array.isArray(hit.parent.children) ? hit.parent.children.map((item) => cloneBlock(item)) : [];
|
||
|
|
const removed = nextChildren.splice(hit.index, 1)[0] ?? null;
|
||
|
|
hit.parent.children = nextChildren;
|
||
|
|
return { removed, nextBlocks: nextTop };
|
||
|
|
}
|
||
|
|
const removed = nextTop.splice(hit.index, 1)[0] ?? null;
|
||
|
|
return { removed, nextBlocks: nextTop };
|
||
|
|
}
|
||
|
|
|
||
|
|
function replaceBlockInTree(blocks: BlockLike[], blockId: string, nextBlock: unknown) {
|
||
|
|
const nextTop = blocks.map((item) => cloneBlock(item));
|
||
|
|
const hit = findBlockInTree(nextTop, blockId);
|
||
|
|
if (!hit || !nextBlock || typeof nextBlock !== "object" || Array.isArray(nextBlock)) {
|
||
|
|
return { ok: false, nextBlocks: nextTop };
|
||
|
|
}
|
||
|
|
const normalized = cloneBlock({ ...(nextBlock as BlockLike), id: blockId });
|
||
|
|
if (hit.parent) {
|
||
|
|
const nextChildren = Array.isArray(hit.parent.children) ? hit.parent.children.map((item) => cloneBlock(item)) : [];
|
||
|
|
nextChildren[hit.index] = normalized;
|
||
|
|
hit.parent.children = nextChildren;
|
||
|
|
return { ok: true, nextBlocks: nextTop };
|
||
|
|
}
|
||
|
|
nextTop[hit.index] = normalized;
|
||
|
|
return { ok: true, nextBlocks: nextTop };
|
||
|
|
}
|
||
|
|
|
||
|
|
function buildReferenceBlock(sourceDocumentId: string, blockId: string): BlockLike {
|
||
|
|
return {
|
||
|
|
id: randomUUID(),
|
||
|
|
type: "blockReference",
|
||
|
|
props: {
|
||
|
|
sourceDocumentId,
|
||
|
|
targetBlockId: blockId,
|
||
|
|
display: "embed",
|
||
|
|
},
|
||
|
|
content: [],
|
||
|
|
children: [],
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
async function buildBridgeContext(request: Request, workspaceId: string | null): Promise<BridgeContext> {
|
||
|
|
return await buildDocumentBridgeContext({ request, workspaceId });
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function executeBlockGetBridgeQuery(input: {
|
||
|
|
request: Request;
|
||
|
|
sourceDocumentId: string;
|
||
|
|
blockId: string;
|
||
|
|
}) {
|
||
|
|
const context = await buildBridgeContext(input.request, null);
|
||
|
|
const envelope = buildDocumentQueryEnvelope({
|
||
|
|
name: "blocks.get",
|
||
|
|
payload: {
|
||
|
|
sourceDocumentId: input.sourceDocumentId,
|
||
|
|
blockId: input.blockId,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
const plan = await resolveRustBridgeQueryPlan({ context, envelope });
|
||
|
|
const { client } = await getAuthedConvexClient();
|
||
|
|
const doc = await executeRustBridgeQueryTransport<{ content?: unknown } | null>({
|
||
|
|
client,
|
||
|
|
plan,
|
||
|
|
});
|
||
|
|
if (!doc) {
|
||
|
|
throw new Error("页面不存在或无权限");
|
||
|
|
}
|
||
|
|
const blocks = extractBlocksFromContent(doc.content) as BlockLike[];
|
||
|
|
const hit = findBlockInTree(blocks, input.blockId);
|
||
|
|
if (!hit) {
|
||
|
|
throw new Error("块不存在或无权限");
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
requestId: context.requestId,
|
||
|
|
traceId: context.traceId,
|
||
|
|
queryName: envelope.name,
|
||
|
|
result: { block: hit.block },
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function executeBlockPatchBridgeCommand(input: {
|
||
|
|
request: Request;
|
||
|
|
sourceDocumentId: string;
|
||
|
|
workspaceId: string | null;
|
||
|
|
blockId: string;
|
||
|
|
nextBlock: unknown;
|
||
|
|
}) {
|
||
|
|
const context = await buildBridgeContext(input.request, input.workspaceId);
|
||
|
|
const envelope = buildDocumentCommandEnvelope({
|
||
|
|
name: "blocks.patch",
|
||
|
|
payload: {
|
||
|
|
documentId: input.sourceDocumentId,
|
||
|
|
workspaceId: input.workspaceId,
|
||
|
|
blockId: input.blockId,
|
||
|
|
nextBlock: input.nextBlock,
|
||
|
|
},
|
||
|
|
context,
|
||
|
|
target: {
|
||
|
|
workspaceId: input.workspaceId,
|
||
|
|
pageId: input.sourceDocumentId,
|
||
|
|
blockId: input.blockId,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
const plan = await resolveRustBridgeCommandPlan({ context, envelope });
|
||
|
|
const { client } = await getAuthedConvexClient();
|
||
|
|
const doc = await client.query(api.documents.getContent, { id: input.sourceDocumentId });
|
||
|
|
if (!doc) {
|
||
|
|
throw new Error("页面不存在或无权限");
|
||
|
|
}
|
||
|
|
const blocks = extractBlocksFromContent(doc.content) as BlockLike[];
|
||
|
|
const replaced = replaceBlockInTree(blocks, input.blockId, input.nextBlock);
|
||
|
|
if (!replaced.ok) {
|
||
|
|
throw new Error("块不存在或无权限");
|
||
|
|
}
|
||
|
|
try {
|
||
|
|
await executeRustBridgeMutationTransport({
|
||
|
|
client,
|
||
|
|
plan,
|
||
|
|
});
|
||
|
|
await client.mutation(api.documents.updateContent, {
|
||
|
|
id: input.sourceDocumentId,
|
||
|
|
content: composeContentWithBlocks(doc.content, replaced.nextBlocks as never),
|
||
|
|
});
|
||
|
|
await recordBridgeCommandArtifacts({ context, envelope });
|
||
|
|
} catch (error) {
|
||
|
|
await recordBridgeCommandFailureArtifacts({
|
||
|
|
client,
|
||
|
|
context,
|
||
|
|
envelope,
|
||
|
|
error,
|
||
|
|
});
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
requestId: context.requestId,
|
||
|
|
traceId: context.traceId,
|
||
|
|
commandId: envelope.commandId,
|
||
|
|
commandName: envelope.name,
|
||
|
|
result: { ok: true as const },
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function executeBlockMoveBridgeCommand(input: {
|
||
|
|
request: Request;
|
||
|
|
sourceDocumentId: string;
|
||
|
|
targetDocumentId: string;
|
||
|
|
blockId: string;
|
||
|
|
}) {
|
||
|
|
const { client } = await getAuthedConvexClient();
|
||
|
|
const source = await client.query(api.documents.getContent, { id: input.sourceDocumentId });
|
||
|
|
if (!source) throw new Error("源页面不存在或无权限");
|
||
|
|
const target = await client.query(api.documents.getContent, { id: input.targetDocumentId });
|
||
|
|
if (!target) throw new Error("目标页面不存在或无权限");
|
||
|
|
const sourceBlocks = extractBlocksFromContent(source.content) as BlockLike[];
|
||
|
|
const removedRes = removeBlockSubtree(sourceBlocks, input.blockId);
|
||
|
|
if (!removedRes.removed) throw new Error("源块不存在或无权限");
|
||
|
|
const targetBlocks = extractBlocksFromContent(target.content) as BlockLike[];
|
||
|
|
const nextSourceContent = composeContentWithBlocks(source.content, removedRes.nextBlocks as never);
|
||
|
|
const nextTargetContent = composeContentWithBlocks(target.content, [...targetBlocks, removedRes.removed] as never);
|
||
|
|
const context = await buildBridgeContext(input.request, null);
|
||
|
|
const envelope = buildDocumentCommandEnvelope({
|
||
|
|
name: "blocks.move",
|
||
|
|
payload: {
|
||
|
|
sourceDocumentId: input.sourceDocumentId,
|
||
|
|
targetDocumentId: input.targetDocumentId,
|
||
|
|
blockId: input.blockId,
|
||
|
|
},
|
||
|
|
context,
|
||
|
|
target: {
|
||
|
|
pageId: input.targetDocumentId,
|
||
|
|
blockId: input.blockId,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
const plan = await resolveRustBridgeCommandPlan({ context, envelope });
|
||
|
|
try {
|
||
|
|
await executeRustBridgeMutationTransport({ client, plan });
|
||
|
|
await client.mutation(api.documents.updateContent, { id: input.sourceDocumentId, content: nextSourceContent });
|
||
|
|
await client.mutation(api.documents.updateContent, { id: input.targetDocumentId, content: nextTargetContent });
|
||
|
|
await recordBridgeCommandArtifacts({ context, envelope });
|
||
|
|
} catch (error) {
|
||
|
|
await recordBridgeCommandFailureArtifacts({
|
||
|
|
client,
|
||
|
|
context,
|
||
|
|
envelope,
|
||
|
|
error,
|
||
|
|
});
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
requestId: context.requestId,
|
||
|
|
traceId: context.traceId,
|
||
|
|
commandId: envelope.commandId,
|
||
|
|
commandName: envelope.name,
|
||
|
|
result: { ok: true as const },
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export async function executeBlockEmbedBridgeCommand(input: {
|
||
|
|
request: Request;
|
||
|
|
sourceDocumentId: string;
|
||
|
|
targetDocumentId: string;
|
||
|
|
blockId: string;
|
||
|
|
}) {
|
||
|
|
const { client } = await getAuthedConvexClient();
|
||
|
|
const source = await client.query(api.documents.getContent, { id: input.sourceDocumentId });
|
||
|
|
if (!source) throw new Error("源页面不存在或无权限");
|
||
|
|
const target = await client.query(api.documents.getContent, { id: input.targetDocumentId });
|
||
|
|
if (!target) throw new Error("目标页面不存在或无权限");
|
||
|
|
const targetMeta = await client.query(api.documents.getMeta, { id: input.targetDocumentId });
|
||
|
|
const sourceBlocks = extractBlocksFromContent(source.content) as BlockLike[];
|
||
|
|
const hit = findBlockInTree(sourceBlocks, input.blockId);
|
||
|
|
if (!hit) throw new Error("源块不存在或无权限");
|
||
|
|
const targetBlocks = extractBlocksFromContent(target.content) as BlockLike[];
|
||
|
|
const anchorId = (targetMeta as { embed_default_block_id?: string | null } | null)?.embed_default_block_id ?? null;
|
||
|
|
const anchorIndex =
|
||
|
|
typeof anchorId === "string" && anchorId.trim()
|
||
|
|
? targetBlocks.findIndex((block) => String(block.id ?? "") === anchorId)
|
||
|
|
: -1;
|
||
|
|
const insertIndex = anchorIndex >= 0 ? anchorIndex + 1 : targetBlocks.length;
|
||
|
|
const nextTargetBlocks = [
|
||
|
|
...targetBlocks.slice(0, insertIndex),
|
||
|
|
buildReferenceBlock(input.sourceDocumentId, input.blockId),
|
||
|
|
...targetBlocks.slice(insertIndex),
|
||
|
|
];
|
||
|
|
const context = await buildBridgeContext(input.request, null);
|
||
|
|
const envelope = buildDocumentCommandEnvelope({
|
||
|
|
name: "blocks.embed",
|
||
|
|
payload: {
|
||
|
|
sourceDocumentId: input.sourceDocumentId,
|
||
|
|
targetDocumentId: input.targetDocumentId,
|
||
|
|
blockId: input.blockId,
|
||
|
|
},
|
||
|
|
context,
|
||
|
|
target: {
|
||
|
|
pageId: input.targetDocumentId,
|
||
|
|
blockId: input.blockId,
|
||
|
|
},
|
||
|
|
});
|
||
|
|
const plan = await resolveRustBridgeCommandPlan({ context, envelope });
|
||
|
|
try {
|
||
|
|
await executeRustBridgeMutationTransport({ client, plan });
|
||
|
|
await client.mutation(api.documents.updateContent, {
|
||
|
|
id: input.targetDocumentId,
|
||
|
|
content: composeContentWithBlocks(target.content, nextTargetBlocks as never),
|
||
|
|
});
|
||
|
|
await recordBridgeCommandArtifacts({ context, envelope });
|
||
|
|
} catch (error) {
|
||
|
|
await recordBridgeCommandFailureArtifacts({
|
||
|
|
client,
|
||
|
|
context,
|
||
|
|
envelope,
|
||
|
|
error,
|
||
|
|
});
|
||
|
|
throw error;
|
||
|
|
}
|
||
|
|
return {
|
||
|
|
requestId: context.requestId,
|
||
|
|
traceId: context.traceId,
|
||
|
|
commandId: envelope.commandId,
|
||
|
|
commandName: envelope.name,
|
||
|
|
result: { ok: true as const },
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
export function handleBlockBridgeError(error: unknown) {
|
||
|
|
return documentBridgeErrorResponse(error);
|
||
|
|
}
|