JSONImporter.swift 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420
  1. import CoreData
  2. import Foundation
  3. /// Migration-specific errors that might happen during migration
  4. enum JSONImporterError: Error {
  5. case missingGlucoseValueInGlucoseEntry
  6. case tempBasalAndDurationMismatch
  7. case missingRequiredPropertyInPumpEntry
  8. case suspendResumePumpEventMismatch
  9. case duplicatePumpEvents
  10. case missingCarbsValueInCarbEntry
  11. }
  12. // MARK: - JSONImporter Class
  13. /// Responsible for importing JSON data into Core Data.
  14. ///
  15. /// The importer handles two important states:
  16. /// - JSON files stored in the file system that contain data to import
  17. /// - Existing entries in CoreData that should not be duplicated
  18. ///
  19. /// Imports are performed when a JSON file exists. The importer checks
  20. /// CoreData for existing entries to avoid duplicating records from partial imports.
  21. class JSONImporter {
  22. private let context: NSManagedObjectContext
  23. private let coreDataStack: CoreDataStack
  24. /// Initializes the importer with a Core Data context.
  25. init(context: NSManagedObjectContext, coreDataStack: CoreDataStack) {
  26. self.context = context
  27. self.coreDataStack = coreDataStack
  28. }
  29. /// Reads and parses a JSON file from the file system.
  30. ///
  31. /// - Parameters:
  32. /// - url: The URL of the JSON file to read.
  33. /// - Returns: A decoded object of the specified type.
  34. /// - Throws: An error if the file cannot be read or decoded.
  35. private func readJsonFile<T: Decodable>(url: URL) throws -> T {
  36. let data = try Data(contentsOf: url)
  37. let decoder = JSONCoding.decoder
  38. return try decoder.decode(T.self, from: data)
  39. }
  40. /// Retrieves the set of dates for all glucose values currently stored in CoreData.
  41. ///
  42. /// - Parameters: the start and end dates to fetch glucose values, inclusive
  43. /// - Returns: A set of dates corresponding to existing glucose readings.
  44. /// - Throws: An error if the fetch operation fails.
  45. private func fetchGlucoseDates(start: Date, end: Date) async throws -> Set<Date> {
  46. let allReadings = try await coreDataStack.fetchEntitiesAsync(
  47. ofType: GlucoseStored.self,
  48. onContext: context,
  49. predicate: .predicateForDateBetween(start: start, end: end),
  50. key: "date",
  51. ascending: false
  52. ) as? [GlucoseStored] ?? []
  53. return Set(allReadings.compactMap(\.date))
  54. }
  55. /// Retrieves the set of timestamps for all pump events currently stored in CoreData.
  56. ///
  57. /// - Parameters: the start and end dates to fetch pump events, inclusive
  58. /// - Returns: A set of dates corresponding to existing pump events.
  59. /// - Throws: An error if the fetch operation fails.
  60. private func fetchPumpTimestamps(start: Date, end: Date) async throws -> Set<Date> {
  61. let allPumpEvents = try await coreDataStack.fetchEntitiesAsync(
  62. ofType: PumpEventStored.self,
  63. onContext: context,
  64. predicate: .predicateForTimestampBetween(start: start, end: end),
  65. key: "timestamp",
  66. ascending: false
  67. ) as? [PumpEventStored] ?? []
  68. return Set(allPumpEvents.compactMap(\.timestamp))
  69. }
  70. /// Retrieves the set of timestamps for all carb entries currently stored in CoreData.
  71. ///
  72. /// - Parameters: the start and end dates to fetch carb entries, inclusive
  73. /// - Returns: A set of dates corresponding to existing carb entries.
  74. /// - Throws: An error if the fetch operation fails.
  75. private func fetchCarbEntryDates(start: Date, end: Date) async throws -> Set<Date> {
  76. let allCarbEntryDates = try await coreDataStack.fetchEntitiesAsync(
  77. ofType: CarbEntryStored.self,
  78. onContext: context,
  79. predicate: .predicateForDateBetween(start: start, end: end),
  80. key: "date",
  81. ascending: false
  82. ) as? [CarbEntryStored] ?? []
  83. return Set(allCarbEntryDates.compactMap(\.date))
  84. }
  85. /// Imports glucose history from a JSON file into CoreData.
  86. ///
  87. /// The function reads glucose data from the provided JSON file and stores new entries
  88. /// in CoreData, skipping entries with dates that already exist in the database.
  89. ///
  90. /// - Parameters:
  91. /// - url: The URL of the JSON file containing glucose history.
  92. /// - now: The current time, used to skip old entries
  93. /// - Throws:
  94. /// - JSONImporterError.missingGlucoseValueInGlucoseEntry if a glucose entry is missing a value.
  95. /// - An error if the file cannot be read or decoded.
  96. /// - An error if the CoreData operation fails.
  97. func importGlucoseHistory(url: URL, now: Date) async throws {
  98. let twentyFourHoursAgo = now - 24.hours.timeInterval
  99. let glucoseHistoryFull: [BloodGlucose] = try readJsonFile(url: url)
  100. let existingDates = try await fetchGlucoseDates(start: twentyFourHoursAgo, end: now)
  101. // only import glucose values from the last 24 hours that don't exist
  102. let glucoseHistory = glucoseHistoryFull
  103. .filter { $0.dateString >= twentyFourHoursAgo && $0.dateString <= now && !existingDates.contains($0.dateString) }
  104. // Create a background context for batch processing
  105. let backgroundContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
  106. backgroundContext.parent = context
  107. try await backgroundContext.perform {
  108. for glucoseEntry in glucoseHistory {
  109. try glucoseEntry.store(in: backgroundContext)
  110. }
  111. try backgroundContext.save()
  112. }
  113. try await context.perform {
  114. try self.context.save()
  115. }
  116. }
  117. /// combines tempBasal and tempBasalDuration events into one PumpHistoryEvent
  118. private func combineTempBasalAndDuration(pumpHistory: [PumpHistoryEvent]) throws -> [PumpHistoryEvent] {
  119. let tempBasal = pumpHistory.filter({ $0.type == .tempBasal }).sorted { $0.timestamp < $1.timestamp }
  120. let tempBasalDuration = pumpHistory.filter({ $0.type == .tempBasalDuration }).sorted { $0.timestamp < $1.timestamp }
  121. let nonTempBasal = pumpHistory.filter { $0.type != .tempBasal && $0.type != .tempBasalDuration }
  122. guard tempBasal.count == tempBasalDuration.count else {
  123. throw JSONImporterError.tempBasalAndDurationMismatch
  124. }
  125. let combinedTempBasal = try zip(tempBasal, tempBasalDuration).map { rate, duration in
  126. guard rate.timestamp == duration.timestamp else {
  127. throw JSONImporterError.tempBasalAndDurationMismatch
  128. }
  129. return PumpHistoryEvent(
  130. id: duration.id,
  131. type: .tempBasal,
  132. timestamp: duration.timestamp,
  133. duration: duration.durationMin,
  134. rate: rate.rate,
  135. temp: rate.temp
  136. )
  137. }
  138. return (combinedTempBasal + nonTempBasal).sorted { $0.timestamp < $1.timestamp }
  139. }
  140. /// checks for pumpHistory inconsistencies that might cause issues if we import these events into CoreData
  141. private func checkForInconsistencies(pumpHistory: [PumpHistoryEvent]) throws {
  142. // make sure that pump suspends / resumes match up
  143. let suspendsAndResumes = pumpHistory.filter({ $0.type == .pumpSuspend || $0.type == .pumpResume })
  144. .sorted { $0.timestamp < $1.timestamp }
  145. for (current, next) in zip(suspendsAndResumes, suspendsAndResumes.dropFirst()) {
  146. guard current.type != next.type else {
  147. throw JSONImporterError.suspendResumePumpEventMismatch
  148. }
  149. }
  150. // check for duplicate events
  151. struct TypeTimestamp: Hashable {
  152. let timestamp: Date
  153. let type: EventType
  154. }
  155. let duplicates = Dictionary(grouping: pumpHistory) { TypeTimestamp(timestamp: $0.timestamp, type: $0.type) }
  156. .values.first(where: { $0.count > 1 })
  157. if duplicates != nil {
  158. throw JSONImporterError.duplicatePumpEvents
  159. }
  160. }
  161. /// Imports pump history from a JSON file into CoreData.
  162. ///
  163. /// The function reads pump history data from the provided JSON file and stores new entries
  164. /// in CoreData, skipping entries with timestamps that already exist in the database.
  165. ///
  166. /// - Parameters:
  167. /// - url: The URL of the JSON file containing pump history.
  168. /// - now: The current time, used to skip old entries
  169. /// - Throws:
  170. /// - JSONImporterError.tempBasalAndDurationMismatch if we can't match tempBasals with their duration.
  171. /// - An error if the file cannot be read or decoded.
  172. /// - An error if the CoreData operation fails.
  173. func importPumpHistory(url: URL, now: Date) async throws {
  174. let twentyFourHoursAgo = now - 24.hours.timeInterval
  175. let pumpHistoryRaw: [PumpHistoryEvent] = try readJsonFile(url: url)
  176. let existingTimestamps = try await fetchPumpTimestamps(start: twentyFourHoursAgo, end: now)
  177. let pumpHistoryFiltered = pumpHistoryRaw
  178. .filter { $0.timestamp >= twentyFourHoursAgo && $0.timestamp <= now && !existingTimestamps.contains($0.timestamp) }
  179. let pumpHistory = try combineTempBasalAndDuration(pumpHistory: pumpHistoryFiltered)
  180. try checkForInconsistencies(pumpHistory: pumpHistory)
  181. // Create a background context for batch processing
  182. let backgroundContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
  183. backgroundContext.parent = context
  184. try await backgroundContext.perform {
  185. for pumpEntry in pumpHistory {
  186. try pumpEntry.store(in: backgroundContext)
  187. }
  188. try backgroundContext.save()
  189. }
  190. try await context.perform {
  191. try self.context.save()
  192. }
  193. }
  194. /// Imports carb history from a JSON file into CoreData.
  195. ///
  196. /// The function reads carb entries data from the provided JSON file and stores new entries
  197. /// in CoreData, skipping entries with dates that already exist in the database.
  198. /// We ignore all FPU entries (aka carb equivalents) when performing an import.
  199. ///
  200. /// - Parameters:
  201. /// - url: The URL of the JSON file containing glucose history.
  202. /// - now: The current datetime
  203. /// - Throws:
  204. /// - JSONImporterError.missingCarbsValueInCarbEntry if a carb entry is missing a `carbs: Decimal` value.
  205. /// - An error if the file cannot be read or decoded.
  206. /// - An error if the CoreData operation fails.
  207. func importCarbHistory(url: URL, now: Date) async throws {
  208. let twentyFourHoursAgo = now - 24.hours.timeInterval
  209. let carbHistoryFull: [CarbsEntry] = try readJsonFile(url: url)
  210. let existingDates = try await fetchCarbEntryDates(start: twentyFourHoursAgo, end: now)
  211. // Only import carb entries from the last 24 hours that do not exist yet in Core Data
  212. // Only import "true" carb entries; ignore all FPU entries (aka carb equivalents)
  213. let carbHistory = carbHistoryFull
  214. .filter {
  215. let dateToCheck = $0.actualDate ?? $0.createdAt
  216. return dateToCheck >= twentyFourHoursAgo && dateToCheck <= now && !existingDates.contains(dateToCheck) && $0
  217. .isFPU ?? false == false }
  218. // Create a background context for batch processing
  219. let backgroundContext = NSManagedObjectContext(concurrencyType: .privateQueueConcurrencyType)
  220. backgroundContext.parent = context
  221. try await backgroundContext.perform {
  222. for carbEntry in carbHistory {
  223. try carbEntry.store(in: backgroundContext)
  224. }
  225. try backgroundContext.save()
  226. }
  227. try await context.perform {
  228. try self.context.save()
  229. }
  230. }
  231. }
  232. // MARK: - Extension for Specific Import Functions
  233. extension BloodGlucose {
  234. /// Helper function to convert `BloodGlucose` to `GlucoseStored` while importing JSON glucose entries
  235. func store(in context: NSManagedObjectContext) throws {
  236. guard let glucoseValue = glucose ?? sgv else {
  237. throw JSONImporterError.missingGlucoseValueInGlucoseEntry
  238. }
  239. let glucoseEntry = GlucoseStored(context: context)
  240. glucoseEntry.id = _id.flatMap({ UUID(uuidString: $0) }) ?? UUID()
  241. glucoseEntry.date = dateString
  242. glucoseEntry.glucose = Int16(glucoseValue)
  243. glucoseEntry.direction = direction?.rawValue
  244. glucoseEntry.isManual = type == "Manual"
  245. glucoseEntry.isUploadedToNS = true
  246. glucoseEntry.isUploadedToHealth = true
  247. glucoseEntry.isUploadedToTidepool = true
  248. }
  249. }
  250. extension PumpHistoryEvent {
  251. /// Helper function to convert `PumpHistoryEvent` to `PumpEventStored` while importing JSON pump histories
  252. func store(in context: NSManagedObjectContext) throws {
  253. let pumpEntry = PumpEventStored(context: context)
  254. pumpEntry.id = id
  255. pumpEntry.timestamp = timestamp
  256. pumpEntry.type = type.rawValue
  257. pumpEntry.isUploadedToNS = true
  258. pumpEntry.isUploadedToHealth = true
  259. pumpEntry.isUploadedToTidepool = true
  260. if type == .bolus {
  261. guard let amount = amount else {
  262. throw JSONImporterError.missingRequiredPropertyInPumpEntry
  263. }
  264. let bolusEntry = BolusStored(context: context)
  265. bolusEntry.amount = NSDecimalNumber(decimal: amount)
  266. bolusEntry.isSMB = isSMB ?? false
  267. bolusEntry.isExternal = isExternal ?? false
  268. pumpEntry.bolus = bolusEntry
  269. } else if type == .tempBasal {
  270. guard let rate = rate, let duration = duration else {
  271. throw JSONImporterError.missingRequiredPropertyInPumpEntry
  272. }
  273. let tempEntry = TempBasalStored(context: context)
  274. tempEntry.rate = NSDecimalNumber(decimal: rate)
  275. tempEntry.duration = Int16(duration)
  276. tempEntry.tempType = temp?.rawValue
  277. pumpEntry.tempBasal = tempEntry
  278. }
  279. }
  280. }
  281. /// Extension to support decoding `CarbsEntry` from JSON with multiple possible key formats for entry notes.
  282. ///
  283. /// This is needed because some JSON sources (e.g., Trio v0.2.5) use the singular key `"note"`
  284. /// for the `note` field, while others (e.g., Nightscout or oref) use the plural `"notes"`.
  285. ///
  286. /// To ensure compatibility across all sources without duplicating models or requiring upstream fixes,
  287. /// this custom implementation attempts to decode the `note` field first from `"note"`, then from `"notes"`.
  288. /// Encoding will always use the canonical `"notes"` key to preserve consistency in output,
  289. /// as this is what's established throughout the backend now.
  290. extension CarbsEntry: Codable {
  291. init(from decoder: Decoder) throws {
  292. let container = try decoder.container(keyedBy: CodingKeys.self)
  293. id = try container.decodeIfPresent(String.self, forKey: .id)
  294. createdAt = try container.decode(Date.self, forKey: .createdAt)
  295. actualDate = try container.decodeIfPresent(Date.self, forKey: .actualDate)
  296. carbs = try container.decode(Decimal.self, forKey: .carbs)
  297. fat = try container.decodeIfPresent(Decimal.self, forKey: .fat)
  298. protein = try container.decodeIfPresent(Decimal.self, forKey: .protein)
  299. // Handle both `note` and `notes`
  300. if let noteValue = try? container.decodeIfPresent(String.self, forKey: .note) {
  301. note = noteValue
  302. } else if let notesValue = try? container.decodeIfPresent(String.self, forKey: .noteAlt) {
  303. note = notesValue
  304. } else {
  305. note = nil
  306. }
  307. enteredBy = try container.decodeIfPresent(String.self, forKey: .enteredBy)
  308. isFPU = try container.decodeIfPresent(Bool.self, forKey: .isFPU)
  309. fpuID = try container.decodeIfPresent(String.self, forKey: .fpuID)
  310. }
  311. func encode(to encoder: Encoder) throws {
  312. var container = encoder.container(keyedBy: CodingKeys.self)
  313. try container.encodeIfPresent(id, forKey: .id)
  314. try container.encode(createdAt, forKey: .createdAt)
  315. try container.encodeIfPresent(actualDate, forKey: .actualDate)
  316. try container.encode(carbs, forKey: .carbs)
  317. try container.encodeIfPresent(fat, forKey: .fat)
  318. try container.encodeIfPresent(protein, forKey: .protein)
  319. try container.encodeIfPresent(note, forKey: .note)
  320. try container.encodeIfPresent(enteredBy, forKey: .enteredBy)
  321. try container.encodeIfPresent(isFPU, forKey: .isFPU)
  322. try container.encodeIfPresent(fpuID, forKey: .fpuID)
  323. }
  324. private enum CodingKeys: String, CodingKey {
  325. case id = "_id"
  326. case createdAt = "created_at"
  327. case actualDate
  328. case carbs
  329. case fat
  330. case protein
  331. case note = "notes" // standard key
  332. case noteAlt = "note" // import key
  333. case enteredBy
  334. case isFPU
  335. case fpuID
  336. }
  337. /// Helper function to convert `CarbsStored` to `CarbEntryStored` while importing JSON carb entries
  338. func store(in context: NSManagedObjectContext) throws {
  339. guard carbs >= 0 else {
  340. throw JSONImporterError.missingCarbsValueInCarbEntry
  341. }
  342. // skip FPU entries for now
  343. let carbEntry = CarbEntryStored(context: context)
  344. carbEntry.id = id
  345. .flatMap({ UUID(uuidString: $0) }) ?? UUID() /// The `CodingKey` of `id` is `_id`, so this fine to use here
  346. carbEntry.date = actualDate ?? createdAt
  347. carbEntry.carbs = Double(truncating: NSDecimalNumber(decimal: carbs))
  348. carbEntry.fat = Double(truncating: NSDecimalNumber(decimal: fat ?? 0))
  349. carbEntry.protein = Double(truncating: NSDecimalNumber(decimal: protein ?? 0))
  350. carbEntry.note = note ?? ""
  351. carbEntry.isFPU = false
  352. carbEntry.isUploadedToNS = true
  353. carbEntry.isUploadedToHealth = true
  354. carbEntry.isUploadedToTidepool = true
  355. if fat != nil, protein != nil, let fpuId = fpuID {
  356. carbEntry.fpuID = UUID(uuidString: fpuId)
  357. }
  358. }
  359. }
  360. extension JSONImporter {
  361. func importGlucoseHistoryIfNeeded() async {}
  362. func importPumpHistoryIfNeeded() async {}
  363. func importCarbHistoryIfNeeded() async {}
  364. }