iBook/ipad_app/Sources/Services/BundleImporter.swift

131 lines
5.1 KiB
Swift

// 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<T:Decodable>(_ 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