Velo / electron /controllers /import-controller.ts
Leon4gr45's picture
Upload folder using huggingface_hub
55847d9 verified
Raw History Blame Contribute Delete
8.18 kB
import { ipcMain, dialog } from 'electron'
import fs from 'node:fs'
import path from 'node:path'
/**
* 导入小说控制器 — 处理文件选择与章节拆分
*
* 拆章策略按优先级顺序尝试匹配:
* 1. 中文标准格式:"第X章 标题" / "第X章:标题"
* 2. 英文标准格式:"Chapter X: Title"
* 3. Markdown 标题格式:"# 第X章 标题"
* 如果所有正则均不命中,则将整个文件视为单章。
*/
// ===== 拆章正则池 =====
/** 中文"第X章"格式(支持中文数字和阿拉伯数字,冒号可有可无) */
const RE_CN_CHAPTER = /^第[一二三四五六七八九十百千零\d]+章[\s::·—-]*(.*)/
/** 英文 "Chapter X" 格式 */
const RE_EN_CHAPTER = /^Chapter\s+(\d+)[\s::·—-]*(.*)/i
/** Markdown 标题格式:"# 第X章" 或 "## Chapter X" */
const RE_MD_HEADING = /^#{1,3}\s+(?:第[一二三四五六七八九十百千零\d]+章|Chapter\s+\d+)[\s::·—-]*(.*)/i
/** 所有候选正则 */
const CHAPTER_PATTERNS = [RE_CN_CHAPTER, RE_EN_CHAPTER, RE_MD_HEADING]
/** 中文数字到阿拉伯数字的映射 */
function chineseNumToArabic(str: string): number {
const map: Record<string, number> = {
'零': 0, '一': 1, '二': 2, '三': 3, '四': 4,
'五': 5, '六': 6, '七': 7, '八': 8, '九': 9,
'十': 10, '百': 100, '千': 1000,
}
// 纯阿拉伯数字
const n = parseInt(str)
if (!isNaN(n)) return n
// 中文数字解析(支持"一百二十三"等简单组合)
let result = 0
let current = 0
for (const ch of str) {
const val = map[ch]
if (val === undefined) continue
if (val >= 10) {
if (current === 0) current = 1
current *= val
result += current
current = 0
} else {
current = val
}
}
return result + current
}
/** 从章节标题行提取章节号 */
function extractChapterNumber(line: string): number {
// 尝试从"第X章"格式提取
const cnMatch = line.match(/第([一二三四五六七八九十百千零\d]+)章/)
if (cnMatch) return chineseNumToArabic(cnMatch[1])
// 尝试从"Chapter X"格式提取
const enMatch = line.match(/Chapter\s+(\d+)/i)
if (enMatch) return parseInt(enMatch[1])
return 0
}
/** 检测一行是否是章节标题 */
function isChapterHeading(line: string): boolean {
const trimmed = line.trim()
return CHAPTER_PATTERNS.some(re => re.test(trimmed))
}
/** 从章节标题行提取标题文字(去掉"第X章"前缀) */
function extractTitle(line: string): string {
const trimmed = line.trim()
for (const re of CHAPTER_PATTERNS) {
const match = trimmed.match(re)
if (match) {
// 取最后一个捕获组(标题部分)
const title = match[match.length - 1]?.trim()
if (title) return title
// 如果标题为空,返回完整行
return trimmed
}
}
return trimmed
}
interface ParsedChapter {
number: number
title: string
content: string
wordCount: number
}
/** 将单个文件内容拆分为章节数组 */
function splitSingleFileContent(content: string): ParsedChapter[] {
const lines = content.split('\n')
const chapters: ParsedChapter[] = []
let currentChapter: { headerLine: string; lines: string[] } | null = null
let autoNumber = 0
for (const line of lines) {
if (isChapterHeading(line)) {
// 保存上一个章节
if (currentChapter) {
autoNumber++
const num = extractChapterNumber(currentChapter.headerLine) || autoNumber
const text = currentChapter.lines.join('\n').trim()
if (text.length > 0) {
chapters.push({
number: num,
title: extractTitle(currentChapter.headerLine),
content: text,
wordCount: text.length,
})
}
}
// 开始新章节
currentChapter = { headerLine: line, lines: [] }
} else if (currentChapter) {
currentChapter.lines.push(line)
} else {
// 在第一个章节标题之前的内容 → 创建前言/序章
if (!currentChapter) {
currentChapter = { headerLine: line, lines: [] }
}
}
}
// 保存最后一个章节
if (currentChapter) {
autoNumber++
const num = extractChapterNumber(currentChapter.headerLine) || autoNumber
const text = currentChapter.lines.join('\n').trim()
if (text.length > 0) {
chapters.push({
number: num,
title: extractTitle(currentChapter.headerLine),
content: text,
wordCount: text.length,
})
}
}
return chapters
}
/** 如果内容中没有匹配到任何章节标题,则整文件视为一章 */
function hasChapterHeadings(content: string): boolean {
const lines = content.split('\n')
return lines.some(line => isChapterHeading(line))
}
export function registerImportController() {
// ===== 文件选择对话框 =====
ipcMain.handle('dialog:select-novel-files', async () => {
const result = await dialog.showOpenDialog({
title: '选择要导入的小说文件',
filters: [
{ name: '小说文本', extensions: ['txt', 'md', 'text'] },
{ name: '所有文件', extensions: ['*'] },
],
properties: ['openFile', 'multiSelections'],
})
if (result.canceled || result.filePaths.length === 0) return null
return result.filePaths
})
// ===== 读取并拆分章节 =====
ipcMain.handle('import:split-chapters', async (_event, filePaths: string[]) => {
try {
const allChapters: ParsedChapter[] = []
if (filePaths.length === 1) {
// ===== 单文件模式 =====
const filePath = filePaths[0]
const content = fs.readFileSync(filePath, 'utf-8')
if (hasChapterHeadings(content)) {
// 文件内含章节标题 → 正则拆章
const chapters = splitSingleFileContent(content)
allChapters.push(...chapters)
} else {
// 无章节标题 → 整文件视为一章
allChapters.push({
number: 1,
title: path.basename(filePath, path.extname(filePath)),
content: content.trim(),
wordCount: content.trim().length,
})
}
} else {
// ===== 多文件模式 =====
// 按文件名自然排序
const sorted = [...filePaths].sort((a, b) => {
const nameA = path.basename(a)
const nameB = path.basename(b)
return nameA.localeCompare(nameB, 'zh-CN', { numeric: true })
})
for (let i = 0; i < sorted.length; i++) {
const filePath = sorted[i]
const content = fs.readFileSync(filePath, 'utf-8').trim()
if (!content) continue
// 尝试从文件内容中检测章节标题
if (hasChapterHeadings(content)) {
// 文件内含多章 → 拆分
const chapters = splitSingleFileContent(content)
allChapters.push(...chapters)
} else {
// 文件内无章节标题 → 整文件视为一章
const fileName = path.basename(filePath, path.extname(filePath))
const num = extractChapterNumber(fileName) || (allChapters.length + 1)
allChapters.push({
number: num,
title: fileName,
content,
wordCount: content.length,
})
}
}
}
// 去重排序:按章节号排序,重复章号保留后者
const chapterMap = new Map<number, ParsedChapter>()
for (const ch of allChapters) {
chapterMap.set(ch.number, ch)
}
const finalChapters = Array.from(chapterMap.values())
.sort((a, b) => a.number - b.number)
// 重新编号(确保从1开始连续)
const renumbered = finalChapters.map((ch, idx) => ({
...ch,
number: idx + 1,
}))
const totalWords = renumbered.reduce((sum, ch) => sum + ch.wordCount, 0)
return {
success: true,
chapters: renumbered,
totalWords,
}
} catch (error) {
return {
success: false,
chapters: [],
totalWords: 0,
error: String(error),
}
}
})
}