From 0088e5df2e1fd14b17044d52b45ed0b4c6da0e81 Mon Sep 17 00:00:00 2001 From: Christoph Hagen Date: Sun, 21 Jan 2024 14:32:00 +0100 Subject: [PATCH] Extract metadata --- HealthImport.xcodeproj/project.pbxproj | 45 +++++++++++++ .../xcshareddata/swiftpm/Package.resolved | 9 +++ .../Database Entries/DBMetadata.swift | 57 ++++++++++++++++ .../Database Entries/DBMetadataKey.swift | 21 ++++++ HealthImport/HealthDatabase.swift | 7 +- HealthImport/Model/MetadataValue.swift | 65 +++++++++++++++++++ HealthImport/Model/Workout.swift | 10 ++- HealthImport/WorkoutDetailView.swift | 11 ++++ 8 files changed, 221 insertions(+), 4 deletions(-) create mode 100644 HealthImport/Database Entries/DBMetadata.swift create mode 100644 HealthImport/Database Entries/DBMetadataKey.swift create mode 100644 HealthImport/Model/MetadataValue.swift diff --git a/HealthImport.xcodeproj/project.pbxproj b/HealthImport.xcodeproj/project.pbxproj index 0062392..a46a9e5 100644 --- a/HealthImport.xcodeproj/project.pbxproj +++ b/HealthImport.xcodeproj/project.pbxproj @@ -31,6 +31,12 @@ 885002992B5D15D200E7D4DB /* HKWorkoutSwimmingLocationType+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 885002982B5D15D200E7D4DB /* HKWorkoutSwimmingLocationType+Extensions.swift */; }; 8850029B2B5D16E200E7D4DB /* TimeInterval+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850029A2B5D16E200E7D4DB /* TimeInterval+Extensions.swift */; }; 8850029D2B5D197300E7D4DB /* EventDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850029C2B5D197300E7D4DB /* EventDetailView.swift */; }; + 8850029F2B5D1C7000E7D4DB /* DBMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850029E2B5D1C7000E7D4DB /* DBMetadata.swift */; }; + 885002A12B5D1E7400E7D4DB /* DBMetadataKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = 885002A02B5D1E7400E7D4DB /* DBMetadataKey.swift */; }; + 885002A32B5D217600E7D4DB /* MetadataValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 885002A22B5D217600E7D4DB /* MetadataValue.swift */; }; + 885002A62B5D296700E7D4DB /* Collections in Frameworks */ = {isa = PBXBuildFile; productRef = 885002A52B5D296700E7D4DB /* Collections */; }; + 885002A82B5D296700E7D4DB /* DequeModule in Frameworks */ = {isa = PBXBuildFile; productRef = 885002A72B5D296700E7D4DB /* DequeModule */; }; + 885002AA2B5D296700E7D4DB /* OrderedCollections in Frameworks */ = {isa = PBXBuildFile; productRef = 885002A92B5D296700E7D4DB /* OrderedCollections */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -58,6 +64,9 @@ 885002982B5D15D200E7D4DB /* HKWorkoutSwimmingLocationType+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HKWorkoutSwimmingLocationType+Extensions.swift"; sourceTree = ""; }; 8850029A2B5D16E200E7D4DB /* TimeInterval+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TimeInterval+Extensions.swift"; sourceTree = ""; }; 8850029C2B5D197300E7D4DB /* EventDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventDetailView.swift; sourceTree = ""; }; + 8850029E2B5D1C7000E7D4DB /* DBMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBMetadata.swift; sourceTree = ""; }; + 885002A02B5D1E7400E7D4DB /* DBMetadataKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBMetadataKey.swift; sourceTree = ""; }; + 885002A22B5D217600E7D4DB /* MetadataValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetadataValue.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -65,7 +74,10 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 885002A62B5D296700E7D4DB /* Collections in Frameworks */, 885002772B5C2FC400E7D4DB /* SQLite in Frameworks */, + 885002AA2B5D296700E7D4DB /* OrderedCollections in Frameworks */, + 885002A82B5D296700E7D4DB /* DequeModule in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -130,6 +142,8 @@ 8850027A2B5C35BF00E7D4DB /* DBWorkout.swift */, 8850027C2B5C360300E7D4DB /* DBWorkoutEvent.swift */, 885002882B5C873C00E7D4DB /* DBWorkoutActivity.swift */, + 8850029E2B5D1C7000E7D4DB /* DBMetadata.swift */, + 885002A02B5D1E7400E7D4DB /* DBMetadataKey.swift */, ); path = "Database Entries"; sourceTree = ""; @@ -140,6 +154,7 @@ 8850027E2B5C36A700E7D4DB /* Workout.swift */, 885002842B5C7AD600E7D4DB /* WorkoutEvent.swift */, 8850028A2B5C896C00E7D4DB /* WorkoutActivity.swift */, + 885002A22B5D217600E7D4DB /* MetadataValue.swift */, ); path = Model; sourceTree = ""; @@ -176,6 +191,9 @@ name = HealthImport; packageProductDependencies = ( 885002762B5C2FC400E7D4DB /* SQLite */, + 885002A52B5D296700E7D4DB /* Collections */, + 885002A72B5D296700E7D4DB /* DequeModule */, + 885002A92B5D296700E7D4DB /* OrderedCollections */, ); productName = HealthImport; productReference = 885002572B5C273C00E7D4DB /* HealthImport.app */; @@ -207,6 +225,7 @@ mainGroup = 8850024E2B5C273C00E7D4DB; packageReferences = ( 885002752B5C2FC400E7D4DB /* XCRemoteSwiftPackageReference "SQLite" */, + 885002A42B5D296700E7D4DB /* XCRemoteSwiftPackageReference "swift-collections" */, ); productRefGroup = 885002582B5C273C00E7D4DB /* Products */; projectDirPath = ""; @@ -245,10 +264,13 @@ 885002852B5C7AD600E7D4DB /* WorkoutEvent.swift in Sources */, 885002912B5D0F9200E7D4DB /* HKWorkoutEventType+Extensions.swift in Sources */, 8850027F2B5C36A700E7D4DB /* Workout.swift in Sources */, + 885002A12B5D1E7400E7D4DB /* DBMetadataKey.swift in Sources */, 885002712B5C299900E7D4DB /* HealthDatabase.swift in Sources */, 885002932B5D129300E7D4DB /* ActivityDetailView.swift in Sources */, + 8850029F2B5D1C7000E7D4DB /* DBMetadata.swift in Sources */, 8850027B2B5C35BF00E7D4DB /* DBWorkout.swift in Sources */, 8850028D2B5D0B5000E7D4DB /* WorkoutDetailView.swift in Sources */, + 885002A32B5D217600E7D4DB /* MetadataValue.swift in Sources */, 8850028B2B5C896C00E7D4DB /* WorkoutActivity.swift in Sources */, 885002952B5D147100E7D4DB /* DetailRow.swift in Sources */, 8850029D2B5D197300E7D4DB /* EventDetailView.swift in Sources */, @@ -470,6 +492,14 @@ minimumVersion = 0.14.1; }; }; + 885002A42B5D296700E7D4DB /* XCRemoteSwiftPackageReference "swift-collections" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-collections.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.6; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -478,6 +508,21 @@ package = 885002752B5C2FC400E7D4DB /* XCRemoteSwiftPackageReference "SQLite" */; productName = SQLite; }; + 885002A52B5D296700E7D4DB /* Collections */ = { + isa = XCSwiftPackageProductDependency; + package = 885002A42B5D296700E7D4DB /* XCRemoteSwiftPackageReference "swift-collections" */; + productName = Collections; + }; + 885002A72B5D296700E7D4DB /* DequeModule */ = { + isa = XCSwiftPackageProductDependency; + package = 885002A42B5D296700E7D4DB /* XCRemoteSwiftPackageReference "swift-collections" */; + productName = DequeModule; + }; + 885002A92B5D296700E7D4DB /* OrderedCollections */ = { + isa = XCSwiftPackageProductDependency; + package = 885002A42B5D296700E7D4DB /* XCRemoteSwiftPackageReference "swift-collections" */; + productName = OrderedCollections; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 8850024F2B5C273C00E7D4DB /* Project object */; diff --git a/HealthImport.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/HealthImport.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index d1a572d..c40a0e4 100644 --- a/HealthImport.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/HealthImport.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -8,6 +8,15 @@ "revision" : "7a2e3cd27de56f6d396e84f63beefd0267b55ccb", "version" : "0.14.1" } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "d029d9d39c87bed85b1c50adee7c41795261a192", + "version" : "1.0.6" + } } ], "version" : 2 diff --git a/HealthImport/Database Entries/DBMetadata.swift b/HealthImport/Database Entries/DBMetadata.swift new file mode 100644 index 0000000..032594e --- /dev/null +++ b/HealthImport/Database Entries/DBMetadata.swift @@ -0,0 +1,57 @@ +import Foundation +import SQLite + +struct DBMetadata { + + private static let table = Table("metadata_values") + + private static let rowKeyId = Expression("key_id") + + private static let rowObjectId = Expression("object_id") + + private static let rowValueType = Expression("value_type") + + private static let rowStringValue = Expression("string_value") + + private static let rowNumericalValue = Expression("numerical_value") + + private static let rowDateValue = Expression("date_value") + + private static let rowDataValue = Expression("data_value") + + static func readAll(in database: Connection) throws -> [Self] { + try database.prepare(table).map(Self.init) + } + + static func metadata(for workoutId: Int, in database: Connection) throws -> [Self] { + try database.prepare(table.filter(rowObjectId == workoutId)).map(Self.init) + } + + let keyId: Int? + + let objectId: Int? + + let valueType: Int + + let string: String? + + let number: Double? + + let date: Double? + + let data: Data? +} + +extension DBMetadata { + + init(row: Row) { + self.keyId = row[DBMetadata.rowKeyId] + self.objectId = row[DBMetadata.rowObjectId] + self.valueType = row[DBMetadata.rowValueType] + self.string = row[DBMetadata.rowStringValue] + self.number = row[DBMetadata.rowNumericalValue] + self.date = row[DBMetadata.rowDateValue] + self.data = row[DBMetadata.rowDataValue] + } +} + diff --git a/HealthImport/Database Entries/DBMetadataKey.swift b/HealthImport/Database Entries/DBMetadataKey.swift new file mode 100644 index 0000000..8f23d77 --- /dev/null +++ b/HealthImport/Database Entries/DBMetadataKey.swift @@ -0,0 +1,21 @@ +import Foundation +import SQLite + +struct DBMetadataKey { + + private static let table = Table("metadata_keys") + + private static let rowId = Expression("ROWID") + + private static let rowKey = Expression("key") + + static func key(for keyId: Int, in database: Connection) throws -> String { + try database.prepare(table.filter(rowId == keyId).limit(1)).map { $0[rowKey] }.first! + } + + static func readAll(in database: Connection) throws -> [ Int: String] { + try database.prepare(table).reduce(into: [:]) { dict, row in + dict[row[rowId]] = row[rowKey] + } + } +} diff --git a/HealthImport/HealthDatabase.swift b/HealthImport/HealthDatabase.swift index 012af32..d972a1f 100644 --- a/HealthImport/HealthDatabase.swift +++ b/HealthImport/HealthDatabase.swift @@ -21,10 +21,15 @@ final class HealthDatabase: ObservableObject { func readAllWorkouts() { do { let dbWorkouts = try DBWorkout.readAll(in: database) + let metadataKeys = try DBMetadataKey.readAll(in: database) let workouts = try dbWorkouts.map { entry in let events = try DBWorkoutEvent.events(for: entry.dataId, in: database) let activities = try DBWorkoutActivity.activities(for: entry.dataId, in: database) - return Workout(entry: entry, events: events, activities: activities) + let metadata: [String : MetadataValue] = try DBMetadata.metadata(for: entry.dataId, in: database).reduce(into: [:]) { dict, item in + let key = metadataKeys[item.keyId!]! + dict[key] = MetadataValue(entry: item) + } + return Workout(entry: entry, events: events, activities: activities, metadata: metadata) } DispatchQueue.main.async { diff --git a/HealthImport/Model/MetadataValue.swift b/HealthImport/Model/MetadataValue.swift new file mode 100644 index 0000000..969d161 --- /dev/null +++ b/HealthImport/Model/MetadataValue.swift @@ -0,0 +1,65 @@ +import Foundation + +enum MetadataValue { + + case string(value: String) + case number(value: Double) + case date(value: Date) + case numerical(value: Double, unit: String) + case data(value: Data) + + enum ValueType: Int { + + /// Uses only the `string_value` column + case string = 0 + + /// Uses only the `numerical_value` column + case number = 1 + + /// Uses only the `date_value` column + case date = 2 + + /// Uses the `string_value` column for the unit, and the `numerical_value` column for the number + case numerical = 3 + + /// Uses only the `data_value` column + case data = 4 + } +} + +extension MetadataValue { + + init(entry: DBMetadata) { + let valueType = ValueType(rawValue: entry.valueType)! + switch valueType { + case .string: + self = .string(value: entry.string!) + case .number: + self = .number(value: entry.number!) + case .date: + self = .date(value: .init(timeIntervalSinceReferenceDate: entry.date!)) + case .numerical: + self = .numerical(value: entry.number!, unit: entry.string!) + case .data: + self = .data(value: entry.data!) + } + } +} + +extension MetadataValue: CustomStringConvertible { + + var description: String { + switch self { + case .string(let value): + return value + case .number(let value): + return "\(value)" + case .date(let value): + return value.timeAndDateText + case .numerical(let value, let unit): + return String(format: "%.3f %s", value, unit) + case .data(let value): + return value.description + } + } +} diff --git a/HealthImport/Model/Workout.swift b/HealthImport/Model/Workout.swift index a084c92..8c8e497 100644 --- a/HealthImport/Model/Workout.swift +++ b/HealthImport/Model/Workout.swift @@ -1,5 +1,5 @@ import Foundation - +import Collections private let df: DateFormatter = { let df = DateFormatter() @@ -28,6 +28,8 @@ struct Workout { let activities: [WorkoutActivity] + let metadata: OrderedDictionary + var firstActivityDate: Date? { activities.map { $0.startDate }.min() } @@ -51,7 +53,7 @@ struct Workout { activities.first?.activityType.description ?? "Unknown activity" } - init(id: Int, totalDistance: Double? = nil, goalType: Int? = nil, goal: Double? = nil, condenserVersion: Int? = nil, condenserDate: Date? = nil, events: [WorkoutEvent] = [], activities: [WorkoutActivity] = []) { + init(id: Int, totalDistance: Double? = nil, goalType: Int? = nil, goal: Double? = nil, condenserVersion: Int? = nil, condenserDate: Date? = nil, events: [WorkoutEvent] = [], activities: [WorkoutActivity] = [], metadata: [String : MetadataValue] = [:]) { self.id = id self.totalDistance = totalDistance self.goalType = goalType @@ -60,12 +62,13 @@ struct Workout { self.condenserDate = condenserDate self.events = events self.activities = activities + self.metadata = .init(uniqueKeys: metadata.keys, values: metadata.values) } } extension Workout { - init(entry: DBWorkout, events: [DBWorkoutEvent], activities: [DBWorkoutActivity]) { + init(entry: DBWorkout, events: [DBWorkoutEvent], activities: [DBWorkoutActivity], metadata: [String : MetadataValue]) { self.id = entry.dataId self.totalDistance = entry.totalDistance self.goalType = entry.goalType @@ -74,6 +77,7 @@ extension Workout { self.condenserDate = entry.condenserDate.map { Date(timeIntervalSinceReferenceDate: $0) } self.events = events.map(WorkoutEvent.init) self.activities = activities.map(WorkoutActivity.init) + self.metadata = .init(uniqueKeys: metadata.keys, values: metadata.values) } } diff --git a/HealthImport/WorkoutDetailView.swift b/HealthImport/WorkoutDetailView.swift index 1462b45..c69718e 100644 --- a/HealthImport/WorkoutDetailView.swift +++ b/HealthImport/WorkoutDetailView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Collections struct WorkoutDetailView: View { @@ -37,6 +38,11 @@ struct WorkoutDetailView: View { } } } + if !workout.metadata.isEmpty { + ForEach(workout.metadata.elements, id:\.key) { (key, value) in + DetailRow(key, value: value) + } + } } .navigationTitle(workout.typeString) .navigationDestination(for: WorkoutActivity.self) { activity in @@ -81,3 +87,8 @@ struct WorkoutDetailView: View { ])) } } + +extension String: Identifiable { + + public var id: Self { self } +}