Files
NoteAI/src/components/import-context.tsx
T
2026-03-03 14:07:56 +08:00

299 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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<ImportContextType | undefined>(undefined);
export function ImportProvider({ children }: { children: React.ReactNode }) {
const [isImporting, setIsImporting] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const targetParentIdRef = useRef<string | null>(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<HTMLInputElement>) => {
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, "");
// 直接存储原始 Markdowntiptap-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<string>();
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<string, string>();
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 (
<ImportContext.Provider value={{ triggerImport, isImporting }}>
{children}
<input
type="file"
ref={fileInputRef}
onChange={handleFileChange}
className="hidden"
accept=".md,.txt,.zip"
/>
</ImportContext.Provider>
);
}
export function useImport() {
const context = useContext(ImportContext);
if (!context) {
throw new Error("useImport must be used within an ImportProvider");
}
return context;
}