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? { var capacity = max(payload.count * 4, 64 * 1_024) while capacity > 0, capacity <= Self.maximumDocumentBytes + 1 { let destination = UnsafeMutablePointer.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 } }