// BundleImporter.swift // 导入 ZIP 包并解析为 BundleIndex import Foundation import ZIPFoundation import UniformTypeIdentifiers #if canImport(UIKit) import UIKit #endif @available(macOS 10.15, iOS 13.0, *) final class BundleImporter: NSObject, ObservableObject { @Published var bundleIndex: BundleIndex? @Published var progressMessage: String? = nil @Published var errorMessage: String? = nil @Published var lastImportedURL: URL? = nil var index: BundleIndex? { bundleIndex } private let fm = FileManager.default // 公开导入方法供CLI使用 func importZipFile(at url: URL) async throws -> BundleIndex { let ts = Int(Date().timeIntervalSince1970) let baseDir = try bundleBaseDirectory() let target = baseDir.appendingPathComponent("bundle_\(ts)") try fm.createDirectory(at: target, withIntermediateDirectories: true) try fm.unzipItem(at: url, to: target) let idx = try parseBundle(at: target) await MainActor.run { self.bundleIndex = idx self.lastImportedURL = url } return idx } // iOS 选择器 func presentPicker() { #if canImport(UIKit) let types: [UTType] = [UTType.filenameExtension("zip")!] let picker = UIDocumentPickerViewController(forOpeningContentTypes: types, asCopy: true) picker.delegate = self picker.allowsMultipleSelection = false UIApplication.shared.firstKeyWindow?.rootViewController?.present(picker, animated: true) #else print("[BundleImporter] presentPicker() unsupported on this platform") #endif } // 存储目录 private func bundleBaseDirectory() throws -> URL { let appSup = try fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true) let dir = appSup.appendingPathComponent("ImportedBundle") if !fm.fileExists(atPath: dir.path) { try fm.createDirectory(at: dir, withIntermediateDirectories: true) } return dir } @MainActor private func update(message: String?) { self.progressMessage = message } // 解析内容 private func parseBundle(at dir: URL) throws -> BundleIndex { func load(_ name: String, _ type: T.Type) throws -> T { let data = try Data(contentsOf: dir.appendingPathComponent(name)) return try JSONDecoder().decode(T.self, from: data) } let books: [BookMeta] = try load("books_meta.json", [BookMeta].self) let stats: StatsPayload = try load("stats.json", StatsPayload.self) let introData = try? Data(contentsOf: dir.appendingPathComponent("bookintro.json")) let bookIntro = (introData.flatMap { try? JSONDecoder().decode([String:String].self, from: $0) }) ?? [:] let annotationsRaw = try Data(contentsOf: dir.appendingPathComponent("annotations.json")) let anyMap = (try? JSONSerialization.jsonObject(with: annotationsRaw)) as? [String:Any] ?? [:] var annMap: [String:[AnnotationRow]] = [:] for (k,v) in anyMap { guard let arr = v as? [[String:Any]] else { continue } let rows: [AnnotationRow] = arr.compactMap { d in guard let uuid = d["uuid"] as? String else { return nil } return AnnotationRow( uuid: uuid, creationdate: d["creationdate"] as? String, idref: d["idref"] as? String, filepos: d["filepos"] as? String, selected: d["selected"] as? String, note: d["note"] as? String ) } annMap[k] = rows } return BundleIndex(books: books, stats: stats, bookIntro: bookIntro, annotationsMap: annMap) } // 导入执行 private func importZip(at url: URL) async { await update(message: "解压中...") do { let ts = Int(Date().timeIntervalSince1970) let baseDir = try bundleBaseDirectory() let target = baseDir.appendingPathComponent("bundle_\(ts)") try fm.createDirectory(at: target, withIntermediateDirectories: true) try fm.unzipItem(at: url, to: target) await update(message: "解析 JSON...") let idx = try parseBundle(at: target) await MainActor.run { self.bundleIndex = idx self.lastImportedURL = url } await update(message: nil) } catch { await MainActor.run { self.errorMessage = error.localizedDescription } await update(message: nil) } } } #if canImport(UIKit) extension BundleImporter: UIDocumentPickerDelegate { func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { guard let url = urls.first else { return } lastImportedURL = url Task { await importZip(at: url) } } } extension UIApplication { var firstKeyWindow: UIWindow? { self.connectedScenes.compactMap { $0 as? UIWindowScene } .flatMap { $0.windows } .first { $0.isKeyWindow } } } #endif