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
+399
View File
@@ -0,0 +1,399 @@
import Foundation
import ImageIO
import SwiftSoup
import ZIPFoundation
public enum EPUBError: Error, LocalizedError, Sendable {
case invalidArchive, invalidPackage, missingResource(String), encrypted, resourceTooLarge, invalidChapter
public var errorDescription: String? {
switch self {
case .invalidArchive: "EPUB 文件已损坏或不是有效的压缩包"
case .invalidPackage: "EPUB 缺少有效的书籍信息或阅读顺序"
case .missingResource(let path): "EPUB 中缺少文件:\(path)"
case .encrypted: "此 EPUB 的正文或图片已加密,暂不支持读取"
case .resourceTooLarge: "EPUB 单个章节或资源过大,无法安全读取"
case .invalidChapter: "EPUB 章节或目录锚点无效,请重新打开书籍"
}
}
}
public struct EPUBIllustration: Identifiable, Sendable {
public let id: Int
public let caption: String
public let data: Data
}
/// Reads resources on demand without unpacking untrusted archive paths to disk.
/// Navigation follows the spine; EPUB 2 NCX and EPUB 3 nav supply chapter labels/anchors.
struct EPUBBook {
private struct Item {
let id: String
let path: String
let mediaType: String
let properties: Set<String>
}
private struct Reference {
let path: String
let fragment: String?
let title: String
}
private let archive: Archive
private let entries: [String: Entry]
private let package: EPUBXMLNode
private let packagePath: String
private let manifest: [Item]
private let spine: [Item]
private let encryptedPaths: Set<String>
private static let resourceLimit = 32 * 1_024 * 1_024
init(url: URL) throws {
let archive: Archive
do { archive = try Archive(url: url, accessMode: .read) }
catch { throw EPUBError.invalidArchive }
var entries: [String: Entry] = [:]
for entry in archive {
guard entries.count < 50_000 else { throw EPUBError.resourceTooLarge }
// Directories and symlinks are never used as book resources.
guard entry.type == .file else { continue }
guard Self.safePath(entry.path), entries[entry.path] == nil else { throw EPUBError.invalidArchive }
entries[entry.path] = entry
}
self.archive = archive
self.entries = entries
let container = try EPUBXMLNode.parse(Self.read("META-INF/container.xml", archive: archive, entries: entries))
guard let rootfile = container.descendants("rootfile").first,
let fullPath = rootfile.attributes["full-path"],
let path = Self.resolve(fullPath, relativeTo: "")?.path else { throw EPUBError.invalidPackage }
packagePath = path
package = try EPUBXMLNode.parse(Self.read(path, archive: archive, entries: entries))
guard package.name == "package", let manifestNode = package.children.first(where: { $0.name == "manifest" }),
let spineNode = package.children.first(where: { $0.name == "spine" }) else { throw EPUBError.invalidPackage }
manifest = manifestNode.children.compactMap { node in
guard node.name == "item", let id = node.attributes["id"], let href = node.attributes["href"],
let ref = Self.resolve(href, relativeTo: path) else { return nil }
return Item(id: id, path: ref.path, mediaType: node.attributes["media-type"] ?? "",
properties: Set((node.attributes["properties"] ?? "").split(whereSeparator: \.isWhitespace).map(String.init)))
}
let items = manifest
let readable = spineNode.children.filter { $0.name == "itemref" && $0.attributes["linear"] != "no" }
spine = try readable.map { node in
guard let item = items.first(where: { $0.id == node.attributes["idref"] }),
["application/xhtml+xml", "text/html"].contains(item.mediaType), entries[item.path] != nil else {
throw EPUBError.invalidPackage
}
return item
}
guard !spine.isEmpty else { throw EPUBError.invalidPackage }
if entries["META-INF/encryption.xml"] != nil {
let encryption = try EPUBXMLNode.parse(Self.read("META-INF/encryption.xml", archive: archive, entries: entries))
encryptedPaths = Set(encryption.descendants("CipherReference").compactMap {
$0.attributes["URI"].flatMap { Self.resolve($0, relativeTo: "")?.path }
})
} else { encryptedPaths = [] }
// Font obfuscation is common and does not prevent text reading.
guard !spine.contains(where: { encryptedPaths.contains($0.path) }) else { throw EPUBError.encrypted }
}
func report(url: URL, originalName: String) throws -> LocalBookImportReport {
let metadata = package.children.first { $0.name == "metadata" }
let title = metadata?.descendants("title").first?.text.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let authors = metadata?.descendants("creator").map { $0.text.trimmingCharacters(in: .whitespacesAndNewlines) }.filter { !$0.isEmpty } ?? []
var book = Book(bookUrl: url.absoluteString, name: title.isEmpty ? (originalName as NSString).deletingPathExtension : title,
author: authors.joined(separator: "、"), origin: "local")
book.originName = originalName
book.intro = MetadataTextSanitizer().clean(metadata?.descendants("description").first?.text)
book.canUpdate = false
var configuration = BookReadConfig()
configuration.useReplaceRule = false
book.readConfig = configuration
let coverID = metadata?.descendants("meta").first { $0.attributes["name"] == "cover" }?.attributes["content"]
if let cover = manifest.first(where: { $0.properties.contains("cover-image") }) ?? manifest.first(where: { $0.id == coverID }),
["image/jpeg", "image/png", "image/gif", "image/webp"].contains(cover.mediaType),
let entry = entries[cover.path], entry.uncompressedSize <= 2 * 1_024 * 1_024,
let data = try? read(cover.path), let thumbnail = Self.coverThumbnail(data) {
// A bounded embedded thumbnail survives file renames, backups and cache eviction.
book.coverUrl = "data:image/png;base64,\(thumbnail.base64EncodedString())"
}
let chapters = try chapters(book: book)
book.totalChapterNum = chapters.count
book.latestChapterTitle = chapters.last?.title
return .init(book: book, chapters: chapters)
}
func chapters(book: Book) throws -> [BookChapter] {
let references = navigation()
var chapters: [BookChapter] = []
for item in spine where !item.properties.contains("nav") {
let document = try document(item.path)
guard let body = document.body() else { continue }
let elements = try body.getAllElements().array()
var positions: [String: Int] = [:]
for (index, element) in elements.enumerated() {
for key in [element.id(), (try? element.attr("name")) ?? ""] where !key.isEmpty && positions[key] == nil {
positions[key] = index
}
}
var seen = Set<String>()
let refs = references.filter { $0.path == item.path }
.filter { ref in
guard ref.fragment == nil || positions[ref.fragment!] != nil else { return false }
return seen.insert(ref.fragment ?? "").inserted
}
.sorted { (positions[$0.fragment ?? ""] ?? -1) < (positions[$1.fragment ?? ""] ?? -1) }
let documentTitle = try document.title().trimmingCharacters(in: .whitespacesAndNewlines)
let heading = try body.select("h1,h2,h3").first()?.text() ?? ""
let fallback = !documentTitle.isEmpty ? documentTitle : (!heading.isEmpty ? heading : "第\(chapters.count + 1)章")
var boundaries = refs
if boundaries.isEmpty {
boundaries = [Reference(path: item.path, fragment: nil, title: fallback)]
} else if let first = boundaries.first, let fragment = first.fragment {
let prefix = try render(body: body, from: nil, to: fragment).text
if !prefix.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
boundaries.insert(Reference(path: item.path, fragment: nil, title: fallback), at: 0)
}
}
for (index, ref) in boundaries.enumerated() {
var chapter = BookChapter(url: Self.referenceURL(path: item.path, fragment: ref.fragment),
title: ref.title.isEmpty ? fallback : ref.title, bookUrl: book.bookUrl, index: chapters.count)
chapter.resourceUrl = item.path
chapter.startFragmentId = ref.fragment
chapter.endFragmentId = boundaries.indices.contains(index + 1) ? boundaries[index + 1].fragment : nil
chapters.append(chapter)
}
}
guard !chapters.isEmpty else { throw EPUBError.invalidPackage }
return chapters
}
func content(chapter: BookChapter) throws -> ChapterContentReport {
let (body, _) = try body(chapter: chapter)
let value = try render(body: body, from: chapter.startFragmentId, to: chapter.endFragmentId)
return .init(title: chapter.title, content: value.text, pagesVisited: 1)
}
func illustrations(chapter: BookChapter) throws -> [EPUBIllustration] {
let (body, path) = try body(chapter: chapter)
let value = try render(body: body, from: chapter.startFragmentId, to: chapter.endFragmentId)
var totalBytes = 0
return try value.images.enumerated().map { index, image in
guard let ref = Self.resolve(image.src, relativeTo: path),
let item = manifest.first(where: { $0.path == ref.path }), item.mediaType.hasPrefix("image/") else {
throw EPUBError.missingResource(image.src)
}
let data = try read(ref.path)
totalBytes += data.count
guard totalBytes <= Self.resourceLimit else { throw EPUBError.resourceTooLarge }
return EPUBIllustration(id: index + 1, caption: image.alt, data: data)
}
}
private func body(chapter: BookChapter) throws -> (Element, String) {
guard let path = chapter.resourceUrl, spine.contains(where: { $0.path == path }),
let body = try document(path).body() else { throw EPUBError.invalidChapter }
return (body, path)
}
private func document(_ path: String) throws -> Document {
let data = try read(path)
let text: String
if data.starts(with: [0xff, 0xfe]) || data.starts(with: [0xfe, 0xff]), let utf16 = String(data: data, encoding: .utf16) {
text = utf16
} else if let utf8 = String(data: data, encoding: .utf8) { text = utf8 }
else { throw EPUBError.invalidPackage }
return try SwiftSoup.parse(text)
}
private func navigation() -> [Reference] {
if let nav = manifest.first(where: { $0.properties.contains("nav") }), let doc = try? document(nav.path),
let nodes = try? doc.select("nav").array() {
let toc = nodes.first { node in
let type = (try? node.attr("epub:type")) ?? ""
return type.split(whereSeparator: \.isWhitespace).contains("toc") || (try? node.attr("role")) == "doc-toc"
}
if let links = try? toc?.select("a[href]").array() {
let refs = links.compactMap { element -> Reference? in
guard let href = try? element.attr("href"), let ref = Self.resolve(href, relativeTo: nav.path) else { return nil }
return Reference(path: ref.path, fragment: ref.fragment, title: (try? element.text()) ?? "")
}
if !refs.isEmpty { return refs }
}
}
let tocID = package.children.first { $0.name == "spine" }?.attributes["toc"]
if let ncx = manifest.first(where: { $0.id == tocID }) ?? manifest.first(where: { $0.mediaType == "application/x-dtbncx+xml" }),
let data = try? read(ncx.path), let root = try? EPUBXMLNode.parse(data) {
return root.descendants("navPoint").compactMap { node in
guard let href = node.children.first(where: { $0.name == "content" })?.attributes["src"],
let ref = Self.resolve(href, relativeTo: ncx.path) else { return nil }
return Reference(path: ref.path, fragment: ref.fragment,
title: node.children.first(where: { $0.name == "navLabel" })?.text.trimmingCharacters(in: .whitespacesAndNewlines) ?? "")
}
}
return []
}
/// Walk DOM boundaries rather than slicing serialized HTML: anchors can be nested
/// in different parents, and all text after the end anchor belongs to the next chapter.
private func render(body: Element, from start: String?, to end: String?) throws -> (text: String, images: [(src: String, alt: String)]) {
var active = start == nil
var foundStart = start == nil
var foundEnd = end == nil
var finished = false
var output = ""
var images: [(src: String, alt: String)] = []
let blocks: Set<String> = ["p", "div", "section", "article", "li", "ul", "ol", "h1", "h2", "h3", "h4", "h5", "h6", "blockquote", "pre", "tr", "hr"]
func visit(_ node: Node, depth: Int) throws {
guard depth < 512 else { throw EPUBError.invalidPackage }
guard !finished else { return }
guard let element = node as? Element else {
if active, let text = node as? TextNode {
output += text.getWholeText().replacingOccurrences(of: #"\s+"#, with: " ", options: .regularExpression)
}
return
}
let id = element.id()
let name = try element.attr("name")
if let end, id == end || name == end { active = false; finished = true; foundEnd = true; return }
if let start, id == start || name == start { active = true; foundStart = true }
let tag = element.tagName().lowercased()
let style = try element.attr("style").lowercased().replacingOccurrences(of: #"\s+"#, with: "", options: .regularExpression)
if ["script", "style", "noscript", "rt", "rp", "title"].contains(tag) || element.hasAttr("hidden") || style.contains("display:none") { return }
if active, blocks.contains(tag) || tag == "br" { output += "\n" }
if active, tag == "img" || tag == "image" {
var src = try element.attr(tag == "img" ? "src" : "xlink:href")
if src.isEmpty, tag == "image" { src = try element.attr("href") }
let alt = try element.attr("alt")
if !src.isEmpty {
images.append((src, alt))
output += "\n【插图 \(images.count)\(alt.isEmpty ? "" : ":" + alt)】\n"
}
}
for child in element.getChildNodes() { try visit(child, depth: depth + 1) }
if active, blocks.contains(tag) { output += "\n" }
if active, tag == "td" || tag == "th" { output += " " }
}
try visit(body, depth: 0)
guard foundStart && foundEnd else { throw EPUBError.invalidChapter }
let text = output.components(separatedBy: .newlines).map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }.joined(separator: "\n")
return (text, images)
}
private func read(_ path: String) throws -> Data {
guard !encryptedPaths.contains(path) else { throw EPUBError.encrypted }
return try Self.read(path, archive: archive, entries: entries)
}
private static func read(_ path: String, archive: Archive, entries: [String: Entry]) throws -> Data {
guard let entry = entries[path] else { throw EPUBError.missingResource(path) }
guard entry.uncompressedSize <= resourceLimit else { throw EPUBError.resourceTooLarge }
var result = Data()
let checksum = try archive.extract(entry) { chunk in
guard result.count + chunk.count <= resourceLimit else { throw EPUBError.resourceTooLarge }
result.append(chunk)
}
guard checksum == entry.checksum, result.count == entry.uncompressedSize else { throw EPUBError.invalidArchive }
return result
}
private static func coverThumbnail(_ data: Data) -> Data? {
guard let source = CGImageSourceCreateWithData(data as CFData, nil),
let image = CGImageSourceCreateThumbnailAtIndex(source, 0, [
kCGImageSourceCreateThumbnailFromImageAlways: true,
kCGImageSourceThumbnailMaxPixelSize: 512,
kCGImageSourceCreateThumbnailWithTransform: true
] as CFDictionary) else { return nil }
let output = NSMutableData()
guard let destination = CGImageDestinationCreateWithData(output, "public.png" as CFString, 1, nil) else { return nil }
CGImageDestinationAddImage(destination, image, nil)
guard CGImageDestinationFinalize(destination) else { return nil }
return output as Data
}
private static func safePath(_ path: String) -> Bool {
!path.isEmpty && !path.hasPrefix("/") && !path.contains("\\") && !path.contains("\0") && !path.split(separator: "/").contains("..")
}
private static func resolve(_ href: String, relativeTo base: String) -> (path: String, fragment: String?)? {
guard !href.hasPrefix("/"), !href.contains("\\"),
URLComponents(string: href)?.scheme == nil else { return nil }
let parts = href.split(separator: "#", maxSplits: 1, omittingEmptySubsequences: false)
guard let pathPart = String(parts[0]).removingPercentEncoding else { return nil }
var components = pathPart.isEmpty ? base.split(separator: "/").map(String.init) : base.split(separator: "/").dropLast().map(String.init)
if !pathPart.isEmpty {
for part in pathPart.split(separator: "/") {
if part == "." { continue }
if part == ".." { guard !components.isEmpty else { return nil }; components.removeLast() }
else { components.append(String(part)) }
}
}
let path = components.joined(separator: "/")
guard safePath(path), !pathPart.hasPrefix("/") else { return nil }
let fragment = parts.count > 1 ? String(parts[1]).removingPercentEncoding : nil
return (path, fragment?.isEmpty == false ? fragment : nil)
}
private static func referenceURL(path: String, fragment: String?) -> String {
var url = URLComponents()
url.scheme = "epub"
url.host = "book"
url.path = "/" + path
url.fragment = fragment
return url.string ?? path
}
}
/// Small namespace-independent XML tree for package metadata and navigation.
private final class EPUBXMLNode: NSObject, XMLParserDelegate {
var name = ""
var attributes: [String: String] = [:]
var children: [EPUBXMLNode] = []
var text = ""
private var stack: [EPUBXMLNode] = []
private var count = 0
func descendants(_ name: String) -> [EPUBXMLNode] {
children.flatMap { ($0.name == name ? [$0] : []) + $0.descendants(name) }
}
static func parse(_ data: Data) throws -> EPUBXMLNode {
let delegate = EPUBXMLNode()
let parser = XMLParser(data: data)
parser.shouldResolveExternalEntities = false
parser.delegate = delegate
guard parser.parse(), let root = delegate.children.first else { throw EPUBError.invalidPackage }
return root
}
func parser(_ parser: XMLParser, didStartElement elementName: String, namespaceURI: String?, qualifiedName qName: String?, attributes attributeDict: [String: String]) {
count += 1
guard stack.count < 128, count <= 50_000 else { parser.abortParsing(); return }
let node = EPUBXMLNode()
node.name = elementName.split(separator: ":").last.map(String.init) ?? elementName
node.attributes = attributeDict
(stack.last ?? self).children.append(node)
stack.append(node)
}
func parser(_ parser: XMLParser, foundCharacters string: String) {
for node in stack {
guard node.text.utf8.count + string.utf8.count <= 4 * 1_024 * 1_024 else { parser.abortParsing(); return }
node.text += string
}
}
func parser(_ parser: XMLParser, foundInternalEntityDeclarationWithName name: String, value: String?) {
parser.abortParsing()
}
func parser(_ parser: XMLParser, foundExternalEntityDeclarationWithName name: String, publicID: String?, systemID: String?) {
parser.abortParsing()
}
func parser(_ parser: XMLParser, foundCDATA CDATABlock: Data) {
if let value = String(data: CDATABlock, encoding: .utf8) { self.parser(parser, foundCharacters: value) }
}
func parser(_ parser: XMLParser, didEndElement elementName: String, namespaceURI: String?, qualifiedName qName: String?) {
_ = stack.popLast()
}
}
@@ -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
@@ -32,7 +32,7 @@ public struct StagedLocalFile: Sendable {
public actor LocalFileService {
public let directory: URL
public init(directory: URL) { self.directory = directory.standardizedFileURL }
public init(directory: URL) { self.directory = URL(fileURLWithPath: directory.path, isDirectory: true).standardizedFileURL }
public func list() throws -> [LocalFileEntry] {
guard FileManager.default.fileExists(atPath: directory.path) else { return [] }
@@ -41,13 +41,13 @@ public actor LocalFileService {
options: [.skipsHiddenFiles]
).compactMap { url in
let values = try url.resourceValues(forKeys: [.isRegularFileKey, .isSymbolicLinkKey, .fileSizeKey, .contentModificationDateKey])
guard values.isRegularFile == true, values.isSymbolicLink != true, url.pathExtension.lowercased() == "txt" else { return nil }
guard values.isRegularFile == true, values.isSymbolicLink != true, LocalBookService.supports(url) else { return nil }
return .init(url: url, bytes: Int64(values.fileSize ?? 0), modified: values.contentModificationDate)
}.sorted { $0.url.lastPathComponent.localizedStandardCompare($1.url.lastPathComponent) == .orderedAscending }
}
public func importFile(from source: URL) throws -> URL {
guard source.pathExtension.lowercased() == "txt" else { throw LocalBookError.unsupportedFormat }
guard LocalBookService.supports(source) else { throw LocalBookError.unsupportedFormat }
let values = try source.resourceValues(forKeys: [.isRegularFileKey])
guard values.isRegularFile == true else { throw LocalFileError.missingFile }
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
@@ -55,7 +55,7 @@ public actor LocalFileService {
var target = directory.appendingPathComponent(source.lastPathComponent)
var suffix = 2
while FileManager.default.fileExists(atPath: target.path) {
target = directory.appendingPathComponent("\(stem) (\(suffix)).txt")
target = directory.appendingPathComponent("\(stem) (\(suffix)).\(source.pathExtension)")
suffix += 1
}
try FileManager.default.copyItem(at: source, to: target)
@@ -69,7 +69,8 @@ public actor LocalFileService {
!trimmed.contains(where: { $0 == "/" || $0 == "\\" || $0.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains) }) else {
throw LocalFileError.invalidName
}
let filename = trimmed.lowercased().hasSuffix(".txt") ? trimmed : trimmed + ".txt"
let suffix = "." + source.pathExtension
let filename = trimmed.lowercased().hasSuffix(suffix.lowercased()) ? trimmed : trimmed + suffix
guard filename.utf8.count <= 255 else { throw LocalFileError.invalidName }
let target = directory.appendingPathComponent(filename)
if source.standardizedFileURL == target.standardizedFileURL { return source }
@@ -83,7 +84,7 @@ public actor LocalFileService {
guard FileManager.default.fileExists(atPath: source.path) else { return nil }
let trash = directory.appendingPathComponent(".Trash", isDirectory: true)
try FileManager.default.createDirectory(at: trash, withIntermediateDirectories: true)
let staged = trash.appendingPathComponent(UUID().uuidString + ".txt")
let staged = trash.appendingPathComponent(UUID().uuidString + "." + source.pathExtension)
try FileManager.default.moveItem(at: source, to: staged)
return .init(original: source, staged: staged)
}
@@ -104,7 +105,7 @@ public actor LocalFileService {
}
private func validate(_ url: URL, allowMissing: Bool = false) throws {
guard url.isFileURL, url.pathExtension.lowercased() == "txt",
guard url.isFileURL, LocalBookService.supports(url),
url.standardizedFileURL.deletingLastPathComponent() == directory,
url.resolvingSymlinksInPath().deletingLastPathComponent() == directory.resolvingSymlinksInPath() else {
throw LocalFileError.outsideLibrary
@@ -131,7 +132,7 @@ public actor BookFileExporter {
}
public func originalFile(_ source: URL, filename: String) throws -> BookFileExport {
let target = try destination(filename: filename)
let target = try destination(filename: filename, extension: source.pathExtension.lowercased())
do {
try FileManager.default.copyItem(at: source, to: target)
return .init(url: target, cachedChapters: 0, totalChapters: 0)
@@ -178,11 +179,11 @@ public actor BookFileExporter {
try FileManager.default.removeItem(at: export.url.deletingLastPathComponent())
}
private func destination(filename: String) throws -> URL {
private func destination(filename: String, extension fileExtension: String = "txt") throws -> URL {
let folder = directory.appendingPathComponent(UUID().uuidString, isDirectory: true)
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
let safe = filename.components(separatedBy: CharacterSet(charactersIn: "/\\:").union(.controlCharacters)).joined(separator: "_")
let stem = String(safe.replacingOccurrences(of: ".txt", with: "", options: [.caseInsensitive, .anchored, .backwards]).prefix(70))
return folder.appendingPathComponent((stem.isEmpty ? "书籍" : stem) + ".txt")
let stem = String(safe.replacingOccurrences(of: "." + fileExtension, with: "", options: [.caseInsensitive, .anchored, .backwards]).prefix(70))
return folder.appendingPathComponent((stem.isEmpty ? "书籍" : stem) + "." + fileExtension)
}
}