feat: 支持 EPUB 阅读并改善规则兼容与阅读稳定性

支持 EPUB 2/3 导入、目录、封面和章节插图,保留本地书管理与阅读进度。
修复 JSON 模板、动态替换与请求参数兼容,以及 RSS 翻页和连续滚动定位。
更新编辑器工具栏和回归测试,发布版本为 0.1.12(build 18)。
This commit is contained in:
Aminark
2026-10-24 00:58:11 +08:00
parent 3322fd71db
commit 67bc212ffd
58 changed files with 2331 additions and 244 deletions
@@ -10,7 +10,7 @@ public enum LocalBookError: Error, LocalizedError, Sendable {
public var errorDescription: String? {
switch self {
case .unsupportedFormat: "当前仅支持 TXT 本地书"
case .unsupportedFormat: "当前支持 TXT 和 EPUB 本地书"
case .oversized: "本地书超过当前配置允许的大小"
case .unreadableEncoding: "无法识别 TXT 编码"
case .missingFile: "本地书文件已丢失"
@@ -104,6 +104,49 @@ public struct LocalBookService: Sendable {
LocalBookService(tocRules: TxtTocRuleDefaults.load())
}
public static func supports(_ url: URL) -> Bool {
url.isFileURL && ["txt", "epub"].contains(url.pathExtension.lowercased())
}
public func openFile(at url: URL) throws -> LocalBookImportReport {
guard Self.supports(url) else { throw LocalBookError.unsupportedFormat }
if url.pathExtension.lowercased() == "epub" {
try validateFile(url)
return try EPUBBook(url: url).report(url: url, originalName: url.lastPathComponent)
}
return try openTextFile(at: url)
}
public func importFile(at source: URL, into directory: URL) throws -> LocalBookImportReport {
guard Self.supports(source) else { throw LocalBookError.unsupportedFormat }
try validateFile(source)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let destination = directory.appendingPathComponent(UUID().uuidString + "." + source.pathExtension.lowercased())
try FileManager.default.copyItem(at: source, to: destination)
do {
if source.pathExtension.lowercased() == "epub" {
return try EPUBBook(url: destination).report(url: destination, originalName: source.lastPathComponent)
}
return try textFileReport(data: Data(contentsOf: destination, options: .mappedIfSafe), sourceURL: source, destination: destination)
} catch {
try? FileManager.default.removeItem(at: destination)
throw error
}
}
public func loadIllustrations(book: Book, chapter: BookChapter) throws -> [EPUBIllustration] {
guard let url = URL(string: book.bookUrl), url.isFileURL, url.pathExtension.lowercased() == "epub" else { return [] }
try validateFile(url)
return try EPUBBook(url: url).illustrations(chapter: chapter)
}
private func validateFile(_ url: URL) throws {
guard FileManager.default.fileExists(atPath: url.path) else { throw LocalBookError.missingFile }
let values = try url.resourceValues(forKeys: [.isRegularFileKey, .fileSizeKey])
guard values.isRegularFile == true else { throw LocalBookError.missingFile }
guard Int64(values.fileSize ?? 0) <= maximumFileSize else { throw LocalBookError.oversized }
}
public func importTextFile(at sourceURL: URL, into directory: URL) throws -> LocalBookImportReport {
guard sourceURL.pathExtension.lowercased() == "txt" else { throw LocalBookError.unsupportedFormat }
let values = try sourceURL.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey])
@@ -181,6 +224,10 @@ public struct LocalBookService: Sendable {
}
public func loadChapters(book: Book) throws -> ChapterListReport {
if let url = URL(string: book.bookUrl), url.isFileURL, url.pathExtension.lowercased() == "epub" {
try validateFile(url)
return try .init(chapters: EPUBBook(url: url).chapters(book: book))
}
let decoded = try read(book)
let selectionVolumeState = TxtVolumeState()
let probeText = textProbe(decoded.data, encoding: decoded.encoding)
@@ -201,6 +248,10 @@ public struct LocalBookService: Sendable {
}
public func loadContent(book: Book, chapter: BookChapter) throws -> ChapterContentReport {
if let url = URL(string: book.bookUrl), url.isFileURL, url.pathExtension.lowercased() == "epub" {
try validateFile(url)
return try EPUBBook(url: url).content(chapter: chapter)
}
guard let url = URL(string: book.bookUrl), url.isFileURL,
FileManager.default.fileExists(atPath: url.path) else { throw LocalBookError.missingFile }
let fileSize = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0