"use client" import { useState } from "react" import type { InputHTMLAttributes } from "react" import { Loader2 } from "lucide-react" import { Input } from "@/components/ui/input" import { Progress } from "@/components/ui/progress" import { toast } from "sonner" import { appendEpubSplitFormFields, DEFAULT_EPUB_CHAPTER_TAG, EPUB_HTML_TAG_PRESETS, type EpubSplitMode, } from "@/lib/epub-split" /** Đồng bộ với `MOD_EPUB_MAX_CHAPTERS` trên reader-api. */ const BATCH_IMPORT_MAX_CHAPTERS = 4000 /** Một EPUB: preview + AI + parse + import — vượt quá thì hủy và sang file kế. */ const BATCH_IMPORT_MAX_MS_PER_FILE = 25 * 60 * 1000 type Genre = { id: string; name: string } type BatchRow = { fileName: string ok: boolean novelId?: string /** Tiêu đề sau preview (dùng so trùng DB / hiển thị cho mod). */ resolvedTitle?: string error?: string skippedDuplicate?: boolean skippedGuard?: boolean chapters?: number } function isAbortError(e: unknown): boolean { return e instanceof DOMException && e.name === "AbortError" } /** Đường dẫn hiển thị khi chọn từ thư mục (Chrome: folder/book.epub) */ function getFileLabel(file: File): string { const rp = (file as File & { webkitRelativePath?: string }).webkitRelativePath return rp && rp.trim().length > 0 ? rp.trim() : file.name || "upload.epub" } function epubFilesOnly(files: FileList | File[]): File[] { return Array.from(files).filter((f) => /\.epub$/i.test(f.name)) } /** Giống chuẩn hóa tiêu đề khi so `Novel.lower(title)` trên API. */ function normalizeNovelTitle(raw: string): string { return raw.split(/\s+/).join(" ").trim() } export function ImportBatchClient() { const [splitMode, setSplitMode] = useState("toc") const [chapterStartPattern, setChapterStartPattern] = useState( "^\\s*(?:[#>*\\-\\[]\\s*)*(?:ch(?:u\\.?|ương|uong)?|chapter|hồi|hoi|quyển|quyen|phần|phan|tập|tap)\\s*\\d+(?:[\\.:\\-\\)]\\s*|\\s+).+$", ) const [tagPreset, setTagPreset] = useState("a") const [customTag, setCustomTag] = useState(DEFAULT_EPUB_CHAPTER_TAG) const getChapterTag = () => { if (tagPreset === "custom") return customTag.trim() || DEFAULT_EPUB_CHAPTER_TAG return EPUB_HTML_TAG_PRESETS.find((p) => p.id === tagPreset)?.tag || DEFAULT_EPUB_CHAPTER_TAG } const [replaceExisting, setReplaceExisting] = useState(false) const [running, setRunning] = useState(false) const [progress, setProgress] = useState({ current: 0, total: 0 }) const [rows, setRows] = useState([]) const fetchGenres = async (signal?: AbortSignal): Promise => { const res = await fetch("/api/mod/the-loai", { credentials: "include", signal }) const data = await res.json() if (!res.ok) throw new Error(data?.error || data?.detail || "Không lấy được thể loại") return Array.isArray(data) ? (data as Genre[]) : [] } const ensureGenreIdsByNames = async (names: string[], signal?: AbortSignal): Promise => { const uniqueNames = [...new Set(names.map((n) => n.trim()).filter(Boolean))].slice(0, 6) if (uniqueNames.length === 0) return [] let genreList = await fetchGenres(signal) const ids: string[] = [] for (const name of uniqueNames) { const existing = genreList.find((g) => g.name.trim().toLowerCase() === name.toLowerCase()) if (existing) { ids.push(existing.id) continue } const res = await fetch("/api/mod/the-loai", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", signal, body: JSON.stringify({ name, description: "" }), }) const data = await res.json().catch(() => ({})) if (!res.ok) { throw new Error(data?.error || data?.detail || `Không thể tạo thể loại: ${name}`) } if (data?.id) { ids.push(data.id) } genreList = await fetchGenres(signal) } return [...new Set(ids)].slice(0, 6) } const processOneFile = async (file: File, signal: AbortSignal): Promise => { const displayPath = getFileLabel(file) const baseName = file.name || "upload.epub" const formPreview = new FormData() formPreview.append("file", file) const r1 = await fetch("/api/import/uploads/preview", { method: "POST", credentials: "include", body: formPreview, signal, }) const d1 = await r1.json().catch(() => ({})) if (!r1.ok) { return { fileName: displayPath, ok: false, error: String(d1?.detail || "Bước 1: preview EPUB thất bại"), } } const title = String(d1?.suggested?.title || "").trim() || baseName.replace(/\.epub$/i, "") || "Untitled" const author = String(d1?.suggested?.author || "").trim() || "Unknown" const normalizedTitle = normalizeNovelTitle(title) || "Untitled" if (!replaceExisting) { const checkUrl = `/api/mod/truyen/by-title?title=${encodeURIComponent(normalizedTitle)}` const cr = await fetch(checkUrl, { credentials: "include", signal }) const cd = await cr.json().catch(() => ({})) if (cr.ok && cd?.exists === true && cd?.novel?.id) { const nt = String(cd.novel.title || normalizedTitle) const nid = String(cd.novel.id) return { fileName: displayPath, ok: false, skippedDuplicate: true, resolvedTitle: title, novelId: nid, error: `Đã có trong DB — bỏ qua batch · «${nt}» · novelId: ${nid}`, } } } const formAi = new FormData() formAi.append("file", file) appendEpubSplitFormFields(formAi, splitMode, { chapterRegex: chapterStartPattern, chapterTag: getChapterTag(), }) formAi.append("title", title) formAi.append("authorName", author) const r2 = await fetch("/api/mod/epub/ai-suggest", { method: "POST", credentials: "include", body: formAi, signal, }) const d2 = await r2.json().catch(() => ({})) if (!r2.ok) { return { fileName: displayPath, ok: false, resolvedTitle: title, error: String(d2?.detail || "Bước 2: AI gợi ý thất bại"), } } const genreNames: string[] = (d2?.suggestedGenres || []).slice(0, 6) let genreIds: string[] = [] try { genreIds = await ensureGenreIdsByNames(genreNames, signal) } catch (e) { return { fileName: displayPath, ok: false, resolvedTitle: title, error: e instanceof Error ? e.message : "Bước 2: tạo/ghép thể loại thất bại", } } const description = String(d2?.shortDescription || "").trim() const statusRaw = String(d2?.suggestedStatus || "").trim() const status = statusRaw === "Hoàn thành" || statusRaw === "Tạm ngưng" || statusRaw === "Đang ra" ? statusRaw : "Đang ra" const formParse = new FormData() formParse.append("file", file) formParse.append("preview", "true") appendEpubSplitFormFields(formParse, splitMode, { chapterRegex: chapterStartPattern, chapterTag: getChapterTag(), }) formParse.append("enforceMaxChapters", "true") const r3 = await fetch("/api/mod/epub", { method: "POST", credentials: "include", body: formParse, signal }) const d3 = await r3.json().catch(() => ({})) if (!r3.ok) { return { fileName: displayPath, ok: false, resolvedTitle: title, error: String(d3?.detail || "Bước 3: tách chương (preview) thất bại"), } } const chapterCount = Number(d3?.novel?.totalChapters ?? d3?.chapterCount ?? 0) if (d3?.importBlocked === true) { return { fileName: displayPath, ok: false, skippedGuard: true, resolvedTitle: title, chapters: chapterCount || undefined, error: String( d3?.importBlockedReason || `Quá ${BATCH_IMPORT_MAX_CHAPTERS.toLocaleString()} chương (${chapterCount.toLocaleString()}) — đã bỏ qua`, ), } } const sampleLen = Array.isArray(d3?.sample) ? d3.sample.length : 0 const chaptersPrevLen = Array.isArray(d3?.chaptersPreview) ? d3.chaptersPreview.length : 0 if (chapterCount <= 0 && sampleLen <= 0 && chaptersPrevLen <= 0) { return { fileName: displayPath, ok: false, resolvedTitle: title, error: "Bước 3: không tách được chương với cấu hình TOC/Regex/Thẻ HTML hiện tại", } } const formImport = new FormData() formImport.append("file", file) formImport.append("preview", "false") appendEpubSplitFormFields(formImport, splitMode, { chapterRegex: chapterStartPattern, chapterTag: getChapterTag(), }) formImport.append("title", title) formImport.append("authorName", author) formImport.append("status", status) formImport.append("description", description) formImport.append("genreIds", genreIds.join(",")) formImport.append("replaceExisting", String(replaceExisting)) formImport.append("enforceMaxChapters", "true") const r4 = await fetch("/api/mod/epub", { method: "POST", credentials: "include", body: formImport, signal }) const d4 = await r4.json().catch(() => ({})) if (r4.status === 409 && d4?.code === "DUPLICATE_TITLE") { const ex = d4?.existingNovel as { id?: string; title?: string } | undefined const nid = ex?.id ? String(ex.id) : "" const nt = ex?.title ? String(ex.title) : "" return { fileName: displayPath, ok: false, skippedDuplicate: true, resolvedTitle: title, novelId: nid || undefined, error: nid ? `Đã có trong DB — bỏ qua · «${nt || title}» · novelId: ${nid}` : String(d4?.error || "Trùng tiêu đề"), } } if (!r4.ok) { const detail = String(d4?.detail || "Bước 4: import thất bại") const limitHit = r4.status === 400 && (detail.includes("giới hạn") || detail.includes("Quá giới hạn") || detail.includes("chương sau khi tách")) return { fileName: displayPath, ok: false, skippedGuard: limitHit, resolvedTitle: title, error: detail, } } return { fileName: displayPath, ok: true, resolvedTitle: title, novelId: String(d4?.novelId || ""), chapters: Number(d4?.totalChapters ?? chapterCount), } } const runBatch = async (fileList: FileList | null) => { if (!fileList?.length) { toast.error("Chọn ít nhất một file EPUB") return } const list = epubFilesOnly(fileList) if (!list.length) { toast.error("Không thấy file .epub (kiểm tra đuôi hoặc chọn đúng thư mục chứa EPUB)") return } setRows([]) setRunning(true) setProgress({ current: 0, total: list.length }) const out: BatchRow[] = [] for (let i = 0; i < list.length; i++) { setProgress({ current: i + 1, total: list.length }) const ac = new AbortController() const limitTimer = window.setTimeout(() => ac.abort(), BATCH_IMPORT_MAX_MS_PER_FILE) try { const row = await processOneFile(list[i], ac.signal) out.push(row) } catch (e) { if (isAbortError(e)) { out.push({ fileName: getFileLabel(list[i]), ok: false, skippedGuard: true, error: `Quá ${Math.round(BATCH_IMPORT_MAX_MS_PER_FILE / 60000)} phút cho một file — đã bỏ qua, chuyển file kế`, }) } else { out.push({ fileName: getFileLabel(list[i]), ok: false, error: e instanceof Error ? e.message : "Lỗi không xác định", }) } } finally { window.clearTimeout(limitTimer) } setRows([...out]) } setRunning(false) const okCount = out.filter((r) => r.ok).length const dupSkip = out.filter((r) => !r.ok && r.skippedDuplicate).length const guardSkip = out.filter((r) => !r.ok && r.skippedGuard).length const errCount = out.filter((r) => !r.ok && !r.skippedDuplicate && !r.skippedGuard).length toast.success( `Hoàn tất ${list.length} file: ${okCount} import OK · ${dupSkip} đã có (bỏ qua) · ${guardSkip} bỏ qua (giới hạn/thời gian) · ${errCount} lỗi`, { duration: 8000 }, ) } const pct = progress.total ? Math.round((progress.current / progress.total) * 100) : 0 return (

Import nhiều EPUB (tự động)

Cấu hình chung

{splitMode === "regex" && ( setChapterStartPattern(e.target.value)} disabled={running} placeholder="Regex bắt đầu chương" /> )} {splitMode === "tag" && ( <> {tagPreset === "custom" && ( setCustomTag(e.target.value)} disabled={running} placeholder="a, h2" /> )} )}

An toàn batch (chỉ khi import nhiều): tối đa {BATCH_IMPORT_MAX_CHAPTERS.toLocaleString()} chương sau khi tách mỗi file; quá{" "} {Math.round(BATCH_IMPORT_MAX_MS_PER_FILE / 60000)} phút/file thì bỏ qua và xử lý file tiếp theo. Import một EPUB trên trang khác không bị giới hạn này.

Thư mục cha (quét đệ quy)

)} disabled={running} className="cursor-pointer" onChange={(e) => { const fl = e.target.files void runBatch(fl) e.currentTarget.value = "" }} />
{running && (
Đang xử lý {progress.current}/{progress.total}…
)}
{rows.length > 0 && (
Kết quả
Thành công:{" "} {rows.filter((r) => r.ok).length} Đã có (skip):{" "} {rows.filter((r) => r.skippedDuplicate).length} Bỏ qua an toàn:{" "} {rows.filter((r) => r.skippedGuard).length} Lỗi:{" "} {rows.filter((r) => !r.ok && !r.skippedDuplicate && !r.skippedGuard).length} Tổng: {rows.length}
{rows.map((r, idx) => ( ))}
File Tiêu đề (preview) Trạng thái Chi tiết
{r.fileName} {r.resolvedTitle || "—"} {r.ok ? ( OK ) : r.skippedDuplicate ? ( Đã có · skip ) : r.skippedGuard ? ( Bỏ qua ) : ( Lỗi )} {r.ok && r.novelId ? ( <> novelId: {r.novelId} {typeof r.chapters === "number" ? ` · ${r.chapters} chương` : ""} ) : ( r.error || "—" )}
)}
) }