0.1.04 文件树与导入mmap

This commit is contained in:
liaibo
2026-01-07 18:38:56 +08:00
parent 103e36b1c5
commit 294d1d5fb1
28 changed files with 2703 additions and 442 deletions
@@ -0,0 +1,393 @@
import { NextResponse } from "next/server";
import { createSupabaseRouteClient } from "@/lib/supabase/server";
import path from "path";
import { promises as fs } from "fs";
import type { Json } from "@/types/supabase";
import { makeUniqueTitle } from "@/lib/file-tree/naming";
export const dynamic = "force-dynamic";
type CopyTreeItem = {
documentId: string;
recursive: boolean;
};
type CopyTreePayload = {
items: CopyTreeItem[];
targetParentId: string | null;
};
type DocRow = {
id: string;
title: string | null;
parent_id: string | null;
workspace_id: string;
access_scope: "private" | "shared" | "public" | null;
sort_order: number | null;
created_at: string | null;
content: Json | null;
};
type AssetRow = {
id: string;
workspace_id: string;
document_id: string;
asset_type: string | null;
file_name: string | null;
file_size: number | null;
mime_type: string | null;
file_url: string | null;
thumbnail_url: string | null;
bucket: string | null;
storage_path: string | null;
};
const documentsBaseDir = path.join(process.cwd(), "public", "documents");
const DEFAULT_DOC_BUCKET = process.env.NEXT_PUBLIC_SUPABASE_DOC_BUCKET ?? "documents";
async function ensureDocumentScaffold(id: string, title: string | null) {
const folder = path.join(documentsBaseDir, id);
const indexFile = path.join(folder, "index.md");
await fs.mkdir(folder, { recursive: true });
try {
await fs.access(indexFile);
} catch {
const safeTitle = title && title.trim() ? title.trim() : "无标题";
const content = `# ${safeTitle}\n`;
await fs.writeFile(indexFile, content, "utf8");
}
}
async function copyMindmapIfExists(sourceId: string, targetId: string) {
const src = path.join(documentsBaseDir, sourceId, "mindmap.json");
const destDir = path.join(documentsBaseDir, targetId);
const dest = path.join(destDir, "mindmap.json");
try {
const buf = await fs.readFile(src);
await fs.mkdir(destDir, { recursive: true });
await fs.writeFile(dest, buf);
} catch {
// 源不存在则忽略
}
}
function normalizeTitle(title: string | null): string {
const safe = title?.trim();
return safe && safe.length > 0 ? safe : "无标题";
}
function parseStoragePath(fileUrl: string): { bucket: string; path: string } | null {
try {
const url = new URL(fileUrl);
const segments = url.pathname.split("/").filter(Boolean);
const objectIdx = segments.findIndex((seg) => seg === "object");
if (objectIdx === -1 || objectIdx + 2 >= segments.length) return null;
if (segments[objectIdx + 1] === "public") {
const bucket = segments[objectIdx + 2];
const p = segments.slice(objectIdx + 3).join("/");
return p ? { bucket, path: p } : null;
}
if (segments[objectIdx + 1] === "sign") {
const bucket = segments[objectIdx + 2];
const p = segments.slice(objectIdx + 3).join("/");
return p ? { bucket, path: p } : null;
}
return null;
} catch {
return null;
}
}
function getChildrenSorted(childrenByParent: Map<string | null, DocRow[]>, parentId: string | null): DocRow[] {
const list = childrenByParent.get(parentId) ?? [];
return [...list].sort((a, b) => {
const orderA = a.sort_order ?? Number.MAX_SAFE_INTEGER;
const orderB = b.sort_order ?? Number.MAX_SAFE_INTEGER;
if (orderA !== orderB) return orderA - orderB;
const timeA = new Date(a.created_at ?? 0).getTime();
const timeB = new Date(b.created_at ?? 0).getTime();
return timeA - timeB;
});
}
function replaceAssetRefsInContent(content: Json | null, assetMap: Map<string, { id: string; signedUrl: string }>, newDocId: string): Json | null {
if (!content) return content;
const transform = (value: unknown): unknown => {
if (Array.isArray(value)) {
return value.map(transform);
}
if (!value || typeof value !== "object") {
return value;
}
const obj = value as Record<string, unknown>;
const next: Record<string, unknown> = {};
Object.keys(obj).forEach((key) => {
next[key] = transform(obj[key]);
});
if (next.type === "media" && next.props && typeof next.props === "object") {
const props = next.props as Record<string, unknown>;
const oldId = typeof props.assetId === "string" ? props.assetId : null;
if (oldId && assetMap.has(oldId)) {
const mapped = assetMap.get(oldId)!;
props.assetId = mapped.id;
props.fileUrl = mapped.signedUrl;
props.thumbnailUrl = mapped.signedUrl;
props.documentId = newDocId;
next.props = props;
}
}
return next;
};
return transform(content) as Json;
}
export async function POST(request: Request) {
const supabase = await createSupabaseRouteClient();
const {
data: { session },
} = await supabase.auth.getSession();
if (!session) {
return NextResponse.json({ error: "未登录" }, { status: 401 });
}
const payload = (await request.json()) as CopyTreePayload;
if (!payload?.items?.length) {
return NextResponse.json({ error: "缺少 items" }, { status: 400 });
}
const normalizedItems = payload.items.filter((it) => it?.documentId);
if (normalizedItems.length === 0) {
return NextResponse.json({ error: "items 为空" }, { status: 400 });
}
const targetParentId = payload.targetParentId ?? null;
let workspaceId: string | null = null;
let targetAccessScope: "private" | "shared" | "public" = "private";
if (targetParentId) {
const { data: targetDoc, error } = await supabase
.from("documents")
.select("workspace_id,access_scope")
.eq("id", targetParentId)
.eq("user_id", session.user.id)
.single();
if (error || !targetDoc) {
return NextResponse.json({ error: "目标页面不存在或无权限" }, { status: 404 });
}
workspaceId = targetDoc.workspace_id;
targetAccessScope = (targetDoc.access_scope ?? "private") as typeof targetAccessScope;
}
const sourceIds = Array.from(new Set(normalizedItems.map((it) => it.documentId)));
const { data: sourceDocs, error: sourceErr } = await supabase
.from("documents")
.select("id,title,parent_id,workspace_id,access_scope,sort_order,created_at,content")
.in("id", sourceIds)
.eq("user_id", session.user.id);
if (sourceErr || !sourceDocs?.length) {
return NextResponse.json({ error: "源页面不存在或无权限" }, { status: 404 });
}
const sourceById = new Map<string, DocRow>();
(sourceDocs as DocRow[]).forEach((d) => sourceById.set(d.id, d));
if (!workspaceId) {
workspaceId = sourceDocs[0].workspace_id;
}
const { data: allDocs, error: allErr } = await supabase
.from("documents")
.select("id,title,parent_id,workspace_id,access_scope,sort_order,created_at,content")
.eq("workspace_id", workspaceId)
.eq("user_id", session.user.id)
.order("sort_order", { ascending: true, nullsFirst: false })
.order("created_at", { ascending: true });
if (allErr) {
return NextResponse.json({ error: allErr.message }, { status: 500 });
}
const childrenByParent = new Map<string | null, DocRow[]>();
(allDocs as DocRow[]).forEach((doc) => {
const list = childrenByParent.get(doc.parent_id) ?? [];
list.push(doc);
childrenByParent.set(doc.parent_id, list);
});
const existingTitleSetByParent = new Map<string | null, Set<string>>();
const seedTitleSet = (parent: string | null) => {
if (existingTitleSetByParent.has(parent)) return;
const titles = new Set<string>();
(childrenByParent.get(parent) ?? []).forEach((d) => titles.add(normalizeTitle(d.title)));
existingTitleSetByParent.set(parent, titles);
};
seedTitleSet(targetParentId);
const newIdByOldId = new Map<string, string>();
const copyQueue: Array<{ old: DocRow; newParentId: string | null; parentKey: string | null }> = [];
const enqueueTree = (root: DocRow, newParent: string | null, recursive: boolean) => {
const visit = (node: DocRow, parentNewId: string | null, parentKey: string | null) => {
const newId = typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${Date.now()}_${Math.random().toString(16).slice(2)}`;
newIdByOldId.set(node.id, newId);
copyQueue.push({ old: node, newParentId: parentNewId, parentKey });
if (!recursive) return;
const children = getChildrenSorted(childrenByParent, node.id);
children.forEach((child) => visit(child, newId, newId));
};
visit(root, newParent, newParent);
};
normalizedItems.forEach((item) => {
const doc = sourceById.get(item.documentId) ?? (allDocs as DocRow[]).find((d) => d.id === item.documentId) ?? null;
if (doc) {
enqueueTree(doc, targetParentId, Boolean(item.recursive));
}
});
if (copyQueue.length === 0) {
return NextResponse.json({ error: "没有可复制的页面" }, { status: 400 });
}
// 计算根级 sort_order 起点
const siblingQuery = supabase
.from("documents")
.select("id", { head: true, count: "exact" })
.eq("workspace_id", workspaceId);
if (targetParentId) {
siblingQuery.eq("parent_id", targetParentId);
} else {
siblingQuery.is("parent_id", null);
}
const { count: siblingCount = 0 } = await siblingQuery;
const nextSortByParent = new Map<string | null, number>([[targetParentId, siblingCount]]);
const insertedDocs: Array<{ oldId: string; newId: string }> = [];
for (const item of copyQueue) {
const newId = newIdByOldId.get(item.old.id)!;
const parentId = item.newParentId;
if (!existingTitleSetByParent.has(parentId)) {
if (newIdByOldId.has(parentId ?? "")) {
existingTitleSetByParent.set(parentId, new Set());
} else {
seedTitleSet(parentId);
}
}
const titleSet = existingTitleSetByParent.get(parentId) ?? new Set<string>();
existingTitleSetByParent.set(parentId, titleSet);
const newTitle = makeUniqueTitle(normalizeTitle(item.old.title), titleSet);
const currentSort = nextSortByParent.get(parentId) ?? 0;
nextSortByParent.set(parentId, currentSort + 1);
const { error: insertError } = await supabase.from("documents").insert({
id: newId,
user_id: session.user.id,
workspace_id: workspaceId,
parent_id: parentId,
access_scope: (item.old.access_scope ?? targetAccessScope) as typeof targetAccessScope,
title: newTitle,
content: item.old.content ?? { blocks: [] },
sort_order: currentSort,
});
if (insertError) {
return NextResponse.json({ error: insertError.message }, { status: 500 });
}
await ensureDocumentScaffold(newId, newTitle);
await copyMindmapIfExists(item.old.id, newId);
insertedDocs.push({ oldId: item.old.id, newId });
}
// 复制附件并回写 content 中的 assetId
const oldDocIds = insertedDocs.map((d) => d.oldId);
const { data: assets, error: assetErr } = await supabase
.from("media_assets")
.select("id,workspace_id,document_id,asset_type,file_name,file_size,mime_type,file_url,thumbnail_url,bucket,storage_path")
.in("document_id", oldDocIds)
.eq("workspace_id", workspaceId);
if (assetErr) {
return NextResponse.json({ error: assetErr.message }, { status: 500 });
}
const assetMapByOldDoc = new Map<string, Map<string, { id: string; signedUrl: string }>>();
for (const asset of (assets as AssetRow[] | null) ?? []) {
const mappedDocId = newIdByOldId.get(asset.document_id);
if (!mappedDocId) continue;
const sourceLocation =
asset.storage_path
? { bucket: asset.bucket ?? DEFAULT_DOC_BUCKET, path: asset.storage_path }
: asset.file_url
? parseStoragePath(asset.file_url)
: null;
if (!sourceLocation) continue;
const fileName = (asset.file_name ?? "附件").replace(/[\\/]/g, "_");
const newAssetId =
typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now()}_${Math.random().toString(16).slice(2)}`;
const targetPath = `${workspaceId}/${mappedDocId}/${newAssetId}-${fileName}`;
const bucket = sourceLocation.bucket || DEFAULT_DOC_BUCKET;
const map = assetMapByOldDoc.get(asset.document_id) ?? new Map<string, { id: string; signedUrl: string }>();
assetMapByOldDoc.set(asset.document_id, map);
const copyRes = await supabase.storage.from(bucket).copy(sourceLocation.path, targetPath);
if (copyRes.error) {
continue;
}
const { data: signed } = await supabase.storage.from(bucket).createSignedUrl(targetPath, 60 * 60 * 24 * 7);
const signedUrl = signed?.signedUrl ?? "";
const { error: insertAssetErr } = await supabase.from("media_assets").insert({
id: newAssetId,
workspace_id: workspaceId,
document_id: mappedDocId,
asset_type: asset.asset_type ?? "file",
file_name: asset.file_name ?? fileName,
file_size: asset.file_size ?? null,
mime_type: asset.mime_type ?? null,
bucket,
storage_path: targetPath,
file_url: signedUrl,
thumbnail_url: signedUrl,
created_by: session.user.id,
});
if (!insertAssetErr) {
map.set(asset.id, { id: newAssetId, signedUrl });
}
}
for (const pair of insertedDocs) {
const map = assetMapByOldDoc.get(pair.oldId);
if (!map || map.size === 0) continue;
const source = (allDocs as DocRow[]).find((d) => d.id === pair.oldId);
if (!source) continue;
const newContent = replaceAssetRefsInContent(source.content, map, pair.newId);
const { error: updateErr } = await supabase
.from("documents")
.update({ content: newContent })
.eq("id", pair.newId)
.eq("user_id", session.user.id);
if (updateErr) {
// ignore
}
}
return NextResponse.json({
items: insertedDocs.map((d) => ({ oldId: d.oldId, newId: d.newId })),
});
}