2026-04-24 06:10:18 +08:00
|
|
|
import { randomUUID } from "node:crypto";
|
|
|
|
|
import { NextResponse } from "next/server";
|
2026-04-26 04:29:23 +08:00
|
|
|
import type { Json } from "@/types/supabase";
|
2026-04-24 06:10:18 +08:00
|
|
|
import { api } from "@/lib/convex/api";
|
|
|
|
|
import { isConvexEnabled } from "@/lib/convex/enabled";
|
|
|
|
|
import { getAuthedConvexClient } from "@/lib/convex/route";
|
2026-04-26 04:29:23 +08:00
|
|
|
import { composeContentWithBlocks, extractBlocksFromContent } from "@/lib/document-content";
|
2026-04-24 06:10:18 +08:00
|
|
|
import {
|
|
|
|
|
assertDocumentId,
|
|
|
|
|
assertTitle,
|
|
|
|
|
buildDocumentBridgeContext,
|
|
|
|
|
buildDocumentCommandEnvelope,
|
|
|
|
|
documentBridgeErrorResponse,
|
|
|
|
|
} from "@/lib/documents/bridge";
|
2026-04-26 04:29:23 +08:00
|
|
|
import {
|
|
|
|
|
recordBridgeCommandFailureArtifacts,
|
|
|
|
|
} from "@/lib/documents/bridge-log";
|
|
|
|
|
import {
|
|
|
|
|
copyMindmapFilesIfExists,
|
|
|
|
|
ensureDocumentScaffold,
|
|
|
|
|
} from "@/lib/documents/page-lifecycle-side-effects";
|
|
|
|
|
import { buildDocumentSavePayload } from "@/lib/documents/save-contract";
|
|
|
|
|
import { loadSidebarDataFromConvex } from "@/lib/server/sidebar-data";
|
2026-04-24 06:10:18 +08:00
|
|
|
import {
|
|
|
|
|
executeRustBridgeMutationTransport,
|
2026-04-26 19:35:52 +08:00
|
|
|
recordRustBridgeCommandArtifacts,
|
2026-04-24 06:10:18 +08:00
|
|
|
resolveRustBridgeCommandPlan,
|
|
|
|
|
} from "@/lib/documents/rust-runtime";
|
|
|
|
|
|
2026-04-26 04:29:23 +08:00
|
|
|
type TreeCommandAction =
|
|
|
|
|
| "create"
|
|
|
|
|
| "rename"
|
|
|
|
|
| "move"
|
|
|
|
|
| "archive"
|
|
|
|
|
| "restore"
|
|
|
|
|
| "purge"
|
|
|
|
|
| "embed"
|
|
|
|
|
| "copy";
|
|
|
|
|
|
|
|
|
|
type TreeCopyItem = {
|
|
|
|
|
documentId: string;
|
|
|
|
|
recursive: boolean;
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-24 06:10:18 +08:00
|
|
|
type TreeCommandPayload = {
|
2026-04-26 04:29:23 +08:00
|
|
|
action?: TreeCommandAction;
|
2026-04-24 06:10:18 +08:00
|
|
|
workspaceId?: string | null;
|
|
|
|
|
documentId?: string | null;
|
|
|
|
|
parentId?: string | null;
|
2026-04-26 04:29:23 +08:00
|
|
|
targetParentId?: string | null;
|
2026-04-24 06:10:18 +08:00
|
|
|
title?: string | null;
|
|
|
|
|
accessScope?: "private" | "shared" | "public" | null;
|
|
|
|
|
content?: unknown;
|
|
|
|
|
sortOrder?: number | null;
|
2026-04-26 04:29:23 +08:00
|
|
|
sourceId?: string | null;
|
|
|
|
|
targetId?: string | null;
|
|
|
|
|
items?: TreeCopyItem[] | null;
|
2026-04-24 06:10:18 +08:00
|
|
|
};
|
|
|
|
|
|
2026-04-26 19:35:52 +08:00
|
|
|
type TreeDeltaDocument = {
|
|
|
|
|
id: string;
|
|
|
|
|
workspace_id: string;
|
|
|
|
|
title: string | null;
|
|
|
|
|
parent_id: string | null;
|
|
|
|
|
sort_order: number | null;
|
|
|
|
|
is_starred: boolean | null;
|
|
|
|
|
access_scope: "private" | "shared" | "public";
|
|
|
|
|
is_template: boolean;
|
|
|
|
|
created_at: string;
|
|
|
|
|
updated_at: string | null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type TreeMutationResult<TResult> = {
|
|
|
|
|
context: Awaited<ReturnType<typeof buildDocumentBridgeContext>>;
|
|
|
|
|
envelope: ReturnType<typeof buildDocumentCommandEnvelope>;
|
|
|
|
|
plan: Awaited<ReturnType<typeof resolveRustBridgeCommandPlan>>;
|
|
|
|
|
result: TResult;
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-24 06:10:18 +08:00
|
|
|
function trimOrNull(value: unknown) {
|
|
|
|
|
if (typeof value !== "string") return null;
|
|
|
|
|
const trimmed = value.trim();
|
|
|
|
|
return trimmed.length > 0 ? trimmed : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeTitle(value: unknown) {
|
|
|
|
|
const title = typeof value === "string" ? value.trim() : "";
|
|
|
|
|
return title || "无标题";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeSortOrder(value: unknown) {
|
|
|
|
|
return typeof value === "number" && Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 04:29:23 +08:00
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
|
|
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function recordTreeCommandSuccess(args: {
|
|
|
|
|
context: Awaited<ReturnType<typeof buildDocumentBridgeContext>>;
|
|
|
|
|
envelope: ReturnType<typeof buildDocumentCommandEnvelope>;
|
|
|
|
|
client: Awaited<ReturnType<typeof getAuthedConvexClient>>["client"];
|
2026-04-26 19:35:52 +08:00
|
|
|
plan: Awaited<ReturnType<typeof resolveRustBridgeCommandPlan>>;
|
|
|
|
|
result: unknown;
|
2026-04-26 04:29:23 +08:00
|
|
|
}) {
|
|
|
|
|
try {
|
2026-04-26 19:35:52 +08:00
|
|
|
await recordRustBridgeCommandArtifacts({
|
2026-04-26 04:29:23 +08:00
|
|
|
context: args.context,
|
|
|
|
|
envelope: args.envelope,
|
|
|
|
|
client: args.client,
|
2026-04-26 19:35:52 +08:00
|
|
|
plan: args.plan,
|
|
|
|
|
result: args.result,
|
2026-04-26 04:29:23 +08:00
|
|
|
});
|
|
|
|
|
} catch (error) {
|
2026-04-26 19:35:52 +08:00
|
|
|
console.warn("[tree.commands] Rust bridge success artifacts skipped:", error);
|
2026-04-26 04:29:23 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function loadTreeCommandSidebarSnapshot(args: {
|
|
|
|
|
client: Awaited<ReturnType<typeof getAuthedConvexClient>>["client"];
|
|
|
|
|
auth: Awaited<ReturnType<typeof getAuthedConvexClient>>["auth"];
|
|
|
|
|
workspaceId: string | null;
|
|
|
|
|
}) {
|
|
|
|
|
if (!args.workspaceId) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
try {
|
|
|
|
|
const result = await loadSidebarDataFromConvex({
|
|
|
|
|
client: args.client,
|
|
|
|
|
auth: {
|
|
|
|
|
userId: args.auth.userId,
|
|
|
|
|
email: args.auth.email,
|
|
|
|
|
name: args.auth.name,
|
|
|
|
|
},
|
|
|
|
|
fallbackName: args.auth.email ?? args.auth.name ?? "我的空间",
|
|
|
|
|
requestedWorkspaceId: args.workspaceId,
|
|
|
|
|
});
|
|
|
|
|
return result.sidebarInitialData ?? null;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
console.warn("[tree.commands] sidebar snapshot skipped:", error);
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildTreeMovePreflightDataFromSidebarSnapshot(
|
|
|
|
|
sidebarSnapshot: unknown,
|
|
|
|
|
): Record<string, unknown> | null {
|
|
|
|
|
if (!isRecord(sidebarSnapshot) || !Array.isArray(sidebarSnapshot.documents)) {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
return {
|
|
|
|
|
documents: sidebarSnapshot.documents,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function resolveTreeMutationResult<TResult>(args: {
|
|
|
|
|
request: Request;
|
|
|
|
|
workspaceId: string | null;
|
|
|
|
|
commandName: string;
|
|
|
|
|
payload: unknown;
|
|
|
|
|
preflightData?: Record<string, unknown> | null;
|
|
|
|
|
pageId?: string | null;
|
|
|
|
|
client: Awaited<ReturnType<typeof getAuthedConvexClient>>["client"];
|
|
|
|
|
}) {
|
|
|
|
|
const context = await buildDocumentBridgeContext({
|
|
|
|
|
request: args.request,
|
|
|
|
|
workspaceId: args.workspaceId,
|
|
|
|
|
});
|
|
|
|
|
const envelope = buildDocumentCommandEnvelope({
|
|
|
|
|
name: args.commandName,
|
|
|
|
|
payload: args.payload,
|
|
|
|
|
context,
|
|
|
|
|
preflightData: args.preflightData ?? null,
|
|
|
|
|
target: {
|
|
|
|
|
workspaceId: args.workspaceId,
|
|
|
|
|
pageId: args.pageId ?? undefined,
|
|
|
|
|
},
|
|
|
|
|
reason: `tree-route ${args.commandName}`,
|
|
|
|
|
refs: ["next-tree-route"],
|
|
|
|
|
});
|
|
|
|
|
const plan = await resolveRustBridgeCommandPlan({
|
|
|
|
|
context,
|
|
|
|
|
envelope,
|
|
|
|
|
});
|
|
|
|
|
try {
|
|
|
|
|
const result = await executeRustBridgeMutationTransport<TResult>({
|
|
|
|
|
client: args.client,
|
|
|
|
|
plan,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
context,
|
|
|
|
|
envelope,
|
2026-04-26 19:35:52 +08:00
|
|
|
plan,
|
2026-04-26 04:29:23 +08:00
|
|
|
result,
|
|
|
|
|
};
|
|
|
|
|
} catch (error) {
|
|
|
|
|
await recordBridgeCommandFailureArtifacts({
|
|
|
|
|
context,
|
|
|
|
|
envelope,
|
|
|
|
|
client: args.client,
|
|
|
|
|
error,
|
|
|
|
|
});
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 06:10:18 +08:00
|
|
|
export async function POST(request: Request) {
|
|
|
|
|
if (!isConvexEnabled()) {
|
|
|
|
|
return NextResponse.json({ error: "当前仅支持 Convex 模式" }, { status: 501 });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const payload = (await request.json()) as TreeCommandPayload;
|
|
|
|
|
switch (payload.action) {
|
|
|
|
|
case "create":
|
2026-04-26 04:29:23 +08:00
|
|
|
return await handleCreate(request, payload);
|
2026-04-24 06:10:18 +08:00
|
|
|
case "move":
|
2026-04-26 04:29:23 +08:00
|
|
|
return await handleMove(request, payload);
|
2026-04-24 06:10:18 +08:00
|
|
|
case "rename":
|
2026-04-26 04:29:23 +08:00
|
|
|
return await handleRename(request, payload);
|
|
|
|
|
case "archive":
|
|
|
|
|
return await handleArchive(request, payload);
|
|
|
|
|
case "restore":
|
|
|
|
|
return await handleRestore(request, payload);
|
|
|
|
|
case "purge":
|
|
|
|
|
return await handlePurge(request, payload);
|
|
|
|
|
case "embed":
|
|
|
|
|
return await handleEmbed(request, payload);
|
|
|
|
|
case "copy":
|
|
|
|
|
return await handleCopy(request, payload);
|
2026-04-24 06:10:18 +08:00
|
|
|
default:
|
|
|
|
|
return NextResponse.json({ error: "不支持的 tree action" }, { status: 400 });
|
|
|
|
|
}
|
|
|
|
|
} catch (error) {
|
|
|
|
|
return documentBridgeErrorResponse(error);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleCreate(request: Request, payload: TreeCommandPayload) {
|
|
|
|
|
const { auth, client } = await getAuthedConvexClient();
|
|
|
|
|
const parentId = trimOrNull(payload.parentId);
|
|
|
|
|
const workspaceBootstrap = await client.mutation(api.workspaces.ensureDefaultWorkspace, {
|
|
|
|
|
fallbackName: auth.email ?? auth.name ?? "我的空间",
|
|
|
|
|
workspaceIdIfCreate: randomUUID(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
let workspaceId: string | null = trimOrNull(payload.workspaceId);
|
|
|
|
|
let accessScope: "private" | "shared" | "public" = "private";
|
|
|
|
|
if (parentId) {
|
|
|
|
|
const parentDoc = await client.query(api.documents.getMeta, { id: parentId });
|
|
|
|
|
if (!parentDoc) {
|
|
|
|
|
return NextResponse.json({ error: "父页面不存在或无权限" }, { status: 404 });
|
|
|
|
|
}
|
|
|
|
|
workspaceId = trimOrNull(parentDoc.workspace_id);
|
|
|
|
|
accessScope = (parentDoc.access_scope ?? "private") as typeof accessScope;
|
|
|
|
|
} else if (!workspaceId) {
|
|
|
|
|
workspaceId = trimOrNull(workspaceBootstrap.activeWorkspaceId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!workspaceId) {
|
|
|
|
|
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const documentId = trimOrNull(payload.documentId) ?? randomUUID();
|
|
|
|
|
const title = normalizeTitle(payload.title);
|
2026-04-26 19:35:52 +08:00
|
|
|
const mutation = await resolveTreeMutationResult<{
|
2026-04-24 06:10:18 +08:00
|
|
|
id: string;
|
2026-04-26 19:35:52 +08:00
|
|
|
document?: TreeDeltaDocument | null;
|
2026-04-24 06:10:18 +08:00
|
|
|
title: string | null;
|
|
|
|
|
parent_id: string | null;
|
|
|
|
|
sort_order: number | null;
|
|
|
|
|
workspace_id: string;
|
|
|
|
|
access_scope: "private" | "shared" | "public";
|
|
|
|
|
is_template: boolean;
|
|
|
|
|
created_at: string;
|
|
|
|
|
updated_at: string;
|
|
|
|
|
}>({
|
2026-04-26 04:29:23 +08:00
|
|
|
request,
|
|
|
|
|
workspaceId,
|
|
|
|
|
commandName: "tree.node.create",
|
|
|
|
|
payload: {
|
|
|
|
|
documentId,
|
|
|
|
|
workspaceId,
|
|
|
|
|
parentId,
|
|
|
|
|
title,
|
|
|
|
|
accessScope: trimOrNull(payload.accessScope) ?? accessScope,
|
|
|
|
|
content: Array.isArray(payload.content) ? payload.content : [],
|
|
|
|
|
},
|
|
|
|
|
pageId: documentId,
|
2026-04-24 06:10:18 +08:00
|
|
|
client,
|
|
|
|
|
});
|
2026-04-26 19:35:52 +08:00
|
|
|
const { context, envelope, result } = mutation;
|
2026-04-24 06:10:18 +08:00
|
|
|
|
|
|
|
|
await ensureDocumentScaffold(result.id, result.title ?? title);
|
2026-04-26 04:29:23 +08:00
|
|
|
await recordTreeCommandSuccess({
|
|
|
|
|
context,
|
|
|
|
|
envelope,
|
|
|
|
|
client,
|
2026-04-26 19:35:52 +08:00
|
|
|
plan: mutation.plan,
|
|
|
|
|
result,
|
2026-04-26 04:29:23 +08:00
|
|
|
});
|
2026-04-24 06:10:18 +08:00
|
|
|
|
|
|
|
|
return NextResponse.json({
|
|
|
|
|
requestId: context.requestId,
|
|
|
|
|
traceId: context.traceId,
|
|
|
|
|
result: {
|
|
|
|
|
action: "create",
|
|
|
|
|
workspaceId,
|
|
|
|
|
documentId: result.id,
|
|
|
|
|
parentId: result.parent_id ?? parentId,
|
|
|
|
|
title: result.title ?? title,
|
|
|
|
|
sortOrder: result.sort_order ?? null,
|
|
|
|
|
updatedAt: result.updated_at ?? null,
|
|
|
|
|
execution: result,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleMove(request: Request, payload: TreeCommandPayload) {
|
2026-04-26 04:29:23 +08:00
|
|
|
const { auth, client } = await getAuthedConvexClient();
|
2026-04-24 06:10:18 +08:00
|
|
|
const documentId = assertDocumentId(payload.documentId ?? null);
|
|
|
|
|
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
|
|
|
|
if (!sourceDoc) {
|
|
|
|
|
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id);
|
|
|
|
|
const parentId = trimOrNull(payload.parentId);
|
|
|
|
|
const sortOrder = normalizeSortOrder(payload.sortOrder);
|
2026-04-26 04:29:23 +08:00
|
|
|
const sidebarSnapshot = await loadTreeCommandSidebarSnapshot({
|
|
|
|
|
client,
|
|
|
|
|
auth,
|
2026-04-24 06:10:18 +08:00
|
|
|
workspaceId,
|
|
|
|
|
});
|
2026-04-26 04:29:23 +08:00
|
|
|
const movePreflightData = buildTreeMovePreflightDataFromSidebarSnapshot(sidebarSnapshot);
|
2026-04-26 19:35:52 +08:00
|
|
|
const mutation = await resolveTreeMutationResult<{
|
2026-04-26 04:29:23 +08:00
|
|
|
ok?: boolean;
|
|
|
|
|
parent_id?: string | null;
|
|
|
|
|
sort_order?: number | null;
|
|
|
|
|
workspace_id?: string | null;
|
|
|
|
|
updated_at?: string | null;
|
|
|
|
|
}>({
|
|
|
|
|
request,
|
|
|
|
|
workspaceId,
|
|
|
|
|
commandName: "tree.subtree.move",
|
2026-04-24 06:10:18 +08:00
|
|
|
payload: {
|
|
|
|
|
documentId,
|
|
|
|
|
parentId,
|
|
|
|
|
sortOrder,
|
|
|
|
|
},
|
2026-04-26 04:29:23 +08:00
|
|
|
preflightData: movePreflightData,
|
|
|
|
|
pageId: documentId,
|
|
|
|
|
client,
|
2026-04-24 06:10:18 +08:00
|
|
|
});
|
2026-04-26 19:35:52 +08:00
|
|
|
const { context, envelope, result } = mutation;
|
2026-04-26 04:29:23 +08:00
|
|
|
await recordTreeCommandSuccess({
|
2026-04-24 06:10:18 +08:00
|
|
|
context,
|
|
|
|
|
envelope,
|
|
|
|
|
client,
|
2026-04-26 19:35:52 +08:00
|
|
|
plan: mutation.plan,
|
|
|
|
|
result,
|
2026-04-24 06:10:18 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return NextResponse.json({
|
|
|
|
|
requestId: context.requestId,
|
|
|
|
|
traceId: context.traceId,
|
|
|
|
|
result: {
|
|
|
|
|
action: "move",
|
2026-04-26 04:29:23 +08:00
|
|
|
workspaceId: trimOrNull(result?.workspace_id) ?? workspaceId,
|
2026-04-24 06:10:18 +08:00
|
|
|
documentId,
|
2026-04-26 04:29:23 +08:00
|
|
|
parentId: trimOrNull(result?.parent_id) ?? parentId,
|
|
|
|
|
sortOrder:
|
|
|
|
|
typeof result?.sort_order === "number" && Number.isFinite(result.sort_order)
|
|
|
|
|
? result.sort_order
|
|
|
|
|
: sortOrder,
|
|
|
|
|
updatedAt: trimOrNull(result?.updated_at) ?? null,
|
|
|
|
|
execution: {
|
|
|
|
|
...(result ?? null),
|
|
|
|
|
parent_id: trimOrNull(result?.parent_id) ?? parentId,
|
|
|
|
|
sort_order:
|
|
|
|
|
typeof result?.sort_order === "number" && Number.isFinite(result.sort_order)
|
|
|
|
|
? result.sort_order
|
|
|
|
|
: sortOrder,
|
|
|
|
|
workspace_id: trimOrNull(result?.workspace_id) ?? workspaceId,
|
|
|
|
|
},
|
2026-04-24 06:10:18 +08:00
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleRename(request: Request, payload: TreeCommandPayload) {
|
|
|
|
|
const { client } = await getAuthedConvexClient();
|
|
|
|
|
const documentId = assertDocumentId(payload.documentId ?? null);
|
|
|
|
|
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
|
|
|
|
if (!sourceDoc) {
|
|
|
|
|
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id);
|
|
|
|
|
const title = assertTitle(payload.title ?? null);
|
2026-04-26 19:35:52 +08:00
|
|
|
const mutation = await resolveTreeMutationResult<{
|
2026-04-26 04:29:23 +08:00
|
|
|
ok?: boolean;
|
2026-04-26 19:35:52 +08:00
|
|
|
document?: TreeDeltaDocument | null;
|
2026-04-26 04:29:23 +08:00
|
|
|
updated_at?: string | null;
|
|
|
|
|
}>({
|
2026-04-24 06:10:18 +08:00
|
|
|
request,
|
|
|
|
|
workspaceId,
|
2026-04-26 04:29:23 +08:00
|
|
|
commandName: "tree.node.rename",
|
2026-04-24 06:10:18 +08:00
|
|
|
payload: {
|
|
|
|
|
documentId,
|
|
|
|
|
workspaceId,
|
|
|
|
|
title,
|
|
|
|
|
},
|
2026-04-26 04:29:23 +08:00
|
|
|
pageId: documentId,
|
|
|
|
|
client,
|
2026-04-24 06:10:18 +08:00
|
|
|
});
|
2026-04-26 19:35:52 +08:00
|
|
|
const { context, envelope, result } = mutation;
|
2026-04-26 04:29:23 +08:00
|
|
|
await recordTreeCommandSuccess({
|
2026-04-24 06:10:18 +08:00
|
|
|
context,
|
|
|
|
|
envelope,
|
|
|
|
|
client,
|
2026-04-26 19:35:52 +08:00
|
|
|
plan: mutation.plan,
|
|
|
|
|
result,
|
2026-04-24 06:10:18 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return NextResponse.json({
|
|
|
|
|
requestId: context.requestId,
|
|
|
|
|
traceId: context.traceId,
|
|
|
|
|
result: {
|
|
|
|
|
action: "rename",
|
|
|
|
|
workspaceId,
|
|
|
|
|
documentId,
|
|
|
|
|
title,
|
|
|
|
|
updatedAt: result?.updated_at ?? null,
|
|
|
|
|
execution: result ?? null,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 04:29:23 +08:00
|
|
|
async function handleArchive(request: Request, payload: TreeCommandPayload) {
|
|
|
|
|
const { client } = await getAuthedConvexClient();
|
|
|
|
|
const documentId = assertDocumentId(payload.documentId ?? null);
|
|
|
|
|
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
|
|
|
|
if (!sourceDoc) {
|
|
|
|
|
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id);
|
2026-04-26 19:35:52 +08:00
|
|
|
const mutation = await resolveTreeMutationResult<{
|
2026-04-26 04:29:23 +08:00
|
|
|
ok?: boolean;
|
2026-04-26 19:35:52 +08:00
|
|
|
document?: TreeDeltaDocument | null;
|
2026-04-26 04:29:23 +08:00
|
|
|
updated_at?: string | null;
|
|
|
|
|
}>({
|
|
|
|
|
request,
|
|
|
|
|
workspaceId,
|
|
|
|
|
commandName: "tree.node.archive",
|
|
|
|
|
payload: {
|
|
|
|
|
documentId,
|
|
|
|
|
workspaceId,
|
|
|
|
|
},
|
|
|
|
|
pageId: documentId,
|
|
|
|
|
client,
|
|
|
|
|
});
|
2026-04-26 19:35:52 +08:00
|
|
|
const { context, envelope, result } = mutation;
|
2026-04-26 04:29:23 +08:00
|
|
|
await recordTreeCommandSuccess({
|
|
|
|
|
context,
|
|
|
|
|
envelope,
|
|
|
|
|
client,
|
2026-04-26 19:35:52 +08:00
|
|
|
plan: mutation.plan,
|
|
|
|
|
result,
|
2026-04-26 04:29:23 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return NextResponse.json({
|
|
|
|
|
requestId: context.requestId,
|
|
|
|
|
traceId: context.traceId,
|
|
|
|
|
result: {
|
|
|
|
|
action: "archive",
|
|
|
|
|
workspaceId,
|
|
|
|
|
documentId,
|
|
|
|
|
updatedAt: result?.updated_at ?? null,
|
|
|
|
|
execution: result ?? null,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleRestore(request: Request, payload: TreeCommandPayload) {
|
2026-04-26 19:35:52 +08:00
|
|
|
const { client } = await getAuthedConvexClient();
|
2026-04-26 04:29:23 +08:00
|
|
|
const documentId = assertDocumentId(payload.documentId ?? null);
|
|
|
|
|
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
|
|
|
|
if (!sourceDoc) {
|
|
|
|
|
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id);
|
2026-04-26 19:35:52 +08:00
|
|
|
const mutation = await resolveTreeMutationResult<{
|
2026-04-26 04:29:23 +08:00
|
|
|
ok?: boolean;
|
2026-04-26 19:35:52 +08:00
|
|
|
document?: TreeDeltaDocument | null;
|
2026-04-26 04:29:23 +08:00
|
|
|
updated_at?: string | null;
|
|
|
|
|
}>({
|
|
|
|
|
request,
|
|
|
|
|
workspaceId,
|
|
|
|
|
commandName: "tree.node.restore",
|
|
|
|
|
payload: {
|
|
|
|
|
documentId,
|
|
|
|
|
workspaceId,
|
|
|
|
|
},
|
|
|
|
|
pageId: documentId,
|
|
|
|
|
client,
|
|
|
|
|
});
|
2026-04-26 19:35:52 +08:00
|
|
|
const { context, envelope, result } = mutation;
|
2026-04-26 04:29:23 +08:00
|
|
|
await recordTreeCommandSuccess({
|
|
|
|
|
context,
|
|
|
|
|
envelope,
|
|
|
|
|
client,
|
2026-04-26 19:35:52 +08:00
|
|
|
plan: mutation.plan,
|
|
|
|
|
result,
|
2026-04-26 04:29:23 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return NextResponse.json({
|
|
|
|
|
requestId: context.requestId,
|
|
|
|
|
traceId: context.traceId,
|
|
|
|
|
result: {
|
|
|
|
|
action: "restore",
|
|
|
|
|
workspaceId,
|
|
|
|
|
documentId,
|
|
|
|
|
updatedAt: result?.updated_at ?? null,
|
|
|
|
|
execution: result ?? null,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handlePurge(request: Request, payload: TreeCommandPayload) {
|
|
|
|
|
const { client } = await getAuthedConvexClient();
|
|
|
|
|
const documentId = assertDocumentId(payload.documentId ?? null);
|
|
|
|
|
const sourceDoc = await client.query(api.documents.getMeta, { id: documentId });
|
|
|
|
|
if (!sourceDoc) {
|
|
|
|
|
return NextResponse.json({ error: "页面不存在或无权访问" }, { status: 404 });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const workspaceId = trimOrNull(payload.workspaceId) ?? trimOrNull(sourceDoc.workspace_id);
|
2026-04-26 19:35:52 +08:00
|
|
|
const mutation = await resolveTreeMutationResult<{
|
2026-04-26 04:29:23 +08:00
|
|
|
ok?: boolean;
|
|
|
|
|
purged?: boolean;
|
|
|
|
|
purged_at?: string | null;
|
|
|
|
|
}>({
|
|
|
|
|
request,
|
|
|
|
|
workspaceId,
|
|
|
|
|
commandName: "tree.node.purge",
|
|
|
|
|
payload: {
|
|
|
|
|
documentId,
|
|
|
|
|
},
|
|
|
|
|
pageId: documentId,
|
|
|
|
|
client,
|
|
|
|
|
});
|
2026-04-26 19:35:52 +08:00
|
|
|
const { context, envelope, result } = mutation;
|
2026-04-26 04:29:23 +08:00
|
|
|
await recordTreeCommandSuccess({
|
|
|
|
|
context,
|
|
|
|
|
envelope,
|
|
|
|
|
client,
|
2026-04-26 19:35:52 +08:00
|
|
|
plan: mutation.plan,
|
|
|
|
|
result,
|
2026-04-26 04:29:23 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return NextResponse.json({
|
|
|
|
|
requestId: context.requestId,
|
|
|
|
|
traceId: context.traceId,
|
|
|
|
|
result: {
|
|
|
|
|
action: "purge",
|
|
|
|
|
workspaceId,
|
|
|
|
|
documentId,
|
|
|
|
|
purged: result?.purged ?? true,
|
|
|
|
|
updatedAt: result?.purged_at ?? null,
|
|
|
|
|
execution: result ?? null,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleEmbed(request: Request, payload: TreeCommandPayload) {
|
|
|
|
|
const { client } = await getAuthedConvexClient();
|
|
|
|
|
const sourceId = assertDocumentId(payload.sourceId ?? null);
|
|
|
|
|
const targetId = assertDocumentId(payload.targetId ?? null);
|
|
|
|
|
|
|
|
|
|
const sourceDoc = await client.query(api.documents.getMeta, { id: sourceId });
|
|
|
|
|
if (!sourceDoc) {
|
|
|
|
|
return NextResponse.json({ error: "原始页面不存在或无权限" }, { status: 404 });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const targetContent = await client.query(api.documents.getContent, { id: targetId });
|
|
|
|
|
if (!targetContent) {
|
|
|
|
|
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
|
|
|
|
|
}
|
|
|
|
|
const targetMeta = await client.query(api.documents.getMeta, { id: targetId });
|
|
|
|
|
|
|
|
|
|
const currentBlocks = extractBlocksFromContent(targetContent.content);
|
|
|
|
|
const anchorId = trimOrNull(
|
|
|
|
|
(targetMeta as { embed_default_block_id?: string | null } | null)?.embed_default_block_id,
|
|
|
|
|
);
|
|
|
|
|
const anchorIndex = anchorId
|
|
|
|
|
? currentBlocks.findIndex(
|
|
|
|
|
(block) =>
|
|
|
|
|
typeof block === "object" &&
|
|
|
|
|
block !== null &&
|
|
|
|
|
String((block as { id?: string }).id ?? "") === anchorId,
|
|
|
|
|
)
|
|
|
|
|
: -1;
|
|
|
|
|
const insertIndex = anchorIndex >= 0 ? anchorIndex + 1 : currentBlocks.length;
|
|
|
|
|
const nextBlocks: Json[] = [
|
|
|
|
|
...currentBlocks.slice(0, insertIndex),
|
|
|
|
|
{
|
|
|
|
|
id: randomUUID(),
|
|
|
|
|
type: "pageReference",
|
|
|
|
|
props: {
|
|
|
|
|
pageId: sourceId,
|
|
|
|
|
title: sourceDoc.title ?? "无标题",
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
...currentBlocks.slice(insertIndex),
|
|
|
|
|
];
|
|
|
|
|
const nextContent = composeContentWithBlocks(targetContent.content, nextBlocks);
|
|
|
|
|
const workspaceId =
|
|
|
|
|
trimOrNull(sourceDoc.workspace_id) ??
|
|
|
|
|
trimOrNull((targetMeta as { workspace_id?: string | null } | null)?.workspace_id);
|
|
|
|
|
|
2026-04-26 19:35:52 +08:00
|
|
|
const mutation = await resolveTreeMutationResult<{
|
2026-04-26 04:29:23 +08:00
|
|
|
revision?: number | null;
|
|
|
|
|
conflict_detection_key?: string | null;
|
|
|
|
|
}>({
|
|
|
|
|
request,
|
|
|
|
|
workspaceId,
|
|
|
|
|
commandName: "tree.node.embed",
|
|
|
|
|
payload: {
|
|
|
|
|
...buildDocumentSavePayload({
|
|
|
|
|
documentId: targetId,
|
|
|
|
|
workspaceId,
|
|
|
|
|
revision:
|
|
|
|
|
typeof targetContent.revision === "number" && Number.isInteger(targetContent.revision)
|
|
|
|
|
? targetContent.revision
|
|
|
|
|
: null,
|
|
|
|
|
content: nextContent,
|
|
|
|
|
conflictDetectionKey:
|
|
|
|
|
typeof targetContent.conflict_detection_key === "string"
|
|
|
|
|
? targetContent.conflict_detection_key
|
|
|
|
|
: null,
|
|
|
|
|
blockCount: nextBlocks.length,
|
|
|
|
|
}),
|
|
|
|
|
sourceDocumentId: sourceId,
|
|
|
|
|
targetDocumentId: targetId,
|
|
|
|
|
anchorBlockId: anchorId,
|
|
|
|
|
},
|
|
|
|
|
pageId: targetId,
|
|
|
|
|
client,
|
|
|
|
|
});
|
2026-04-26 19:35:52 +08:00
|
|
|
const { context, envelope, result } = mutation;
|
2026-04-26 04:29:23 +08:00
|
|
|
await recordTreeCommandSuccess({
|
|
|
|
|
context,
|
|
|
|
|
envelope,
|
|
|
|
|
client,
|
2026-04-26 19:35:52 +08:00
|
|
|
plan: mutation.plan,
|
|
|
|
|
result,
|
2026-04-26 04:29:23 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return NextResponse.json({
|
|
|
|
|
requestId: context.requestId,
|
|
|
|
|
traceId: context.traceId,
|
|
|
|
|
result: {
|
|
|
|
|
action: "embed",
|
|
|
|
|
workspaceId,
|
|
|
|
|
documentId: targetId,
|
|
|
|
|
sourceDocumentId: sourceId,
|
|
|
|
|
targetDocumentId: targetId,
|
|
|
|
|
execution: result ?? null,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function handleCopy(request: Request, payload: TreeCommandPayload) {
|
2026-04-26 19:35:52 +08:00
|
|
|
const { client } = await getAuthedConvexClient();
|
2026-04-26 04:29:23 +08:00
|
|
|
const normalizedItems = (payload.items ?? [])
|
|
|
|
|
.filter((item) => item?.documentId)
|
|
|
|
|
.map((item) => ({
|
|
|
|
|
documentId: assertDocumentId(item.documentId),
|
|
|
|
|
recursive: Boolean(item.recursive),
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
if (normalizedItems.length === 0) {
|
|
|
|
|
return NextResponse.json({ error: "items 为空" }, { status: 400 });
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const targetParentId = trimOrNull(payload.targetParentId);
|
|
|
|
|
let workspaceId: string | null = null;
|
|
|
|
|
|
|
|
|
|
if (targetParentId) {
|
|
|
|
|
const targetDoc = await client.query(api.documents.getMeta, { id: targetParentId });
|
|
|
|
|
if (!targetDoc) {
|
|
|
|
|
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
|
|
|
|
|
}
|
|
|
|
|
workspaceId = trimOrNull(targetDoc.workspace_id);
|
|
|
|
|
} else {
|
|
|
|
|
const firstDoc = await client.query(api.documents.getMeta, {
|
|
|
|
|
id: normalizedItems[0]?.documentId ?? "",
|
|
|
|
|
});
|
|
|
|
|
if (!firstDoc) {
|
|
|
|
|
return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
|
|
|
|
|
}
|
|
|
|
|
workspaceId = trimOrNull(firstDoc.workspace_id);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!workspaceId) {
|
|
|
|
|
return NextResponse.json({ error: "缺少目标工作空间" }, { status: 400 });
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-26 19:35:52 +08:00
|
|
|
const mutation = await resolveTreeMutationResult<{
|
2026-04-26 04:29:23 +08:00
|
|
|
items: Array<{
|
|
|
|
|
oldId: string;
|
|
|
|
|
newId: string;
|
|
|
|
|
title?: string | null;
|
2026-04-26 19:35:52 +08:00
|
|
|
document?: TreeDeltaDocument | null;
|
2026-04-26 04:29:23 +08:00
|
|
|
}>;
|
|
|
|
|
}>({
|
|
|
|
|
request,
|
|
|
|
|
workspaceId,
|
|
|
|
|
commandName: "tree.subtree.copy",
|
|
|
|
|
payload: {
|
|
|
|
|
workspaceId,
|
|
|
|
|
targetParentId,
|
|
|
|
|
items: normalizedItems,
|
|
|
|
|
},
|
|
|
|
|
pageId: targetParentId,
|
|
|
|
|
client,
|
|
|
|
|
});
|
2026-04-26 19:35:52 +08:00
|
|
|
const { context, envelope, result } = mutation;
|
2026-04-26 04:29:23 +08:00
|
|
|
|
|
|
|
|
await Promise.all(
|
|
|
|
|
(result.items ?? []).map(async (item) => {
|
|
|
|
|
await ensureDocumentScaffold(item.newId, item.title ?? "无标题");
|
|
|
|
|
await copyMindmapFilesIfExists(item.oldId, item.newId);
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
await recordTreeCommandSuccess({
|
|
|
|
|
context,
|
|
|
|
|
envelope,
|
|
|
|
|
client,
|
2026-04-26 19:35:52 +08:00
|
|
|
plan: mutation.plan,
|
|
|
|
|
result,
|
2026-04-26 04:29:23 +08:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return NextResponse.json({
|
|
|
|
|
requestId: context.requestId,
|
|
|
|
|
traceId: context.traceId,
|
|
|
|
|
result: {
|
|
|
|
|
action: "copy",
|
|
|
|
|
workspaceId,
|
|
|
|
|
targetParentId,
|
|
|
|
|
items: (result.items ?? []).map((item) => ({
|
|
|
|
|
oldId: item.oldId,
|
|
|
|
|
newId: item.newId,
|
|
|
|
|
})),
|
|
|
|
|
execution: result ?? null,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-24 06:10:18 +08:00
|
|
|
export const runtime = "nodejs";
|