201 lines
6.5 KiB
TypeScript
201 lines
6.5 KiB
TypeScript
import { NextRequest, NextResponse } from "next/server";
|
|
import JSZip from "jszip";
|
|
import { marked } from "marked";
|
|
import { prisma } from "@/lib/prisma";
|
|
import { requireApiAuth } from "@/lib/api-auth";
|
|
|
|
type ZipEntry = {
|
|
path: string;
|
|
isDir: boolean;
|
|
content?: string;
|
|
};
|
|
|
|
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");
|
|
|
|
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>[] = [];
|
|
|
|
zip.forEach((rawPath, zipEntry) => {
|
|
if (rawPath.startsWith("__MACOSX") || rawPath.includes(".DS_Store")) return;
|
|
|
|
loadTasks.push((async () => {
|
|
const path = normalizeZipPath(rawPath);
|
|
if (!path) return;
|
|
|
|
if (zipEntry.dir) {
|
|
entries.push({ path, isDir: true });
|
|
return;
|
|
}
|
|
if (!path.endsWith(".md")) return;
|
|
|
|
const content = await zipEntry.async("string");
|
|
entries.push({ path, isDir: false, content });
|
|
})());
|
|
});
|
|
|
|
await Promise.all(loadTasks);
|
|
if (entries.length === 0) {
|
|
return NextResponse.json({ error: "No valid markdown entries found in zip" }, { status: 400 });
|
|
}
|
|
|
|
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));
|
|
|
|
// 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 });
|
|
}
|
|
}
|