"use client"; import React, { createContext, useContext, useRef, useState, useCallback } from "react"; import { useEditorStore } from "@/lib/store"; import JSZip from "jszip"; interface ImportContextType { triggerImport: (parentId?: string | null) => void; 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(undefined); export function ImportProvider({ children }: { children: React.ReactNode }) { const [isImporting, setIsImporting] = useState(false); const fileInputRef = useRef(null); const targetParentIdRef = useRef(null); const { pages, fetchPages } = useEditorStore(); const postPage = useCallback( async ( payload: { title: string; content?: string; importMarkdown?: 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 putPage = useCallback( async ( id: string, payload: { title?: string; content?: string; importMarkdown?: string; parentId?: string | null; type?: "file" | "folder"; }, contextLabel: string ) => { const res = await fetch(`/api/pages/${id}`, { method: "PUT", 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 = ""; fileInputRef.current.click(); } }, []); const handleFileChange = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; setIsImporting(true); try { if (file.name.toLowerCase().endsWith(".zip")) { await handleZipImport(file); } else { await handleSingleFileImport(file); } } catch (error) { console.error("Import failed", error); const message = error instanceof Error ? error.message : "导入失败,请检查文件"; alert(message); } finally { setIsImporting(false); fetchPages(); } }; const handleSingleFileImport = async (file: File) => { const text = await file.text(); const title = file.name.replace(/\.md$/i, ""); // 直接存储原始 Markdown,tiptap-markdown 扩展会在编辑器加载时自动解析 const parentId = targetParentIdRef.current; const existing = pages.find((p) => p.type === "file" && p.parentId === parentId && (p.title || "").trim() === title.trim()); if (existing) { await putPage( existing.id, { title, content: text, parentId, type: "file", }, "更新导入文件" ); return; } await postPage( { title, content: text, parentId, type: "file", }, "创建导入文件" ); }; const handleZipImport = async (file: File) => { const zip = await JSZip.loadAsync(file); const rootName = file.name.replace(/(\.md)?\.zip$/i, ""); let rootFolder: { id: string }; 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", }, "创建导入根目录" ); } const rootId = rootFolder.id; const fileEntries: { path: string; file: JSZip.JSZipObject }[] = []; const folderPaths = new Set(); zip.forEach((relativePath, zipEntry) => { if (relativePath.startsWith("__MACOSX") || relativePath.includes("/.") || relativePath.startsWith(".")) return; if (zipEntry.dir) { const cleanPath = relativePath.endsWith("/") ? relativePath.slice(0, -1) : relativePath; if (cleanPath) folderPaths.add(cleanPath); } else { fileEntries.push({ path: relativePath, file: zipEntry }); 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); } } }); const pathMap = new Map(); pathMap.set("", rootId); const sortedFolders = Array.from(folderPaths).sort((a, b) => a.split("/").length - b.split("/").length); for (const folderPath of sortedFolders) { const parts = folderPath.split("/"); const name = parts[parts.length - 1]; const parentPath = parts.slice(0, -1).join("/"); const parentId = pathMap.get(parentPath); if (!parentId) { console.warn(`Parent not found for ${folderPath}, skipping`); continue; } const folder = await postPage( { title: name, parentId, type: "folder", }, `创建目录 ${folderPath}` ); pathMap.set(folderPath, folder.id); } for (const { path, file: zipFile } of fileEntries) { const parts = path.split("/"); const fileName = parts.pop() || ""; const parentPath = parts.join("/"); const parentId = pathMap.get(parentPath); if (!parentId) continue; if (!fileName.endsWith(".md") && !fileName.endsWith(".txt")) continue; const markdown = await zipFile.async("string"); const title = fileName.replace(/\.md$/i, "").replace(/\.txt$/i, ""); const existing = pages.find((p) => p.type === "file" && p.parentId === parentId && (p.title || "").trim() === title.trim()); if (existing) { await putPage( existing.id, { title, content: markdown, parentId, type: "file", }, `更新文件 ${path}` ); } else { await postPage( { title, content: markdown, parentId, type: "file", }, `创建文件 ${path}` ); } } }; return ( {children} ); } export function useImport() { const context = useContext(ImportContext); if (!context) { throw new Error("useImport must be used within an ImportProvider"); } return context; }