This commit is contained in:
2026-02-26 11:07:10 +08:00
parent 85edf0d847
commit eb68553baf
9 changed files with 329 additions and 168 deletions
+116 -79
View File
@@ -1,4 +1,4 @@
"use client";
"use client";
import React, { createContext, useContext, useRef, useState, useCallback } from "react";
import { useEditorStore } from "@/lib/store";
@@ -10,6 +10,17 @@ interface ImportContextType {
isImporting: boolean;
}
class ImportApiError extends Error {
status: number;
detail: string;
constructor(message: string, status: number, detail: string) {
super(message);
this.status = status;
this.detail = detail;
}
}
const ImportContext = createContext<ImportContextType | undefined>(undefined);
export function ImportProvider({ children }: { children: React.ReactNode }) {
@@ -18,10 +29,43 @@ export function ImportProvider({ children }: { children: React.ReactNode }) {
const targetParentIdRef = useRef<string | null>(null);
const { fetchPages } = useEditorStore();
const postPage = useCallback(
async (
payload: {
title: string;
content?: string;
parentId?: string | null;
type: "file" | "folder";
},
contextLabel: string
) => {
const res = await fetch("/api/pages", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
credentials: "same-origin",
});
if (!res.ok) {
let detail = "";
try {
const data = await res.json();
detail = typeof data?.error === "string" ? data.error : JSON.stringify(data);
} catch {
detail = await res.text();
}
throw new ImportApiError(`${contextLabel}失败(${res.status}${detail ? `: ${detail}` : ""}`, res.status, detail);
}
return res.json();
},
[]
);
const triggerImport = useCallback((parentId: string | null = null) => {
targetParentIdRef.current = parentId;
if (fileInputRef.current) {
fileInputRef.current.value = ''; // Reset
fileInputRef.current.value = "";
fileInputRef.current.click();
}
}, []);
@@ -32,14 +76,15 @@ export function ImportProvider({ children }: { children: React.ReactNode }) {
setIsImporting(true);
try {
if (file.name.toLowerCase().endsWith('.zip')) {
if (file.name.toLowerCase().endsWith(".zip")) {
await handleZipImport(file);
} else {
await handleSingleFileImport(file);
}
} catch (error) {
console.error("Import failed", error);
alert("导入失败,请检查文件");
const message = error instanceof Error ? error.message : "导入失败,请检查文件";
alert(message);
} finally {
setIsImporting(false);
fetchPages();
@@ -48,131 +93,123 @@ export function ImportProvider({ children }: { children: React.ReactNode }) {
const handleSingleFileImport = async (file: File) => {
const text = await file.text();
const title = file.name.replace(/\.md$/i, '');
const title = file.name.replace(/\.md$/i, "");
const html = await marked.parse(text);
await fetch('/api/pages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
await postPage(
{
title,
content: html,
parentId: targetParentIdRef.current,
type: 'file'
}),
});
type: "file",
},
"创建导入文件"
);
};
const handleZipImport = async (file: File) => {
const zip = await JSZip.loadAsync(file);
// Create root folder based on zip name
// Remove .md.zip or .zip
const rootName = file.name.replace(/(\.md)?\.zip$/i, '');
const rootName = file.name.replace(/(\.md)?\.zip$/i, "");
let rootFolder: { id: string };
const rootRes = await fetch('/api/pages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
title: rootName,
parentId: targetParentIdRef.current,
type: 'folder'
}),
});
try {
rootFolder = await postPage(
{
title: rootName,
parentId: targetParentIdRef.current,
type: "folder",
},
"创建导入根目录"
);
} catch (error) {
const invalidParent =
error instanceof ImportApiError && error.status === 400 && /Parent folder not found/i.test(error.detail);
if (!invalidParent || !targetParentIdRef.current) {
throw error;
}
// Parent folder may have been deleted; fallback to root level and continue import.
rootFolder = await postPage(
{
title: rootName,
parentId: null,
type: "folder",
},
"创建导入根目录"
);
}
if (!rootRes.ok) throw new Error("Failed to create root folder");
const rootFolder = await rootRes.json();
const rootId = rootFolder.id;
// Collect all file entries and infer folder structure
const fileEntries: { path: string, file: JSZip.JSZipObject }[] = [];
const fileEntries: { path: string; file: JSZip.JSZipObject }[] = [];
const folderPaths = new Set<string>();
zip.forEach((relativePath, zipEntry) => {
if (relativePath.startsWith('__MACOSX') || relativePath.includes('/.') || relativePath.startsWith('.')) return; // Skip hidden/mac files
if (relativePath.startsWith("__MACOSX") || relativePath.includes("/.") || relativePath.startsWith(".")) return;
if (zipEntry.dir) {
// Remove trailing slash
const cleanPath = relativePath.endsWith('/') ? relativePath.slice(0, -1) : relativePath;
const cleanPath = relativePath.endsWith("/") ? relativePath.slice(0, -1) : relativePath;
if (cleanPath) folderPaths.add(cleanPath);
} else {
fileEntries.push({ path: relativePath, file: zipEntry });
// Also infer parent folders for files
const parts = relativePath.split('/');
let currentPath = '';
for (let i = 0; i < parts.length - 1; i++) {
const parts = relativePath.split("/");
let currentPath = "";
for (let i = 0; i < parts.length - 1; i += 1) {
currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i];
folderPaths.add(currentPath);
}
}
});
// Map path (e.g. "folder/sub") to database ID. Empty key '' maps to rootId.
const pathMap = new Map<string, string>();
pathMap.set('', rootId);
pathMap.set("", rootId);
// Sort folders by depth (length of path splits) to create parents before children
const sortedFolders = Array.from(folderPaths).sort((a, b) => {
return a.split('/').length - b.split('/').length;
});
const sortedFolders = Array.from(folderPaths).sort((a, b) => a.split("/").length - b.split("/").length);
// Create folders sequentially
for (const folderPath of sortedFolders) {
const parts = folderPath.split('/');
const parts = folderPath.split("/");
const name = parts[parts.length - 1];
const parentPath = parts.slice(0, -1).join('/');
const parentId = pathMap.get(parentPath); // Should exist because we sorted by depth
const parentPath = parts.slice(0, -1).join("/");
const parentId = pathMap.get(parentPath);
if (!parentId) {
console.warn(`Parent not found for ${folderPath}, skipping`);
continue;
}
// Check if we need to create it (users might have zip with folder/ and folder/file, avoiding dups)
// But we didn't check if it existed on server. We assume new import.
// Actually, we are just building map here.
const res = await fetch('/api/pages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
const folder = await postPage(
{
title: name,
parentId: parentId,
type: 'folder'
}),
});
if (res.ok) {
const folder = await res.json();
pathMap.set(folderPath, folder.id);
}
parentId,
type: "folder",
},
`创建目录 ${folderPath}`
);
pathMap.set(folderPath, folder.id);
}
// Create files parallel-ish or sequential? Sequential is safer for order but parallel faster.
// Let's do batch sequential to avoid overwhelming server if huge
for (const { path, file } of fileEntries) {
const parts = path.split('/');
for (const { path, file: zipFile } of fileEntries) {
const parts = path.split("/");
const fileName = parts.pop() || "";
const parentPath = parts.join('/');
const parentPath = parts.join("/");
const parentId = pathMap.get(parentPath);
if (!parentId) continue;
if (!fileName.endsWith('.md') && !fileName.endsWith('.txt')) continue; // Verify extension again
if (!fileName.endsWith(".md") && !fileName.endsWith(".txt")) continue;
const contentMsg = await file.async('string');
const title = fileName.replace(/\.md$/i, '').replace(/\.txt$/i, '');
const html = await marked.parse(contentMsg);
const markdown = await zipFile.async("string");
const title = fileName.replace(/\.md$/i, "").replace(/\.txt$/i, "");
const html = await marked.parse(markdown);
await fetch('/api/pages', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
await postPage(
{
title,
content: html,
parentId: parentId,
type: 'file'
}),
});
parentId,
type: "file",
},
`创建文件 ${path}`
);
}
};