feat: open-source 小浣熊阅读 for iOS

This commit is contained in:
Aminark
2026-10-14 11:37:36 +08:00
commit ffc9e9d91d
193 changed files with 41382 additions and 0 deletions
@@ -0,0 +1,767 @@
import CoreFoundation
import CryptoKit
import Foundation
public enum LocalBookError: Error, LocalizedError, Sendable {
case unsupportedFormat
case oversized
case unreadableEncoding
case missingFile
public var errorDescription: String? {
switch self {
case .unsupportedFormat: "当前仅支持 TXT 本地书"
case .oversized: "本地书超过当前配置允许的大小"
case .unreadableEncoding: "无法识别 TXT 编码"
case .missingFile: "本地书文件已丢失"
}
}
}
public struct LocalBookImportReport: Sendable {
public var book: Book
public var chapters: [BookChapter]
public init(book: Book, chapters: [BookChapter]) {
self.book = book
self.chapters = chapters
}
}
public enum TxtTocRuleSelector {
public static let separator = "🫅🈳🏻"
public static func select(
content: String,
rules: [TxtTocRule],
transformTitle: (TxtTocRule, String, Int, String?, Int) -> String = { _, value, _, _, _ in value }
) -> TxtTocRule? {
let source = content as NSString
var maximum = -1
var selected: TxtTocRule?
for rule in rules.filter(\.enable).sorted(by: { $0.serialNumber < $1.serialNumber }) {
guard !rule.rule.isEmpty else { continue }
guard let expression = try? NSRegularExpression(pattern: rule.rule, options: .anchorsMatchLines) else { continue }
var start = 0
var validCount = 0
var shortGapCount = 0
var lastTitle: String?
for match in expression.matches(in: content, range: NSRange(location: 0, length: source.length)) {
let length = match.range.location - start
if start == 0 || length > 1_000 {
let rawTitle = source.substring(with: match.range)
let title = transformTitle(rule, rawTitle, validCount, lastTitle, length)
if !title.isEmpty {
lastTitle = title
validCount += 1
}
start = NSMaxRange(match.range)
} else if length < 100 {
shortGapCount += 1
}
}
if validCount >= shortGapCount * 3, validCount > maximum + 2 {
maximum = validCount
selected = rule
if maximum > 70 { break }
}
}
return selected
}
}
public enum TxtTocRuleDefaults {
public static func load(bundle: Bundle = .main) -> [TxtTocRule] {
guard let url = bundle.url(forResource: "txtTocRule", withExtension: "json"),
let data = try? Data(contentsOf: url),
let values = try? JSONDecoder().decode([TxtTocRule].self, from: data) else { return [] }
return values.sorted { $0.serialNumber < $1.serialNumber }
}
}
private final class TxtVolumeState {
var lastTitle = ""
}
private struct TxtHeadingMatch {
var title: String
var start: Int64
var end: Int64
var previousLength: Int
}
public struct LocalBookService: Sendable {
public var maximumFileSize: Int64
public var tocRules: [TxtTocRule]
private var loadsLiveTocRules = false
public init(maximumFileSize: Int64 = .max, tocRules: [TxtTocRule] = []) {
self.maximumFileSize = maximumFileSize
self.tocRules = tocRules.filter(\.enable).sorted { $0.serialNumber < $1.serialNumber }
}
public static func live() -> LocalBookService {
LocalBookService(tocRules: TxtTocRuleDefaults.load())
}
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])
guard values.isRegularFile == true else { throw LocalBookError.missingFile }
if Int64(values.fileSize ?? 0) > maximumFileSize { throw LocalBookError.oversized }
let data = try Data(contentsOf: sourceURL, options: .mappedIfSafe)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let destination = directory.appendingPathComponent("\(UUID().uuidString).txt")
try data.write(to: destination, options: .atomic)
do {
return try textFileReport(data: data, sourceURL: sourceURL, destination: destination)
} catch {
try? FileManager.default.removeItem(at: destination)
throw error
}
}
public func openTextFile(at url: URL) throws -> LocalBookImportReport {
guard url.pathExtension.lowercased() == "txt" else { throw LocalBookError.unsupportedFormat }
let values = try url.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey])
guard values.isRegularFile == true else { throw LocalBookError.missingFile }
guard Int64(values.fileSize ?? 0) <= maximumFileSize else { throw LocalBookError.oversized }
return try textFileReport(data: Data(contentsOf: url, options: .mappedIfSafe), sourceURL: url, destination: url)
}
private func textFileReport(data: Data, sourceURL: URL, destination: URL) throws -> LocalBookImportReport {
let decoded = try decode(Data(data.prefix(512_000)))
let name = sourceURL.deletingPathExtension().lastPathComponent
var book = Book(bookUrl: destination.absoluteString, name: name, origin: "local")
book.originName = sourceURL.lastPathComponent
book.charset = decoded.charset
book.canUpdate = false
let selectionVolumeState = TxtVolumeState()
let probeText = textProbe(data, encoding: decoded.encoding)
let selectedRule = TxtTocRuleSelector.select(content: probeText, rules: effectiveTocRules()) {
rule, title, index, previous, length in
transformedTitle(title, script: rule.replacement, index: index, previousTitle: previous, previousLength: length, book: book, volumeState: selectionVolumeState)
}
if let selectedRule {
book.tocUrl = selectedRule.rule + TxtTocRuleSelector.separator + selectedRule.replacement
}
let fullBOMLength = bomLength(in: data, encoding: decoded.encoding)
let hasTOC = !headingMatches(
data: data,
encoding: decoded.encoding,
bomLength: fullBOMLength,
pattern: selectedRule?.rule ?? fallbackTocPattern,
usesFirstCapture: selectedRule == nil
).isEmpty
var chapters = chapters(
data: data,
encoding: decoded.encoding,
bomLength: fullBOMLength,
book: book,
fallbackTitle: name,
selectedRule: selectedRule
)
let totalWordCount = applyWordCounts(&chapters, data: data, encoding: decoded.encoding)
book.wordCount = wordCountFormat(totalWordCount)
if hasTOC,
let first = chapters.first(where: { !$0.isVolume && $0.start == Int64(fullBOMLength) }),
first.start == Int64(fullBOMLength),
let endValue = first.end,
endValue > Int64(fullBOMLength),
let preface = String(
data: data.subdata(in: fullBOMLength..<min(Int(endValue), data.count)),
encoding: decoded.encoding
) {
book.intro = preface.utf16.count > 600 ? String(preface.prefix(600)) : preface
}
book.totalChapterNum = chapters.count
book.latestChapterTitle = chapters.last?.title
return .init(book: book, chapters: chapters)
}
public func loadChapters(book: Book) throws -> ChapterListReport {
let decoded = try read(book)
let selectionVolumeState = TxtVolumeState()
let probeText = textProbe(decoded.data, encoding: decoded.encoding)
let selectedRule = rule(storedIn: book.tocUrl) ?? TxtTocRuleSelector.select(content: probeText, rules: effectiveTocRules()) {
rule, title, index, previous, length in
transformedTitle(title, script: rule.replacement, index: index, previousTitle: previous, previousLength: length, book: book, volumeState: selectionVolumeState)
}
var chapters = chapters(
data: decoded.data,
encoding: decoded.encoding,
bomLength: decoded.bomLength,
book: book,
fallbackTitle: book.name,
selectedRule: selectedRule
)
_ = applyWordCounts(&chapters, data: decoded.data, encoding: decoded.encoding)
return .init(chapters: chapters)
}
public func loadContent(book: Book, chapter: BookChapter) throws -> ChapterContentReport {
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
let start = max(Int(chapter.start ?? 0), 0)
let end = min(Int(chapter.end ?? Int64(fileSize)), fileSize)
guard start <= end else {
return .init(title: chapter.title, diagnostics: [
.init(severity: .error, code: "local.range", message: "本地章节范围无效", rule: chapter.url)
])
}
let handle = try FileHandle(forReadingFrom: url)
defer { try? handle.close() }
try handle.seek(toOffset: UInt64(start))
let contentData = try handle.read(upToCount: end - start) ?? Data()
let encoding: String.Encoding
if let charset = book.charset, let stored = self.encoding(named: charset) {
encoding = stored
} else {
try handle.seek(toOffset: 0)
let probe = try handle.read(upToCount: 512_000) ?? Data()
encoding = try decode(probe).encoding
}
let content = (String(data: contentData, encoding: encoding) ?? "")
.replacingOccurrences(of: #"^[\n\s]+"#, with: "  ", options: .regularExpression)
return .init(title: chapter.title, content: content, pagesVisited: 1)
}
private func read(_ book: Book) throws -> (charset: String, encoding: String.Encoding, bomLength: Int, data: Data) {
guard let url = URL(string: book.bookUrl), url.isFileURL,
FileManager.default.fileExists(atPath: url.path) else { throw LocalBookError.missingFile }
let data = try Data(contentsOf: url, options: .mappedIfSafe)
if let charset = book.charset, let encoding = encoding(named: charset) {
return (charset, encoding, bomLength(in: data, encoding: encoding), data)
}
let decoded = try decode(Data(data.prefix(512_000)))
return (decoded.charset, decoded.encoding, bomLength(in: data, encoding: decoded.encoding), data)
}
private func decode(_ data: Data, preferredCharset: String? = nil) throws -> (text: String, charset: String, encoding: String.Encoding, bomLength: Int) {
if let preferredCharset, let encoding = encoding(named: preferredCharset),
let value = String(data: data, encoding: encoding) {
return (stripBOM(value), preferredCharset, encoding, bomLength(in: data, encoding: encoding))
}
if data.starts(with: [0xFF, 0xFE]),
let value = String(data: data, encoding: .utf16LittleEndian) {
return (stripBOM(value), "UTF-16LE", .utf16LittleEndian, 2)
}
if data.starts(with: [0xFE, 0xFF]),
let value = String(data: data, encoding: .utf16BigEndian) {
return (stripBOM(value), "UTF-16BE", .utf16BigEndian, 2)
}
let candidates: [(String, String.Encoding)] = [
("UTF-8", .utf8),
("UTF-16", .utf16),
("GB18030", .gb18030),
("Big5", .big5)
]
for (name, encoding) in candidates {
if let value = String(data: data, encoding: encoding), isPlausible(value) {
return (stripBOM(value), name, encoding, bomLength(in: data, encoding: encoding))
}
// A bounded probe can end halfway through a UTF-8 scalar. Keep
// UTF-8 as the selected charset instead of misclassifying the
// truncated probe as UTF-16 and producing unreadable chapters.
if encoding == .utf8 {
let value = String(decoding: data, as: UTF8.self)
let replacementCount = value.reduce(into: 0) { count, character in
if character == "\u{FFFD}" { count += 1 }
}
if replacementCount <= 2, isPlausible(value) {
return (stripBOM(value), name, encoding, bomLength(in: data, encoding: encoding))
}
}
}
throw LocalBookError.unreadableEncoding
}
private func chapters(
data: Data,
encoding: String.Encoding,
bomLength: Int,
book: Book,
fallbackTitle: String,
selectedRule: TxtTocRule?
) -> [BookChapter] {
let pattern = selectedRule?.rule ?? fallbackTocPattern
let matches = headingMatches(
data: data,
encoding: encoding,
bomLength: bomLength,
pattern: pattern,
usesFirstCapture: selectedRule == nil
)
guard !matches.isEmpty else {
if data.count - bomLength > 10 * 1_024 {
return chaptersWithoutTOC(data, encoding: encoding, bomLength: bomLength, book: book)
}
var chapter = BookChapter(url: "local://chapter/0", title: fallbackTitle, bookUrl: book.bookUrl, index: 0)
chapter.start = Int64(bomLength)
chapter.end = Int64(data.count)
chapter.baseUrl = book.bookUrl
return finalized([chapter], book: book)
}
var previousTitle: String?
var chapters: [BookChapter] = []
let volumeState = TxtVolumeState()
if let first = matches.first,
first.start > Int64(bomLength),
let preface = String(
data: data.subdata(in: bomLength..<min(Int(first.start), data.count)),
encoding: encoding
),
!preface.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
var insertedVolumes: [String] = []
let title = transformedTitle(
"前言",
script: selectedRule?.replacement,
index: chapters.count,
book: book,
volumeState: volumeState,
onVolume: { insertedVolumes.append($0) }
)
for volumeTitle in insertedVolumes {
var volume = BookChapter(url: "", title: volumeTitle, bookUrl: book.bookUrl, index: chapters.count)
volume.start = Int64(bomLength)
volume.end = Int64(bomLength)
volume.baseUrl = book.bookUrl
volume.isVolume = true
chapters.append(volume)
}
if !title.isEmpty {
var chapter = BookChapter(url: "", title: title, bookUrl: book.bookUrl, index: chapters.count)
chapter.start = Int64(bomLength)
chapter.end = first.start
chapter.baseUrl = book.bookUrl
chapters.append(chapter)
}
}
for (matchIndex, match) in matches.enumerated() {
splitLastLongChapterIfNeeded(
&chapters,
data: data,
encoding: encoding,
book: book,
volumeState: volumeState,
enabled: selectedRule != nil
)
var insertedVolumes: [String] = []
let title = transformedTitle(
match.title.isEmpty ? "第 \(matchIndex + 1) 章" : match.title,
script: selectedRule?.replacement,
index: chapters.count,
previousTitle: previousTitle,
previousLength: match.previousLength,
book: book,
volumeState: volumeState,
onVolume: { insertedVolumes.append($0) }
)
previousTitle = title
let start = match.end
let end = matchIndex + 1 < matches.count ? matches[matchIndex + 1].start : Int64(data.count)
for volumeTitle in insertedVolumes {
let offset = chapters.last?.end ?? 0
var volume = BookChapter(url: "local://chapter/\(chapters.count)", title: volumeTitle, bookUrl: book.bookUrl, index: chapters.count)
volume.start = offset
volume.end = offset
volume.baseUrl = book.bookUrl
volume.isVolume = true
chapters.append(volume)
}
var chapter = BookChapter(url: "local://chapter/\(chapters.count)", title: title, bookUrl: book.bookUrl, index: chapters.count)
chapter.start = start
chapter.end = max(end, start)
chapter.baseUrl = book.bookUrl
chapters.append(chapter)
}
splitLastLongChapterIfNeeded(
&chapters,
data: data,
encoding: encoding,
book: book,
volumeState: volumeState,
enabled: selectedRule != nil
)
return finalized(chapters, book: book)
}
private var fallbackTocPattern: String {
#"(?mi)^[\t ]*((?:第[0-9零〇一二三四五六七八九十百千万两]+[章节卷回部篇集][^\r\n]{0,50})|(?:chapter\s+\d+[^\r\n]{0,50}))[\t ]*$"#
}
private func splitLastLongChapterIfNeeded(
_ chapters: inout [BookChapter],
data: Data,
encoding: String.Encoding,
book: Book,
volumeState: TxtVolumeState,
enabled: Bool
) {
guard enabled, book.readConfig?.splitLongChapter ?? true,
var chapter = chapters.last,
!chapter.isVolume,
let startValue = chapter.start,
let endValue = chapter.end,
endValue - startValue > 102_400 else { return }
let start = max(Int(startValue), 0)
let end = min(max(Int(endValue), start), data.count)
chapters.removeLast()
chapter.end = chapter.start
chapter.isVolume = true
chapter.tag = nil
chapters.append(chapter)
volumeState.lastTitle = chapter.title
chapters.append(contentsOf: splitByteRange(
data,
start: start,
end: end,
encoding: encoding,
book: book,
title: { "\(chapter.title)(\($0 + 1))" }
))
}
private func splitByteRange(
_ data: Data,
start initialStart: Int,
end rangeEnd: Int,
encoding: String.Encoding,
book: Book,
title: (Int) -> String
) -> [BookChapter] {
splitHeadinglessRange(data, start: initialStart, end: rangeEnd, encoding: encoding, book: book) { _, _, index in title(index) }
}
private func headingMatches(
data: Data,
encoding: String.Encoding,
bomLength: Int,
pattern: String,
usesFirstCapture: Bool
) -> [TxtHeadingMatch] {
guard let expression = try? NSRegularExpression(pattern: pattern, options: .anchorsMatchLines) else { return [] }
if data.count <= 2 * 1_024 * 1_024,
bomLength <= data.count,
let text = String(data: data.subdata(in: bomLength..<data.count), encoding: encoding) {
let source = text as NSString
let offsets = byteOffsetMap(text, encoding: encoding, initialOffset: bomLength)
return expression.matches(in: text, range: NSRange(location: 0, length: source.length)).map { match in
let titleRange = usesFirstCapture && match.numberOfRanges > 1 ? match.range(at: 1) : match.range
let title = titleRange.location == NSNotFound ? "" : source.substring(with: titleRange)
let start = min(match.range.location, offsets.count - 1)
let end = min(NSMaxRange(match.range), offsets.count - 1)
return .init(title: title, start: offsets[start], end: offsets[end], previousLength: match.range.location)
}
}
let blockSize = 512_000
var position = bomLength
var output: [TxtHeadingMatch] = []
while position < data.count {
let limit = min(position + blockSize, data.count)
var end = limit
if limit < data.count,
let newline = newlineIndex(in: data, range: position..<limit, encoding: encoding, fromEnd: true),
newline > position {
end = newline
}
if end <= position { end = limit }
let blockData = data.subdata(in: position..<end)
guard let block = String(data: blockData, encoding: encoding) else {
position = end
continue
}
let source = block as NSString
let offsets = byteOffsetMap(block, encoding: encoding, initialOffset: position)
var previousEnd = 0
for match in expression.matches(in: block, range: NSRange(location: 0, length: source.length)) {
let titleRange = usesFirstCapture && match.numberOfRanges > 1 ? match.range(at: 1) : match.range
let title = titleRange.location == NSNotFound ? "" : source.substring(with: titleRange)
let startIndex = min(match.range.location, offsets.count - 1)
let endIndex = min(NSMaxRange(match.range), offsets.count - 1)
output.append(.init(
title: title,
start: offsets[startIndex],
end: offsets[endIndex],
previousLength: max(match.range.location - previousEnd, 0)
))
previousEnd = NSMaxRange(match.range)
}
position = end
}
return output
}
private func chaptersWithoutTOC(_ data: Data, encoding: String.Encoding, bomLength: Int, book: Book) -> [BookChapter] {
let chapters = splitHeadinglessRange(data, start: bomLength, end: data.count, encoding: encoding, book: book) {
block, chapter, _ in "第\(block)章(\(chapter))"
}
return finalized(chapters, book: book)
}
private func splitHeadinglessRange(
_ data: Data,
start rangeStart: Int,
end rangeEnd: Int,
encoding: String.Encoding,
book: Book,
title: (Int, Int, Int) -> String
) -> [BookChapter] {
let bufferSize = 512_000
let maximumLength = 10 * 1_024
var cursor = rangeStart
var block = 0
var output: [BookChapter] = []
var finalChapterNumber = 0
while cursor < rangeEnd {
block += 1
let blockEnd = min(cursor + bufferSize, rangeEnd)
var chapterInBlock = 0
while blockEnd - cursor > maximumLength {
chapterInBlock += 1
let searchStart = cursor + maximumLength
let newline = newlineIndex(
in: data,
range: searchStart..<blockEnd,
encoding: encoding,
fromEnd: false
)
let end = newline ?? blockEnd
var chapter = BookChapter(
url: "",
title: title(block, chapterInBlock, output.count),
bookUrl: book.bookUrl,
index: output.count
)
chapter.start = Int64(cursor)
chapter.end = Int64(end)
chapter.baseUrl = book.bookUrl
output.append(chapter)
cursor = end
}
finalChapterNumber = chapterInBlock + 1
if blockEnd == rangeEnd { break }
}
if cursor < rangeEnd {
if rangeEnd - cursor <= 100, !output.isEmpty {
output[output.count - 1].end = Int64(rangeEnd)
} else {
var chapter = BookChapter(
url: "",
title: title(block, finalChapterNumber, output.count),
bookUrl: book.bookUrl,
index: output.count
)
chapter.start = Int64(cursor)
chapter.end = Int64(rangeEnd)
chapter.baseUrl = book.bookUrl
output.append(chapter)
}
}
return output
}
@discardableResult
private func applyWordCounts(
_ chapters: inout [BookChapter],
data: Data,
encoding: String.Encoding
) -> Int {
var total = 0
for index in chapters.indices where !chapters[index].isVolume {
let start = max(Int(chapters[index].start ?? 0), 0)
let end = min(max(Int(chapters[index].end ?? Int64(start)), start), data.count)
guard start < end else {
chapters[index].wordCount = ""
continue
}
let text = String(data: data.subdata(in: start..<end), encoding: encoding) ?? ""
let count = text.utf16.count
chapters[index].wordCount = wordCountFormat(count)
total += count
}
return total
}
private func wordCountFormat(_ words: Int) -> String {
guard words > 0 else { return "" }
guard words > 10_000 else { return "\(words)字" }
let value = Double(words) / 10_000
let rounded = (value * 10).rounded() / 10
if rounded.rounded() == rounded { return "\(Int(rounded))万字" }
return String(format: "%.1f万字", rounded)
}
private func finalized(_ chapters: [BookChapter], book: Book) -> [BookChapter] {
chapters.enumerated().map { index, value in
var chapter = value
chapter.index = index
chapter.bookUrl = book.bookUrl
let seed = book.originName + String(index) + chapter.title
let digest = Insecure.MD5.hash(data: Data(seed.utf8)).map { String(format: "%02x", $0) }.joined()
chapter.url = String(digest.dropFirst(8).prefix(16))
return chapter
}
}
private func rule(storedIn value: String) -> TxtTocRule? {
guard !value.isEmpty else { return nil }
let parts = value.components(separatedBy: TxtTocRuleSelector.separator)
return .init(name: "本书目录规则", rule: parts[0], replacement: parts.dropFirst().first ?? "")
}
private func effectiveTocRules() -> [TxtTocRule] {
tocRules
}
private func transformedTitle(
_ value: String,
script: String?,
index: Int,
previousTitle: String? = nil,
previousLength: Int = -1,
book: Book,
volumeState: TxtVolumeState = .init(),
onVolume: ((String) -> Void)? = nil
) -> String {
guard let script = script?.trimmingCharacters(in: .whitespacesAndNewlines), !script.isEmpty else { return value }
let previous: String
if let previousTitle,
let data = try? JSONSerialization.data(withJSONObject: [previousTitle]),
let encoded = String(data: data, encoding: .utf8) {
previous = String(encoded.dropFirst().dropLast())
} else {
previous = "null"
}
let volumeLiteral = (try? JSONSerialization.data(withJSONObject: [volumeState.lastTitle]))
.flatMap { String(data: $0, encoding: .utf8) }
.map { String($0.dropFirst().dropLast()) } ?? "\"\""
let wrapped = """
var index = \(index + 1); var prevTitle = \(previous); var prevLength = \(previousLength);
var lastVolumeTitle = \(volumeLiteral);
\(script)
"""
guard let evaluated = try? JavaScriptRuleEvaluator().evaluate(
script: wrapped,
result: .string(value),
executionContext: .init(page: index + 1, book: book)
) else { return value }
let title = evaluated.value.stringValue
let volumes = evaluated.variables["__legadoVolumes"]
.flatMap { try? JSONDecoder().decode([String].self, from: Data($0.utf8)) } ?? []
for volume in volumes {
volumeState.lastTitle = volume
onVolume?(volume)
}
return title
}
private func encoding(named name: String) -> String.Encoding? {
switch name.uppercased() {
case "UTF-8", "UTF8": .utf8
case "UTF-16", "UTF16": .utf16
case "UTF-16LE", "UTF16LE": .utf16LittleEndian
case "UTF-16BE", "UTF16BE": .utf16BigEndian
case "GB18030", "GBK", "GB2312": .gb18030
case "BIG5": .big5
default: nil
}
}
private func bomLength(in data: Data, encoding: String.Encoding) -> Int {
if data.starts(with: [0xEF, 0xBB, 0xBF]) { return 3 }
if isUTF16(encoding),
data.starts(with: [0xFF, 0xFE]) || data.starts(with: [0xFE, 0xFF]) { return 2 }
return 0
}
private func isUTF16(_ encoding: String.Encoding) -> Bool {
encoding == .utf16 || encoding == .utf16LittleEndian || encoding == .utf16BigEndian
}
private func newlineIndex(
in data: Data,
range: Range<Int>,
encoding: String.Encoding,
fromEnd: Bool
) -> Int? {
guard !range.isEmpty else { return nil }
let bytePair: (UInt8, UInt8)?
if encoding == .utf16LittleEndian || (encoding == .utf16 && data.starts(with: [0xFF, 0xFE])) {
bytePair = (0x0A, 0x00)
} else if encoding == .utf16BigEndian || (encoding == .utf16 && data.starts(with: [0xFE, 0xFF])) {
bytePair = (0x00, 0x0A)
} else {
bytePair = nil
}
guard let bytePair else {
return fromEnd ? data[range].lastIndex(of: 0x0A) : data[range].firstIndex(of: 0x0A)
}
var index = range.lowerBound + (range.lowerBound.isMultiple(of: 2) ? 0 : 1)
guard index + 1 < range.upperBound else { return nil }
if fromEnd {
index = range.upperBound - 2
if !index.isMultiple(of: 2) { index -= 1 }
while index >= range.lowerBound {
if data[index] == bytePair.0, data[index + 1] == bytePair.1 { return index }
index -= 2
}
} else {
while index + 1 < range.upperBound {
if data[index] == bytePair.0, data[index + 1] == bytePair.1 { return index }
index += 2
}
}
return nil
}
private func byteOffsetMap(_ content: String, encoding: String.Encoding, initialOffset: Int) -> [Int64] {
var offsets = [Int64](repeating: Int64(initialOffset), count: content.utf16.count + 1)
var utf16Index = 0
var byteOffset = initialOffset
for scalar in content.unicodeScalars {
let scalarText = String(scalar)
let unitCount = scalarText.utf16.count
let byteCount = isUTF16(encoding)
? unitCount * 2
: (scalarText.data(using: encoding, allowLossyConversion: true)?.count ?? 0)
if unitCount > 1 {
for step in 1..<unitCount { offsets[utf16Index + step] = Int64(byteOffset) }
}
utf16Index += unitCount
byteOffset += byteCount
offsets[utf16Index] = Int64(byteOffset)
}
return offsets
}
private func textProbe(_ data: Data, encoding: String.Encoding) -> String {
let prefix = Data(data.prefix(512_000))
for removed in 0...8 where prefix.count >= removed {
let candidate = prefix.dropLast(removed)
if let value = String(data: Data(candidate), encoding: encoding) { return stripBOM(value) }
}
return ""
}
private func stripBOM(_ value: String) -> String {
value.hasPrefix("\u{feff}") ? String(value.dropFirst()) : value
}
private func isPlausible(_ value: String) -> Bool {
guard !value.isEmpty else { return true }
let controls = value.unicodeScalars.filter { $0.value < 32 && !CharacterSet.whitespacesAndNewlines.contains($0) }.count
return controls * 100 < max(value.unicodeScalars.count, 1)
}
}
private extension String.Encoding {
static let gb18030 = String.Encoding(rawValue: CFStringConvertEncodingToNSStringEncoding(
CFStringEncoding(CFStringEncodings.GB_18030_2000.rawValue)
))
static let big5 = String.Encoding(rawValue: CFStringConvertEncodingToNSStringEncoding(
CFStringEncoding(CFStringEncodings.big5.rawValue)
))
}
@@ -0,0 +1,188 @@
import Foundation
public struct LocalFileEntry: Identifiable, Sendable, Equatable {
public let url: URL
public let bytes: Int64
public let modified: Date?
public var id: String { url.absoluteString }
}
public enum LocalFileError: Error, LocalizedError, Equatable {
case outsideLibrary, invalidName, alreadyExists, missingFile, emptyCache, missingDirectory
case saveFailed(String)
public var errorDescription: String? {
switch self {
case .outsideLibrary: "只能管理应用内的本地书文件。"
case .invalidName: "文件名不能为空,不能包含路径分隔符或控制字符。"
case .alreadyExists: "同名文件已存在,请换一个名称。"
case .missingFile: "文件已不存在,请重新导入。"
case .emptyCache: "这本书还没有可导出的缓存正文,请先缓存章节。"
case .missingDirectory: "还没有这本书的目录,请先打开详情加载目录。"
case .saveFailed(let message): "书架保存失败:\(message)"
}
}
}
public struct StagedLocalFile: Sendable {
public let original: URL
public let staged: URL
}
public actor LocalFileService {
public let directory: URL
public init(directory: URL) { self.directory = directory.standardizedFileURL }
public func list() throws -> [LocalFileEntry] {
guard FileManager.default.fileExists(atPath: directory.path) else { return [] }
return try FileManager.default.contentsOfDirectory(
at: directory, includingPropertiesForKeys: [.isRegularFileKey, .isSymbolicLinkKey, .fileSizeKey, .contentModificationDateKey],
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 }
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 }
let values = try source.resourceValues(forKeys: [.isRegularFileKey])
guard values.isRegularFile == true else { throw LocalFileError.missingFile }
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let stem = source.deletingPathExtension().lastPathComponent
var target = directory.appendingPathComponent(source.lastPathComponent)
var suffix = 2
while FileManager.default.fileExists(atPath: target.path) {
target = directory.appendingPathComponent("\(stem) (\(suffix)).txt")
suffix += 1
}
try FileManager.default.copyItem(at: source, to: target)
return target
}
public func rename(_ source: URL, to name: String) throws -> URL {
try validate(source)
let trimmed = name.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, trimmed != ".", trimmed != "..", !trimmed.hasPrefix("."),
!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"
guard filename.utf8.count <= 255 else { throw LocalFileError.invalidName }
let target = directory.appendingPathComponent(filename)
if source.standardizedFileURL == target.standardizedFileURL { return source }
guard !FileManager.default.fileExists(atPath: target.path) else { throw LocalFileError.alreadyExists }
try FileManager.default.moveItem(at: source, to: target)
return target
}
public func stageRemoval(_ source: URL) throws -> StagedLocalFile? {
try validate(source, allowMissing: true)
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")
try FileManager.default.moveItem(at: source, to: staged)
return .init(original: source, staged: staged)
}
public func restore(_ file: StagedLocalFile) throws {
try validate(file.original, allowMissing: true)
guard file.staged.deletingLastPathComponent().standardizedFileURL == directory.appendingPathComponent(".Trash").standardizedFileURL else {
throw LocalFileError.outsideLibrary
}
try FileManager.default.moveItem(at: file.staged, to: file.original)
}
public func finishRemoval(_ file: StagedLocalFile) throws {
guard file.staged.deletingLastPathComponent().standardizedFileURL == directory.appendingPathComponent(".Trash").standardizedFileURL else {
throw LocalFileError.outsideLibrary
}
try FileManager.default.removeItem(at: file.staged)
}
private func validate(_ url: URL, allowMissing: Bool = false) throws {
guard url.isFileURL, url.pathExtension.lowercased() == "txt",
url.standardizedFileURL.deletingLastPathComponent() == directory,
url.resolvingSymlinksInPath().deletingLastPathComponent() == directory.resolvingSymlinksInPath() else {
throw LocalFileError.outsideLibrary
}
if !allowMissing, !FileManager.default.fileExists(atPath: url.path) { throw LocalFileError.missingFile }
}
}
public struct BookFileExport: Identifiable, Sendable {
public var id: String { url.absoluteString }
public let url: URL
public let cachedChapters: Int
public let totalChapters: Int
public var isPartial: Bool { cachedChapters < totalChapters }
}
public actor BookFileExporter {
private let directory: URL
private let cache: ChapterContentCache
public init(directory: URL = FileManager.default.temporaryDirectory.appendingPathComponent("LegadoExports"), cache: ChapterContentCache = .live()) {
self.directory = directory
self.cache = cache
}
public func originalFile(_ source: URL, filename: String) throws -> BookFileExport {
let target = try destination(filename: filename)
do {
try FileManager.default.copyItem(at: source, to: target)
return .init(url: target, cachedChapters: 0, totalChapters: 0)
} catch {
try? FileManager.default.removeItem(at: target.deletingLastPathComponent())
throw error
}
}
public func cachedBook(_ book: Book, chapters: [BookChapter]) async throws -> BookFileExport {
guard chapters.contains(where: { !$0.isVolume }) else { throw LocalFileError.missingDirectory }
let target = try destination(filename: book.name + ".txt")
do {
_ = FileManager.default.createFile(atPath: target.path, contents: nil)
let output = try FileHandle(forWritingTo: target)
defer { try? output.close() }
try output.write(contentsOf: Data((book.name + (book.author.isEmpty ? "" : "\n作者:" + book.author) + "\n\n").utf8))
var cached = 0
var total = 0
for chapter in chapters.sorted(by: { $0.index < $1.index }) {
try Task.checkCancellation()
try output.write(contentsOf: Data((chapter.title + "\n\n").utf8))
guard !chapter.isVolume else { continue }
total += 1
if let entry = try? await cache.load(bookURL: book.bookUrl, chapterURL: chapter.url) {
cached += 1
try output.write(contentsOf: Data((entry.content + "\n" + (entry.supplementalContent ?? "") + "\n\n").utf8))
} else {
try output.write(contentsOf: Data("【本章尚未缓存】\n\n".utf8))
}
}
guard cached > 0 else { throw LocalFileError.emptyCache }
return .init(url: target, cachedChapters: cached, totalChapters: total)
} catch {
try? FileManager.default.removeItem(at: target.deletingLastPathComponent())
throw error
}
}
public func discard(_ export: BookFileExport) throws {
guard export.url.deletingLastPathComponent().deletingLastPathComponent().standardizedFileURL == directory.standardizedFileURL else {
throw LocalFileError.outsideLibrary
}
try FileManager.default.removeItem(at: export.url.deletingLastPathComponent())
}
private func destination(filename: String) 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")
}
}