使用openai进行了代码review

This commit is contained in:
2026-02-24 11:33:08 +08:00
parent 9efdf060f5
commit 0c97f02e51
32 changed files with 1499 additions and 969 deletions
+39 -7
View File
@@ -1,20 +1,52 @@
import { NextRequest, NextResponse } from "next/server";
import { requireApiAuth } from "@/lib/api-auth";
export const runtime = "edge"; // Optional: Use edge runtime for lower latency
export async function POST(req: NextRequest) {
const authError = await requireApiAuth();
if (authError) return authError;
try {
const { messages, config } = await req.json();
const { apiKey, baseURL, model } = config || {};
if (!apiKey) {
return NextResponse.json({ error: "Missing API Key" }, { status: 401 });
if (!apiKey || typeof apiKey !== "string") {
return NextResponse.json({ error: "Missing API Key" }, { status: 400 });
}
if (!baseURL || typeof baseURL !== "string") {
return NextResponse.json({ error: "Missing API base URL" }, { status: 400 });
}
if (!Array.isArray(messages)) {
return NextResponse.json({ error: "Invalid messages payload" }, { status: 400 });
}
if (messages.length === 0 || messages.length > 100) {
return NextResponse.json({ error: "Messages count out of range" }, { status: 400 });
}
const isValidMessage = messages.every(
(msg) =>
msg &&
typeof msg === "object" &&
typeof msg.role === "string" &&
typeof msg.content === "string" &&
msg.content.length <= 20000
);
if (!isValidMessage) {
return NextResponse.json({ error: "Invalid message format" }, { status: 400 });
}
// Clean up baseURL: ensure no trailing slash, add /chat/completions if missing?
// Actually, usually users provide standard base URL "https://api.openai.com/v1"
// We should append /chat/completions.
const url = `${baseURL.replace(/\/$/, "")}/chat/completions`;
let normalizedBaseUrl: string;
try {
const parsed = new URL(baseURL);
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
return NextResponse.json({ error: "Unsupported API base URL protocol" }, { status: 400 });
}
normalizedBaseUrl = parsed.origin + parsed.pathname.replace(/\/$/, "");
} catch {
return NextResponse.json({ error: "Invalid API base URL" }, { status: 400 });
}
const url = `${normalizedBaseUrl}/chat/completions`;
const res = await fetch(url, {
method: "POST",
@@ -23,7 +55,7 @@ export async function POST(req: NextRequest) {
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: model || "gpt-3.5-turbo",
model: typeof model === "string" && model.trim() ? model.trim() : "gpt-3.5-turbo",
messages,
stream: true, // Force streaming
}),
+7 -2
View File
@@ -5,7 +5,10 @@ import { createSessionToken, getSessionCookieName, getSessionTtlSeconds } from "
export async function POST(req: Request) {
try {
const { password, rememberMe, durationDays } = await req.json();
const { password, rememberMe, durationDays } = await req.json().catch(() => ({}));
if (typeof password !== "string" || password.length === 0) {
return NextResponse.json({ error: "Invalid password" }, { status: 400 });
}
const settings = await prisma.globalSettings.findUnique({
where: { id: "default" },
@@ -18,7 +21,9 @@ export async function POST(req: Request) {
const isValid = await verifyPassword(password, settings.password);
if (isValid) {
const days = rememberMe ? Number(durationDays) || 1 : 1;
const rawDays = rememberMe ? Number(durationDays) : 1;
const boundedDays = Number.isFinite(rawDays) ? Math.floor(rawDays) : 1;
const days = rememberMe ? Math.max(1, Math.min(30, boundedDays)) : 1;
const token = await createSessionToken(days);
const response = NextResponse.json({ success: true });
response.cookies.set(getSessionCookieName(), token, {
+168 -13
View File
@@ -1,18 +1,87 @@
import { NextResponse } from 'next/server';
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import type { Prisma } from '@prisma/client';
import { requireApiAuth } from '@/lib/api-auth';
type PageRef = { id: string; parentId: string | null };
const MAX_TITLE_LENGTH = 200;
const MAX_TAGS = 20;
const MAX_TAG_LENGTH = 50;
function buildChildrenMap(pages: PageRef[]): Map<string | null, string[]> {
const childrenMap = new Map<string | null, string[]>();
for (const page of pages) {
const siblings = childrenMap.get(page.parentId) || [];
siblings.push(page.id);
childrenMap.set(page.parentId, siblings);
}
return childrenMap;
}
function isCycleMove(pages: PageRef[], nodeId: string, targetParentId: string): boolean {
const parentMap = new Map<string, string | null>(pages.map((p) => [p.id, p.parentId]));
let cursor: string | null = targetParentId;
while (cursor) {
if (cursor === nodeId) return true;
cursor = parentMap.get(cursor) ?? null;
}
return false;
}
function collectDeleteOrder(rootId: string, pages: PageRef[]): string[] {
const childrenMap = buildChildrenMap(pages);
const order: string[] = [];
const walk = (id: string) => {
const children = childrenMap.get(id) || [];
for (const childId of children) walk(childId);
order.push(id);
};
walk(rootId);
return order;
}
function safeParseTags(tags: string | null): string[] {
if (!tags) return [];
try {
const parsed = JSON.parse(tags);
return Array.isArray(parsed) ? parsed.filter((t): t is string => typeof t === 'string') : [];
} catch {
return [];
}
}
function normalizeTags(input: unknown): string[] {
if (!Array.isArray(input)) return [];
const unique = new Set<string>();
for (const raw of input) {
if (typeof raw !== 'string') continue;
const normalized = raw.trim();
if (!normalized || normalized.length > MAX_TAG_LENGTH) continue;
unique.add(normalized);
if (unique.size >= MAX_TAGS) break;
}
return Array.from(unique);
}
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const authError = await requireApiAuth();
if (authError) return authError;
const id = (await params).id;
try {
const page = await prisma.page.findUnique({
where: { id },
});
if (!page) return NextResponse.json({ error: 'Page not found' }, { status: 404 });
return NextResponse.json({ ...page, tags: JSON.parse(page.tags || "[]") });
return NextResponse.json({ ...page, tags: safeParseTags(page.tags) });
} catch {
return NextResponse.json({ error: 'Error fetching page' }, { status: 500 });
}
@@ -22,23 +91,96 @@ export async function PUT(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const authError = await requireApiAuth();
if (authError) return authError;
const id = (await params).id;
try {
const body = await request.json();
// Separate update logic for flexibility (e.g. only updating title)
const body = await request.json().catch(() => null);
if (!body || typeof body !== 'object') {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
}
const current = await prisma.page.findUnique({ where: { id }, select: { id: true } });
if (!current) {
return NextResponse.json({ error: 'Page not found' }, { status: 404 });
}
const updateData: Prisma.PageUncheckedUpdateInput = {};
if (body.title !== undefined) updateData.title = body.title;
if (body.content !== undefined) updateData.content = body.content;
if (body.parentId !== undefined) updateData.parentId = body.parentId;
if (body.tags !== undefined) updateData.tags = JSON.stringify(body.tags);
if (body.icon !== undefined) updateData.icon = body.icon;
if (body.isLocked !== undefined) updateData.isLocked = body.isLocked;
if (body.title !== undefined) {
if (typeof body.title !== 'string') {
return NextResponse.json({ error: 'Invalid title' }, { status: 400 });
}
const trimmed = body.title.trim();
if (!trimmed || trimmed.length > MAX_TITLE_LENGTH) {
return NextResponse.json({ error: 'Invalid title' }, { status: 400 });
}
updateData.title = trimmed;
}
if (body.content !== undefined) {
if (typeof body.content !== 'string') {
return NextResponse.json({ error: 'Invalid content' }, { status: 400 });
}
updateData.content = body.content;
}
if (body.icon !== undefined) {
updateData.icon = typeof body.icon === 'string' ? body.icon : null;
}
if (body.isLocked !== undefined) {
if (typeof body.isLocked !== 'boolean') {
return NextResponse.json({ error: 'Invalid isLocked' }, { status: 400 });
}
updateData.isLocked = body.isLocked;
}
if (body.tags !== undefined) {
updateData.tags = JSON.stringify(normalizeTags(body.tags));
}
if (body.parentId !== undefined) {
if (body.parentId === null) {
updateData.parentId = null;
} else if (typeof body.parentId === 'string') {
const targetParentId = body.parentId;
if (targetParentId === id) {
return NextResponse.json({ error: 'Invalid parentId' }, { status: 400 });
}
const targetParent = await prisma.page.findUnique({
where: { id: targetParentId },
select: { id: true, type: true },
});
if (!targetParent || targetParent.type !== 'folder') {
return NextResponse.json({ error: 'Parent folder not found' }, { status: 400 });
}
const refs = await prisma.page.findMany({ select: { id: true, parentId: true } });
if (isCycleMove(refs, id, targetParentId)) {
return NextResponse.json({ error: 'Cannot move page into its own subtree' }, { status: 400 });
}
updateData.parentId = targetParentId;
} else {
return NextResponse.json({ error: 'Invalid parentId' }, { status: 400 });
}
}
if (body.order !== undefined) {
if (!Number.isInteger(body.order)) {
return NextResponse.json({ error: 'Invalid order' }, { status: 400 });
}
updateData.order = body.order;
}
const page = await prisma.page.update({
where: { id },
data: updateData,
});
return NextResponse.json({ ...page, tags: JSON.parse(page.tags || "[]") });
return NextResponse.json({ ...page, tags: safeParseTags(page.tags) });
} catch {
return NextResponse.json({ error: 'Error updating page' }, { status: 500 });
}
@@ -48,11 +190,24 @@ export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const authError = await requireApiAuth();
if (authError) return authError;
const id = (await params).id;
try {
await prisma.page.delete({
where: { id },
const refs = await prisma.page.findMany({ select: { id: true, parentId: true } });
const exists = refs.some((p) => p.id === id);
if (!exists) {
return NextResponse.json({ error: 'Page not found' }, { status: 404 });
}
const deleteOrder = collectDeleteOrder(id, refs);
await prisma.$transaction(async (tx) => {
for (const pageId of deleteOrder) {
await tx.page.delete({ where: { id: pageId } });
}
});
return NextResponse.json({ success: true });
} catch {
return NextResponse.json({ error: 'Error deleting page' }, { status: 500 });
+22
View File
@@ -1,7 +1,11 @@
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { requireApiAuth } from '@/lib/api-auth';
export async function PUT(request: Request) {
const authError = await requireApiAuth();
if (authError) return authError;
try {
const body = await request.json();
const { updates } = body;
@@ -9,6 +13,24 @@ export async function PUT(request: Request) {
if (!Array.isArray(updates)) {
return NextResponse.json({ error: 'Invalid updates' }, { status: 400 });
}
if (updates.length === 0) {
return NextResponse.json({ success: true });
}
const isValid = updates.every(
(update) =>
update &&
typeof update === 'object' &&
typeof update.id === 'string' &&
update.id.length > 0 &&
Number.isInteger(update.order)
);
if (!isValid) {
return NextResponse.json({ error: 'Invalid updates payload' }, { status: 400 });
}
const ids = updates.map((update: { id: string }) => update.id);
if (new Set(ids).size !== ids.length) {
return NextResponse.json({ error: 'Duplicate page ids in updates' }, { status: 400 });
}
// Transaction for batch update
await prisma.$transaction(
+75 -15
View File
@@ -1,14 +1,45 @@
import { NextResponse } from 'next/server';
import { NextResponse } from 'next/server';
import { prisma } from '@/lib/prisma';
import { requireApiAuth } from '@/lib/api-auth';
const MAX_TITLE_LENGTH = 200;
const MAX_TAGS = 20;
const MAX_TAG_LENGTH = 50;
function safeParseTags(tags: string | null): string[] {
if (!tags) return [];
try {
const parsed = JSON.parse(tags);
return Array.isArray(parsed) ? parsed.filter((t): t is string => typeof t === 'string') : [];
} catch {
return [];
}
}
function normalizeTags(input: unknown): string[] {
if (!Array.isArray(input)) return [];
const unique = new Set<string>();
for (const raw of input) {
if (typeof raw !== 'string') continue;
const normalized = raw.trim();
if (!normalized || normalized.length > MAX_TAG_LENGTH) continue;
unique.add(normalized);
if (unique.size >= MAX_TAGS) break;
}
return Array.from(unique);
}
export async function GET() {
const authError = await requireApiAuth();
if (authError) return authError;
try {
const pages = await prisma.page.findMany({
orderBy: [{ order: 'asc' }, { createdAt: 'desc' }],
});
const parsedPages = pages.map(p => ({
const parsedPages = pages.map((p) => ({
...p,
tags: JSON.parse(p.tags || "[]")
tags: safeParseTags(p.tags),
}));
return NextResponse.json(parsedPages);
} catch {
@@ -17,30 +48,59 @@ export async function GET() {
}
export async function POST(request: Request) {
const authError = await requireApiAuth();
if (authError) return authError;
try {
const body = await request.json();
const { title, content, parentId, type } = body;
const body = await request.json().catch(() => null);
if (!body || typeof body !== 'object') {
return NextResponse.json({ error: 'Invalid request body' }, { status: 400 });
}
const rawTitle = typeof body.title === 'string' ? body.title.trim() : '';
const title = rawTitle || '无标题';
if (title.length > MAX_TITLE_LENGTH) {
return NextResponse.json({ error: 'Title too long' }, { status: 400 });
}
const type = body.type === 'folder' ? 'folder' : 'file';
const content = typeof body.content === 'string' ? body.content : '';
const parentId = typeof body.parentId === 'string' ? body.parentId : null;
const tags = normalizeTags(body.tags);
const icon = typeof body.icon === 'string' ? body.icon : null;
const isLocked = typeof body.isLocked === 'boolean' ? body.isLocked : false;
const requestedOrder = Number.isInteger(body.order) ? body.order : undefined;
if (parentId) {
const parent = await prisma.page.findUnique({
where: { id: parentId },
select: { id: true, type: true },
});
if (!parent || parent.type !== 'folder') {
return NextResponse.json({ error: 'Parent folder not found' }, { status: 400 });
}
}
const page = await prisma.page.create({
data: {
title: title || '无标题',
content: content || '',
tags: JSON.stringify(body.tags || []),
parentId: parentId || null,
type: type || 'file',
title,
content,
tags: JSON.stringify(tags),
parentId,
type,
order: await (async () => {
if (body.order !== undefined) return body.order;
if (requestedOrder !== undefined) return requestedOrder;
const lastPage = await prisma.page.findFirst({
where: { parentId: parentId || null },
where: { parentId },
orderBy: { order: 'desc' },
});
return (lastPage?.order ?? -1) + 1;
})(),
icon: body.icon || null,
isLocked: body.isLocked || false,
icon,
isLocked,
},
});
return NextResponse.json({ ...page, tags: JSON.parse(page.tags || "[]") });
return NextResponse.json({ ...page, tags: safeParseTags(page.tags) });
} catch {
return NextResponse.json({ error: 'Error creating page' }, { status: 500 });
}
+31 -4
View File
@@ -1,16 +1,43 @@
import { NextResponse } from "next/server";
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { hashPassword } from "@/lib/auth";
export async function POST() {
export async function POST(req: Request) {
try {
const count = await prisma.globalSettings.count();
if (count > 0) {
return NextResponse.json({ message: "Settings already initialized" }, { status: 200 });
}
// Default password: "admin"
const hashedPassword = await hashPassword("admin");
const body = await req.json().catch(() => ({}));
const passwordFromBody = typeof body?.password === "string" ? body.password : "";
const passwordFromEnv = process.env.INIT_DEFAULT_PASSWORD || "";
const initPassword = passwordFromBody || passwordFromEnv;
if (!initPassword || initPassword.length < 6) {
return NextResponse.json(
{ error: "Missing init password. Provide body.password or INIT_DEFAULT_PASSWORD (min 6 chars)." },
{ status: 400 }
);
}
const initToken = process.env.INIT_SETUP_TOKEN;
const isProduction = process.env.NODE_ENV === "production";
if (isProduction && !initToken) {
return NextResponse.json(
{ error: "Server misconfigured: INIT_SETUP_TOKEN is required in production." },
{ status: 500 }
);
}
if (initToken) {
const providedToken = req.headers.get("x-init-token");
if (providedToken !== initToken) {
return NextResponse.json({ error: "Invalid init token" }, { status: 401 });
}
}
const hashedPassword = await hashPassword(initPassword);
await prisma.globalSettings.create({
data: {
id: "default",
+14 -1
View File
@@ -1,10 +1,23 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { hashPassword, verifyPassword } from "@/lib/auth";
import { requireApiAuth } from "@/lib/api-auth";
export async function PUT(req: Request) {
const authError = await requireApiAuth();
if (authError) return authError;
try {
const { currentPassword, newPassword } = await req.json();
const { currentPassword, newPassword } = await req.json().catch(() => ({}));
if (typeof currentPassword !== "string" || currentPassword.length === 0) {
return NextResponse.json({ error: "Invalid current password" }, { status: 400 });
}
if (typeof newPassword !== "string" || newPassword.length < 6) {
return NextResponse.json({ error: "New password must be at least 6 characters" }, { status: 400 });
}
if (newPassword === currentPassword) {
return NextResponse.json({ error: "New password must be different" }, { status: 400 });
}
const settings = await prisma.globalSettings.findUnique({
where: { id: "default" },
+173 -167
View File
@@ -1,194 +1,200 @@
import { NextRequest, NextResponse } from "next/server";
import { PrismaClient } from "@prisma/client";
import JSZip from "jszip";
import { marked } from "marked";
import { prisma } from "@/lib/prisma";
import { requireApiAuth } from "@/lib/api-auth";
// Use a global prisma instance to avoid "too many connections" in dev
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
const prisma = globalForPrisma.prisma || new PrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
type ZipEntry = {
path: string;
isDir: boolean;
content?: string;
};
// Disable body parser strictly (Next.js App Router handles FormData naturally)
// export const config = {
// api: {
// bodyParser: false,
// },
// };
// No need for config in App Router route handlers.
type ParsedMarkdown = {
title: string;
order: number;
tags: string[];
htmlContent: string;
};
const MAX_RESTORE_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
function normalizeZipPath(input: string): string {
const noBackslashes = input.replace(/\\/g, "/");
const noTrailingSlash = noBackslashes.endsWith("/") ? noBackslashes.slice(0, -1) : noBackslashes;
return noTrailingSlash.trim();
}
function getDepth(path: string): number {
return path.split("/").length;
}
function getParentPath(path: string): string | null {
const parts = path.split("/");
if (parts.length <= 1) return null;
return parts.slice(0, -1).join("/");
}
function getNameFromPath(path: string): string {
return path.split("/").pop() || "Untitled";
}
async function parseMarkdownWithFrontmatter(filePath: string, content: string): Promise<ParsedMarkdown> {
const defaultTitle = getNameFromPath(filePath).replace(/\.md$/i, "") || "Untitled";
let title = defaultTitle;
let tags: string[] = [];
let order = 0;
let markdownBody = content;
const fmMatch = markdownBody.match(/^---\n([\s\S]*?)\n---\n/);
if (fmMatch) {
const fmString = fmMatch[1];
markdownBody = markdownBody.slice(fmMatch[0].length);
const titleMatch = fmString.match(/title:\s*"(.*)"/);
if (titleMatch && titleMatch[1]) title = titleMatch[1];
const tagsMatch = fmString.match(/tags:\s*\[(.*)\]/);
if (tagsMatch && tagsMatch[1]) {
tags = tagsMatch[1]
.split(",")
.map((item) => item.trim().replace(/^"|"$/g, ""))
.filter(Boolean);
}
const orderMatch = fmString.match(/order:\s*(\d+)/);
if (orderMatch && orderMatch[1]) {
order = parseInt(orderMatch[1], 10);
}
}
const htmlContent = await marked(markdownBody, { gfm: true, breaks: true });
return { title, order, tags, htmlContent };
}
function collectFolderPaths(entries: ZipEntry[]): string[] {
const folderSet = new Set<string>();
for (const entry of entries) {
const normalized = normalizeZipPath(entry.path);
if (!normalized) continue;
if (entry.isDir) {
folderSet.add(normalized);
}
let current = getParentPath(normalized);
while (current) {
folderSet.add(current);
current = getParentPath(current);
}
}
return Array.from(folderSet).sort((a, b) => getDepth(a) - getDepth(b));
}
export async function POST(req: NextRequest) {
const authError = await requireApiAuth();
if (authError) return authError;
try {
const formData = await req.formData();
const file = formData.get("file") as File;
const file = formData.get("file");
if (!file) {
if (!(file instanceof File)) {
return NextResponse.json({ error: "No file uploaded" }, { status: 400 });
}
if (file.size <= 0) {
return NextResponse.json({ error: "Empty file" }, { status: 400 });
}
if (file.size > MAX_RESTORE_FILE_SIZE) {
return NextResponse.json({ error: "File too large" }, { status: 413 });
}
const buffer = await file.arrayBuffer();
const zip = await JSZip.loadAsync(buffer);
const entries: ZipEntry[] = [];
const loadTasks: Promise<void>[] = [];
// Map to store directory paths to their new DB IDs
// Data format: "folder/subfolder" -> UUID
const pathIdMap = new Map<string, string>();
zip.forEach((rawPath, zipEntry) => {
if (rawPath.startsWith("__MACOSX") || rawPath.includes(".DS_Store")) return;
// Prepare data for proper insertion order (Folders first, then files?)
// Actually, we need to process by path depth to ensure parents exist.
const entries: Array<{ path: string; isDir: boolean; content?: string }> = [];
loadTasks.push((async () => {
const path = normalizeZipPath(rawPath);
if (!path) return;
// 1. Read all entries
const filePromises: Promise<void>[] = [];
zip.forEach((relativePath, zipEntry) => {
if (relativePath.startsWith("__MACOSX") || relativePath.includes(".DS_Store")) {
return; // Skip system files
}
const promise = (async () => {
if (zipEntry.dir) {
// Remove trailing slash for consistency
const cleanPath = relativePath.endsWith('/') ? relativePath.slice(0, -1) : relativePath;
if (cleanPath) {
entries.push({ path: cleanPath, isDir: true });
}
} else {
if (relativePath.endsWith(".md")) {
const content = await zipEntry.async("string");
entries.push({ path: relativePath, isDir: false, content });
}
entries.push({ path, isDir: true });
return;
}
})();
filePromises.push(promise);
if (!path.endsWith(".md")) return;
const content = await zipEntry.async("string");
entries.push({ path, isDir: false, content });
})());
});
await Promise.all(filePromises);
// 2. Clear existing Pages (Transaction usually)
// Since sqlite doesn't support nested transactions well in Prisma sometimes,
// we'll just do it sequentially but quickly.
// Ideally: await prisma.$transaction([prisma.page.deleteMany(), ...])
// But logic is complex (recursive id generation), so we delete first.
// WARNING: This is destructive.
await prisma.page.deleteMany();
// 3. Sort entries by path depth (number of slashes)
entries.sort((a, b) => {
const depthA = a.path.split('/').length;
const depthB = b.path.split('/').length;
return depthA - depthB;
});
// Helper to get or create parent folder
const ensureParent = async (entryPath: string): Promise<string | null> => {
const parts = entryPath.split('/');
if (parts.length <= 1) return null; // Root level
const parentPath = parts.slice(0, -1).join('/');
// If parent already processed
if (pathIdMap.has(parentPath)) {
return pathIdMap.get(parentPath)!;
}
// If parent folder was not explicitly in Zip (implicit folder), create it
// Recursively ensure its parent exists
const grandParentId = await ensureParent(parentPath);
const folderName = parts[parts.length - 2];
const newFolder = await prisma.page.create({
data: {
title: folderName,
type: 'folder',
parentId: grandParentId
}
});
pathIdMap.set(parentPath, newFolder.id);
return newFolder.id;
};
// 4. Process entries
for (const entry of entries) {
// Determine parent
// If it's a file "A/B.md", parent path is "A".
// If it's a folder "A/B", parent path is "A".
// Since we sorted by depth, "A" should be processed before "A/B".
// However, implicit folders might be skipped in sorting if they aren't in `entries`.
// So `ensureParent` handles implicit creation.
const parentId = await ensureParent(entry.path);
if (entry.isDir) {
// Check if already created by ensureParent
if (!pathIdMap.has(entry.path)) {
const name = entry.path.split('/').pop() || "Untitled Folder";
const folder = await prisma.page.create({
data: {
title: name,
type: 'folder',
parentId: parentId
}
});
pathIdMap.set(entry.path, folder.id);
}
} else {
// It is a File (.md)
const filename = entry.path.split('/').pop()?.replace('.md', '') || "Untitled";
// Parse Frontmatter
let title = filename;
let tags: string[] = [];
let order = 0;
let markdownBody = entry.content || "";
// Regex for frontmatter
const fmMatch = markdownBody.match(/^---\n([\s\S]*?)\n---\n/);
if (fmMatch) {
const fmString = fmMatch[1];
markdownBody = markdownBody.slice(fmMatch[0].length);
// Simple parsing
// title: "Foo"
// tags: ["a", "b"]
// order: 1
const titleMatch = fmString.match(/title:\s*"(.*)"/);
if (titleMatch) title = titleMatch[1];
const tagsMatch = fmString.match(/tags:\s*\[(.*)\]/);
if (tagsMatch) {
// "a", "b" -> split
tags = tagsMatch[1].split(',').map(s => s.trim().replace(/^"|"$/g, '')).filter(Boolean);
}
const orderMatch = fmString.match(/order:\s*(\d+)/);
if (orderMatch) order = parseInt(orderMatch[1]);
}
// Convert Markdown to HTML for storage (Editor uses HTML)
// Ensure GFM is enabled (default true in new versions, but explicit is good)
// breaks: true converts \n to <br> (GitHub style)
const htmlContent = await marked(markdownBody, { gfm: true, breaks: true });
await prisma.page.create({
data: {
title: title,
type: 'file',
content: htmlContent,
tags: JSON.stringify(tags),
order: order,
parentId: parentId
}
});
}
await Promise.all(loadTasks);
if (entries.length === 0) {
return NextResponse.json({ error: "No valid markdown entries found in zip" }, { status: 400 });
}
return NextResponse.json({ success: true, count: entries.length });
const folderPaths = collectFolderPaths(entries);
const fileEntries = entries
.filter((entry): entry is ZipEntry & { content: string } => !entry.isDir && typeof entry.content === "string")
.sort((a, b) => getDepth(a.path) - getDepth(b.path));
} catch (e) {
console.error("Restore failed:", e);
return NextResponse.json({ error: "Restore failed: " + String(e) }, { status: 500 });
// Parse markdown before DB transaction to keep lock time low.
const parsedFiles = await Promise.all(
fileEntries.map(async (entry) => {
const parsed = await parseMarkdownWithFrontmatter(entry.path, entry.content);
return { entry, parsed };
})
);
await prisma.$transaction(async (tx) => {
await tx.page.deleteMany();
const folderIdMap = new Map<string, string>();
for (const folderPath of folderPaths) {
const parentPath = getParentPath(folderPath);
const parentId = parentPath ? folderIdMap.get(parentPath) || null : null;
const folder = await tx.page.create({
data: {
title: getNameFromPath(folderPath),
type: "folder",
parentId,
},
});
folderIdMap.set(folderPath, folder.id);
}
for (const { entry, parsed } of parsedFiles) {
const parentPath = getParentPath(entry.path);
const parentId = parentPath ? folderIdMap.get(parentPath) || null : null;
await tx.page.create({
data: {
title: parsed.title,
type: "file",
content: parsed.htmlContent,
tags: JSON.stringify(parsed.tags),
order: parsed.order,
parentId,
},
});
}
});
return NextResponse.json({
success: true,
count: entries.length,
files: parsedFiles.length,
folders: folderPaths.length,
});
} catch (error) {
console.error("Restore failed:", error);
return NextResponse.json({ error: "Restore failed" }, { status: 500 });
}
}