refactor: Streamline EPUB handling with new split modes and improved error management
Build and Push Reader Image / docker (push) Successful in 1m32s
Build and Push Reader Image / docker (push) Successful in 1m32s
- Removed legacy AI Tool references and unnecessary fields from the README and various components. - Introduced new EPUB split modes (toc, regex, tag) to enhance flexibility in chapter extraction. - Updated import and chapter management components to utilize the new EPUB split functionality. - Improved error handling in the login API route for better user feedback. - Cleaned up unused files and optimized the overall code structure for maintainability.
This commit is contained in:
@@ -44,11 +44,6 @@ READER_API_ORIGIN="http://localhost:8000"
|
|||||||
GOOGLE_CLIENT_ID="your_google_client_id"
|
GOOGLE_CLIENT_ID="your_google_client_id"
|
||||||
GOOGLE_CLIENT_SECRET="your_google_client_secret"
|
GOOGLE_CLIENT_SECRET="your_google_client_secret"
|
||||||
|
|
||||||
# AI Tool cho MOD (LLM + web search)
|
|
||||||
OPENAI_API_KEY="your_openai_api_key"
|
|
||||||
# Tùy chọn, mặc định: gpt-4o-mini-search-preview
|
|
||||||
OPENAI_WEB_MODEL="gpt-4o-mini-search-preview"
|
|
||||||
|
|
||||||
# Cloudflare R2 (lưu ảnh bìa)
|
# Cloudflare R2 (lưu ảnh bìa)
|
||||||
R2_ACCOUNT_ID="your_cloudflare_account_id"
|
R2_ACCOUNT_ID="your_cloudflare_account_id"
|
||||||
R2_ACCESS_KEY_ID="your_r2_access_key_id"
|
R2_ACCESS_KEY_ID="your_r2_access_key_id"
|
||||||
|
|||||||
@@ -36,8 +36,19 @@ export async function POST(req: NextRequest) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (!upstream.ok) {
|
if (!upstream.ok) {
|
||||||
const message = await upstream.text()
|
const raw = await upstream.text()
|
||||||
return NextResponse.json({ detail: message || "Authentication failed" }, { status: upstream.status })
|
let detail = "Authentication failed"
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as { detail?: unknown }
|
||||||
|
if (typeof parsed.detail === "string") {
|
||||||
|
detail = parsed.detail
|
||||||
|
} else if (raw.trim()) {
|
||||||
|
detail = raw.trim()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (raw.trim()) detail = raw.trim()
|
||||||
|
}
|
||||||
|
return NextResponse.json({ detail }, { status: upstream.status })
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = (await upstream.json()) as MobileLoginResponse
|
const data = (await upstream.json()) as MobileLoginResponse
|
||||||
@@ -66,6 +77,19 @@ export async function POST(req: NextRequest) {
|
|||||||
return response
|
return response
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("/api/auth/login failed", error)
|
console.error("/api/auth/login failed", error)
|
||||||
|
const cause = error instanceof Error ? error.cause : null
|
||||||
|
const code =
|
||||||
|
cause && typeof cause === "object" && "code" in cause
|
||||||
|
? String((cause as { code?: unknown }).code || "")
|
||||||
|
: ""
|
||||||
|
if (code === "ECONNREFUSED" || (error instanceof Error && error.message.includes("fetch failed"))) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
detail: `Không kết nối được reader-api tại ${readerApiOrigin}. Kiểm tra READER_API_ORIGIN và đảm bảo API đang chạy (ví dụ cổng 18080).`,
|
||||||
|
},
|
||||||
|
{ status: 503 },
|
||||||
|
)
|
||||||
|
}
|
||||||
return NextResponse.json({ detail: "Internal Server Error" }, { status: 500 })
|
return NextResponse.json({ detail: "Internal Server Error" }, { status: 500 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,12 @@ import {
|
|||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
||||||
import { FileText, Loader2, Plus, ArrowLeft, Wand2, Edit, Trash2, Upload, Search, BookOpen } from "lucide-react"
|
import { FileText, Loader2, Plus, ArrowLeft, Wand2, Edit, Trash2, Upload, Search, BookOpen } from "lucide-react"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
import {
|
||||||
|
appendEpubSplitFormFields,
|
||||||
|
DEFAULT_EPUB_CHAPTER_TAG,
|
||||||
|
EPUB_HTML_TAG_PRESETS,
|
||||||
|
type EpubSplitMode,
|
||||||
|
} from "@/lib/epub-split"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
|
||||||
@@ -58,11 +64,12 @@ interface Chapter {
|
|||||||
interface EpubPreviewData {
|
interface EpubPreviewData {
|
||||||
preview: true
|
preview: true
|
||||||
fileName: string
|
fileName: string
|
||||||
splitMode: "toc" | "regex"
|
splitMode: EpubSplitMode
|
||||||
detectedStructureType: "light_novel" | "standard"
|
detectedStructureType: "light_novel" | "standard"
|
||||||
parserInfo?: {
|
parserInfo?: {
|
||||||
splitMode: string
|
splitMode: string
|
||||||
chapterRegexUsed?: string
|
chapterRegexUsed?: string
|
||||||
|
chapterTagUsed?: string
|
||||||
regexPreset?: string
|
regexPreset?: string
|
||||||
sourceSections: number
|
sourceSections: number
|
||||||
chaptersDetected: number
|
chaptersDetected: number
|
||||||
@@ -116,6 +123,8 @@ function ChapterManager() {
|
|||||||
const [loadingOptimizeSource, setLoadingOptimizeSource] = useState(false)
|
const [loadingOptimizeSource, setLoadingOptimizeSource] = useState(false)
|
||||||
const [optRemovePrefix, setOptRemovePrefix] = useState(true)
|
const [optRemovePrefix, setOptRemovePrefix] = useState(true)
|
||||||
const [optRenumber, setOptRenumber] = useState(true)
|
const [optRenumber, setOptRenumber] = useState(true)
|
||||||
|
const [optNormalizeTitles, setOptNormalizeTitles] = useState(true)
|
||||||
|
const [optNormalizeGenericOnly, setOptNormalizeGenericOnly] = useState(true)
|
||||||
const [optimizedChapters, setOptimizedChapters] = useState<Chapter[]>([])
|
const [optimizedChapters, setOptimizedChapters] = useState<Chapter[]>([])
|
||||||
const [optimizeSourceChapters, setOptimizeSourceChapters] = useState<Chapter[]>([])
|
const [optimizeSourceChapters, setOptimizeSourceChapters] = useState<Chapter[]>([])
|
||||||
|
|
||||||
@@ -147,9 +156,11 @@ function ChapterManager() {
|
|||||||
const [openEpubAppend, setOpenEpubAppend] = useState(false)
|
const [openEpubAppend, setOpenEpubAppend] = useState(false)
|
||||||
const [epubFile, setEpubFile] = useState<File | null>(null)
|
const [epubFile, setEpubFile] = useState<File | null>(null)
|
||||||
const epubInputRef = useRef<HTMLInputElement>(null)
|
const epubInputRef = useRef<HTMLInputElement>(null)
|
||||||
const [epubSplitMode, setEpubSplitMode] = useState<"toc" | "regex">("regex")
|
const [epubSplitMode, setEpubSplitMode] = useState<EpubSplitMode>("regex")
|
||||||
const [epubRegexPreset, setEpubRegexPreset] = useState("vi_chuong_hoi")
|
const [epubRegexPreset, setEpubRegexPreset] = useState("vi_chuong_hoi")
|
||||||
const [epubCustomRegex, setEpubCustomRegex] = useState("")
|
const [epubCustomRegex, setEpubCustomRegex] = useState("")
|
||||||
|
const [epubTagPreset, setEpubTagPreset] = useState("a")
|
||||||
|
const [epubCustomTag, setEpubCustomTag] = useState(DEFAULT_EPUB_CHAPTER_TAG)
|
||||||
const [appendingEpub, setAppendingEpub] = useState(false)
|
const [appendingEpub, setAppendingEpub] = useState(false)
|
||||||
const [epubPreviewData, setEpubPreviewData] = useState<EpubPreviewData | null>(null)
|
const [epubPreviewData, setEpubPreviewData] = useState<EpubPreviewData | null>(null)
|
||||||
|
|
||||||
@@ -160,6 +171,13 @@ function ChapterManager() {
|
|||||||
return CHAPTER_REGEX_PRESETS.find((preset) => preset.id === epubRegexPreset)?.pattern || CHAPTER_REGEX_PRESETS[0].pattern
|
return CHAPTER_REGEX_PRESETS.find((preset) => preset.id === epubRegexPreset)?.pattern || CHAPTER_REGEX_PRESETS[0].pattern
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getEpubSourceTag = () => {
|
||||||
|
if (epubTagPreset === "custom") {
|
||||||
|
return epubCustomTag.trim() || DEFAULT_EPUB_CHAPTER_TAG
|
||||||
|
}
|
||||||
|
return EPUB_HTML_TAG_PRESETS.find((preset) => preset.id === epubTagPreset)?.tag || DEFAULT_EPUB_CHAPTER_TAG
|
||||||
|
}
|
||||||
|
|
||||||
const handleEpubAppendPreview = async () => {
|
const handleEpubAppendPreview = async () => {
|
||||||
if (!epubFile || !novelId) return
|
if (!epubFile || !novelId) return
|
||||||
|
|
||||||
@@ -170,8 +188,10 @@ function ChapterManager() {
|
|||||||
try {
|
try {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append("file", epubFile)
|
formData.append("file", epubFile)
|
||||||
formData.append("splitMode", epubSplitMode)
|
appendEpubSplitFormFields(formData, epubSplitMode, {
|
||||||
formData.append("chapterRegex", epubSplitMode === "regex" ? getEpubSourceRegex() : "")
|
chapterRegex: getEpubSourceRegex(),
|
||||||
|
chapterTag: getEpubSourceTag(),
|
||||||
|
})
|
||||||
formData.append("chapterRegexPreset", epubRegexPreset)
|
formData.append("chapterRegexPreset", epubRegexPreset)
|
||||||
formData.append("appendTargetNovelId", novelId)
|
formData.append("appendTargetNovelId", novelId)
|
||||||
formData.append("preview", "true")
|
formData.append("preview", "true")
|
||||||
@@ -209,8 +229,10 @@ function ChapterManager() {
|
|||||||
try {
|
try {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append("file", epubFile)
|
formData.append("file", epubFile)
|
||||||
formData.append("splitMode", epubSplitMode)
|
appendEpubSplitFormFields(formData, epubSplitMode, {
|
||||||
formData.append("chapterRegex", epubSplitMode === "regex" ? getEpubSourceRegex() : "")
|
chapterRegex: getEpubSourceRegex(),
|
||||||
|
chapterTag: getEpubSourceTag(),
|
||||||
|
})
|
||||||
formData.append("chapterRegexPreset", epubRegexPreset)
|
formData.append("chapterRegexPreset", epubRegexPreset)
|
||||||
formData.append("appendTargetNovelId", novelId)
|
formData.append("appendTargetNovelId", novelId)
|
||||||
|
|
||||||
@@ -256,7 +278,7 @@ function ChapterManager() {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) throw new Error(data.error || "Xóa thất bại")
|
if (!res.ok) throw new Error(data.error || data.detail || "Xóa thất bại")
|
||||||
|
|
||||||
toast.success(`Đã xóa thành công ${data.deletedCount} chương!`, { id: toastId })
|
toast.success(`Đã xóa thành công ${data.deletedCount} chương!`, { id: toastId })
|
||||||
setOpenBulkDelete(false)
|
setOpenBulkDelete(false)
|
||||||
@@ -429,7 +451,7 @@ function ChapterManager() {
|
|||||||
const handlePreviewOptimize = async () => {
|
const handlePreviewOptimize = async () => {
|
||||||
if (!novelId) return
|
if (!novelId) return
|
||||||
|
|
||||||
if (!optRemovePrefix && !optRenumber) {
|
if (!optRemovePrefix && !optRenumber && !optNormalizeTitles) {
|
||||||
toast.error("Vui lòng chọn ít nhất một tùy chọn tối ưu hóa")
|
toast.error("Vui lòng chọn ít nhất một tùy chọn tối ưu hóa")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -446,6 +468,36 @@ function ChapterManager() {
|
|||||||
setOptimizeSourceChapters(allChapters)
|
setOptimizeSourceChapters(allChapters)
|
||||||
let newChapters = [...allChapters]
|
let newChapters = [...allChapters]
|
||||||
|
|
||||||
|
if (optNormalizeTitles) {
|
||||||
|
const previewRes = await fetch("/api/mod/chuong/normalize-titles/preview", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
novelId,
|
||||||
|
overwriteGenericOnly: optNormalizeGenericOnly,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
const previewData = await previewRes.json().catch(() => ({}))
|
||||||
|
if (!previewRes.ok) {
|
||||||
|
throw new Error(previewData.detail || previewData.error || "Không thể tạo gợi ý tiêu đề chương")
|
||||||
|
}
|
||||||
|
const titleById = new Map<string, string>(
|
||||||
|
(previewData.items || []).map((item: { id: string; suggestedTitle: string }) => [
|
||||||
|
item.id,
|
||||||
|
item.suggestedTitle,
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
newChapters = newChapters.map((ch) => {
|
||||||
|
const key = ch._id || ch.id
|
||||||
|
const suggested = key ? titleById.get(key) : undefined
|
||||||
|
return suggested ? { ...ch, title: suggested } : ch
|
||||||
|
})
|
||||||
|
const changeCount = Number(previewData.changeCount || 0)
|
||||||
|
if (changeCount === 0) {
|
||||||
|
toast.info("Không tìm thấy tiêu đề chương nào cần chuẩn hóa theo bộ lọc hiện tại")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (optRenumber) {
|
if (optRenumber) {
|
||||||
newChapters.sort((a, b) => a.number - b.number)
|
newChapters.sort((a, b) => a.number - b.number)
|
||||||
newChapters = newChapters.map((ch, idx) => ({
|
newChapters = newChapters.map((ch, idx) => ({
|
||||||
@@ -636,10 +688,10 @@ function ChapterManager() {
|
|||||||
<h3 className="font-semibold text-sm border-b pb-2">Giải Thuật Tách Chương</h3>
|
<h3 className="font-semibold text-sm border-b pb-2">Giải Thuật Tách Chương</h3>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Label className="w-1/4">Phiên bản Text</Label>
|
<Label className="w-1/4">Phiên bản Text</Label>
|
||||||
<RadioGroup value={epubSplitMode} onValueChange={(v: "toc" | "regex") => {
|
<RadioGroup value={epubSplitMode} onValueChange={(v: EpubSplitMode) => {
|
||||||
setEpubSplitMode(v)
|
setEpubSplitMode(v)
|
||||||
setEpubPreviewData(null)
|
setEpubPreviewData(null)
|
||||||
}} className="flex items-center gap-4">
|
}} className="flex flex-wrap items-center gap-4">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value="toc" id="toc_mode_a" />
|
<RadioGroupItem value="toc" id="toc_mode_a" />
|
||||||
<Label htmlFor="toc_mode_a" className="cursor-pointer font-normal">Mục lục (TOC)</Label>
|
<Label htmlFor="toc_mode_a" className="cursor-pointer font-normal">Mục lục (TOC)</Label>
|
||||||
@@ -648,9 +700,50 @@ function ChapterManager() {
|
|||||||
<RadioGroupItem value="regex" id="regex_mode_a" />
|
<RadioGroupItem value="regex" id="regex_mode_a" />
|
||||||
<Label htmlFor="regex_mode_a" className="cursor-pointer font-normal">Quy tắc (Regex)</Label>
|
<Label htmlFor="regex_mode_a" className="cursor-pointer font-normal">Quy tắc (Regex)</Label>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="tag" id="tag_mode_a" />
|
||||||
|
<Label htmlFor="tag_mode_a" className="cursor-pointer font-normal">Thẻ HTML</Label>
|
||||||
|
</div>
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{epubSplitMode === "tag" && (
|
||||||
|
<div className="space-y-3 pt-2">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>Thẻ HTML tách chương</Label>
|
||||||
|
<Select value={epubTagPreset} onValueChange={(v) => {
|
||||||
|
setEpubTagPreset(v)
|
||||||
|
setEpubPreviewData(null)
|
||||||
|
}}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{EPUB_HTML_TAG_PRESETS.map((preset) => (
|
||||||
|
<SelectItem key={preset.id} value={preset.id}>
|
||||||
|
{preset.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
<SelectItem value="custom">Tùy chỉnh tên thẻ...</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{epubTagPreset === "custom" && (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label>Tên thẻ (ví dụ: a, h2)</Label>
|
||||||
|
<Input
|
||||||
|
value={epubCustomTag}
|
||||||
|
onChange={(e) => {
|
||||||
|
setEpubCustomTag(e.target.value)
|
||||||
|
setEpubPreviewData(null)
|
||||||
|
}}
|
||||||
|
placeholder="a"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{epubSplitMode === "regex" && (
|
{epubSplitMode === "regex" && (
|
||||||
<div className="space-y-3 pt-2">
|
<div className="space-y-3 pt-2">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
@@ -696,12 +789,15 @@ function ChapterManager() {
|
|||||||
<div className="text-xs space-y-1 text-right">
|
<div className="text-xs space-y-1 text-right">
|
||||||
<p><span className="text-muted-foreground">Flow file HTML:</span> {epubPreviewData.parserInfo.sourceSections}</p>
|
<p><span className="text-muted-foreground">Flow file HTML:</span> {epubPreviewData.parserInfo.sourceSections}</p>
|
||||||
<p><span className="text-muted-foreground">Phát hiện:</span> {epubPreviewData.parserInfo.chaptersDetected} chương</p>
|
<p><span className="text-muted-foreground">Phát hiện:</span> {epubPreviewData.parserInfo.chaptersDetected} chương</p>
|
||||||
|
{epubPreviewData.parserInfo.chapterTagUsed && (
|
||||||
|
<p><span className="text-muted-foreground">Thẻ HTML:</span> <{epubPreviewData.parserInfo.chapterTagUsed}></p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{epubPreviewData.chaptersPreview.length === 0 ? (
|
{epubPreviewData.chaptersPreview.length === 0 ? (
|
||||||
<div className="p-4 text-center border border-dashed rounded bg-muted/40 text-muted-foreground text-sm">
|
<div className="p-4 text-center border border-dashed rounded bg-muted/40 text-muted-foreground text-sm">
|
||||||
Không tách được chương nào. Xem lại Tùy chọn Tách / Mẫu Regex.
|
Không tách được chương nào. Xem lại chế độ tách (TOC / Regex / Thẻ HTML).
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3 max-h-[300px] overflow-y-auto border rounded-md p-2 bg-card">
|
<div className="space-y-3 max-h-[300px] overflow-y-auto border rounded-md p-2 bg-card">
|
||||||
@@ -730,7 +826,7 @@ function ChapterManager() {
|
|||||||
<DialogFooter className="mt-auto pt-4 border-t">
|
<DialogFooter className="mt-auto pt-4 border-t">
|
||||||
<Button variant="outline" onClick={() => setOpenEpubAppend(false)} disabled={appendingEpub}>Huỷ</Button>
|
<Button variant="outline" onClick={() => setOpenEpubAppend(false)} disabled={appendingEpub}>Huỷ</Button>
|
||||||
{!epubPreviewData ? (
|
{!epubPreviewData ? (
|
||||||
<Button onClick={handleEpubAppendPreview} disabled={appendingEpub}>
|
<Button onClick={handleEpubAppendPreview} disabled={appendingEpub || (epubSplitMode === "regex" && epubRegexPreset === "custom" && !epubCustomRegex.trim()) || (epubSplitMode === "tag" && epubTagPreset === "custom" && !epubCustomTag.trim())}>
|
||||||
{appendingEpub && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
{appendingEpub && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
Xem trước dữ liệu
|
Xem trước dữ liệu
|
||||||
</Button>
|
</Button>
|
||||||
@@ -887,6 +983,26 @@ function ChapterManager() {
|
|||||||
<p className="text-sm text-muted-foreground mt-1">Sắp xếp và gán lại số chương liên tục từ 1 đến N để sửa lỗi nhảy cóc</p>
|
<p className="text-sm text-muted-foreground mt-1">Sắp xếp và gán lại số chương liên tục từ 1 đến N để sửa lỗi nhảy cóc</p>
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
<label className="flex items-center gap-3 p-4 border rounded-lg cursor-pointer hover:bg-muted/50 transition-colors">
|
||||||
|
<input type="checkbox" className="w-5 h-5 rounded" checked={optNormalizeTitles} onChange={(e) => setOptNormalizeTitles(e.target.checked)} />
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-base">Chuẩn hóa tiêu đề từ nội dung đầu chương</p>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Ví dụ: dòng <span className="line-through">Chương 8</span> + dòng <strong>Tất Cả Đều Là Ảo Giác</strong> → tiêu đề <strong>Tất Cả Đều Là Ảo Giác</strong>
|
||||||
|
</p>
|
||||||
|
{optNormalizeTitles && (
|
||||||
|
<label className="mt-3 flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="w-4 h-4 rounded"
|
||||||
|
checked={optNormalizeGenericOnly}
|
||||||
|
onChange={(e) => setOptNormalizeGenericOnly(e.target.checked)}
|
||||||
|
/>
|
||||||
|
Chỉ sửa tiêu đề dạng "Chương N" (giữ tiêu đề đã đặt tay)
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 overflow-auto border rounded-lg my-4 custom-scrollbar">
|
<div className="flex-1 overflow-auto border rounded-lg my-4 custom-scrollbar">
|
||||||
|
|||||||
@@ -6,6 +6,12 @@ import { Loader2 } from "lucide-react"
|
|||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Progress } from "@/components/ui/progress"
|
import { Progress } from "@/components/ui/progress"
|
||||||
import { toast } from "sonner"
|
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. */
|
/** Đồng bộ với `MOD_EPUB_MAX_CHAPTERS` trên reader-api. */
|
||||||
const BATCH_IMPORT_MAX_CHAPTERS = 4000
|
const BATCH_IMPORT_MAX_CHAPTERS = 4000
|
||||||
@@ -46,10 +52,17 @@ function normalizeNovelTitle(raw: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ImportBatchClient() {
|
export function ImportBatchClient() {
|
||||||
const [splitMode, setSplitMode] = useState<"toc" | "regex">("toc")
|
const [splitMode, setSplitMode] = useState<EpubSplitMode>("toc")
|
||||||
const [chapterStartPattern, setChapterStartPattern] = useState(
|
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+).+$",
|
"^\\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 [replaceExisting, setReplaceExisting] = useState(false)
|
||||||
const [running, setRunning] = useState(false)
|
const [running, setRunning] = useState(false)
|
||||||
const [progress, setProgress] = useState({ current: 0, total: 0 })
|
const [progress, setProgress] = useState({ current: 0, total: 0 })
|
||||||
@@ -142,8 +155,10 @@ export function ImportBatchClient() {
|
|||||||
|
|
||||||
const formAi = new FormData()
|
const formAi = new FormData()
|
||||||
formAi.append("file", file)
|
formAi.append("file", file)
|
||||||
formAi.append("splitMode", splitMode)
|
appendEpubSplitFormFields(formAi, splitMode, {
|
||||||
if (splitMode === "regex") formAi.append("chapterRegex", chapterStartPattern)
|
chapterRegex: chapterStartPattern,
|
||||||
|
chapterTag: getChapterTag(),
|
||||||
|
})
|
||||||
formAi.append("title", title)
|
formAi.append("title", title)
|
||||||
formAi.append("authorName", author)
|
formAi.append("authorName", author)
|
||||||
|
|
||||||
@@ -184,8 +199,10 @@ export function ImportBatchClient() {
|
|||||||
const formParse = new FormData()
|
const formParse = new FormData()
|
||||||
formParse.append("file", file)
|
formParse.append("file", file)
|
||||||
formParse.append("preview", "true")
|
formParse.append("preview", "true")
|
||||||
formParse.append("splitMode", splitMode)
|
appendEpubSplitFormFields(formParse, splitMode, {
|
||||||
if (splitMode === "regex") formParse.append("chapterRegex", chapterStartPattern)
|
chapterRegex: chapterStartPattern,
|
||||||
|
chapterTag: getChapterTag(),
|
||||||
|
})
|
||||||
formParse.append("enforceMaxChapters", "true")
|
formParse.append("enforceMaxChapters", "true")
|
||||||
|
|
||||||
const r3 = await fetch("/api/mod/epub", { method: "POST", credentials: "include", body: formParse, signal })
|
const r3 = await fetch("/api/mod/epub", { method: "POST", credentials: "include", body: formParse, signal })
|
||||||
@@ -220,15 +237,17 @@ export function ImportBatchClient() {
|
|||||||
fileName: displayPath,
|
fileName: displayPath,
|
||||||
ok: false,
|
ok: false,
|
||||||
resolvedTitle: title,
|
resolvedTitle: title,
|
||||||
error: "Bước 3: không tách được chương với cấu hình TOC/Regex hiện tại",
|
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()
|
const formImport = new FormData()
|
||||||
formImport.append("file", file)
|
formImport.append("file", file)
|
||||||
formImport.append("preview", "false")
|
formImport.append("preview", "false")
|
||||||
formImport.append("splitMode", splitMode)
|
appendEpubSplitFormFields(formImport, splitMode, {
|
||||||
if (splitMode === "regex") formImport.append("chapterRegex", chapterStartPattern)
|
chapterRegex: chapterStartPattern,
|
||||||
|
chapterTag: getChapterTag(),
|
||||||
|
})
|
||||||
formImport.append("title", title)
|
formImport.append("title", title)
|
||||||
formImport.append("authorName", author)
|
formImport.append("authorName", author)
|
||||||
formImport.append("status", status)
|
formImport.append("status", status)
|
||||||
@@ -348,11 +367,12 @@ export function ImportBatchClient() {
|
|||||||
<select
|
<select
|
||||||
className="rounded border px-2 py-1 text-sm"
|
className="rounded border px-2 py-1 text-sm"
|
||||||
value={splitMode}
|
value={splitMode}
|
||||||
onChange={(e) => setSplitMode(e.target.value as "toc" | "regex")}
|
onChange={(e) => setSplitMode(e.target.value as EpubSplitMode)}
|
||||||
disabled={running}
|
disabled={running}
|
||||||
>
|
>
|
||||||
<option value="toc">TOC</option>
|
<option value="toc">TOC</option>
|
||||||
<option value="regex">Regex tiếng Việt</option>
|
<option value="regex">Regex tiếng Việt</option>
|
||||||
|
<option value="tag">Thẻ HTML</option>
|
||||||
</select>
|
</select>
|
||||||
{splitMode === "regex" && (
|
{splitMode === "regex" && (
|
||||||
<Input
|
<Input
|
||||||
@@ -363,6 +383,30 @@ export function ImportBatchClient() {
|
|||||||
placeholder="Regex bắt đầu chương"
|
placeholder="Regex bắt đầu chương"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{splitMode === "tag" && (
|
||||||
|
<>
|
||||||
|
<select
|
||||||
|
className="rounded border px-2 py-1 text-sm"
|
||||||
|
value={tagPreset}
|
||||||
|
onChange={(e) => setTagPreset(e.target.value)}
|
||||||
|
disabled={running}
|
||||||
|
>
|
||||||
|
{EPUB_HTML_TAG_PRESETS.map((preset) => (
|
||||||
|
<option key={preset.id} value={preset.id}>{preset.name}</option>
|
||||||
|
))}
|
||||||
|
<option value="custom">Tùy chỉnh tên thẻ...</option>
|
||||||
|
</select>
|
||||||
|
{tagPreset === "custom" && (
|
||||||
|
<Input
|
||||||
|
className="max-w-[120px] font-mono text-xs"
|
||||||
|
value={customTag}
|
||||||
|
onChange={(e) => setCustomTag(e.target.value)}
|
||||||
|
disabled={running}
|
||||||
|
placeholder="a, h2"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center gap-2 text-sm">
|
<label className="flex items-center gap-2 text-sm">
|
||||||
<input type="checkbox" checked={replaceExisting} onChange={(e) => setReplaceExisting(e.target.checked)} disabled={running} />
|
<input type="checkbox" checked={replaceExisting} onChange={(e) => setReplaceExisting(e.target.checked)} disabled={running} />
|
||||||
|
|||||||
@@ -7,6 +7,12 @@ import { Input } from "@/components/ui/input"
|
|||||||
import { Textarea } from "@/components/ui/textarea"
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
import { Progress } from "@/components/ui/progress"
|
import { Progress } from "@/components/ui/progress"
|
||||||
import { toast } from "sonner"
|
import { toast } from "sonner"
|
||||||
|
import {
|
||||||
|
appendEpubSplitFormFields,
|
||||||
|
DEFAULT_EPUB_CHAPTER_TAG,
|
||||||
|
EPUB_HTML_TAG_PRESETS,
|
||||||
|
type EpubSplitMode,
|
||||||
|
} from "@/lib/epub-split"
|
||||||
|
|
||||||
type AssetItem = {
|
type AssetItem = {
|
||||||
id: string
|
id: string
|
||||||
@@ -49,8 +55,15 @@ export function ImportClient() {
|
|||||||
const [selectedGenreIds, setSelectedGenreIds] = useState<string[]>([])
|
const [selectedGenreIds, setSelectedGenreIds] = useState<string[]>([])
|
||||||
const [genreQuery, setGenreQuery] = useState("")
|
const [genreQuery, setGenreQuery] = useState("")
|
||||||
const [addingGenre, setAddingGenre] = useState(false)
|
const [addingGenre, setAddingGenre] = useState(false)
|
||||||
const [splitMode, setSplitMode] = useState<"toc" | "regex">("toc")
|
const [splitMode, setSplitMode] = useState<EpubSplitMode>("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 [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 [replaceExisting, setReplaceExisting] = useState(false)
|
||||||
|
|
||||||
const [previewLoading, setPreviewLoading] = useState(false)
|
const [previewLoading, setPreviewLoading] = useState(false)
|
||||||
@@ -242,8 +255,10 @@ export function ImportClient() {
|
|||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append("file", epubFile)
|
form.append("file", epubFile)
|
||||||
form.append("preview", "true")
|
form.append("preview", "true")
|
||||||
form.append("splitMode", splitMode)
|
appendEpubSplitFormFields(form, splitMode, {
|
||||||
if (splitMode === "regex") form.append("chapterRegex", chapterStartPattern)
|
chapterRegex: chapterStartPattern,
|
||||||
|
chapterTag: getChapterTag(),
|
||||||
|
})
|
||||||
form.append("title", title)
|
form.append("title", title)
|
||||||
form.append("authorName", author)
|
form.append("authorName", author)
|
||||||
const res = await fetch("/api/mod/epub/ai-suggest", { method: "POST", credentials: "include", body: form })
|
const res = await fetch("/api/mod/epub/ai-suggest", { method: "POST", credentials: "include", body: form })
|
||||||
@@ -291,8 +306,10 @@ export function ImportClient() {
|
|||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append("file", epubFile)
|
form.append("file", epubFile)
|
||||||
form.append("preview", "true")
|
form.append("preview", "true")
|
||||||
form.append("splitMode", splitMode)
|
appendEpubSplitFormFields(form, splitMode, {
|
||||||
if (splitMode === "regex") form.append("chapterRegex", chapterStartPattern)
|
chapterRegex: chapterStartPattern,
|
||||||
|
chapterTag: getChapterTag(),
|
||||||
|
})
|
||||||
const res = await fetch("/api/mod/epub", { method: "POST", credentials: "include", body: form })
|
const res = await fetch("/api/mod/epub", { method: "POST", credentials: "include", body: form })
|
||||||
const data = await res.json()
|
const data = await res.json()
|
||||||
if (!res.ok) throw new Error(data?.detail || "Parse preview thất bại")
|
if (!res.ok) throw new Error(data?.detail || "Parse preview thất bại")
|
||||||
@@ -306,7 +323,7 @@ export function ImportClient() {
|
|||||||
const total = Number(data?.novel?.totalChapters || data?.chapterCount || 0)
|
const total = Number(data?.novel?.totalChapters || data?.chapterCount || 0)
|
||||||
setChapterCount(total)
|
setChapterCount(total)
|
||||||
if (total <= 0) {
|
if (total <= 0) {
|
||||||
setParseError("Không tách được chương từ EPUB này với cấu hình hiện tại. Thử đổi TOC/Regex rồi parse lại.")
|
setParseError("Không tách được chương từ EPUB này với cấu hình hiện tại. Thử đổi TOC / Regex / Thẻ HTML rồi parse lại.")
|
||||||
}
|
}
|
||||||
toast.success("Đã tạo preview chương")
|
toast.success("Đã tạo preview chương")
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -324,8 +341,10 @@ export function ImportClient() {
|
|||||||
const form = new FormData()
|
const form = new FormData()
|
||||||
form.append("file", epubFile)
|
form.append("file", epubFile)
|
||||||
form.append("preview", "false")
|
form.append("preview", "false")
|
||||||
form.append("splitMode", splitMode)
|
appendEpubSplitFormFields(form, splitMode, {
|
||||||
if (splitMode === "regex") form.append("chapterRegex", chapterStartPattern)
|
chapterRegex: chapterStartPattern,
|
||||||
|
chapterTag: getChapterTag(),
|
||||||
|
})
|
||||||
form.append("title", title)
|
form.append("title", title)
|
||||||
form.append("originalTitle", originalTitle)
|
form.append("originalTitle", originalTitle)
|
||||||
form.append("authorName", author)
|
form.append("authorName", author)
|
||||||
@@ -351,7 +370,7 @@ export function ImportClient() {
|
|||||||
setPreviewItems([])
|
setPreviewItems([])
|
||||||
setChapterCount(0)
|
setChapterCount(0)
|
||||||
setParseError("")
|
setParseError("")
|
||||||
}, [splitMode, chapterStartPattern])
|
}, [splitMode, chapterStartPattern, tagPreset, customTag])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (step === 3 && asset) {
|
if (step === 3 && asset) {
|
||||||
@@ -523,13 +542,27 @@ export function ImportClient() {
|
|||||||
<section className="space-y-3 rounded-xl border p-4">
|
<section className="space-y-3 rounded-xl border p-4">
|
||||||
<div className="flex flex-wrap items-center gap-3 rounded-md border bg-muted/30 p-3">
|
<div className="flex flex-wrap items-center gap-3 rounded-md border bg-muted/30 p-3">
|
||||||
<label className="text-sm font-medium">Tách chương:</label>
|
<label className="text-sm font-medium">Tách chương:</label>
|
||||||
<select className="rounded border px-2 py-1 text-sm" value={splitMode} onChange={(e) => setSplitMode(e.target.value as "toc" | "regex")}>
|
<select className="rounded border px-2 py-1 text-sm" value={splitMode} onChange={(e) => setSplitMode(e.target.value as EpubSplitMode)}>
|
||||||
<option value="toc">TOC (lọc intro/mục lục)</option>
|
<option value="toc">TOC (lọc intro/mục lục)</option>
|
||||||
<option value="regex">Regex tiếng Việt</option>
|
<option value="regex">Regex tiếng Việt</option>
|
||||||
|
<option value="tag">Thẻ HTML</option>
|
||||||
</select>
|
</select>
|
||||||
{splitMode === "regex" && (
|
{splitMode === "regex" && (
|
||||||
<Input value={chapterStartPattern} onChange={(e) => setChapterStartPattern(e.target.value)} placeholder="Regex bắt đầu chương" />
|
<Input value={chapterStartPattern} onChange={(e) => setChapterStartPattern(e.target.value)} placeholder="Regex bắt đầu chương" />
|
||||||
)}
|
)}
|
||||||
|
{splitMode === "tag" && (
|
||||||
|
<>
|
||||||
|
<select className="rounded border px-2 py-1 text-sm" value={tagPreset} onChange={(e) => setTagPreset(e.target.value)}>
|
||||||
|
{EPUB_HTML_TAG_PRESETS.map((preset) => (
|
||||||
|
<option key={preset.id} value={preset.id}>{preset.name}</option>
|
||||||
|
))}
|
||||||
|
<option value="custom">Tùy chỉnh tên thẻ...</option>
|
||||||
|
</select>
|
||||||
|
{tagPreset === "custom" && (
|
||||||
|
<Input value={customTag} onChange={(e) => setCustomTag(e.target.value)} placeholder="Ví dụ: a, h2" className="max-w-[120px]" />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="font-semibold">Preview chapters</h2>
|
<h2 className="font-semibold">Preview chapters</h2>
|
||||||
|
|||||||
@@ -18,7 +18,13 @@ import { toast } from "sonner"
|
|||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
import { getNovelStatusBadgeClass } from "@/lib/novel-status"
|
import { getNovelStatusBadgeClass } from "@/lib/novel-status"
|
||||||
import { Progress } from "@/components/ui/progress"
|
import { Progress } from "@/components/ui/progress"
|
||||||
import { MOD_AI_PREFILL_STORAGE_KEY, type AINovelPrefillPayload } from "@/lib/mod-ai-tools"
|
import {
|
||||||
|
appendEpubSplitFormFields,
|
||||||
|
DEFAULT_EPUB_CHAPTER_TAG,
|
||||||
|
EPUB_HTML_TAG_PRESETS,
|
||||||
|
type EpubSplitMode,
|
||||||
|
splitModeLabel,
|
||||||
|
} from "@/lib/epub-split"
|
||||||
|
|
||||||
interface Novel {
|
interface Novel {
|
||||||
id: string
|
id: string
|
||||||
@@ -37,13 +43,14 @@ interface Genre {
|
|||||||
|
|
||||||
interface EpubPreviewData {
|
interface EpubPreviewData {
|
||||||
fileName: string
|
fileName: string
|
||||||
splitMode: "toc" | "regex"
|
splitMode: EpubSplitMode
|
||||||
detectedStructureType: "standard" | "light_novel"
|
detectedStructureType: "standard" | "light_novel"
|
||||||
hasCoverFromEpub?: boolean
|
hasCoverFromEpub?: boolean
|
||||||
coverPreviewDataUrl?: string | null
|
coverPreviewDataUrl?: string | null
|
||||||
parserInfo?: {
|
parserInfo?: {
|
||||||
splitMode: "toc" | "regex"
|
splitMode: EpubSplitMode
|
||||||
chapterRegexUsed?: string | null
|
chapterRegexUsed?: string | null
|
||||||
|
chapterTagUsed?: string | null
|
||||||
regexPreset?: string | null
|
regexPreset?: string | null
|
||||||
sourceSections: number
|
sourceSections: number
|
||||||
chaptersDetected: number
|
chaptersDetected: number
|
||||||
@@ -125,9 +132,11 @@ export function NovelClient() {
|
|||||||
const [epubTitle, setEpubTitle] = useState("")
|
const [epubTitle, setEpubTitle] = useState("")
|
||||||
const [epubAuthorName, setEpubAuthorName] = useState("")
|
const [epubAuthorName, setEpubAuthorName] = useState("")
|
||||||
const [epubDescription, setEpubDescription] = useState("")
|
const [epubDescription, setEpubDescription] = useState("")
|
||||||
const [epubSplitMode, setEpubSplitMode] = useState<"toc" | "regex">("toc")
|
const [epubSplitMode, setEpubSplitMode] = useState<EpubSplitMode>("toc")
|
||||||
const [epubRegexPreset, setEpubRegexPreset] = useState<string>("vi_chuong_hoi")
|
const [epubRegexPreset, setEpubRegexPreset] = useState<string>("vi_chuong_hoi")
|
||||||
const [epubCustomRegex, setEpubCustomRegex] = useState("")
|
const [epubCustomRegex, setEpubCustomRegex] = useState("")
|
||||||
|
const [epubTagPreset, setEpubTagPreset] = useState<string>("a")
|
||||||
|
const [epubCustomTag, setEpubCustomTag] = useState(DEFAULT_EPUB_CHAPTER_TAG)
|
||||||
const epubInputRef = useRef<HTMLInputElement>(null)
|
const epubInputRef = useRef<HTMLInputElement>(null)
|
||||||
const epubFolderInputRef = useRef<HTMLInputElement>(null)
|
const epubFolderInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
@@ -167,7 +176,6 @@ export function NovelClient() {
|
|||||||
const [pageSize, setPageSize] = useState(20)
|
const [pageSize, setPageSize] = useState(20)
|
||||||
const [bulkProgress, setBulkProgress] = useState<Record<string, BulkUploadProgressItem>>({})
|
const [bulkProgress, setBulkProgress] = useState<Record<string, BulkUploadProgressItem>>({})
|
||||||
const [bulkDuplicateHandling, setBulkDuplicateHandling] = useState<BulkDuplicateHandling>("ask")
|
const [bulkDuplicateHandling, setBulkDuplicateHandling] = useState<BulkDuplicateHandling>("ask")
|
||||||
const [pendingAIPrefill, setPendingAIPrefill] = useState<AINovelPrefillPayload | null>(null)
|
|
||||||
|
|
||||||
const getSelectedChapterRegex = () => {
|
const getSelectedChapterRegex = () => {
|
||||||
if (epubRegexPreset === "custom") {
|
if (epubRegexPreset === "custom") {
|
||||||
@@ -177,6 +185,13 @@ export function NovelClient() {
|
|||||||
return CHAPTER_REGEX_PRESETS.find((preset) => preset.id === epubRegexPreset)?.pattern || CHAPTER_REGEX_PRESETS[0].pattern
|
return CHAPTER_REGEX_PRESETS.find((preset) => preset.id === epubRegexPreset)?.pattern || CHAPTER_REGEX_PRESETS[0].pattern
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getSelectedChapterTag = () => {
|
||||||
|
if (epubTagPreset === "custom") {
|
||||||
|
return epubCustomTag.trim() || DEFAULT_EPUB_CHAPTER_TAG
|
||||||
|
}
|
||||||
|
return EPUB_HTML_TAG_PRESETS.find((preset) => preset.id === epubTagPreset)?.tag || DEFAULT_EPUB_CHAPTER_TAG
|
||||||
|
}
|
||||||
|
|
||||||
const normalizeEpubFiles = (files: File[]) => {
|
const normalizeEpubFiles = (files: File[]) => {
|
||||||
return files.filter((file) => file.name.toLowerCase().endsWith(".epub"))
|
return files.filter((file) => file.name.toLowerCase().endsWith(".epub"))
|
||||||
}
|
}
|
||||||
@@ -338,79 +353,6 @@ export function NovelClient() {
|
|||||||
setGenreQuery("")
|
setGenreQuery("")
|
||||||
}
|
}
|
||||||
|
|
||||||
const applyAIPrefillToAddForm = (prefill: AINovelPrefillPayload) => {
|
|
||||||
const nextTitle = prefill.title?.trim() || ""
|
|
||||||
const nextAuthor = prefill.authorName?.trim() || ""
|
|
||||||
|
|
||||||
setTitle(nextTitle)
|
|
||||||
setOriginalTitle((prefill.originalTitle || nextTitle).trim())
|
|
||||||
setAuthorName(nextAuthor)
|
|
||||||
setOriginalAuthorName((prefill.originalAuthorName || nextAuthor).trim())
|
|
||||||
setDescription((prefill.description || "").trim())
|
|
||||||
setCoverUrl((prefill.coverUrl || "").trim())
|
|
||||||
setGenreQuery("")
|
|
||||||
|
|
||||||
const validStatus = ["Đang ra", "Hoàn thành", "Tạm ngưng"].includes(prefill.status || "")
|
|
||||||
? (prefill.status as "Đang ra" | "Hoàn thành" | "Tạm ngưng")
|
|
||||||
: "Đang ra"
|
|
||||||
setStatus(validStatus)
|
|
||||||
|
|
||||||
const suggestedGenres = Array.isArray(prefill.genresSuggested) ? prefill.genresSuggested : []
|
|
||||||
if (suggestedGenres.length === 0 || genres.length === 0) {
|
|
||||||
setSelectedGenres([])
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const byName = new Map(genres.map((genre) => [normalizeGenreName(genre.name), genre.id]))
|
|
||||||
const pickedIds: string[] = []
|
|
||||||
const missing: string[] = []
|
|
||||||
|
|
||||||
for (const name of suggestedGenres) {
|
|
||||||
const id = byName.get(normalizeGenreName(name))
|
|
||||||
if (!id) {
|
|
||||||
missing.push(name)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if (!pickedIds.includes(id)) pickedIds.push(id)
|
|
||||||
}
|
|
||||||
|
|
||||||
setSelectedGenres(pickedIds)
|
|
||||||
|
|
||||||
if (missing.length > 0) {
|
|
||||||
toast.info(`The loai goi y chua co san: ${missing.slice(0, 4).join(", ")}`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (typeof window === "undefined") return
|
|
||||||
|
|
||||||
const raw = window.localStorage.getItem(MOD_AI_PREFILL_STORAGE_KEY)
|
|
||||||
if (!raw) return
|
|
||||||
|
|
||||||
window.localStorage.removeItem(MOD_AI_PREFILL_STORAGE_KEY)
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw) as AINovelPrefillPayload
|
|
||||||
setPendingAIPrefill(parsed)
|
|
||||||
setOpenAdd(true)
|
|
||||||
} catch {
|
|
||||||
toast.error("Du lieu AI Tool khong hop le")
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!pendingAIPrefill) return
|
|
||||||
|
|
||||||
const needsGenres = Array.isArray(pendingAIPrefill.genresSuggested)
|
|
||||||
&& pendingAIPrefill.genresSuggested.length > 0
|
|
||||||
&& genres.length === 0
|
|
||||||
|
|
||||||
if (needsGenres) return
|
|
||||||
|
|
||||||
applyAIPrefillToAddForm(pendingAIPrefill)
|
|
||||||
setPendingAIPrefill(null)
|
|
||||||
toast.success("Da nap du lieu de xuat tu AI Tool")
|
|
||||||
}, [pendingAIPrefill, genres])
|
|
||||||
|
|
||||||
const filteredNovels = useMemo(() => {
|
const filteredNovels = useMemo(() => {
|
||||||
const keyword = searchKeyword.trim().toLowerCase()
|
const keyword = searchKeyword.trim().toLowerCase()
|
||||||
if (!keyword) return novels
|
if (!keyword) return novels
|
||||||
@@ -739,7 +681,7 @@ export function NovelClient() {
|
|||||||
const requestEpubPreview = async (
|
const requestEpubPreview = async (
|
||||||
file: File,
|
file: File,
|
||||||
options?: {
|
options?: {
|
||||||
splitMode?: "toc" | "regex"
|
splitMode?: EpubSplitMode
|
||||||
regexPreset?: string
|
regexPreset?: string
|
||||||
regexInput?: string
|
regexInput?: string
|
||||||
preserveEditedMetadata?: boolean
|
preserveEditedMetadata?: boolean
|
||||||
@@ -758,11 +700,12 @@ export function NovelClient() {
|
|||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append("file", file)
|
formData.append("file", file)
|
||||||
formData.append("preview", "true")
|
formData.append("preview", "true")
|
||||||
formData.append("splitMode", splitMode)
|
appendEpubSplitFormFields(formData, splitMode, {
|
||||||
|
chapterRegex: regexInput,
|
||||||
|
chapterTag: getSelectedChapterTag(),
|
||||||
|
})
|
||||||
if (splitMode === "regex") {
|
if (splitMode === "regex") {
|
||||||
formData.append("chapterRegexPreset", regexPreset)
|
formData.append("chapterRegexPreset", regexPreset)
|
||||||
formData.append("chapterRegex", regexInput)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const res = await fetch("/api/mod/epub", {
|
const res = await fetch("/api/mod/epub", {
|
||||||
@@ -873,6 +816,10 @@ export function NovelClient() {
|
|||||||
toast.error("Vui lòng nhập regex tùy chỉnh trước khi tải lên")
|
toast.error("Vui lòng nhập regex tùy chỉnh trước khi tải lên")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if (epubSplitMode === "tag" && epubTagPreset === "custom" && !epubCustomTag.trim()) {
|
||||||
|
toast.error("Vui lòng nhập tên thẻ HTML trước khi tải lên")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setUploadingEpub(true)
|
setUploadingEpub(true)
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
@@ -880,12 +827,13 @@ export function NovelClient() {
|
|||||||
formData.append("title", epubTitle)
|
formData.append("title", epubTitle)
|
||||||
formData.append("authorName", epubAuthorName)
|
formData.append("authorName", epubAuthorName)
|
||||||
formData.append("description", epubDescription)
|
formData.append("description", epubDescription)
|
||||||
formData.append("splitMode", epubSplitMode)
|
appendEpubSplitFormFields(formData, epubSplitMode, {
|
||||||
|
chapterRegex: epubRegexPreset === "custom" ? epubCustomRegex.trim() : getSelectedChapterRegex(),
|
||||||
|
chapterTag: getSelectedChapterTag(),
|
||||||
|
})
|
||||||
|
|
||||||
if (epubSplitMode === "regex") {
|
if (epubSplitMode === "regex") {
|
||||||
const selectedRegex = epubRegexPreset === "custom" ? epubCustomRegex.trim() : getSelectedChapterRegex()
|
|
||||||
formData.append("chapterRegexPreset", epubRegexPreset)
|
formData.append("chapterRegexPreset", epubRegexPreset)
|
||||||
formData.append("chapterRegex", selectedRegex)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1286,7 +1234,8 @@ export function NovelClient() {
|
|||||||
<>
|
<>
|
||||||
<p>
|
<p>
|
||||||
<span className="font-semibold">Parser:</span>{" "}
|
<span className="font-semibold">Parser:</span>{" "}
|
||||||
{epubPreviewData.parserInfo.splitMode === "regex" ? "Regex" : "TOC"}
|
{splitModeLabel(epubPreviewData.parserInfo.splitMode)}
|
||||||
|
{epubPreviewData.parserInfo.chapterTagUsed ? ` (${epubPreviewData.parserInfo.chapterTagUsed})` : ""}
|
||||||
</p>
|
</p>
|
||||||
<p>
|
<p>
|
||||||
<span className="font-semibold">Nguồn phân tích:</span>{" "}
|
<span className="font-semibold">Nguồn phân tích:</span>{" "}
|
||||||
@@ -1310,6 +1259,12 @@ export function NovelClient() {
|
|||||||
{epubPreviewData.parserInfo.chapterRegexUsed}
|
{epubPreviewData.parserInfo.chapterRegexUsed}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{epubPreviewData.parserInfo.chapterTagUsed && (
|
||||||
|
<p>
|
||||||
|
<span className="font-semibold">Thẻ HTML dùng:</span>{" "}
|
||||||
|
<{epubPreviewData.parserInfo.chapterTagUsed}>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1321,14 +1276,31 @@ export function NovelClient() {
|
|||||||
<label className="text-xs font-medium text-muted-foreground">Chế độ tách chương</label>
|
<label className="text-xs font-medium text-muted-foreground">Chế độ tách chương</label>
|
||||||
<select
|
<select
|
||||||
value={epubSplitMode}
|
value={epubSplitMode}
|
||||||
onChange={(e) => setEpubSplitMode(e.target.value as "toc" | "regex")}
|
onChange={(e) => setEpubSplitMode(e.target.value as EpubSplitMode)}
|
||||||
className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm"
|
className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||||
>
|
>
|
||||||
<option value="toc">Theo TOC trong EPUB</option>
|
<option value="toc">Theo TOC trong EPUB</option>
|
||||||
<option value="regex">Theo Regex</option>
|
<option value="regex">Theo Regex</option>
|
||||||
|
<option value="tag">Theo thẻ HTML</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{epubSplitMode === "tag" && (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground">Thẻ tách chương</label>
|
||||||
|
<select
|
||||||
|
value={epubTagPreset}
|
||||||
|
onChange={(e) => setEpubTagPreset(e.target.value)}
|
||||||
|
className="flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
{EPUB_HTML_TAG_PRESETS.map((preset) => (
|
||||||
|
<option key={preset.id} value={preset.id}>{preset.name}</option>
|
||||||
|
))}
|
||||||
|
<option value="custom">Tự nhập tên thẻ</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{epubSplitMode === "regex" && (
|
{epubSplitMode === "regex" && (
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<label className="text-xs font-medium text-muted-foreground">Preset Regex</label>
|
<label className="text-xs font-medium text-muted-foreground">Preset Regex</label>
|
||||||
@@ -1365,12 +1337,35 @@ export function NovelClient() {
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{epubSplitMode === "tag" && (
|
||||||
|
<>
|
||||||
|
{epubTagPreset !== "custom" ? (
|
||||||
|
<div className="rounded-md bg-muted/40 p-2 text-xs">
|
||||||
|
Mỗi thẻ <{getSelectedChapterTag()}> mở đầu một chương. Tiêu đề lấy từ nội dung trong thẻ (nếu có).
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<label className="text-xs font-medium text-muted-foreground">Tên thẻ HTML</label>
|
||||||
|
<Input
|
||||||
|
value={epubCustomTag}
|
||||||
|
onChange={(e) => setEpubCustomTag(e.target.value)}
|
||||||
|
placeholder="Ví dụ: a, h2, h1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex justify-end">
|
<div className="flex justify-end">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={handleReparseEpub}
|
onClick={handleReparseEpub}
|
||||||
disabled={previewingEpub || (epubSplitMode === "regex" && epubRegexPreset === "custom" && !epubCustomRegex.trim())}
|
disabled={
|
||||||
|
previewingEpub
|
||||||
|
|| (epubSplitMode === "regex" && epubRegexPreset === "custom" && !epubCustomRegex.trim())
|
||||||
|
|| (epubSplitMode === "tag" && epubTagPreset === "custom" && !epubCustomTag.trim())
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{previewingEpub && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
{previewingEpub && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
Phân tích lại theo cấu hình
|
Phân tích lại theo cấu hình
|
||||||
|
|||||||
@@ -68,6 +68,9 @@ async function initializeGoogleIdentity(clientId: string): Promise<void> {
|
|||||||
|
|
||||||
if (googleInitializedClientId === clientId) return
|
if (googleInitializedClientId === clientId) return
|
||||||
|
|
||||||
|
const hostname = typeof window !== "undefined" ? window.location.hostname : ""
|
||||||
|
const isLocalHost = hostname === "localhost" || hostname === "127.0.0.1"
|
||||||
|
|
||||||
// Re-initialize with new clientId (or first time)
|
// Re-initialize with new clientId (or first time)
|
||||||
googleApi.initialize({
|
googleApi.initialize({
|
||||||
client_id: clientId,
|
client_id: clientId,
|
||||||
@@ -86,7 +89,8 @@ async function initializeGoogleIdentity(clientId: string): Promise<void> {
|
|||||||
},
|
},
|
||||||
auto_select: false,
|
auto_select: false,
|
||||||
cancel_on_tap_outside: true,
|
cancel_on_tap_outside: true,
|
||||||
use_fedcm_for_prompt: true,
|
// FedCM on localhost often yields tokens backend cannot verify; production keeps FedCM.
|
||||||
|
use_fedcm_for_prompt: !isLocalHost,
|
||||||
})
|
})
|
||||||
|
|
||||||
googleInitializedClientId = clientId
|
googleInitializedClientId = clientId
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
export type EpubSplitMode = "toc" | "regex" | "tag"
|
||||||
|
|
||||||
|
export const EPUB_HTML_TAG_PRESETS = [
|
||||||
|
{ id: "a", name: "Thẻ <a> (anchor / mục lục liên kết)", tag: "a" },
|
||||||
|
{ id: "h2", name: "Thẻ <h2>", tag: "h2" },
|
||||||
|
{ id: "h1", name: "Thẻ <h1>", tag: "h1" },
|
||||||
|
{ id: "h3", name: "Thẻ <h3>", tag: "h3" },
|
||||||
|
{ id: "p", name: "Thẻ <p>", tag: "p" },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const DEFAULT_EPUB_CHAPTER_TAG = "a"
|
||||||
|
|
||||||
|
export function splitModeLabel(mode: string | undefined): string {
|
||||||
|
if (mode === "regex") return "Regex"
|
||||||
|
if (mode === "tag") return "Thẻ HTML"
|
||||||
|
return "TOC"
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appendEpubSplitFormFields(
|
||||||
|
form: FormData,
|
||||||
|
splitMode: EpubSplitMode,
|
||||||
|
options?: { chapterRegex?: string; chapterTag?: string },
|
||||||
|
) {
|
||||||
|
form.append("splitMode", splitMode)
|
||||||
|
if (splitMode === "regex" && options?.chapterRegex) {
|
||||||
|
form.append("chapterRegex", options.chapterRegex)
|
||||||
|
}
|
||||||
|
if (splitMode === "tag") {
|
||||||
|
form.append("chapterTag", (options?.chapterTag || DEFAULT_EPUB_CHAPTER_TAG).trim() || DEFAULT_EPUB_CHAPTER_TAG)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
export const MOD_AI_PREFILL_STORAGE_KEY = "mod:ai-tool:novel-prefill"
|
|
||||||
export const MOD_AI_MODEL_STORAGE_KEY = "mod:ai-tool:model"
|
|
||||||
export const MOD_AI_WEB_DEFAULT_MODEL = "gpt-4o-mini-search-preview"
|
|
||||||
|
|
||||||
export const MOD_AI_WEB_MODEL_OPTIONS = [
|
|
||||||
{
|
|
||||||
value: "gpt-4o-mini-search-preview",
|
|
||||||
label: "gpt-4o-mini-search-preview (nhanh)",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: "gpt-4o-search-preview",
|
|
||||||
label: "gpt-4o-search-preview (chat luong cao)",
|
|
||||||
},
|
|
||||||
] as const
|
|
||||||
|
|
||||||
export type AINovelPrefillPayload = {
|
|
||||||
title?: string
|
|
||||||
originalTitle?: string
|
|
||||||
authorName?: string
|
|
||||||
originalAuthorName?: string
|
|
||||||
description?: string
|
|
||||||
coverUrl?: string
|
|
||||||
status?: "Đang ra" | "Hoàn thành" | "Tạm ngưng"
|
|
||||||
genresSuggested?: string[]
|
|
||||||
}
|
|
||||||
-68
@@ -1,68 +0,0 @@
|
|||||||
require('dotenv').config({ path: '.env' });
|
|
||||||
|
|
||||||
async function fetchJsonWithTimeout(url, init, timeoutMs = 25000) {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
||||||
try {
|
|
||||||
const res = await fetch(url, { ...init, signal: controller.signal, cache: "no-store" });
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text().catch(() => "");
|
|
||||||
throw new Error(`HTTP ${res.status}: ${text.slice(0, 500)}`);
|
|
||||||
}
|
|
||||||
return await res.json();
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const prompt = "Please reply with { \"ready\": true } in JSON";
|
|
||||||
|
|
||||||
async function tryGoogle() {
|
|
||||||
const apiKey = process.env.GOOGLE_AI_KEY;
|
|
||||||
if (!apiKey) return console.log("Google: No key");
|
|
||||||
try {
|
|
||||||
const data = await fetchJsonWithTimeout(
|
|
||||||
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${apiKey}`,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
contents: [{ role: "user", parts: [{ text: prompt }] }],
|
|
||||||
tools: [{ googleSearch: {} }],
|
|
||||||
generationConfig: { temperature: 0.2, maxOutputTokens: 1400 },
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
console.log("Google Success:", JSON.stringify(data).slice(0, 100));
|
|
||||||
} catch(e) { console.log("Google Error:", e.stack || e.message); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function tryDeepSeek() {
|
|
||||||
const apiKey = process.env.DEEKSEEK_KEY || process.env.DEEPSEEK_KEY;
|
|
||||||
if (!apiKey) return console.log("DeepSeek: No key");
|
|
||||||
try {
|
|
||||||
const data = await fetchJsonWithTimeout(
|
|
||||||
"https://api.deepseek.com/v1/chat/completions",
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
Authorization: `Bearer ${apiKey}`,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: "deepseek-chat",
|
|
||||||
temperature: 0.2,
|
|
||||||
max_tokens: 500,
|
|
||||||
messages: [{ role: "user", content: prompt }],
|
|
||||||
}),
|
|
||||||
}
|
|
||||||
);
|
|
||||||
console.log("DeepSeek Success:", JSON.stringify(data).slice(0,100));
|
|
||||||
} catch(e) { console.log("DeepSeek Error:", e.stack || e.message); }
|
|
||||||
}
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
await tryGoogle();
|
|
||||||
await tryDeepSeek();
|
|
||||||
}
|
|
||||||
run();
|
|
||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user