Extract tables, get timezone for samples

This commit is contained in:
Christoph Hagen 2024-02-02 13:06:27 +01:00
parent 77be6d5989
commit da0e758b35
16 changed files with 594 additions and 124 deletions

View File

@ -38,7 +38,7 @@
E201EC772B626FC1005B83D3 /* MetadataKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = E201EC762B626FC1005B83D3 /* MetadataKey.swift */; };
E201EC792B627572005B83D3 /* MetadataKey+SQLite.swift in Sources */ = {isa = PBXBuildFile; fileRef = E201EC782B627572005B83D3 /* MetadataKey+SQLite.swift */; };
E201EC7B2B6275CA005B83D3 /* Metadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = E201EC7A2B6275CA005B83D3 /* Metadata.swift */; };
E201EC7D2B62930E005B83D3 /* Sample+SQLite.swift in Sources */ = {isa = PBXBuildFile; fileRef = E201EC7C2B62930E005B83D3 /* Sample+SQLite.swift */; };
E201EC7D2B62930E005B83D3 /* SamplesTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E201EC7C2B62930E005B83D3 /* SamplesTable.swift */; };
E201EC7F2B629B4C005B83D3 /* SampleListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E201EC7E2B629B4C005B83D3 /* SampleListView.swift */; };
E201EC812B631708005B83D3 /* Goal.swift in Sources */ = {isa = PBXBuildFile; fileRef = E201EC802B631708005B83D3 /* Goal.swift */; };
E27BC67A2B5D99AC003A8873 /* LocationSample.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6792B5D99AC003A8873 /* LocationSample.swift */; };
@ -47,8 +47,8 @@
E27BC6822B5E762D003A8873 /* LocationSampleDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6812B5E762D003A8873 /* LocationSampleDetailView.swift */; };
E27BC6842B5E76A4003A8873 /* Location+Mock.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6832B5E76A4003A8873 /* Location+Mock.swift */; };
E27BC6862B5FBF0B003A8873 /* Sample.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6852B5FBF0B003A8873 /* Sample.swift */; };
E27BC6882B5FC220003A8873 /* Sample+Quantity.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6872B5FC220003A8873 /* Sample+Quantity.swift */; };
E27BC68A2B5FC255003A8873 /* Sample+Unit.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6892B5FC255003A8873 /* Sample+Unit.swift */; };
E27BC6882B5FC220003A8873 /* QuantitySamplesTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6872B5FC220003A8873 /* QuantitySamplesTable.swift */; };
E27BC68A2B5FC255003A8873 /* UnitStringsTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC6892B5FC255003A8873 /* UnitStringsTable.swift */; };
E27BC68C2B5FC842003A8873 /* ActivitySamplesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC68B2B5FC842003A8873 /* ActivitySamplesView.swift */; };
E27BC68E2B5FCBD5003A8873 /* WorkoutEvent+SQLite.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC68D2B5FCBD5003A8873 /* WorkoutEvent+SQLite.swift */; };
E27BC6902B5FCEA4003A8873 /* WorkoutActivity+SQLite.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27BC68F2B5FCEA4003A8873 /* WorkoutActivity+SQLite.swift */; };
@ -62,6 +62,9 @@
E2FDFF1C2B6BD0D20080A7B3 /* HKDatabaseFile+Interface.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FDFF1B2B6BD0D20080A7B3 /* HKDatabaseFile+Interface.swift */; };
E2FDFF202B6BE34C0080A7B3 /* SwiftProtobuf in Frameworks */ = {isa = PBXBuildFile; productRef = E2FDFF1F2B6BE34C0080A7B3 /* SwiftProtobuf */; };
E2FDFF222B6BE35B0080A7B3 /* EventMetadata.pb.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FDFF212B6BE35B0080A7B3 /* EventMetadata.pb.swift */; };
E2FDFF252B6C50A80080A7B3 /* ObjectsTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FDFF242B6C50A80080A7B3 /* ObjectsTable.swift */; };
E2FDFF272B6C56C70080A7B3 /* DataProvenancesTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FDFF262B6C56C70080A7B3 /* DataProvenancesTable.swift */; };
E2FDFF292B6D10D60080A7B3 /* String+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = E2FDFF282B6D10D60080A7B3 /* String+Extensions.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@ -93,7 +96,7 @@
E201EC762B626FC1005B83D3 /* MetadataKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetadataKey.swift; sourceTree = "<group>"; };
E201EC782B627572005B83D3 /* MetadataKey+SQLite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MetadataKey+SQLite.swift"; sourceTree = "<group>"; };
E201EC7A2B6275CA005B83D3 /* Metadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Metadata.swift; sourceTree = "<group>"; };
E201EC7C2B62930E005B83D3 /* Sample+SQLite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sample+SQLite.swift"; sourceTree = "<group>"; };
E201EC7C2B62930E005B83D3 /* SamplesTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SamplesTable.swift; sourceTree = "<group>"; };
E201EC7E2B629B4C005B83D3 /* SampleListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SampleListView.swift; sourceTree = "<group>"; };
E201EC802B631708005B83D3 /* Goal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Goal.swift; sourceTree = "<group>"; };
E27BC6792B5D99AC003A8873 /* LocationSample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationSample.swift; sourceTree = "<group>"; };
@ -102,8 +105,8 @@
E27BC6812B5E762D003A8873 /* LocationSampleDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationSampleDetailView.swift; sourceTree = "<group>"; };
E27BC6832B5E76A4003A8873 /* Location+Mock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Location+Mock.swift"; sourceTree = "<group>"; };
E27BC6852B5FBF0B003A8873 /* Sample.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sample.swift; sourceTree = "<group>"; };
E27BC6872B5FC220003A8873 /* Sample+Quantity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sample+Quantity.swift"; sourceTree = "<group>"; };
E27BC6892B5FC255003A8873 /* Sample+Unit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sample+Unit.swift"; sourceTree = "<group>"; };
E27BC6872B5FC220003A8873 /* QuantitySamplesTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuantitySamplesTable.swift; sourceTree = "<group>"; };
E27BC6892B5FC255003A8873 /* UnitStringsTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnitStringsTable.swift; sourceTree = "<group>"; };
E27BC68B2B5FC842003A8873 /* ActivitySamplesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivitySamplesView.swift; sourceTree = "<group>"; };
E27BC68D2B5FCBD5003A8873 /* WorkoutEvent+SQLite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkoutEvent+SQLite.swift"; sourceTree = "<group>"; };
E27BC68F2B5FCEA4003A8873 /* WorkoutActivity+SQLite.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "WorkoutActivity+SQLite.swift"; sourceTree = "<group>"; };
@ -115,6 +118,9 @@
E2FDFF192B6BB6A40080A7B3 /* HKHealthStore+Interface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HKHealthStore+Interface.swift"; sourceTree = "<group>"; };
E2FDFF1B2B6BD0D20080A7B3 /* HKDatabaseFile+Interface.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HKDatabaseFile+Interface.swift"; sourceTree = "<group>"; };
E2FDFF212B6BE35B0080A7B3 /* EventMetadata.pb.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EventMetadata.pb.swift; sourceTree = "<group>"; };
E2FDFF242B6C50A80080A7B3 /* ObjectsTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ObjectsTable.swift; sourceTree = "<group>"; };
E2FDFF262B6C56C70080A7B3 /* DataProvenancesTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataProvenancesTable.swift; sourceTree = "<group>"; };
E2FDFF282B6D10D60080A7B3 /* String+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Extensions.swift"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@ -199,6 +205,7 @@
885002812B5C37B700E7D4DB /* Model */ = {
isa = PBXGroup;
children = (
E2FDFF232B6C509D0080A7B3 /* Tables */,
E2FDFF212B6BE35B0080A7B3 /* EventMetadata.pb.swift */,
E201EC802B631708005B83D3 /* Goal.swift */,
E27BC6792B5D99AC003A8873 /* LocationSample.swift */,
@ -208,9 +215,6 @@
885002A22B5D217600E7D4DB /* MetadataValue.swift */,
8850029E2B5D1C7000E7D4DB /* MetadataValue+SQLite.swift */,
E27BC6852B5FBF0B003A8873 /* Sample.swift */,
E27BC6872B5FC220003A8873 /* Sample+Quantity.swift */,
E201EC7C2B62930E005B83D3 /* Sample+SQLite.swift */,
E27BC6892B5FC255003A8873 /* Sample+Unit.swift */,
8850027E2B5C36A700E7D4DB /* Workout.swift */,
8850027A2B5C35BF00E7D4DB /* Workout+SQLite.swift */,
E27BC68F2B5FCEA4003A8873 /* WorkoutActivity+SQLite.swift */,
@ -232,6 +236,7 @@
885002982B5D15D200E7D4DB /* HKWorkoutSwimmingLocationType+Extensions.swift */,
8850029A2B5D16E200E7D4DB /* TimeInterval+Extensions.swift */,
E27BC67D2B5E6CE3003A8873 /* Sequence+Extensions.swift */,
E2FDFF282B6D10D60080A7B3 /* String+Extensions.swift */,
);
path = Support;
sourceTree = "<group>";
@ -246,6 +251,18 @@
path = API;
sourceTree = "<group>";
};
E2FDFF232B6C509D0080A7B3 /* Tables */ = {
isa = PBXGroup;
children = (
E2FDFF262B6C56C70080A7B3 /* DataProvenancesTable.swift */,
E2FDFF242B6C50A80080A7B3 /* ObjectsTable.swift */,
E27BC6872B5FC220003A8873 /* QuantitySamplesTable.swift */,
E201EC7C2B62930E005B83D3 /* SamplesTable.swift */,
E27BC6892B5FC255003A8873 /* UnitStringsTable.swift */,
);
path = Tables;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@ -339,6 +356,7 @@
885002972B5D157900E7D4DB /* HKWorkoutSessionLocationType+Extensions.swift in Sources */,
885002792B5C320400E7D4DB /* Optional+Extensions.swift in Sources */,
E201EC792B627572005B83D3 /* MetadataKey+SQLite.swift in Sources */,
E2FDFF252B6C50A80080A7B3 /* ObjectsTable.swift in Sources */,
885002992B5D15D200E7D4DB /* HKWorkoutSwimmingLocationType+Extensions.swift in Sources */,
E27BC6822B5E762D003A8873 /* LocationSampleDetailView.swift in Sources */,
E201EC752B626B19005B83D3 /* Metadata+Mock.swift in Sources */,
@ -355,8 +373,8 @@
E27BC6842B5E76A4003A8873 /* Location+Mock.swift in Sources */,
885002932B5D129300E7D4DB /* ActivityDetailView.swift in Sources */,
8850029F2B5D1C7000E7D4DB /* MetadataValue+SQLite.swift in Sources */,
E27BC6882B5FC220003A8873 /* Sample+Quantity.swift in Sources */,
E201EC7D2B62930E005B83D3 /* Sample+SQLite.swift in Sources */,
E27BC6882B5FC220003A8873 /* QuantitySamplesTable.swift in Sources */,
E201EC7D2B62930E005B83D3 /* SamplesTable.swift in Sources */,
8850027B2B5C35BF00E7D4DB /* Workout+SQLite.swift in Sources */,
E27BC6962B5FD61D003A8873 /* WorkoutEvent+Mock.swift in Sources */,
E27BC6802B5E74D7003A8873 /* LocationSampleListView.swift in Sources */,
@ -366,13 +384,15 @@
E201EC7B2B6275CA005B83D3 /* Metadata.swift in Sources */,
E27BC67A2B5D99AC003A8873 /* LocationSample.swift in Sources */,
885002952B5D147100E7D4DB /* DetailRow.swift in Sources */,
E2FDFF292B6D10D60080A7B3 /* String+Extensions.swift in Sources */,
E201EC732B626A30005B83D3 /* WorkoutActivity+Mock.swift in Sources */,
E2FDFF1A2B6BB6A40080A7B3 /* HKHealthStore+Interface.swift in Sources */,
E27BC68A2B5FC255003A8873 /* Sample+Unit.swift in Sources */,
E27BC68A2B5FC255003A8873 /* UnitStringsTable.swift in Sources */,
8850029D2B5D197300E7D4DB /* EventDetailView.swift in Sources */,
E27BC6862B5FBF0B003A8873 /* Sample.swift in Sources */,
E27BC67E2B5E6CE3003A8873 /* Sequence+Extensions.swift in Sources */,
E27BC68C2B5FC842003A8873 /* ActivitySamplesView.swift in Sources */,
E2FDFF272B6C56C70080A7B3 /* DataProvenancesTable.swift in Sources */,
E201EC812B631708005B83D3 /* Goal.swift in Sources */,
E27BC6942B5FD587003A8873 /* Workout+Mock.swift in Sources */,
E27BC68E2B5FCBD5003A8873 /* WorkoutEvent+SQLite.swift in Sources */,

View File

@ -11,12 +11,22 @@ struct ActivitySamplesView: View {
@State var samples: [(type: Sample.DataType, samples: [Sample])] = []
@State var timeZones: [TimeZone] = []
init(activity: HKWorkoutActivity) {
self.activity = activity
}
var body: some View {
List {
if !timeZones.isEmpty {
Section("Time Zones") {
ForEach(timeZones, id: \.identifier) { timeZone in
Text(timeZone.debugDescription)
}
}
}
Section("Samples") {
ForEach(samples, id: \.0) { entry in
NavigationLink {
SampleListView(type: entry.type, samples: entry.samples)
@ -24,6 +34,7 @@ struct ActivitySamplesView: View {
DetailRow(entry.type.description, value: entry.samples.count)
}
}
}
}.onAppear(perform: load)
}
@ -39,9 +50,15 @@ struct ActivitySamplesView: View {
let ordered = samples
.sorted(using: { $0.key.rawValue })
.map { (type: $0, samples: $1) }
let timeZones: Set<TimeZone> = samples.reduce(into: Set()) { timeZones, sample in
timeZones.formUnion(sample.value.compactMap { $0.timeZone })
}
DispatchQueue.main.async {
self.samples = ordered
self.timeZones = timeZones.sorted { $0.identifier }
}
} catch {
print("Failed to load samples: \(error)")
}

View File

@ -11,6 +11,8 @@ final class HealthDatabase: ObservableObject {
private let database: Connection
private let samples: SamplesTable
@Published
var workouts: [Workout] = []
@ -22,6 +24,7 @@ final class HealthDatabase: ObservableObject {
init(fileUrl: URL, database: Connection) {
self.fileUrl = fileUrl
self.database = database
self.samples = .init(database: database)
DispatchQueue.global().async {
self.readAllWorkouts()
}
@ -49,13 +52,13 @@ final class HealthDatabase: ObservableObject {
}
func samples(for activity: HKWorkoutActivity) throws -> [Sample.DataType : [Sample]] {
try Sample.samples(from: activity.startDate, to: activity.currentEndDate, in: database).reduce(into: [:]) {
try samples.samples(from: activity.startDate, to: activity.currentEndDate).reduce(into: [:]) {
$0[$1.dataType] = ($0[$1.dataType] ?? []) + [$1]
}
}
func sampleCount(for activity: HKWorkoutActivity) throws -> Int {
try Sample.sampleCount(from: activity.startDate, to: activity.currentEndDate, in: database)
try samples.sampleCount(from: activity.startDate, to: activity.currentEndDate)
}
var activities: [HKWorkoutActivity] {
@ -79,6 +82,15 @@ final class HealthDatabase: ObservableObject {
convenience init(database: Database) {
self.init(fileUrl: .init(filePath: "/"), database: database)
}
func insert(workout: Workout, into store: HKHealthStore) async throws -> HKWorkout? {
guard let configuration = workout.activities.first?.workoutConfiguration else {
return nil
}
let builder = HKWorkoutBuilder(healthStore: store, configuration: configuration, device: nil)
return try await builder.finishWorkout()
}
}
private extension HKWorkoutActivity {

View File

@ -1,23 +0,0 @@
import Foundation
import SQLite
extension Sample {
private static let table = Table("quantity_samples")
private static let rowDataId = Expression<Int>("data_id")
// NOTE: Technically optional
private static let rowQuantity = Expression<Double>("quantity")
private static let rowOriginalQuantity = Expression<Double?>("original_quantity")
/// References `ROW_ID` on table `unit_strings`
private static let rowOriginalUnit = Expression<Int?>("original_unit")
static func quantity(for id: Int, in database: Database) throws -> (quantity: Double, original: Double?, unit: Int?)? {
try database.prepare(table.filter(rowDataId == id).limit(1)).map {
(quantity: $0[rowQuantity], original: $0[rowOriginalQuantity], unit: $0[rowOriginalUnit])
}.first
}
}

View File

@ -1,44 +0,0 @@
import Foundation
import SQLite
extension Sample {
private static let table = Table("samples")
private static let columnDataId = Expression<Int>("data_id")
// NOTE: Technically optional
private static let columnStartDate = Expression<Double>("start_date")
// NOTE: Technically optional
private static let columnEndDate = Expression<Double>("end_date")
private static let columnDataType = Expression<Int>("data_type")
static func samples(from start: Date, to end: Date, in database: Database) throws -> [Sample] {
let start = start.timeIntervalSinceReferenceDate
let end = end.timeIntervalSinceReferenceDate
return try database.prepare(table.filter(columnStartDate >= start && columnEndDate <= end)).compactMap { (row: Row) -> Sample? in
let dataId = row[columnDataId]
guard let quantity = try Sample.quantity(for: dataId, in: database) else {
return nil
}
let unit = try quantity.unit.map { try Sample.unit(for: $0, in: database) }
return Sample(
startDate: Date(timeIntervalSinceReferenceDate: row[columnStartDate]),
endDate: Date(timeIntervalSinceReferenceDate: row[columnEndDate]),
dataType: .init(rawValue: row[columnDataType]),
quantity: quantity.quantity,
originalQuantity: quantity.original,
originalUnit: unit)
}
}
static func sampleCount(from start: Date, to end: Date, in database: Database) throws -> Int {
let start = start.timeIntervalSinceReferenceDate
let end = end.timeIntervalSinceReferenceDate
return try database.scalar(table.filter(columnStartDate >= start && columnEndDate <= end).count)
}
}

View File

@ -1,18 +0,0 @@
import Foundation
import SQLite
extension Sample {
private static let table = Table("unit_strings")
private static let rowId = Expression<Int>("ROW_ID")
// - NOTE: Technically optional
private static let rowUnitString = Expression<String>("quantity")
static func unit(for id: Int, in database: Database) throws -> String? {
try database.prepare(table.filter(rowId == id).limit(1)).map { row in
row[rowUnitString]
}.first
}
}

View File

@ -8,12 +8,25 @@ struct Sample {
let dataType: DataType
let quantity: Double
let quantity: Double?
let originalQuantity: Double?
let originalUnit: String?
let timeZoneName: String?
var timeZone: TimeZone? {
guard let timeZoneName else {
return nil
}
guard let zone = TimeZone(identifier: timeZoneName) else {
print("No time zone for '\(timeZoneName)'")
return nil
}
return zone
}
var duration: TimeInterval {
endDate.timeIntervalSince(startDate)
}
@ -25,12 +38,23 @@ struct Sample {
return " (\(originalQuantity) \(originalUnit))"
}
var quantityText: String {
guard let quantity else {
return "-"
}
return "\(quantity)"
}
var dateText: String {
startDate.timeAndDateText(in: timeZone ?? .current)
}
}
extension Sample: CustomStringConvertible {
var description: String {
"\(startDate.timeAndDateText) (\(Int(duration)) s) \(quantity)\(originalQuantityText)"
"\(dateText) (\(Int(duration)) s) \(quantityText)\(originalQuantityText)"
}
}

View File

@ -0,0 +1,266 @@
import Foundation
import SQLite
import HealthKit
struct DataProvenancesTable {
private let database: Connection
init(database: Connection) {
self.database = database
}
func create() throws {
try database.execute("CREATE TABLE data_provenances (ROWID INTEGER PRIMARY KEY AUTOINCREMENT, sync_provenance INTEGER NOT NULL, origin_product_type TEXT NOT NULL, origin_build TEXT NOT NULL, local_product_type TEXT NOT NULL, local_build TEXT NOT NULL, source_id INTEGER NOT NULL, device_id INTEGER NOT NULL, contributor_id INTEGER NOT NULL, source_version TEXT NOT NULL, tz_name TEXT NOT NULL, origin_major_version INTEGER NOT NULL, origin_minor_version INTEGER NOT NULL, origin_patch_version INTEGER NOT NULL, sync_identity INTEGER NOT NULL, derived_flags INTEGER NOT NULL, UNIQUE(sync_provenance, origin_product_type, origin_build, local_product_type, local_build, source_id, device_id, contributor_id, source_version, tz_name, origin_major_version, origin_minor_version, origin_patch_version, sync_identity))")
}
let table = Table("data_provenances")
let rowId = Expression<Int>("ROWID")
let syncProvenance = Expression<Int>("sync_provenance")
/// Device that created the data (e.g. Watch)
let originProductType = Expression<String>("origin_product_type")
let originBuild = Expression<String>("origin_build")
/// Device saving the data (e.g. iPhone)
let localProductType = Expression<String>("local_product_type")
let localBuild = Expression<String>("local_build")
let sourceId = Expression<Int>("source_id")
let deviceId = Expression<Int>("device_id")
let contributorId = Expression<Int>("contributor_id")
let sourceVersion = Expression<String>("source_version")
let tzName = Expression<String>("tz_name")
let originMajorVersion = Expression<Int>("origin_major_version")
let originMinorVersion = Expression<Int>("origin_minor_version")
let originPatchVersion = Expression<Int>("origin_patch_version")
let syncIdentity = Expression<Int>("sync_identity")
let derivedFlags = Expression<Int>("derived_flags")
func device(for rowId: Int) throws -> HKDevice? {
try database.pluck(table.filter(self.rowId == rowId)).map { row in
let productType = row[originProductType]
return HKDevice(
name: nil,
manufacturer: "Apple Inc.",
model: productTypeToHumanName[productType],
hardwareVersion: productType,
firmwareVersion: nil,
softwareVersion: "\(row[originMajorVersion]).\(row[originMinorVersion]).\(row[originPatchVersion])",
localIdentifier: nil,
udiDeviceIdentifier: nil)
}
}
func timeZoneName(for rowId: Int) throws -> String? {
try database.pluck(table.filter(self.rowId == rowId)).map { $0[tzName] }
}
}
private let productTypeToHumanName: [String : String] = [
"i386" : "iPhone Simulator",
"x86_64" : "iPhone Simulator",
"arm64" : "iPhone Simulator",
"iPhone1,1" : "iPhone",
"iPhone1,2" : "iPhone 3G",
"iPhone2,1" : "iPhone 3GS",
"iPhone3,1" : "iPhone 4",
"iPhone3,2" : "iPhone 4 GSM Rev A",
"iPhone3,3" : "iPhone 4 CDMA",
"iPhone4,1" : "iPhone 4S",
"iPhone5,1" : "iPhone 5 (GSM)",
"iPhone5,2" : "iPhone 5 (GSM+CDMA)",
"iPhone5,3" : "iPhone 5C (GSM)",
"iPhone5,4" : "iPhone 5C (Global)",
"iPhone6,1" : "iPhone 5S (GSM)",
"iPhone6,2" : "iPhone 5S (Global)",
"iPhone7,1" : "iPhone 6 Plus",
"iPhone7,2" : "iPhone 6",
"iPhone8,1" : "iPhone 6s",
"iPhone8,2" : "iPhone 6s Plus",
"iPhone8,4" : "iPhone SE (GSM)",
"iPhone9,1" : "iPhone 7",
"iPhone9,2" : "iPhone 7 Plus",
"iPhone9,3" : "iPhone 7",
"iPhone9,4" : "iPhone 7 Plus",
"iPhone10,1" : "iPhone 8",
"iPhone10,2" : "iPhone 8 Plus",
"iPhone10,3" : "iPhone X Global",
"iPhone10,4" : "iPhone 8",
"iPhone10,5" : "iPhone 8 Plus",
"iPhone10,6" : "iPhone X GSM",
"iPhone11,2" : "iPhone XS",
"iPhone11,4" : "iPhone XS Max",
"iPhone11,6" : "iPhone XS Max Global",
"iPhone11,8" : "iPhone XR",
"iPhone12,1" : "iPhone 11",
"iPhone12,3" : "iPhone 11 Pro",
"iPhone12,5" : "iPhone 11 Pro Max",
"iPhone12,8" : "iPhone SE 2nd Gen",
"iPhone13,1" : "iPhone 12 Mini",
"iPhone13,2" : "iPhone 12",
"iPhone13,3" : "iPhone 12 Pro",
"iPhone13,4" : "iPhone 12 Pro Max",
"iPhone14,2" : "iPhone 13 Pro",
"iPhone14,3" : "iPhone 13 Pro Max",
"iPhone14,4" : "iPhone 13 Mini",
"iPhone14,5" : "iPhone 13",
"iPhone14,6" : "iPhone SE 3rd Gen",
"iPhone14,7" : "iPhone 14",
"iPhone14,8" : "iPhone 14 Plus",
"iPhone15,2" : "iPhone 14 Pro",
"iPhone15,3" : "iPhone 14 Pro Max",
"iPhone15,4" : "iPhone 15",
"iPhone15,5" : "iPhone 15 Plus",
"iPhone16,1" : "iPhone 15 Pro",
"iPhone16,2" : "iPhone 15 Pro Max",
"iPod1,1" : "1st Gen iPod",
"iPod2,1" : "2nd Gen iPod",
"iPod3,1" : "3rd Gen iPod",
"iPod4,1" : "4th Gen iPod",
"iPod5,1" : "5th Gen iPod",
"iPod7,1" : "6th Gen iPod",
"iPod9,1" : "7th Gen iPod",
"iPad1,1" : "iPad",
"iPad1,2" : "iPad 3G",
"iPad2,1" : "2nd Gen iPad",
"iPad2,2" : "2nd Gen iPad GSM",
"iPad2,3" : "2nd Gen iPad CDMA",
"iPad2,4" : "2nd Gen iPad New Revision",
"iPad3,1" : "3rd Gen iPad",
"iPad3,2" : "3rd Gen iPad CDMA",
"iPad3,3" : "3rd Gen iPad GSM",
"iPad2,5" : "iPad mini",
"iPad2,6" : "iPad mini GSM+LTE",
"iPad2,7" : "iPad mini CDMA+LTE",
"iPad3,4" : "4th Gen iPad",
"iPad3,5" : "4th Gen iPad GSM+LTE",
"iPad3,6" : "4th Gen iPad CDMA+LTE",
"iPad4,1" : "iPad Air (WiFi)",
"iPad4,2" : "iPad Air (GSM+CDMA)",
"iPad4,3" : "1st Gen iPad Air (China)",
"iPad4,4" : "iPad mini Retina (WiFi)",
"iPad4,5" : "iPad mini Retina (GSM+CDMA)",
"iPad4,6" : "iPad mini Retina (China)",
"iPad4,7" : "iPad mini 3 (WiFi)",
"iPad4,8" : "iPad mini 3 (GSM+CDMA)",
"iPad4,9" : "iPad Mini 3 (China)",
"iPad5,1" : "iPad mini 4 (WiFi)",
"iPad5,2" : "4th Gen iPad mini (WiFi+Cellular)",
"iPad5,3" : "iPad Air 2 (WiFi)",
"iPad5,4" : "iPad Air 2 (Cellular)",
"iPad6,3" : "iPad Pro (9.7 inch, WiFi)",
"iPad6,4" : "iPad Pro (9.7 inch, WiFi+LTE)",
"iPad6,7" : "iPad Pro (12.9 inch, WiFi)",
"iPad6,8" : "iPad Pro (12.9 inch, WiFi+LTE)",
"iPad6,11" : "iPad (2017)",
"iPad6,12" : "iPad (2017)",
"iPad7,1" : "iPad Pro 2nd Gen (WiFi)",
"iPad7,2" : "iPad Pro 2nd Gen (WiFi+Cellular)",
"iPad7,3" : "iPad Pro 10.5-inch 2nd Gen",
"iPad7,4" : "iPad Pro 10.5-inch 2nd Gen",
"iPad7,5" : "iPad 6th Gen (WiFi)",
"iPad7,6" : "iPad 6th Gen (WiFi+Cellular)",
"iPad7,11" : "iPad 7th Gen 10.2-inch (WiFi)",
"iPad7,12" : "iPad 7th Gen 10.2-inch (WiFi+Cellular)",
"iPad8,1" : "iPad Pro 11 inch 3rd Gen (WiFi)",
"iPad8,2" : "iPad Pro 11 inch 3rd Gen (1TB, WiFi)",
"iPad8,3" : "iPad Pro 11 inch 3rd Gen (WiFi+Cellular)",
"iPad8,4" : "iPad Pro 11 inch 3rd Gen (1TB, WiFi+Cellular)",
"iPad8,5" : "iPad Pro 12.9 inch 3rd Gen (WiFi)",
"iPad8,6" : "iPad Pro 12.9 inch 3rd Gen (1TB, WiFi)",
"iPad8,7" : "iPad Pro 12.9 inch 3rd Gen (WiFi+Cellular)",
"iPad8,8" : "iPad Pro 12.9 inch 3rd Gen (1TB, WiFi+Cellular)",
"iPad8,9" : "iPad Pro 11 inch 4th Gen (WiFi)",
"iPad8,10" : "iPad Pro 11 inch 4th Gen (WiFi+Cellular)",
"iPad8,11" : "iPad Pro 12.9 inch 4th Gen (WiFi)",
"iPad8,12" : "iPad Pro 12.9 inch 4th Gen (WiFi+Cellular)",
"iPad11,1" : "iPad mini 5th Gen (WiFi)",
"iPad11,2" : "iPad mini 5th Gen",
"iPad11,3" : "iPad Air 3rd Gen (WiFi)",
"iPad11,4" : "iPad Air 3rd Gen",
"iPad11,6" : "iPad 8th Gen (WiFi)",
"iPad11,7" : "iPad 8th Gen (WiFi+Cellular)",
"iPad12,1" : "iPad 9th Gen (WiFi)",
"iPad12,2" : "iPad 9th Gen (WiFi+Cellular)",
"iPad14,1" : "iPad mini 6th Gen (WiFi)",
"iPad14,2" : "iPad mini 6th Gen (WiFi+Cellular)",
"iPad13,1" : "iPad Air 4th Gen (WiFi)",
"iPad13,2" : "iPad Air 4th Gen (WiFi+Cellular)",
"iPad13,4" : "iPad Pro 11 inch 5th Gen",
"iPad13,5" : "iPad Pro 11 inch 5th Gen",
"iPad13,6" : "iPad Pro 11 inch 5th Gen",
"iPad13,7" : "iPad Pro 11 inch 5th Gen",
"iPad13,8" : "iPad Pro 12.9 inch 5th Gen",
"iPad13,9" : "iPad Pro 12.9 inch 5th Gen",
"iPad13,10" : "iPad Pro 12.9 inch 5th Gen",
"iPad13,11" : "iPad Pro 12.9 inch 5th Gen",
"iPad13,16" : "iPad Air 5th Gen (WiFi)",
"iPad13,17" : "iPad Air 5th Gen (WiFi+Cellular)",
"iPad13,18" : "iPad 10th Gen",
"iPad13,19" : "iPad 10th Gen",
"iPad14,3" : "iPad Pro 11 inch 4th Gen",
"iPad14,4" : "iPad Pro 11 inch 4th Gen",
"iPad14,5" : "iPad Pro 12.9 inch 6th Gen",
"iPad14,6" : "iPad Pro 12.9 inch 6th Gen",
"Watch1,1" : "Apple Watch 38mm case",
"Watch1,2" : "Apple Watch 42mm case",
"Watch2,6" : "Apple Watch Series 1 38mm case",
"Watch2,7" : "Apple Watch Series 1 42mm case",
"Watch2,3" : "Apple Watch Series 2 38mm case",
"Watch2,4" : "Apple Watch Series 2 42mm case",
"Watch3,1" : "Apple Watch Series 3 38mm case (GPS+Cellular)",
"Watch3,2" : "Apple Watch Series 3 42mm case (GPS+Cellular)",
"Watch3,3" : "Apple Watch Series 3 38mm case (GPS)",
"Watch3,4" : "Apple Watch Series 3 42mm case (GPS)",
"Watch4,1" : "Apple Watch Series 4 40mm case (GPS)",
"Watch4,2" : "Apple Watch Series 4 44mm case (GPS)",
"Watch4,3" : "Apple Watch Series 4 40mm case (GPS+Cellular)",
"Watch4,4" : "Apple Watch Series 4 44mm case (GPS+Cellular)",
"Watch5,1" : "Apple Watch Series 5 40mm case (GPS)",
"Watch5,2" : "Apple Watch Series 5 44mm case (GPS)",
"Watch5,3" : "Apple Watch Series 5 40mm case (GPS+Cellular)",
"Watch5,4" : "Apple Watch Series 5 44mm case (GPS+Cellular)",
"Watch5,9" : "Apple Watch SE 40mm case (GPS)",
"Watch5,10" : "Apple Watch SE 44mm case (GPS)",
"Watch5,11" : "Apple Watch SE 40mm case (GPS+Cellular)",
"Watch5,12" : "Apple Watch SE 44mm case (GPS+Cellular)",
"Watch6,1" : "Apple Watch Series 6 40mm case (GPS)",
"Watch6,2" : "Apple Watch Series 6 44mm case (GPS)",
"Watch6,3" : "Apple Watch Series 6 40mm case (GPS+Cellular)",
"Watch6,4" : "Apple Watch Series 6 44mm case (GPS+Cellular)",
"Watch6,6" : "Apple Watch Series 7 41mm case (GPS)",
"Watch6,7" : "Apple Watch Series 7 45mm case (GPS)",
"Watch6,8" : "Apple Watch Series 7 41mm case (GPS+Cellular)",
"Watch6,9" : "Apple Watch Series 7 45mm case (GPS+Cellular)",
"Watch6,10" : "Apple Watch SE 40mm case (GPS)",
"Watch6,11" : "Apple Watch SE 44mm case (GPS)",
"Watch6,12" : "Apple Watch SE 40mm case (GPS+Cellular)",
"Watch6,13" : "Apple Watch SE 44mm case (GPS+Cellular)",
"Watch6,14" : "Apple Watch Series 8 41mm case (GPS)",
"Watch6,15" : "Apple Watch Series 8 45mm case (GPS)",
"Watch6,16" : "Apple Watch Series 8 41mm case (GPS+Cellular)",
"Watch6,17" : "Apple Watch Series 8 45mm case (GPS+Cellular)",
"Watch6,18" : "Apple Watch Ultra",
"Watch7,1" : "Apple Watch Series 9 41mm case (GPS)",
"Watch7,2" : "Apple Watch Series 9 45mm case (GPS)",
"Watch7,3" : "Apple Watch Series 9 41mm case (GPS+Cellular)",
"Watch7,4" : "Apple Watch Series 9 45mm case (GPS+Cellular)",
"Watch7,5" : "Apple Watch Ultra 2",
]

View File

@ -0,0 +1,37 @@
import Foundation
import SQLite
struct ObjectsTable {
private let database: Connection
init(database: Connection) {
self.database = database
}
func create() throws {
try database.execute("CREATE TABLE objects (data_id INTEGER PRIMARY KEY AUTOINCREMENT, uuid BLOB UNIQUE, provenance INTEGER NOT NULL REFERENCES data_provenances (ROWID) ON DELETE CASCADE, type INTEGER, creation_date REAL)")
}
let table = Table("objects")
let dataId = Expression<Int>("data_id")
let uuid = Expression<Data?>("uuid")
let provenance = Expression<Int>("provenance")
let type = Expression<Int?>("type")
let creationDate = Expression<Double?>("creation_date")
func object(for dataId: Int) throws -> (uuid: UUID, provenance: Int, type: Int, creationDate: Date)? {
try database.pluck(table.filter(self.dataId == dataId)).map { row in
let uuid = row[uuid]!.asUUID()!
let provenance = row[provenance]
let type = row[type]!
let creationDate = Date(timeIntervalSinceReferenceDate: row[creationDate]!)
return (uuid, provenance, type, creationDate)
}
}
}

View File

@ -0,0 +1,32 @@
import Foundation
import SQLite
struct QuantitySamplesTable {
private let database: Connection
init(database: Connection) {
self.database = database
}
func create() throws {
try database.execute("CREATE TABLE quantity_samples (data_id INTEGER PRIMARY KEY, quantity REAL, original_quantity REAL, original_unit INTEGER REFERENCES unit_strings (ROWID) ON DELETE NO ACTION)")
}
let table = Table("quantity_samples")
let dataId = Expression<Int>("data_id")
let quantity = Expression<Double?>("quantity")
let originalQuantity = Expression<Double?>("original_quantity")
/// References `ROWID` on table `unit_strings`
let originalUnit = Expression<Int?>("original_unit")
func quantity(for id: Int, in database: Database) throws -> (quantity: Double?, original: Double?, unit: Int?) {
try database.prepare(table.filter(dataId == id).limit(1)).map {
(quantity: $0[quantity], original: $0[originalQuantity], unit: $0[originalUnit])
}.first ?? (nil, nil, nil)
}
}

View File

@ -0,0 +1,88 @@
import Foundation
import SQLite
struct SamplesTable {
private let database: Connection
private let quantitySamples: QuantitySamplesTable
private let objects: ObjectsTable
private let dataProvenances: DataProvenancesTable
private let unitStrings: UnitStringsTable
init(database: Connection) {
self.database = database
self.quantitySamples = .init(database: database)
self.objects = .init(database: database)
self.dataProvenances = .init(database: database)
self.unitStrings = .init(database: database)
}
func create() throws {
try database.execute("CREATE TABLE samples (data_id INTEGER PRIMARY KEY, start_date REAL, end_date REAL, data_type INTEGER)")
}
private let table = Table("samples")
private let dataId = Expression<Int>("data_id")
// NOTE: Technically optional
private let startDate = Expression<Double>("start_date")
// NOTE: Technically optional
private let endDate = Expression<Double>("end_date")
private let dataType = Expression<Int>("data_type")
func samples(from start: Date, to end: Date) throws -> [Sample] {
let start = start.timeIntervalSinceReferenceDate
let end = end.timeIntervalSinceReferenceDate
// Samples: data_id, start_date, end_date, data_type
// JOIN quantity_samples on samples.data_id == quantity_samples.data_id
// quantity_samples: quantity, original_quantity, original_unit
// JOIN objects on samples.data_id == objects.data_id
// objects: data_id, uuid, provenance, type, creation_date
// JOIN data_provenances on objects.provenance == data_provenances.ROWID
// SELECT tz_name FROM data_provenances
let selection = table
.select(table[*],
quantitySamples.table[*],
dataProvenances.table[dataProvenances.tzName],
unitStrings.table[unitStrings.unitString])
.filter(startDate >= start && endDate <= end)
.join(.leftOuter, quantitySamples.table, on: table[dataId] == quantitySamples.table[quantitySamples.dataId])
.join(.leftOuter, objects.table, on: table[dataId] == objects.table[objects.dataId])
.join(.leftOuter, dataProvenances.table, on: objects.table[objects.provenance] == dataProvenances.table[dataProvenances.rowId])
.join(.leftOuter, unitStrings.table, on: quantitySamples.table[quantitySamples.originalUnit] == unitStrings.table[unitStrings.rowId])
return try database.prepare(selection).map { row in
let startDate = Date(timeIntervalSinceReferenceDate: row[startDate])
let endDate = Date(timeIntervalSinceReferenceDate: row[endDate])
let dataType = Sample.DataType(rawValue: row[dataType])
let quantity = row[quantitySamples.quantity]
let original = row[quantitySamples.originalQuantity]
let unit = row[unitStrings.unitString]
let timeZone = row[dataProvenances.tzName].nonEmpty
return .init(
startDate: startDate,
endDate: endDate,
dataType: dataType,
quantity: quantity,
originalQuantity: original,
originalUnit: unit,
timeZoneName: timeZone)
}
}
func sampleCount(from start: Date, to end: Date) throws -> Int {
let start = start.timeIntervalSinceReferenceDate
let end = end.timeIntervalSinceReferenceDate
return try database.scalar(table.filter(startDate >= start && endDate <= end).count)
}
}

View File

@ -0,0 +1,27 @@
import Foundation
import SQLite
struct UnitStringsTable {
private let database: Connection
init(database: Connection) {
self.database = database
}
func create() throws {
try database.execute("CREATE TABLE unit_strings (ROWID INTEGER PRIMARY KEY AUTOINCREMENT, unit_string TEXT UNIQUE)")
}
let table = Table("unit_strings")
let rowId = Expression<Int>("ROWID")
let unitString = Expression<String?>("unit_string")
func unit(for id: Int) throws -> String? {
try database.pluck(table.filter(rowId == id).limit(1)).map { row in
row[unitString]
}
}
}

View File

@ -51,7 +51,10 @@ enum WorkoutActivityTable {
let start = Date(timeIntervalSinceReferenceDate: row[columnStartDate])
let end = Date(timeIntervalSinceReferenceDate: row[columnEndDate])
let uuid = row[columnUUID].uuidString
let metadata: [String : Any] = [HKMetadataKeyExternalUUID : uuid]
var metadata: [String : Any] = [ : ]
metadata[HKMetadataKeyExternalUUID] = uuid
// duration: row[columnDuration]
// isPrimaryActivity: row[columnIsPrimaryActivity]
@ -123,19 +126,3 @@ private extension WorkoutActivityTable {
try NSKeyedUnarchiver.unarchivedObject(ofClass: HKQuantity.self, from: data)
}
}
private extension Data {
var uuidString: String {
let h = Array(self)
let parts = [h[0..<4], h[4..<6], h[6..<8], h[8..<10], h[10..<16]]
return parts.map { Data($0).hex }.joined(separator: "-")
}
}
private extension UUID {
init?(data: Data) {
self.init(uuidString: data.uuidString)
}
}

View File

@ -41,6 +41,37 @@ extension Data {
}
}
extension Data {
func asUUID() -> UUID? {
.init(data: self)
}
var uuidString: String? {
guard self.count == 16 else {
return nil
}
let h = Array(self)
let parts = [h[0..<4], h[4..<6], h[6..<8], h[8..<10], h[10..<16]]
return parts.map { Data($0).hex }.joined(separator: "-")
}
}
extension UUID {
init?(data: Data) {
guard let uuidString = data.uuidString else {
return nil
}
self.init(uuidString: uuidString)
}
var data: Data? {
.init(hex: uuidString.replacingOccurrences(of: "-", with: ""))
}
}
extension Data {

View File

@ -65,7 +65,13 @@ extension Date {
}
var timeAndDateText: String {
dateFormatter.string(from: self)
dateFormatter.timeZone = .current
return dateFormatter.string(from: self)
}
func timeAndDateText(in timeZone: TimeZone) -> String {
dateFormatter.timeZone = timeZone
return dateFormatter.string(from: self)
}
var timeAndDateWithSecondsText: String {

View File

@ -0,0 +1,8 @@
import Foundation
extension String {
var nonEmpty: String? {
self != "" ? self : nil
}
}