Extract metadata

This commit is contained in:
Christoph Hagen 2024-01-21 14:32:00 +01:00
parent 8ace8e9319
commit 0088e5df2e
8 changed files with 221 additions and 4 deletions

View File

@ -31,6 +31,12 @@
885002992B5D15D200E7D4DB /* HKWorkoutSwimmingLocationType+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 885002982B5D15D200E7D4DB /* HKWorkoutSwimmingLocationType+Extensions.swift */; }; 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 */; }; 8850029B2B5D16E200E7D4DB /* TimeInterval+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850029A2B5D16E200E7D4DB /* TimeInterval+Extensions.swift */; };
8850029D2B5D197300E7D4DB /* EventDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8850029C2B5D197300E7D4DB /* EventDetailView.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 */ /* End PBXBuildFile section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
@ -58,6 +64,9 @@
885002982B5D15D200E7D4DB /* HKWorkoutSwimmingLocationType+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HKWorkoutSwimmingLocationType+Extensions.swift"; sourceTree = "<group>"; }; 885002982B5D15D200E7D4DB /* HKWorkoutSwimmingLocationType+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "HKWorkoutSwimmingLocationType+Extensions.swift"; sourceTree = "<group>"; };
8850029A2B5D16E200E7D4DB /* TimeInterval+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TimeInterval+Extensions.swift"; sourceTree = "<group>"; }; 8850029A2B5D16E200E7D4DB /* TimeInterval+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TimeInterval+Extensions.swift"; sourceTree = "<group>"; };
8850029C2B5D197300E7D4DB /* EventDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventDetailView.swift; sourceTree = "<group>"; }; 8850029C2B5D197300E7D4DB /* EventDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventDetailView.swift; sourceTree = "<group>"; };
8850029E2B5D1C7000E7D4DB /* DBMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBMetadata.swift; sourceTree = "<group>"; };
885002A02B5D1E7400E7D4DB /* DBMetadataKey.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBMetadataKey.swift; sourceTree = "<group>"; };
885002A22B5D217600E7D4DB /* MetadataValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetadataValue.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@ -65,7 +74,10 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
885002A62B5D296700E7D4DB /* Collections in Frameworks */,
885002772B5C2FC400E7D4DB /* SQLite in Frameworks */, 885002772B5C2FC400E7D4DB /* SQLite in Frameworks */,
885002AA2B5D296700E7D4DB /* OrderedCollections in Frameworks */,
885002A82B5D296700E7D4DB /* DequeModule in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
@ -130,6 +142,8 @@
8850027A2B5C35BF00E7D4DB /* DBWorkout.swift */, 8850027A2B5C35BF00E7D4DB /* DBWorkout.swift */,
8850027C2B5C360300E7D4DB /* DBWorkoutEvent.swift */, 8850027C2B5C360300E7D4DB /* DBWorkoutEvent.swift */,
885002882B5C873C00E7D4DB /* DBWorkoutActivity.swift */, 885002882B5C873C00E7D4DB /* DBWorkoutActivity.swift */,
8850029E2B5D1C7000E7D4DB /* DBMetadata.swift */,
885002A02B5D1E7400E7D4DB /* DBMetadataKey.swift */,
); );
path = "Database Entries"; path = "Database Entries";
sourceTree = "<group>"; sourceTree = "<group>";
@ -140,6 +154,7 @@
8850027E2B5C36A700E7D4DB /* Workout.swift */, 8850027E2B5C36A700E7D4DB /* Workout.swift */,
885002842B5C7AD600E7D4DB /* WorkoutEvent.swift */, 885002842B5C7AD600E7D4DB /* WorkoutEvent.swift */,
8850028A2B5C896C00E7D4DB /* WorkoutActivity.swift */, 8850028A2B5C896C00E7D4DB /* WorkoutActivity.swift */,
885002A22B5D217600E7D4DB /* MetadataValue.swift */,
); );
path = Model; path = Model;
sourceTree = "<group>"; sourceTree = "<group>";
@ -176,6 +191,9 @@
name = HealthImport; name = HealthImport;
packageProductDependencies = ( packageProductDependencies = (
885002762B5C2FC400E7D4DB /* SQLite */, 885002762B5C2FC400E7D4DB /* SQLite */,
885002A52B5D296700E7D4DB /* Collections */,
885002A72B5D296700E7D4DB /* DequeModule */,
885002A92B5D296700E7D4DB /* OrderedCollections */,
); );
productName = HealthImport; productName = HealthImport;
productReference = 885002572B5C273C00E7D4DB /* HealthImport.app */; productReference = 885002572B5C273C00E7D4DB /* HealthImport.app */;
@ -207,6 +225,7 @@
mainGroup = 8850024E2B5C273C00E7D4DB; mainGroup = 8850024E2B5C273C00E7D4DB;
packageReferences = ( packageReferences = (
885002752B5C2FC400E7D4DB /* XCRemoteSwiftPackageReference "SQLite" */, 885002752B5C2FC400E7D4DB /* XCRemoteSwiftPackageReference "SQLite" */,
885002A42B5D296700E7D4DB /* XCRemoteSwiftPackageReference "swift-collections" */,
); );
productRefGroup = 885002582B5C273C00E7D4DB /* Products */; productRefGroup = 885002582B5C273C00E7D4DB /* Products */;
projectDirPath = ""; projectDirPath = "";
@ -245,10 +264,13 @@
885002852B5C7AD600E7D4DB /* WorkoutEvent.swift in Sources */, 885002852B5C7AD600E7D4DB /* WorkoutEvent.swift in Sources */,
885002912B5D0F9200E7D4DB /* HKWorkoutEventType+Extensions.swift in Sources */, 885002912B5D0F9200E7D4DB /* HKWorkoutEventType+Extensions.swift in Sources */,
8850027F2B5C36A700E7D4DB /* Workout.swift in Sources */, 8850027F2B5C36A700E7D4DB /* Workout.swift in Sources */,
885002A12B5D1E7400E7D4DB /* DBMetadataKey.swift in Sources */,
885002712B5C299900E7D4DB /* HealthDatabase.swift in Sources */, 885002712B5C299900E7D4DB /* HealthDatabase.swift in Sources */,
885002932B5D129300E7D4DB /* ActivityDetailView.swift in Sources */, 885002932B5D129300E7D4DB /* ActivityDetailView.swift in Sources */,
8850029F2B5D1C7000E7D4DB /* DBMetadata.swift in Sources */,
8850027B2B5C35BF00E7D4DB /* DBWorkout.swift in Sources */, 8850027B2B5C35BF00E7D4DB /* DBWorkout.swift in Sources */,
8850028D2B5D0B5000E7D4DB /* WorkoutDetailView.swift in Sources */, 8850028D2B5D0B5000E7D4DB /* WorkoutDetailView.swift in Sources */,
885002A32B5D217600E7D4DB /* MetadataValue.swift in Sources */,
8850028B2B5C896C00E7D4DB /* WorkoutActivity.swift in Sources */, 8850028B2B5C896C00E7D4DB /* WorkoutActivity.swift in Sources */,
885002952B5D147100E7D4DB /* DetailRow.swift in Sources */, 885002952B5D147100E7D4DB /* DetailRow.swift in Sources */,
8850029D2B5D197300E7D4DB /* EventDetailView.swift in Sources */, 8850029D2B5D197300E7D4DB /* EventDetailView.swift in Sources */,
@ -470,6 +492,14 @@
minimumVersion = 0.14.1; 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 */ /* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */ /* Begin XCSwiftPackageProductDependency section */
@ -478,6 +508,21 @@
package = 885002752B5C2FC400E7D4DB /* XCRemoteSwiftPackageReference "SQLite" */; package = 885002752B5C2FC400E7D4DB /* XCRemoteSwiftPackageReference "SQLite" */;
productName = 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 */ /* End XCSwiftPackageProductDependency section */
}; };
rootObject = 8850024F2B5C273C00E7D4DB /* Project object */; rootObject = 8850024F2B5C273C00E7D4DB /* Project object */;

View File

@ -8,6 +8,15 @@
"revision" : "7a2e3cd27de56f6d396e84f63beefd0267b55ccb", "revision" : "7a2e3cd27de56f6d396e84f63beefd0267b55ccb",
"version" : "0.14.1" "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 "version" : 2

View File

@ -0,0 +1,57 @@
import Foundation
import SQLite
struct DBMetadata {
private static let table = Table("metadata_values")
private static let rowKeyId = Expression<Int?>("key_id")
private static let rowObjectId = Expression<Int?>("object_id")
private static let rowValueType = Expression<Int>("value_type")
private static let rowStringValue = Expression<String?>("string_value")
private static let rowNumericalValue = Expression<Double?>("numerical_value")
private static let rowDateValue = Expression<Double?>("date_value")
private static let rowDataValue = Expression<Data?>("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]
}
}

View File

@ -0,0 +1,21 @@
import Foundation
import SQLite
struct DBMetadataKey {
private static let table = Table("metadata_keys")
private static let rowId = Expression<Int>("ROWID")
private static let rowKey = Expression<String>("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]
}
}
}

View File

@ -21,10 +21,15 @@ final class HealthDatabase: ObservableObject {
func readAllWorkouts() { func readAllWorkouts() {
do { do {
let dbWorkouts = try DBWorkout.readAll(in: database) let dbWorkouts = try DBWorkout.readAll(in: database)
let metadataKeys = try DBMetadataKey.readAll(in: database)
let workouts = try dbWorkouts.map { entry in let workouts = try dbWorkouts.map { entry in
let events = try DBWorkoutEvent.events(for: entry.dataId, in: database) let events = try DBWorkoutEvent.events(for: entry.dataId, in: database)
let activities = try DBWorkoutActivity.activities(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 { DispatchQueue.main.async {

View File

@ -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
}
}
}

View File

@ -1,5 +1,5 @@
import Foundation import Foundation
import Collections
private let df: DateFormatter = { private let df: DateFormatter = {
let df = DateFormatter() let df = DateFormatter()
@ -28,6 +28,8 @@ struct Workout {
let activities: [WorkoutActivity] let activities: [WorkoutActivity]
let metadata: OrderedDictionary<String, MetadataValue>
var firstActivityDate: Date? { var firstActivityDate: Date? {
activities.map { $0.startDate }.min() activities.map { $0.startDate }.min()
} }
@ -51,7 +53,7 @@ struct Workout {
activities.first?.activityType.description ?? "Unknown activity" 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.id = id
self.totalDistance = totalDistance self.totalDistance = totalDistance
self.goalType = goalType self.goalType = goalType
@ -60,12 +62,13 @@ struct Workout {
self.condenserDate = condenserDate self.condenserDate = condenserDate
self.events = events self.events = events
self.activities = activities self.activities = activities
self.metadata = .init(uniqueKeys: metadata.keys, values: metadata.values)
} }
} }
extension Workout { extension Workout {
init(entry: DBWorkout, events: [DBWorkoutEvent], activities: [DBWorkoutActivity]) { init(entry: DBWorkout, events: [DBWorkoutEvent], activities: [DBWorkoutActivity], metadata: [String : MetadataValue]) {
self.id = entry.dataId self.id = entry.dataId
self.totalDistance = entry.totalDistance self.totalDistance = entry.totalDistance
self.goalType = entry.goalType self.goalType = entry.goalType
@ -74,6 +77,7 @@ extension Workout {
self.condenserDate = entry.condenserDate.map { Date(timeIntervalSinceReferenceDate: $0) } self.condenserDate = entry.condenserDate.map { Date(timeIntervalSinceReferenceDate: $0) }
self.events = events.map(WorkoutEvent.init) self.events = events.map(WorkoutEvent.init)
self.activities = activities.map(WorkoutActivity.init) self.activities = activities.map(WorkoutActivity.init)
self.metadata = .init(uniqueKeys: metadata.keys, values: metadata.values)
} }
} }

View File

@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import Collections
struct WorkoutDetailView: View { 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) .navigationTitle(workout.typeString)
.navigationDestination(for: WorkoutActivity.self) { activity in .navigationDestination(for: WorkoutActivity.self) { activity in
@ -81,3 +87,8 @@ struct WorkoutDetailView: View {
])) ]))
} }
} }
extension String: Identifiable {
public var id: Self { self }
}