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,242 @@
import Compression
import CoreFoundation
import Foundation
public enum DuplicatePolicy: Sendable {
case replace
case skip
case fail
}
public enum BookSourceImportError: Error, Equatable, LocalizedError {
case emptyDocument
case invalidDocument(String)
case invalidSources([BookSourceIssue])
case duplicate(String)
public var errorDescription: String? {
switch self {
case .emptyDocument:
return "导入内容为空"
case .invalidDocument(let detail):
return "无法解析书源 JSON:\(detail)"
case .invalidSources(let issues):
return issues.map(\.message).joined(separator: ";")
case .duplicate(let url):
return "书源已存在:\(url)"
}
}
}
public struct BookSourceImportResult: Sendable {
public var sources: [BookSource]
public var inserted: Int
public var replaced: Int
public var skipped: Int
public var warnings: [BookSourceIssue]
public init(
sources: [BookSource],
inserted: Int,
replaced: Int,
skipped: Int,
warnings: [BookSourceIssue]
) {
self.sources = sources
self.inserted = inserted
self.replaced = replaced
self.skipped = skipped
self.warnings = warnings
}
}
public struct BookSourceImporter: Sendable {
public static let maximumDocumentBytes = 50 * 1_024 * 1_024
private let decoder: JSONDecoder
public init() {
decoder = JSONDecoder()
}
public struct RejectedSource: Sendable {
public var source: BookSource
public var issues: [BookSourceIssue]
}
public struct Inspection: Sendable {
public var sources: [BookSource]
public var rejected: [RejectedSource]
}
public func inspect(_ data: Data) throws -> Inspection {
let cleaned = try normalizedData(data)
let root: JSONValue
do { root = try JSONValue.decode(data: cleaned) }
catch { throw BookSourceImportError.invalidDocument(error.localizedDescription) }
let entries: [JSONValue]
switch root {
case .array(let array): entries = array
case .object: entries = [root]
default: throw BookSourceImportError.invalidDocument("需要书源对象或数组")
}
guard !entries.isEmpty else { throw BookSourceImportError.emptyDocument }
var sources: [BookSource] = []
var rejected: [RejectedSource] = []
for (index, entry) in entries.enumerated() {
do {
guard case .object = entry else { throw BookSourceImportError.invalidDocument("第 \(index + 1) 项不是对象") }
let source = try decoder.decode(BookSource.self, from: JSONEncoder().encode(entry))
let errors = BookSourceValidator.validate(source).filter { $0.severity == .error }
if errors.isEmpty { sources.append(source) }
else { rejected.append(.init(source: source, issues: errors)) }
} catch {
let placeholder = BookSource(bookSourceUrl: entry["bookSourceUrl"]?.stringValue ?? "",
bookSourceName: entry["bookSourceName"]?.stringValue ?? "第 \(index + 1) 项")
rejected.append(.init(source: placeholder, issues: [.init(severity: .error, code: "source.decode",
field: "[\(index)]", message: "第 \(index + 1) 项:\(error.localizedDescription)")]))
}
}
return .init(sources: sources, rejected: rejected)
}
public func decode(_ data: Data) throws -> [BookSource] {
let result = try inspect(data)
guard result.rejected.isEmpty else { throw BookSourceImportError.invalidSources(result.rejected.flatMap(\.issues)) }
return result.sources
}
public func normalizedData(_ data: Data) throws -> Data {
guard data.count <= Self.maximumDocumentBytes else { throw BookSourceImportError.invalidDocument("书源文件超过 50 MB") }
let cleaned = decodeText(stripUTF8BOM(decompressIfGzip(data)))
guard cleaned.count <= Self.maximumDocumentBytes else { throw BookSourceImportError.invalidDocument("解压后书源超过 50 MB") }
guard !String(decoding: cleaned, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
throw BookSourceImportError.emptyDocument
}
return cleaned
}
public func prepare(
data: Data,
existing: [BookSource],
duplicatePolicy: DuplicatePolicy = .replace
) throws -> BookSourceImportResult {
let incoming = try decode(data)
var fatalIssues: [BookSourceIssue] = []
var warnings: [BookSourceIssue] = []
for source in incoming {
for issue in BookSourceValidator.validate(source) {
if issue.severity == .error {
fatalIssues.append(issue)
} else {
warnings.append(issue)
}
}
}
guard fatalIssues.isEmpty else {
throw BookSourceImportError.invalidSources(fatalIssues)
}
var merged = existing
var index = Dictionary(uniqueKeysWithValues: merged.enumerated().map { ($1.id, $0) })
var inserted = 0
var replaced = 0
var skipped = 0
for source in incoming {
if let existingIndex = index[source.id] {
switch duplicatePolicy {
case .replace:
merged[existingIndex] = source
replaced += 1
case .skip:
skipped += 1
case .fail:
throw BookSourceImportError.duplicate(source.id)
}
} else {
index[source.id] = merged.count
merged.append(source)
inserted += 1
}
}
return .init(
sources: merged,
inserted: inserted,
replaced: replaced,
skipped: skipped,
warnings: warnings
)
}
private func stripUTF8BOM(_ data: Data) -> Data {
let bom = Data([0xEF, 0xBB, 0xBF])
return data.starts(with: bom) ? Data(data.dropFirst(3)) : data
}
private func decodeText(_ data: Data) -> Data {
if let value = String(data: data, encoding: .utf8) { return Data(value.utf8) }
if data.starts(with: [0xFF, 0xFE]), let value = String(data: data, encoding: .utf16LittleEndian) {
return Data(value.utf8)
}
if data.starts(with: [0xFE, 0xFF]), let value = String(data: data, encoding: .utf16BigEndian) {
return Data(value.utf8)
}
if let value = String(data: data, encoding: Self.gb18030Encoding) {
return Data(value.utf8)
}
return data
}
private static var gb18030Encoding: String.Encoding {
String.Encoding(rawValue: CFStringConvertEncodingToNSStringEncoding(CFStringEncoding(CFStringEncodings.GB_18030_2000.rawValue)))
}
private func decompressIfGzip(_ data: Data) -> Data {
guard data.count > 10, data[0] == 0x1F, data[1] == 0x8B, data[2] == 0x08 else { return data }
var offset = 10
let flags = Int(data[3])
if flags & 0x04 != 0 {
guard offset + 2 <= data.count - 8 else { return data }
let extraLength = Int(data[offset]) | (Int(data[offset + 1]) << 8)
offset += 2 + extraLength
}
if flags & 0x08 != 0 {
while offset < data.count, data[offset] != 0 { offset += 1 }
offset += 1
}
if flags & 0x10 != 0 {
while offset < data.count, data[offset] != 0 { offset += 1 }
offset += 1
}
if flags & 0x02 != 0 { offset += 2 }
guard offset < data.count - 8 else { return data }
let payload = Data(data[offset..<data.count - min(8, data.count - offset)])
return inflate(payload) ?? data
}
private func inflate(_ payload: Data) -> Data? {
var capacity = max(payload.count * 4, 64 * 1_024)
while capacity > 0, capacity <= Self.maximumDocumentBytes + 1 {
let destination = UnsafeMutablePointer<UInt8>.allocate(capacity: capacity)
let produced = payload.withUnsafeBytes { (source: UnsafeRawBufferPointer) -> Int in
guard let base = source.bindMemory(to: UInt8.self).baseAddress else { return 0 }
return compression_decode_buffer(
destination, capacity,
base, payload.count,
nil,
COMPRESSION_ZLIB
)
}
if produced > 0, produced < capacity {
let result = Data(bytes: destination, count: produced)
destination.deallocate()
return result
}
destination.deallocate()
guard produced == capacity, capacity <= Int.max / 2 else { return nil }
capacity = min(capacity * 2, Self.maximumDocumentBytes + 1)
if capacity == produced { return nil }
}
return nil
}
}