Files
legado-ios/Sources/LegadoCore/Import/BookSourceImporter.swift
T

243 lines
9.1 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
}