| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203 |
- import CoreData
- import Foundation
- /// Migration-specific errors that might happen during migration
- enum JSONImporterError: Error {
- case missingGlucoseValueInGlucoseEntry
- case tempBasalAndDurationMismatch
- }
- // MARK: - JSONImporter Class
- /// Responsible for importing JSON data into Core Data.
- ///
- /// The importer handles two important states:
- /// - JSON files stored in the file system that contain data to import
- /// - Existing entries in CoreData that should not be duplicated
- ///
- /// Imports are performed when a JSON file exists. The importer checks
- /// CoreData for existing entries to avoid duplicating records from partial imports.
- class JSONImporter {
- private let context: NSManagedObjectContext
- private let coreDataStack: CoreDataStack
- /// Initializes the importer with a Core Data context.
- init(context: NSManagedObjectContext, coreDataStack: CoreDataStack) {
- self.context = context
- self.coreDataStack = coreDataStack
- }
- /// Reads and parses a JSON file from the file system.
- ///
- /// - Parameters:
- /// - url: The URL of the JSON file to read.
- /// - Returns: A decoded object of the specified type.
- /// - Throws: An error if the file cannot be read or decoded.
- private func readJsonFile<T: Decodable>(url: URL) throws -> T {
- let data = try Data(contentsOf: url)
- let decoder = JSONCoding.decoder
- return try decoder.decode(T.self, from: data)
- }
- /// Retrieves the set of dates for all glucose values currently stored in CoreData.
- ///
- /// - Parameters: the start and end dates to fetch glucose values, inclusive
- /// - Returns: A set of dates corresponding to existing glucose readings.
- /// - Throws: An error if the fetch operation fails.
- private func fetchGlucoseDates(start: Date, end: Date) async throws -> Set<Date> {
- let allReadings = try await coreDataStack.fetchEntitiesAsync(
- ofType: GlucoseStored.self,
- onContext: context,
- predicate: .predicateForDateBetween(start: start, end: end),
- key: "date",
- ascending: false
- ) as? [GlucoseStored] ?? []
- return Set(allReadings.compactMap(\.date))
- }
- /// Retrieves the set of timestamps for all pump evets currently stored in CoreData.
- ///
- /// - Parameters: the start and end dates to fetch pump events, inclusive
- /// - Returns: A set of dates corresponding to existing pump events.
- /// - Throws: An error if the fetch operation fails.
- private func fetchPumpTimestamps(start: Date, end: Date) async throws -> Set<Date> {
- let allReadings = try await coreDataStack.fetchEntitiesAsync(
- ofType: PumpEventStored.self,
- onContext: context,
- predicate: .predicateForTimestampBetween(start: start, end: end),
- key: "timestamp",
- ascending: false
- ) as? [PumpEventStored] ?? []
- return Set(allReadings.compactMap(\.timestamp))
- }
- /// Imports glucose history from a JSON file into CoreData.
- ///
- /// The function reads glucose data from the provided JSON file and stores new entries
- /// in CoreData, skipping entries with dates that already exist in the database.
- ///
- /// - Parameters:
- /// - url: The URL of the JSON file containing glucose history.
- /// - Throws:
- /// - JSONImporterError.missingGlucoseValueInGlucoseEntry if a glucose entry is missing a value.
- /// - An error if the file cannot be read or decoded.
- /// - An error if the CoreData operation fails.
- func importGlucoseHistory(url: URL, now: Date) async throws {
- let twentyFourHoursAgo = now - 24.hours.timeInterval
- let glucoseHistoryFull: [BloodGlucose] = try readJsonFile(url: url)
- let existingDates = try await fetchGlucoseDates(start: twentyFourHoursAgo, end: now)
- // only import glucose values from the last 24 hours that don't exist
- let glucoseHistory = glucoseHistoryFull
- .filter { $0.dateString >= twentyFourHoursAgo && $0.dateString <= now && !existingDates.contains($0.dateString) }
- // Create a background context for batch processing
- let backgroundContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
- backgroundContext.parent = context
- try await backgroundContext.perform {
- for glucoseEntry in glucoseHistory {
- try glucoseEntry.store(in: backgroundContext)
- }
- try backgroundContext.save()
- }
- try await context.perform {
- try self.context.save()
- }
- }
- private func combineTempBasalAndDuration(pumpHistory: [PumpHistoryEvent]) throws -> [PumpHistoryEvent] {
- let tempBasal = pumpHistory.filter({ $0.type == .tempBasal }).sorted { $0.timestamp < $1.timestamp }
- let tempBasalDuration = pumpHistory.filter({ $0.type == .tempBasalDuration }).sorted { $0.timestamp < $1.timestamp }
- let nonTempBasal = pumpHistory.filter { $0.type != .tempBasal && $0.type != .tempBasalDuration }
- guard tempBasal.count == tempBasalDuration.count else {
- throw JSONImporterError.tempBasalAndDurationMismatch
- }
- let combinedTempBasal = try zip(tempBasal, tempBasalDuration).map { rate, duration in
- guard rate.timestamp == duration.timestamp else {
- throw JSONImporterError.tempBasalAndDurationMismatch
- }
- return PumpHistoryEvent(
- id: duration.id,
- type: .tempBasal,
- timestamp: duration.timestamp,
- duration: duration.durationMin,
- rate: rate.rate,
- temp: rate.temp
- )
- }
- return (combinedTempBasal + nonTempBasal).sorted { $0.timestamp < $1.timestamp }
- }
- func importPumpHistory(url: URL, now: Date) async throws {
- let twentyFourHoursAgo = now - 24.hours.timeInterval
- let pumpHistoryRaw: [PumpHistoryEvent] = try readJsonFile(url: url)
- let existingTimestamps = try await fetchPumpTimestamps(start: twentyFourHoursAgo, end: now)
- let pumpHistoryFiltered = pumpHistoryRaw
- .filter { $0.timestamp >= twentyFourHoursAgo && $0.timestamp <= now && !existingTimestamps.contains($0.timestamp) }
- let pumpHistory = try combineTempBasalAndDuration(pumpHistory: pumpHistoryFiltered)
- for pumpEntry in pumpHistory {
- try pumpEntry.store(in: context)
- }
- }
- }
- // MARK: - Extension for Specific Import Functions
- extension BloodGlucose {
- /// Helper function to convert `BloodGlucose` to `GlucoseStored` while importing JSON glucose entries
- func store(in context: NSManagedObjectContext) throws {
- guard let glucoseValue = glucose ?? sgv else {
- throw JSONImporterError.missingGlucoseValueInGlucoseEntry
- }
- let glucoseEntry = GlucoseStored(context: context)
- glucoseEntry.id = _id.flatMap({ UUID(uuidString: $0) }) ?? UUID()
- glucoseEntry.date = dateString
- glucoseEntry.glucose = Int16(glucoseValue)
- glucoseEntry.direction = direction?.rawValue
- glucoseEntry.isManual = type == "Manual"
- glucoseEntry.isUploadedToNS = true
- glucoseEntry.isUploadedToHealth = true
- glucoseEntry.isUploadedToTidepool = true
- }
- }
- extension PumpHistoryEvent {
- func store(in context: NSManagedObjectContext) throws {
- let pumpEntry = PumpEventStored(context: context)
- pumpEntry.id = id
- pumpEntry.timestamp = timestamp
- pumpEntry.type = type.rawValue
- pumpEntry.isUploadedToNS = true
- pumpEntry.isUploadedToHealth = true
- pumpEntry.isUploadedToTidepool = true
- if type == .bolus {
- let bolusEntry = BolusStored(context: context)
- bolusEntry.amount = amount.flatMap { NSDecimalNumber(decimal: $0) }
- bolusEntry.isSMB = isSMB ?? false
- bolusEntry.isExternal = isExternal ?? false
- pumpEntry.bolus = bolusEntry
- } else if type == .tempBasal {
- let tempEntry = TempBasalStored(context: context)
- tempEntry.rate = rate.flatMap { NSDecimalNumber(decimal: $0) }
- tempEntry.duration = duration.flatMap({ Int16($0) }) ?? 0
- tempEntry.tempType = temp?.rawValue
- pumpEntry.tempBasal = tempEntry
- }
- }
- }
- extension JSONImporter {
- func importGlucoseHistoryIfNeeded() async {}
- }
|