@@ -28,9 +28,9 @@ There are no real deployments, only test installations. Existing prototype datab |
||
| 28 | 28 |
| SQLite archive | Archive v2 schema, snapshot-level observation grouping, differential write path, v2 verification/delete bookkeeping, daily aggregate rebuilds, integrity report, v2 record reads, SQL diff/count/aggregate/provenance/consolidation-evidence APIs, large synthetic diff pagination coverage, formal timing/memory metrics, and XCTest coverage are in place; the legacy `archive_samples` mirror has been removed | Move Snapshots/Data Types from SwiftData previews to archive/cache DTOs | |
| 29 | 29 |
| Core Data cache | Initial programmatic Core Data model, full-cache rebuild service, read DTOs for observation/type/diff/health rows, and Dashboard archive-cache status wiring are in place | Move remaining export/report paths to cache DTOs and add targeted partial invalidation | |
| 30 | 30 |
| SwiftData cache | Exists; test builds now reset legacy prototype UI/archive/cache stores once for archive v2 so old SwiftData-only snapshots are not treated as backed-up observations | Treat as disposable prototype data; reset/ignore during v2 transition | |
| 31 |
-| UI | Prototype exists; Snapshots/Data Types now default to the local device timeline instead of a multi-device picker. Dashboard status prefers archive/cache observation rows and shows cache health; Snapshots timeline, snapshot detail summaries/type rows, and Data Types list prefer Core Data cache rows when archive observation ids exist; data type detail reads Core Data type/diff summaries and uses SQLite `diffRecords` for paged drill-down; visible change labels now use neutral new/missing/change-review language, with SwiftData detail cache as transition fallback | Finish export previews on paged SQLite DTOs | |
|
| 31 |
+| UI | Prototype exists; Snapshots/Data Types now default to the local device timeline instead of a multi-device picker. Dashboard status prefers archive/cache observation rows and shows cache health; Snapshots timeline, snapshot detail summaries/type rows, and Data Types list prefer Core Data cache rows when archive observation ids exist; data type detail reads Core Data type/diff summaries and uses SQLite `diffRecords` for paged drill-down; export preview reads the archive export API before showing/exporting JSON; visible change labels now use neutral new/missing/change-review language, with SwiftData detail cache as transition fallback | Add legacy/small-device simplifications and remove remaining SwiftData navigation handles | |
|
| 32 | 32 |
| Diff/change explanation | Prototype/legacy anomaly logic exists | Move heavy diffing into SQLite and use neutral change classifications | |
| 33 |
-| Export | Prototype scoped JSON export exists | Add recovery-compatible manifests and streaming/paged export | |
|
| 33 |
+| Export | SQLite export preview, paged JSON writing, SHA256 manifest hashing, and `export_manifests` rows are in place for selected records and observation diffs | Fill remaining recovery-compatible envelope metadata, CSV export, relationship preservation, and reproducibility checks | |
|
| 34 | 34 |
| Legacy device support | Not implemented | Remove SwiftData dependency and simplify heavy views for low-memory devices | |
| 35 | 35 |
| Recovery workflows | Not supported | Preserve export/archive structure for external recovery tools only | |
| 36 | 36 |
|
@@ -41,7 +41,7 @@ Detailed checkable milestones live in [`Refactoring-Plan.md`](Refactoring-Plan.m |
||
| 41 | 41 |
1. Move Snapshots/Data Types from SwiftData model reads to Core Data/cache DTOs. |
| 42 | 42 |
2. Add targeted cache invalidation for affected observation/type ranges. |
| 43 | 43 |
3. Finish remaining UI language cleanup from anomaly/status to observation/diff/export where legacy model names still leak into active flows. |
| 44 |
-4. Add streaming exports with manifests. |
|
| 44 |
+4. Complete recovery-compatible export metadata, CSV output, and reproducibility checks. |
|
| 45 | 45 |
5. Validate on low-memory/legacy-class devices. |
| 46 | 46 |
|
| 47 | 47 |
## Known Prototype Mismatches |
@@ -49,12 +49,12 @@ Detailed checkable milestones live in [`Refactoring-Plan.md`](Refactoring-Plan.m |
||
| 49 | 49 |
- SwiftData currently blocks iOS 15-era device support. |
| 50 | 50 |
- Existing `Anomaly*` model/service names are legacy language. |
| 51 | 51 |
- Some screens still imply snapshot-count monitoring rather than Time Machine inspection. |
| 52 |
-- Current UI/cache layers still depend on SwiftData prototype models for capture review actions, navigation handles, some charts, and export/PDF paths. |
|
| 52 |
+- Current UI/cache layers still depend on SwiftData prototype models for capture review actions, navigation handles, some charts, and PDF paths. |
|
| 53 | 53 |
- Snapshots timeline, snapshot detail summary/type rows, Data Types list rows, and record drill-down are archive/cache-backed for new archive v2 observations when cache rows exist, but navigation still uses SwiftData snapshot handles during the transition. |
| 54 | 54 |
- Legacy SwiftData-only snapshots can show diffs without archive-backed values; they are now reset for archive v2 test installs rather than migrated. |
| 55 | 55 |
- Existing implementation may decode or cache too much data for low-end devices. |
| 56 | 56 |
- Old prototype database compatibility is no longer required. |
| 57 |
-- Initial SQLite archive tests cover open/init/reset/idempotency, snapshot-level observation grouping, legacy mirror removal, small observation diffs, large synthetic diff pagination, formal timing/memory metrics, materialized aggregate comparison, source/provenance breakdowns, and consolidation-evidence labels, but not yet export behavior. |
|
| 57 |
+- Initial SQLite archive tests cover open/init/reset/idempotency, snapshot-level observation grouping, legacy mirror removal, small observation diffs, large synthetic diff pagination, formal timing/memory metrics, materialized aggregate comparison, source/provenance breakdowns, consolidation-evidence labels, export preview, paged JSON output, and manifest row persistence. |
|
| 58 | 58 |
- Initial Core Data cache tests cover full rebuild from SQLite and delete-cache-then-rebuild without losing archive data. |
| 59 | 59 |
|
| 60 | 60 |
## Verification Checklist |
@@ -70,8 +70,8 @@ Detailed checkable milestones live in [`Refactoring-Plan.md`](Refactoring-Plan.m |
||
| 70 | 70 |
- [x] Expensive counts used by reports/UI are cached and rebuildable. |
| 71 | 71 |
- [x] Deleting Core Data cache and rebuilding from SQLite restores UI/report summaries. |
| 72 | 72 |
- [x] Dashboard surfaces SQLite/Core Data cache health, cache schema, cache errors, and latest archive observation counts. |
| 73 |
-- [ ] Export can stream large selected record sets. |
|
| 74 |
-- [ ] Export manifests include hashes and observation metadata. |
|
| 73 |
+- [x] Export can stream/page selected record sets without materializing the full record list. |
|
| 74 |
+- [x] Export manifests include hashes and diff observation metadata when applicable. |
|
| 75 | 75 |
- [ ] iOS app remains read-only with respect to HealthKit. |
| 76 | 76 |
- [ ] Docs and UI do not claim in-app restore/re-publication support. |
| 77 | 77 |
- [ ] Legacy/small-device UI mode preserves capture/report/export. |
@@ -196,21 +196,21 @@ Acceptance: |
||
| 196 | 196 |
**Purpose:** Produce scoped, recovery-compatible exports. |
| 197 | 197 |
|
| 198 | 198 |
Checklist: |
| 199 |
-- [ ] Define JSON export envelope. |
|
| 199 |
+- [x] Define JSON export envelope. |
|
| 200 | 200 |
- [ ] Define CSV record-table export. |
| 201 |
-- [ ] Define manifest hash algorithm. |
|
| 201 |
+- [x] Define manifest hash algorithm. |
|
| 202 | 202 |
- [ ] Include archive/app/schema/observation metadata. |
| 203 | 203 |
- [ ] Include sample identity and payload version hashes. |
| 204 |
-- [ ] Include values/dates/units/type fields. |
|
| 205 |
-- [ ] Include source/provenance metadata where available and allowed. |
|
| 204 |
+- [x] Include values/dates/units/type fields. |
|
| 205 |
+- [x] Include source/provenance metadata where available and allowed. |
|
| 206 | 206 |
- [ ] Include relationships where available. |
| 207 | 207 |
- [ ] Include provenance-loss warning for external HealthKit re-publication. |
| 208 |
-- [ ] Stream/page export from SQLite. |
|
| 209 |
-- [ ] Store export manifest rows. |
|
| 208 |
+- [x] Stream/page export from SQLite. |
|
| 209 |
+- [x] Store export manifest rows. |
|
| 210 | 210 |
- [ ] Add reproducibility test for export manifests. |
| 211 | 211 |
|
| 212 | 212 |
Acceptance: |
| 213 |
-- [ ] Large export does not materialize full record set in RAM. |
|
| 213 |
+- [x] Large export does not materialize full record set in RAM. |
|
| 214 | 214 |
- [ ] Export can be verified against archive hashes. |
| 215 | 215 |
- [ ] Export contains enough structure for external recovery/salvage tooling. |
| 216 | 216 |
- [ ] App still does not perform restore, backup patching, or HealthKit re-publication. |
@@ -229,7 +229,7 @@ Checklist: |
||
| 229 | 229 |
- [x] Data type new/missing drill-down pages through SQLite `diffRecords` when archive observation ids exist. |
| 230 | 230 |
- [x] Diff detail fully uses cached summary plus paged SQLite DTOs. |
| 231 | 231 |
- [x] Data type screens use target change labels. |
| 232 |
-- [ ] Export preview uses export query/manifest APIs. |
|
| 232 |
+- [x] Export preview uses export query/manifest APIs. |
|
| 233 | 233 |
- [x] Archive status reflects SQLite/Core Data cache health. |
| 234 | 234 |
- [ ] Legacy/small-device UI mode simplifies heavy visualizations. |
| 235 | 235 |
|
@@ -17,6 +17,7 @@ protocol HealthArchiveStore {
|
||
| 17 | 17 |
func aggregateComparison(_ request: HealthArchiveAggregateComparisonRequest) async throws -> [HealthArchiveAggregateComparisonRow] |
| 18 | 18 |
func sourceProvenanceBreakdown(_ request: HealthArchiveSourceProvenanceRequest) async throws -> [HealthArchiveSourceProvenanceRow] |
| 19 | 19 |
func consolidationEvidence(_ request: HealthArchiveConsolidationEvidenceRequest) async throws -> [HealthArchiveConsolidationEvidence] |
| 20 |
+ func exportPreview(_ request: HealthArchiveReportRequest) async throws -> HealthArchiveExportPreview |
|
| 20 | 21 |
func exportReport(_ request: HealthArchiveReportRequest) async throws -> URL |
| 21 | 22 |
func checkIntegrity() async throws -> HealthArchiveIntegrityReport |
| 22 | 23 |
} |
@@ -236,6 +237,9 @@ struct HealthArchiveReportRequest: Equatable, Sendable {
|
||
| 236 | 237 |
let disappearedOnly: Bool |
| 237 | 238 |
let firstSeenAfter: Date? |
| 238 | 239 |
let firstSeenBefore: Date? |
| 240 |
+ let diffFromObservationID: Int64? |
|
| 241 |
+ let diffToObservationID: Int64? |
|
| 242 |
+ let diffKind: HealthArchiveDiffKind? |
|
| 239 | 243 |
|
| 240 | 244 |
init( |
| 241 | 245 |
reportID: UUID, |
@@ -244,7 +248,10 @@ struct HealthArchiveReportRequest: Equatable, Sendable {
|
||
| 244 | 248 |
typeIdentifierFilter: String? = nil, |
| 245 | 249 |
disappearedOnly: Bool = false, |
| 246 | 250 |
firstSeenAfter: Date? = nil, |
| 247 |
- firstSeenBefore: Date? = nil |
|
| 251 |
+ firstSeenBefore: Date? = nil, |
|
| 252 |
+ diffFromObservationID: Int64? = nil, |
|
| 253 |
+ diffToObservationID: Int64? = nil, |
|
| 254 |
+ diffKind: HealthArchiveDiffKind? = nil |
|
| 248 | 255 |
) {
|
| 249 | 256 |
self.reportID = reportID |
| 250 | 257 |
self.title = title |
@@ -253,9 +260,34 @@ struct HealthArchiveReportRequest: Equatable, Sendable {
|
||
| 253 | 260 |
self.disappearedOnly = disappearedOnly |
| 254 | 261 |
self.firstSeenAfter = firstSeenAfter |
| 255 | 262 |
self.firstSeenBefore = firstSeenBefore |
| 263 |
+ self.diffFromObservationID = diffFromObservationID |
|
| 264 |
+ self.diffToObservationID = diffToObservationID |
|
| 265 |
+ self.diffKind = diffKind |
|
| 256 | 266 |
} |
| 257 | 267 |
} |
| 258 | 268 |
|
| 269 |
+struct HealthArchiveExportPreview: Equatable, Sendable {
|
|
| 270 |
+ let reportID: UUID |
|
| 271 |
+ let title: String |
|
| 272 |
+ let exportKind: String |
|
| 273 |
+ let estimatedRecordCount: Int |
|
| 274 |
+ let typeIdentifierFilter: String? |
|
| 275 |
+ let disappearedOnly: Bool |
|
| 276 |
+ let firstSeenAfter: Date? |
|
| 277 |
+ let firstSeenBefore: Date? |
|
| 278 |
+ let diffFromObservationID: Int64? |
|
| 279 |
+ let diffToObservationID: Int64? |
|
| 280 |
+ let diffKind: HealthArchiveDiffKind? |
|
| 281 |
+} |
|
| 282 |
+ |
|
| 283 |
+struct HealthArchiveExportManifest: Equatable, Sendable {
|
|
| 284 |
+ let exportID: UUID |
|
| 285 |
+ let exportKind: String |
|
| 286 |
+ let createdAt: Date |
|
| 287 |
+ let recordCount: Int |
|
| 288 |
+ let manifestHash: String |
|
| 289 |
+} |
|
| 290 |
+ |
|
| 259 | 291 |
struct ArchivedHealthRecord: Identifiable, Equatable, Sendable, Encodable {
|
| 260 | 292 |
let id: String |
| 261 | 293 |
let sampleTypeIdentifier: String |
@@ -1,3 +1,4 @@ |
||
| 1 |
+import CryptoKit |
|
| 1 | 2 |
import Foundation |
| 2 | 3 |
import HealthKit |
| 3 | 4 |
import SQLite3 |
@@ -952,8 +953,45 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
|
||
| 952 | 953 |
} |
| 953 | 954 |
} |
| 954 | 955 |
|
| 956 |
+ func exportPreview(_ request: HealthArchiveReportRequest) async throws -> HealthArchiveExportPreview {
|
|
| 957 |
+ let db = try openDatabase() |
|
| 958 |
+ defer { sqlite3_close(db) }
|
|
| 959 |
+ try prepareSchemaIfNeeded(db) |
|
| 960 |
+ |
|
| 961 |
+ let recordCount = try await exportRecordCount(for: request, db: db) |
|
| 962 |
+ return HealthArchiveExportPreview( |
|
| 963 |
+ reportID: request.reportID, |
|
| 964 |
+ title: request.title, |
|
| 965 |
+ exportKind: "observation_records_json", |
|
| 966 |
+ estimatedRecordCount: recordCount, |
|
| 967 |
+ typeIdentifierFilter: request.typeIdentifierFilter, |
|
| 968 |
+ disappearedOnly: request.disappearedOnly, |
|
| 969 |
+ firstSeenAfter: request.firstSeenAfter, |
|
| 970 |
+ firstSeenBefore: request.firstSeenBefore, |
|
| 971 |
+ diffFromObservationID: request.diffFromObservationID, |
|
| 972 |
+ diffToObservationID: request.diffToObservationID, |
|
| 973 |
+ diffKind: request.diffKind |
|
| 974 |
+ ) |
|
| 975 |
+ } |
|
| 976 |
+ |
|
| 955 | 977 |
func exportReport(_ request: HealthArchiveReportRequest) async throws -> URL {
|
| 956 |
- let recordRequest = HealthArchiveRecordRequest( |
|
| 978 |
+ let exportedAt = Date() |
|
| 979 |
+ let manifest = try await buildExportManifest(request: request, exportedAt: exportedAt) |
|
| 980 |
+ let exportURL = URL.temporaryDirectory |
|
| 981 |
+ .appending(path: "HealthProbe-\(request.reportID.uuidString)") |
|
| 982 |
+ .appendingPathExtension("json")
|
|
| 983 |
+ try await writeExportReport( |
|
| 984 |
+ request: request, |
|
| 985 |
+ manifest: manifest, |
|
| 986 |
+ exportedAt: exportedAt, |
|
| 987 |
+ to: exportURL |
|
| 988 |
+ ) |
|
| 989 |
+ try recordExportManifest(request: request, manifest: manifest) |
|
| 990 |
+ return exportURL |
|
| 991 |
+ } |
|
| 992 |
+ |
|
| 993 |
+ private func recordRequest(from request: HealthArchiveReportRequest) -> HealthArchiveRecordRequest {
|
|
| 994 |
+ HealthArchiveRecordRequest( |
|
| 957 | 995 |
sampleTypeIdentifier: request.typeIdentifierFilter, |
| 958 | 996 |
fingerprints: request.includedFingerprints, |
| 959 | 997 |
disappearedOnly: request.disappearedOnly, |
@@ -961,19 +999,365 @@ actor SQLiteHealthArchiveStore: HealthArchiveStore {
|
||
| 961 | 999 |
firstSeenBefore: request.firstSeenBefore, |
| 962 | 1000 |
limit: nil |
| 963 | 1001 |
) |
| 964 |
- let records = try await records(for: recordRequest) |
|
| 965 |
- let payload = HealthArchiveReportPayload( |
|
| 966 |
- reportID: request.reportID, |
|
| 967 |
- title: request.title, |
|
| 968 |
- exportedAt: Date(), |
|
| 969 |
- records: records |
|
| 1002 |
+ } |
|
| 1003 |
+ |
|
| 1004 |
+ private func exportRecordCount(for request: HealthArchiveReportRequest, db: OpaquePointer?) async throws -> Int {
|
|
| 1005 |
+ if let fromObservationID = request.diffFromObservationID, |
|
| 1006 |
+ let toObservationID = request.diffToObservationID, |
|
| 1007 |
+ let diffKind = request.diffKind {
|
|
| 1008 |
+ let summary = try await diffSummary(HealthArchiveDiffRequest( |
|
| 1009 |
+ fromObservationID: fromObservationID, |
|
| 1010 |
+ toObservationID: toObservationID, |
|
| 1011 |
+ sampleTypeIdentifier: request.typeIdentifierFilter |
|
| 1012 |
+ )) |
|
| 1013 |
+ switch diffKind {
|
|
| 1014 |
+ case .appeared: |
|
| 1015 |
+ return summary.appearedCount |
|
| 1016 |
+ case .disappeared: |
|
| 1017 |
+ return summary.disappearedCount |
|
| 1018 |
+ case .representationChanged: |
|
| 1019 |
+ return summary.representationChangedCount |
|
| 1020 |
+ } |
|
| 1021 |
+ } |
|
| 1022 |
+ |
|
| 1023 |
+ return try exportRecordCount(for: recordRequest(from: request), db: db) |
|
| 1024 |
+ } |
|
| 1025 |
+ |
|
| 1026 |
+ private func exportRecordCount(for request: HealthArchiveRecordRequest, db: OpaquePointer?) throws -> Int {
|
|
| 1027 |
+ var clauses: [String] = [] |
|
| 1028 |
+ if request.sampleTypeIdentifier != nil {
|
|
| 1029 |
+ clauses.append("t.type_identifier = ?")
|
|
| 1030 |
+ } |
|
| 1031 |
+ if !request.fingerprints.isEmpty {
|
|
| 1032 |
+ clauses.append("s.strict_fingerprint IN (\(Array(repeating: "?", count: request.fingerprints.count).joined(separator: ",")))")
|
|
| 1033 |
+ } |
|
| 1034 |
+ if request.disappearedOnly {
|
|
| 1035 |
+ clauses.append("rr.last_observation_id IS NOT NULL AND es.disappeared_at IS NOT NULL")
|
|
| 1036 |
+ } |
|
| 1037 |
+ if request.firstSeenAfter != nil {
|
|
| 1038 |
+ clauses.append("s.first_seen_at >= ?")
|
|
| 1039 |
+ } |
|
| 1040 |
+ if request.firstSeenBefore != nil {
|
|
| 1041 |
+ clauses.append("s.first_seen_at <= ?")
|
|
| 1042 |
+ } |
|
| 1043 |
+ let whereClause = clauses.isEmpty ? "" : "WHERE \(clauses.joined(separator: " AND "))" |
|
| 1044 |
+ let sql = """ |
|
| 1045 |
+ WITH selected_ranges AS ( |
|
| 1046 |
+ SELECT |
|
| 1047 |
+ r.sample_id, r.version_id, r.first_observation_id, r.last_observation_id, |
|
| 1048 |
+ r.first_seen_at, r.last_seen_at, |
|
| 1049 |
+ ROW_NUMBER() OVER ( |
|
| 1050 |
+ PARTITION BY r.sample_id |
|
| 1051 |
+ ORDER BY |
|
| 1052 |
+ CASE |
|
| 1053 |
+ WHEN ? IS NOT NULL THEN 0 |
|
| 1054 |
+ WHEN r.last_observation_id IS NULL THEN 0 |
|
| 1055 |
+ ELSE 1 |
|
| 1056 |
+ END, |
|
| 1057 |
+ COALESCE(r.last_observation_id, 9223372036854775807) DESC, |
|
| 1058 |
+ r.first_observation_id DESC |
|
| 1059 |
+ ) AS record_rank |
|
| 1060 |
+ FROM sample_visibility_ranges r |
|
| 1061 |
+ WHERE (? IS NULL OR ( |
|
| 1062 |
+ r.first_observation_id <= ? |
|
| 1063 |
+ AND (r.last_observation_id IS NULL OR r.last_observation_id > ?) |
|
| 1064 |
+ )) |
|
| 1065 |
+ ), |
|
| 1066 |
+ record_ranges AS ( |
|
| 1067 |
+ SELECT * |
|
| 1068 |
+ FROM selected_ranges |
|
| 1069 |
+ WHERE record_rank = 1 |
|
| 1070 |
+ ), |
|
| 1071 |
+ event_summary AS ( |
|
| 1072 |
+ SELECT |
|
| 1073 |
+ sample_id, |
|
| 1074 |
+ MAX(CASE WHEN event_kind != 'disappeared' THEN observed_at END) AS last_seen_at, |
|
| 1075 |
+ MAX(observed_at) AS last_verified_at, |
|
| 1076 |
+ MAX(CASE WHEN event_kind = 'disappeared' THEN observed_at END) AS disappeared_at |
|
| 1077 |
+ FROM sample_observation_events |
|
| 1078 |
+ GROUP BY sample_id |
|
| 970 | 1079 |
) |
| 971 |
- let data = try JSONEncoder.healthArchive.encode(payload) |
|
| 972 |
- let exportURL = URL.temporaryDirectory |
|
| 973 |
- .appending(path: "HealthProbe-\(request.reportID.uuidString)") |
|
| 974 |
- .appendingPathExtension("json")
|
|
| 975 |
- try data.write(to: exportURL, options: [.atomic]) |
|
| 976 |
- return exportURL |
|
| 1080 |
+ SELECT COUNT(*) |
|
| 1081 |
+ FROM record_ranges rr |
|
| 1082 |
+ JOIN samples s ON s.id = rr.sample_id |
|
| 1083 |
+ JOIN sample_types t ON t.id = s.sample_type_id |
|
| 1084 |
+ JOIN sample_versions v ON v.id = rr.version_id |
|
| 1085 |
+ LEFT JOIN event_summary es ON es.sample_id = s.id |
|
| 1086 |
+ \(whereClause) |
|
| 1087 |
+ """ |
|
| 1088 |
+ |
|
| 1089 |
+ return try withStatement(sql, db: db) { statement in
|
|
| 1090 |
+ var index: Int32 = 1 |
|
| 1091 |
+ try bindRecordFilterValues(request, to: statement, startingAt: &index, includeCursor: false) |
|
| 1092 |
+ guard sqlite3_step(statement) == SQLITE_ROW else { return 0 }
|
|
| 1093 |
+ return columnInt(statement, 0) ?? 0 |
|
| 1094 |
+ } |
|
| 1095 |
+ } |
|
| 1096 |
+ |
|
| 1097 |
+ private func bindRecordFilterValues( |
|
| 1098 |
+ _ request: HealthArchiveRecordRequest, |
|
| 1099 |
+ to statement: OpaquePointer?, |
|
| 1100 |
+ startingAt index: inout Int32, |
|
| 1101 |
+ includeCursor: Bool |
|
| 1102 |
+ ) throws {
|
|
| 1103 |
+ bindInt64(request.visibleAtObservationID, to: index, in: statement) |
|
| 1104 |
+ index += 1 |
|
| 1105 |
+ bindInt64(request.visibleAtObservationID, to: index, in: statement) |
|
| 1106 |
+ index += 1 |
|
| 1107 |
+ bindInt64(request.visibleAtObservationID, to: index, in: statement) |
|
| 1108 |
+ index += 1 |
|
| 1109 |
+ bindInt64(request.visibleAtObservationID, to: index, in: statement) |
|
| 1110 |
+ index += 1 |
|
| 1111 |
+ if let typeIdentifier = request.sampleTypeIdentifier {
|
|
| 1112 |
+ bindText(typeIdentifier, to: index, in: statement) |
|
| 1113 |
+ index += 1 |
|
| 1114 |
+ } |
|
| 1115 |
+ for fingerprint in request.fingerprints.sorted() {
|
|
| 1116 |
+ bindText(fingerprint, to: index, in: statement) |
|
| 1117 |
+ index += 1 |
|
| 1118 |
+ } |
|
| 1119 |
+ if let firstSeenAfter = request.firstSeenAfter {
|
|
| 1120 |
+ sqlite3_bind_double(statement, index, firstSeenAfter.timeIntervalSince1970) |
|
| 1121 |
+ index += 1 |
|
| 1122 |
+ } |
|
| 1123 |
+ if let firstSeenBefore = request.firstSeenBefore {
|
|
| 1124 |
+ sqlite3_bind_double(statement, index, firstSeenBefore.timeIntervalSince1970) |
|
| 1125 |
+ index += 1 |
|
| 1126 |
+ } |
|
| 1127 |
+ if includeCursor, let cursor = request.afterCursor {
|
|
| 1128 |
+ sqlite3_bind_double(statement, index, cursor.startDate.timeIntervalSince1970) |
|
| 1129 |
+ index += 1 |
|
| 1130 |
+ sqlite3_bind_double(statement, index, cursor.startDate.timeIntervalSince1970) |
|
| 1131 |
+ index += 1 |
|
| 1132 |
+ bindText(cursor.strictFingerprint, to: index, in: statement) |
|
| 1133 |
+ index += 1 |
|
| 1134 |
+ } |
|
| 1135 |
+ } |
|
| 1136 |
+ |
|
| 1137 |
+ private func buildExportManifest( |
|
| 1138 |
+ request: HealthArchiveReportRequest, |
|
| 1139 |
+ exportedAt: Date |
|
| 1140 |
+ ) async throws -> HealthArchiveExportManifest {
|
|
| 1141 |
+ var hasher = SHA256() |
|
| 1142 |
+ hasher.update(data: Data("hp:export:manifest:v1".utf8))
|
|
| 1143 |
+ hasher.update(data: Data(request.reportID.uuidString.utf8)) |
|
| 1144 |
+ hasher.update(data: Data(request.title.utf8)) |
|
| 1145 |
+ hasher.update(data: Data(exportedAt.timeIntervalSince1970.description.utf8)) |
|
| 1146 |
+ hasher.update(data: Data((request.typeIdentifierFilter ?? "*").utf8)) |
|
| 1147 |
+ hasher.update(data: Data(request.disappearedOnly.description.utf8)) |
|
| 1148 |
+ if let firstSeenAfter = request.firstSeenAfter {
|
|
| 1149 |
+ hasher.update(data: Data(firstSeenAfter.timeIntervalSince1970.description.utf8)) |
|
| 1150 |
+ } |
|
| 1151 |
+ if let firstSeenBefore = request.firstSeenBefore {
|
|
| 1152 |
+ hasher.update(data: Data(firstSeenBefore.timeIntervalSince1970.description.utf8)) |
|
| 1153 |
+ } |
|
| 1154 |
+ if let diffFromObservationID = request.diffFromObservationID {
|
|
| 1155 |
+ hasher.update(data: Data(diffFromObservationID.description.utf8)) |
|
| 1156 |
+ } |
|
| 1157 |
+ if let diffToObservationID = request.diffToObservationID {
|
|
| 1158 |
+ hasher.update(data: Data(diffToObservationID.description.utf8)) |
|
| 1159 |
+ } |
|
| 1160 |
+ if let diffKind = request.diffKind {
|
|
| 1161 |
+ hasher.update(data: Data(diffKind.rawValue.utf8)) |
|
| 1162 |
+ } |
|
| 1163 |
+ |
|
| 1164 |
+ var recordCount = 0 |
|
| 1165 |
+ try await forEachReportRecord(request) { record in
|
|
| 1166 |
+ recordCount += 1 |
|
| 1167 |
+ let data = try JSONEncoder.healthArchive.encode(ExportRecordPayload(record)) |
|
| 1168 |
+ let itemHash = Self.hexDigest(SHA256.hash(data: data)) |
|
| 1169 |
+ hasher.update(data: Data(itemHash.utf8)) |
|
| 1170 |
+ } |
|
| 1171 |
+ |
|
| 1172 |
+ let manifestHash = Self.hexDigest(hasher.finalize()) |
|
| 1173 |
+ return HealthArchiveExportManifest( |
|
| 1174 |
+ exportID: request.reportID, |
|
| 1175 |
+ exportKind: "observation_records_json", |
|
| 1176 |
+ createdAt: exportedAt, |
|
| 1177 |
+ recordCount: recordCount, |
|
| 1178 |
+ manifestHash: manifestHash |
|
| 1179 |
+ ) |
|
| 1180 |
+ } |
|
| 1181 |
+ |
|
| 1182 |
+ private func writeExportReport( |
|
| 1183 |
+ request: HealthArchiveReportRequest, |
|
| 1184 |
+ manifest: HealthArchiveExportManifest, |
|
| 1185 |
+ exportedAt: Date, |
|
| 1186 |
+ to exportURL: URL |
|
| 1187 |
+ ) async throws {
|
|
| 1188 |
+ FileManager.default.createFile(atPath: exportURL.path, contents: nil) |
|
| 1189 |
+ let handle = try FileHandle(forWritingTo: exportURL) |
|
| 1190 |
+ defer { try? handle.close() }
|
|
| 1191 |
+ |
|
| 1192 |
+ try handle.write(contentsOf: Data("{\n".utf8))
|
|
| 1193 |
+ try writeJSONField("exportFormatVersion", value: "1", quoted: false, trailingComma: true, to: handle)
|
|
| 1194 |
+ try writeJSONField("reportID", value: request.reportID.uuidString, trailingComma: true, to: handle)
|
|
| 1195 |
+ try writeJSONField("title", value: request.title, trailingComma: true, to: handle)
|
|
| 1196 |
+ try writeJSONField("exportedAt", value: JSONEncoder.healthArchiveDateString(exportedAt), trailingComma: true, to: handle)
|
|
| 1197 |
+ try handle.write(contentsOf: Data(" \"manifest\" : {\n".utf8))
|
|
| 1198 |
+ try writeJSONField("exportKind", value: manifest.exportKind, trailingComma: true, indent: " ", to: handle)
|
|
| 1199 |
+ try writeJSONField("recordCount", value: "\(manifest.recordCount)", quoted: false, trailingComma: true, indent: " ", to: handle)
|
|
| 1200 |
+ try writeJSONField("manifestHash", value: manifest.manifestHash, trailingComma: false, indent: " ", to: handle)
|
|
| 1201 |
+ try handle.write(contentsOf: Data(" },\n".utf8))
|
|
| 1202 |
+ try handle.write(contentsOf: Data(" \"records\" : [\n".utf8))
|
|
| 1203 |
+ |
|
| 1204 |
+ var isFirstRecord = true |
|
| 1205 |
+ try await forEachReportRecord(request) { record in
|
|
| 1206 |
+ if isFirstRecord {
|
|
| 1207 |
+ isFirstRecord = false |
|
| 1208 |
+ } else {
|
|
| 1209 |
+ try handle.write(contentsOf: Data(",\n".utf8))
|
|
| 1210 |
+ } |
|
| 1211 |
+ let data = try JSONEncoder.healthArchive.encode(ExportRecordPayload(record)) |
|
| 1212 |
+ try handle.write(contentsOf: data) |
|
| 1213 |
+ } |
|
| 1214 |
+ |
|
| 1215 |
+ try handle.write(contentsOf: Data("\n ]\n}\n".utf8))
|
|
| 1216 |
+ } |
|
| 1217 |
+ |
|
| 1218 |
+ private func forEachReportRecord( |
|
| 1219 |
+ _ request: HealthArchiveReportRequest, |
|
| 1220 |
+ _ body: (ArchivedHealthRecord) throws -> Void |
|
| 1221 |
+ ) async throws {
|
|
| 1222 |
+ if let fromObservationID = request.diffFromObservationID, |
|
| 1223 |
+ let toObservationID = request.diffToObservationID, |
|
| 1224 |
+ let diffKind = request.diffKind {
|
|
| 1225 |
+ try await forEachDiffExportRecord( |
|
| 1226 |
+ HealthArchiveDiffRecordRequest( |
|
| 1227 |
+ fromObservationID: fromObservationID, |
|
| 1228 |
+ toObservationID: toObservationID, |
|
| 1229 |
+ sampleTypeIdentifier: request.typeIdentifierFilter, |
|
| 1230 |
+ kind: diffKind |
|
| 1231 |
+ ), |
|
| 1232 |
+ body |
|
| 1233 |
+ ) |
|
| 1234 |
+ return |
|
| 1235 |
+ } |
|
| 1236 |
+ |
|
| 1237 |
+ try await forEachExportRecord(recordRequest(from: request), body) |
|
| 1238 |
+ } |
|
| 1239 |
+ |
|
| 1240 |
+ private func writeJSONField( |
|
| 1241 |
+ _ key: String, |
|
| 1242 |
+ value: String, |
|
| 1243 |
+ quoted: Bool = true, |
|
| 1244 |
+ trailingComma: Bool, |
|
| 1245 |
+ indent: String = " ", |
|
| 1246 |
+ to handle: FileHandle |
|
| 1247 |
+ ) throws {
|
|
| 1248 |
+ let encodedValue: String |
|
| 1249 |
+ if quoted {
|
|
| 1250 |
+ let data = try JSONEncoder.healthArchive.encode(value) |
|
| 1251 |
+ encodedValue = String(decoding: data, as: UTF8.self) |
|
| 1252 |
+ } else {
|
|
| 1253 |
+ encodedValue = value |
|
| 1254 |
+ } |
|
| 1255 |
+ let comma = trailingComma ? "," : "" |
|
| 1256 |
+ try handle.write(contentsOf: Data("\(indent)\"\(key)\" : \(encodedValue)\(comma)\n".utf8))
|
|
| 1257 |
+ } |
|
| 1258 |
+ |
|
| 1259 |
+ private func forEachExportRecord( |
|
| 1260 |
+ _ request: HealthArchiveRecordRequest, |
|
| 1261 |
+ _ body: (ArchivedHealthRecord) throws -> Void |
|
| 1262 |
+ ) async throws {
|
|
| 1263 |
+ var cursor: RecordCursor? |
|
| 1264 |
+ let pageSize = 500 |
|
| 1265 |
+ while true {
|
|
| 1266 |
+ let page = try await records(for: HealthArchiveRecordRequest( |
|
| 1267 |
+ visibleAtObservationID: request.visibleAtObservationID, |
|
| 1268 |
+ sampleTypeIdentifier: request.sampleTypeIdentifier, |
|
| 1269 |
+ fingerprints: request.fingerprints, |
|
| 1270 |
+ disappearedOnly: request.disappearedOnly, |
|
| 1271 |
+ firstSeenAfter: request.firstSeenAfter, |
|
| 1272 |
+ firstSeenBefore: request.firstSeenBefore, |
|
| 1273 |
+ afterCursor: cursor, |
|
| 1274 |
+ limit: pageSize |
|
| 1275 |
+ )) |
|
| 1276 |
+ guard !page.isEmpty else { break }
|
|
| 1277 |
+ for record in page {
|
|
| 1278 |
+ try body(record) |
|
| 1279 |
+ } |
|
| 1280 |
+ guard page.count == pageSize, let last = page.last else { break }
|
|
| 1281 |
+ cursor = RecordCursor(startDate: last.startDate, strictFingerprint: last.strictFingerprint) |
|
| 1282 |
+ } |
|
| 1283 |
+ } |
|
| 1284 |
+ |
|
| 1285 |
+ private func forEachDiffExportRecord( |
|
| 1286 |
+ _ request: HealthArchiveDiffRecordRequest, |
|
| 1287 |
+ _ body: (ArchivedHealthRecord) throws -> Void |
|
| 1288 |
+ ) async throws {
|
|
| 1289 |
+ var cursor: RecordCursor? |
|
| 1290 |
+ let pageSize = 500 |
|
| 1291 |
+ while true {
|
|
| 1292 |
+ let page = try await diffRecords(HealthArchiveDiffRecordRequest( |
|
| 1293 |
+ fromObservationID: request.fromObservationID, |
|
| 1294 |
+ toObservationID: request.toObservationID, |
|
| 1295 |
+ sampleTypeIdentifier: request.sampleTypeIdentifier, |
|
| 1296 |
+ kind: request.kind, |
|
| 1297 |
+ afterCursor: cursor, |
|
| 1298 |
+ limit: pageSize |
|
| 1299 |
+ )) |
|
| 1300 |
+ guard !page.isEmpty else { break }
|
|
| 1301 |
+ for record in page {
|
|
| 1302 |
+ try body(record) |
|
| 1303 |
+ } |
|
| 1304 |
+ guard page.count == pageSize, let last = page.last else { break }
|
|
| 1305 |
+ cursor = RecordCursor(startDate: last.startDate, strictFingerprint: last.strictFingerprint) |
|
| 1306 |
+ } |
|
| 1307 |
+ } |
|
| 1308 |
+ |
|
| 1309 |
+ private func recordExportManifest( |
|
| 1310 |
+ request: HealthArchiveReportRequest, |
|
| 1311 |
+ manifest: HealthArchiveExportManifest |
|
| 1312 |
+ ) throws {
|
|
| 1313 |
+ let db = try openDatabase() |
|
| 1314 |
+ defer { sqlite3_close(db) }
|
|
| 1315 |
+ try prepareSchemaIfNeeded(db) |
|
| 1316 |
+ |
|
| 1317 |
+ let filterJSON = exportFilterJSON(request) |
|
| 1318 |
+ let sql = """ |
|
| 1319 |
+ INSERT OR REPLACE INTO export_manifests ( |
|
| 1320 |
+ export_id, created_at, export_kind, from_observation_id, to_observation_id, |
|
| 1321 |
+ filter_json, record_count, manifest_hash |
|
| 1322 |
+ ) |
|
| 1323 |
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?) |
|
| 1324 |
+ """ |
|
| 1325 |
+ try withStatement(sql, db: db) { statement in
|
|
| 1326 |
+ bindText(manifest.exportID.uuidString, to: 1, in: statement) |
|
| 1327 |
+ sqlite3_bind_double(statement, 2, manifest.createdAt.timeIntervalSince1970) |
|
| 1328 |
+ bindText(manifest.exportKind, to: 3, in: statement) |
|
| 1329 |
+ bindInt64(request.diffFromObservationID, to: 4, in: statement) |
|
| 1330 |
+ bindInt64(request.diffToObservationID, to: 5, in: statement) |
|
| 1331 |
+ bindText(filterJSON, to: 6, in: statement) |
|
| 1332 |
+ sqlite3_bind_int64(statement, 7, Int64(manifest.recordCount)) |
|
| 1333 |
+ bindText(manifest.manifestHash, to: 8, in: statement) |
|
| 1334 |
+ guard sqlite3_step(statement) == SQLITE_DONE else {
|
|
| 1335 |
+ throw SQLiteHealthArchiveStoreError.stepFailed(lastErrorMessage(db)) |
|
| 1336 |
+ } |
|
| 1337 |
+ } |
|
| 1338 |
+ } |
|
| 1339 |
+ |
|
| 1340 |
+ nonisolated private func exportFilterJSON(_ request: HealthArchiveReportRequest) -> String {
|
|
| 1341 |
+ let payload: [String: Any] = [ |
|
| 1342 |
+ "typeIdentifier": request.typeIdentifierFilter ?? "*", |
|
| 1343 |
+ "disappearedOnly": request.disappearedOnly, |
|
| 1344 |
+ "firstSeenAfter": request.firstSeenAfter?.timeIntervalSince1970 ?? NSNull(), |
|
| 1345 |
+ "firstSeenBefore": request.firstSeenBefore?.timeIntervalSince1970 ?? NSNull(), |
|
| 1346 |
+ "diffFromObservationID": request.diffFromObservationID ?? NSNull(), |
|
| 1347 |
+ "diffToObservationID": request.diffToObservationID ?? NSNull(), |
|
| 1348 |
+ "diffKind": request.diffKind?.rawValue ?? NSNull() |
|
| 1349 |
+ ] |
|
| 1350 |
+ guard |
|
| 1351 |
+ let data = try? JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]), |
|
| 1352 |
+ let json = String(data: data, encoding: .utf8) |
|
| 1353 |
+ else {
|
|
| 1354 |
+ return "{}"
|
|
| 1355 |
+ } |
|
| 1356 |
+ return json |
|
| 1357 |
+ } |
|
| 1358 |
+ |
|
| 1359 |
+ nonisolated private static func hexDigest<D: Sequence>(_ bytes: D) -> String where D.Element == UInt8 {
|
|
| 1360 |
+ bytes.map { String(format: "%02x", $0) }.joined()
|
|
| 977 | 1361 |
} |
| 978 | 1362 |
|
| 979 | 1363 |
func checkIntegrity() async throws -> HealthArchiveIntegrityReport {
|
@@ -2695,11 +3079,52 @@ private struct ArchiveV2DailyAggregateRow {
|
||
| 2695 | 3079 |
} |
| 2696 | 3080 |
} |
| 2697 | 3081 |
|
| 2698 |
-nonisolated private struct HealthArchiveReportPayload: Encodable {
|
|
| 2699 |
- let reportID: UUID |
|
| 2700 |
- let title: String |
|
| 2701 |
- let exportedAt: Date |
|
| 2702 |
- let records: [ArchivedHealthRecord] |
|
| 3082 |
+nonisolated private struct ExportRecordPayload: Encodable {
|
|
| 3083 |
+ let id: String |
|
| 3084 |
+ let sampleTypeIdentifier: String |
|
| 3085 |
+ let strictFingerprint: String |
|
| 3086 |
+ let semanticFingerprint: String? |
|
| 3087 |
+ let healthKitUUIDHash: String? |
|
| 3088 |
+ let startDate: Date |
|
| 3089 |
+ let endDate: Date |
|
| 3090 |
+ let firstSeenAt: Date |
|
| 3091 |
+ let lastSeenAt: Date? |
|
| 3092 |
+ let lastVerifiedAt: Date? |
|
| 3093 |
+ let disappearedAt: Date? |
|
| 3094 |
+ let valueKind: String? |
|
| 3095 |
+ let value: Double? |
|
| 3096 |
+ let unit: String? |
|
| 3097 |
+ let categoryValue: Int? |
|
| 3098 |
+ let workoutActivityType: Int? |
|
| 3099 |
+ let durationSeconds: Double? |
|
| 3100 |
+ let sourceName: String? |
|
| 3101 |
+ let sourceBundleIdentifier: String? |
|
| 3102 |
+ let deviceName: String? |
|
| 3103 |
+ let displayValue: String? |
|
| 3104 |
+ |
|
| 3105 |
+ init(_ record: ArchivedHealthRecord) {
|
|
| 3106 |
+ id = record.id |
|
| 3107 |
+ sampleTypeIdentifier = record.sampleTypeIdentifier |
|
| 3108 |
+ strictFingerprint = record.strictFingerprint |
|
| 3109 |
+ semanticFingerprint = record.semanticFingerprint |
|
| 3110 |
+ healthKitUUIDHash = record.healthKitUUIDHash |
|
| 3111 |
+ startDate = record.startDate |
|
| 3112 |
+ endDate = record.endDate |
|
| 3113 |
+ firstSeenAt = record.firstSeenAt |
|
| 3114 |
+ lastSeenAt = record.lastSeenAt |
|
| 3115 |
+ lastVerifiedAt = record.lastVerifiedAt |
|
| 3116 |
+ disappearedAt = record.disappearedAt |
|
| 3117 |
+ valueKind = record.valueKind |
|
| 3118 |
+ value = record.value |
|
| 3119 |
+ unit = record.unit |
|
| 3120 |
+ categoryValue = record.categoryValue |
|
| 3121 |
+ workoutActivityType = record.workoutActivityType |
|
| 3122 |
+ durationSeconds = record.durationSeconds |
|
| 3123 |
+ sourceName = record.sourceName |
|
| 3124 |
+ sourceBundleIdentifier = record.sourceBundleIdentifier |
|
| 3125 |
+ deviceName = record.deviceName |
|
| 3126 |
+ displayValue = record.displayValue |
|
| 3127 |
+ } |
|
| 2703 | 3128 |
} |
| 2704 | 3129 |
|
| 2705 | 3130 |
nonisolated private extension JSONEncoder {
|
@@ -2709,6 +3134,12 @@ nonisolated private extension JSONEncoder {
|
||
| 2709 | 3134 |
encoder.outputFormatting = [.prettyPrinted, .sortedKeys] |
| 2710 | 3135 |
return encoder |
| 2711 | 3136 |
} |
| 3137 |
+ |
|
| 3138 |
+ static func healthArchiveDateString(_ date: Date) -> String {
|
|
| 3139 |
+ let data = try? healthArchive.encode(date) |
|
| 3140 |
+ let encoded = data.map { String(decoding: $0, as: UTF8.self) } ?? "\"\(date.timeIntervalSince1970)\""
|
|
| 3141 |
+ return encoded.trimmingCharacters(in: CharacterSet(charactersIn: "\"")) |
|
| 3142 |
+ } |
|
| 2712 | 3143 |
} |
| 2713 | 3144 |
|
| 2714 | 3145 |
nonisolated private func bindText(_ value: String?, to index: Int32, in statement: OpaquePointer?) {
|
@@ -52,6 +52,8 @@ final class DataTypeRecordListViewModel {
|
||
| 52 | 52 |
|
| 53 | 53 |
var displayRecords: [HealthRecordValue] = [] |
| 54 | 54 |
var isLoadingMore = false |
| 55 |
+ var exportPreview: HealthArchiveExportPreview? |
|
| 56 |
+ var exportPreviewError: String? |
|
| 55 | 57 |
var hasMore: Bool {
|
| 56 | 58 |
if mode.archiveDiff != nil {
|
| 57 | 59 |
return archiveHasMore && displayRecords.count < totalCount |
@@ -63,6 +65,7 @@ final class DataTypeRecordListViewModel {
|
||
| 63 | 65 |
private var pageIndex = 0 |
| 64 | 66 |
private let pageSize = 50 |
| 65 | 67 |
private let archiveStore: HealthArchiveStore |
| 68 |
+ private let reportID = UUID() |
|
| 66 | 69 |
private var archiveCursor: RecordCursor? |
| 67 | 70 |
private var archiveHasMore = true |
| 68 | 71 |
|
@@ -84,10 +87,21 @@ final class DataTypeRecordListViewModel {
|
||
| 84 | 87 |
|
| 85 | 88 |
func loadFirstPage() async {
|
| 86 | 89 |
guard displayRecords.isEmpty else { return }
|
| 90 |
+ await loadExportPreview() |
|
| 87 | 91 |
pageIndex = 0 |
| 88 | 92 |
await loadNextPage() |
| 89 | 93 |
} |
| 90 | 94 |
|
| 95 |
+ func loadExportPreview() async {
|
|
| 96 |
+ do {
|
|
| 97 |
+ exportPreview = try await archiveStore.exportPreview(buildExportRequest()) |
|
| 98 |
+ exportPreviewError = nil |
|
| 99 |
+ } catch {
|
|
| 100 |
+ exportPreview = nil |
|
| 101 |
+ exportPreviewError = error.localizedDescription |
|
| 102 |
+ } |
|
| 103 |
+ } |
|
| 104 |
+ |
|
| 91 | 105 |
func loadNextPage() async {
|
| 92 | 106 |
guard !isLoadingMore, hasMore else { return }
|
| 93 | 107 |
isLoadingMore = true |
@@ -192,22 +206,42 @@ final class DataTypeRecordListViewModel {
|
||
| 192 | 206 |
|
| 193 | 207 |
private func buildExportRequest() -> HealthArchiveReportRequest {
|
| 194 | 208 |
switch mode {
|
| 195 |
- case .disappeared(let typeID), .disappearedDiff(let typeID, _, _): |
|
| 209 |
+ case .disappeared(let typeID): |
|
| 196 | 210 |
return HealthArchiveReportRequest( |
| 197 |
- reportID: UUID(), |
|
| 211 |
+ reportID: reportID, |
|
| 198 | 212 |
title: "Missing Records - \(displayName)", |
| 199 | 213 |
typeIdentifierFilter: typeID, |
| 200 | 214 |
disappearedOnly: true |
| 201 | 215 |
) |
| 202 |
- case .added(let typeID, let afterDate, let beforeDate), |
|
| 203 |
- .addedDiff(let typeID, let afterDate, let beforeDate, _, _): |
|
| 216 |
+ case .disappearedDiff(let typeID, let fromObservationID, let toObservationID): |
|
| 204 | 217 |
return HealthArchiveReportRequest( |
| 205 |
- reportID: UUID(), |
|
| 218 |
+ reportID: reportID, |
|
| 219 |
+ title: "Missing Records - \(displayName)", |
|
| 220 |
+ typeIdentifierFilter: typeID, |
|
| 221 |
+ disappearedOnly: true, |
|
| 222 |
+ diffFromObservationID: fromObservationID, |
|
| 223 |
+ diffToObservationID: toObservationID, |
|
| 224 |
+ diffKind: .disappeared |
|
| 225 |
+ ) |
|
| 226 |
+ case .added(let typeID, let afterDate, let beforeDate): |
|
| 227 |
+ return HealthArchiveReportRequest( |
|
| 228 |
+ reportID: reportID, |
|
| 206 | 229 |
title: "New Records - \(displayName)", |
| 207 | 230 |
typeIdentifierFilter: typeID, |
| 208 | 231 |
firstSeenAfter: afterDate, |
| 209 | 232 |
firstSeenBefore: beforeDate |
| 210 | 233 |
) |
| 234 |
+ case .addedDiff(let typeID, let afterDate, let beforeDate, let fromObservationID, let toObservationID): |
|
| 235 |
+ return HealthArchiveReportRequest( |
|
| 236 |
+ reportID: reportID, |
|
| 237 |
+ title: "New Records - \(displayName)", |
|
| 238 |
+ typeIdentifierFilter: typeID, |
|
| 239 |
+ firstSeenAfter: afterDate, |
|
| 240 |
+ firstSeenBefore: beforeDate, |
|
| 241 |
+ diffFromObservationID: fromObservationID, |
|
| 242 |
+ diffToObservationID: toObservationID, |
|
| 243 |
+ diffKind: .appeared |
|
| 244 |
+ ) |
|
| 211 | 245 |
} |
| 212 | 246 |
} |
| 213 | 247 |
} |
@@ -63,6 +63,27 @@ struct DataTypeRecordListView: View {
|
||
| 63 | 63 |
Text("HealthProbe stores timestamps, fingerprints, and metadata. Export includes complete record details.")
|
| 64 | 64 |
.font(.caption) |
| 65 | 65 |
.foregroundStyle(.secondary) |
| 66 |
+ |
|
| 67 |
+ if let preview = viewModel.exportPreview {
|
|
| 68 |
+ Divider() |
|
| 69 |
+ DataTypeDetailRow(label: "Export Preview") {
|
|
| 70 |
+ Text("\(preview.estimatedRecordCount) JSON records")
|
|
| 71 |
+ .font(.caption) |
|
| 72 |
+ .foregroundStyle(.secondary) |
|
| 73 |
+ .monospacedDigit() |
|
| 74 |
+ } |
|
| 75 |
+ DataTypeDetailRow(label: "Export Kind") {
|
|
| 76 |
+ Text(preview.exportKind) |
|
| 77 |
+ .font(.caption) |
|
| 78 |
+ .foregroundStyle(.secondary) |
|
| 79 |
+ .lineLimit(1) |
|
| 80 |
+ } |
|
| 81 |
+ } else if let error = viewModel.exportPreviewError {
|
|
| 82 |
+ Divider() |
|
| 83 |
+ Label(error, systemImage: "exclamationmark.triangle.fill") |
|
| 84 |
+ .font(.caption) |
|
| 85 |
+ .foregroundStyle(Color.warningAmber) |
|
| 86 |
+ } |
|
| 66 | 87 |
} |
| 67 | 88 |
|
| 68 | 89 |
if viewModel.displayRecords.isEmpty {
|
@@ -101,8 +122,13 @@ struct DataTypeRecordListView: View {
|
||
| 101 | 122 |
ToolbarItem(placement: .topBarTrailing) {
|
| 102 | 123 |
Menu {
|
| 103 | 124 |
Button(action: { Task { await viewModel.exportAllRecords() } }) {
|
| 104 |
- Label("Export as JSON", systemImage: "square.and.arrow.up")
|
|
| 125 |
+ if viewModel.exportState == .loading {
|
|
| 126 |
+ Label("Exporting...", systemImage: "hourglass")
|
|
| 127 |
+ } else {
|
|
| 128 |
+ Label("Export as JSON", systemImage: "square.and.arrow.up")
|
|
| 129 |
+ } |
|
| 105 | 130 |
} |
| 131 |
+ .disabled(viewModel.exportState == .loading) |
|
| 106 | 132 |
} label: {
|
| 107 | 133 |
Image(systemName: "ellipsis.circle") |
| 108 | 134 |
.foregroundStyle(.primary) |
@@ -149,6 +149,50 @@ final class SQLiteHealthArchiveStoreTests: XCTestCase {
|
||
| 149 | 149 |
XCTAssertEqual(disappearedSummary.representationChangedCount, 0) |
| 150 | 150 |
XCTAssertEqual(disappearedRecords.map(\.displayValue), ["42.0 count"]) |
| 151 | 151 |
XCTAssertNotNil(disappearedRecords.first?.disappearedAt) |
| 152 |
+ |
|
| 153 |
+ let exportRequest = HealthArchiveReportRequest( |
|
| 154 |
+ reportID: UUID(), |
|
| 155 |
+ title: "Disappeared Step Count Export", |
|
| 156 |
+ typeIdentifierFilter: typeIdentifier, |
|
| 157 |
+ diffFromObservationID: observationIDs[1], |
|
| 158 |
+ diffToObservationID: observationIDs[2], |
|
| 159 |
+ diffKind: .disappeared |
|
| 160 |
+ ) |
|
| 161 |
+ let exportPreview = try await store.exportPreview(exportRequest) |
|
| 162 |
+ let exportURL = try await store.exportReport(exportRequest) |
|
| 163 |
+ |
|
| 164 |
+ XCTAssertEqual(exportPreview.estimatedRecordCount, 1) |
|
| 165 |
+ XCTAssertTrue(FileManager.default.fileExists(atPath: exportURL.path)) |
|
| 166 |
+ XCTAssertEqual(try countRows(in: "export_manifests WHERE record_count = 1 AND from_observation_id = \(observationIDs[1]) AND to_observation_id = \(observationIDs[2])", at: url), 1) |
|
| 167 |
+ } |
|
| 168 |
+ |
|
| 169 |
+ func testExportPreviewAndReportUseSQLiteManifest() async throws {
|
|
| 170 |
+ let url = databaseURL() |
|
| 171 |
+ let store = SQLiteHealthArchiveStore(databaseURL: url) |
|
| 172 |
+ let typeIdentifier = HKQuantityTypeIdentifier.stepCount.rawValue |
|
| 173 |
+ let samples = [ |
|
| 174 |
+ makeStepCountSample(value: 42, start: 1_000), |
|
| 175 |
+ makeStepCountSample(value: 7, start: 2_000) |
|
| 176 |
+ ] |
|
| 177 |
+ |
|
| 178 |
+ _ = try await store.upsertSamples(samples, observedAt: Date(timeIntervalSince1970: 3_000)) |
|
| 179 |
+ |
|
| 180 |
+ let request = HealthArchiveReportRequest( |
|
| 181 |
+ reportID: UUID(), |
|
| 182 |
+ title: "Step Count Export", |
|
| 183 |
+ typeIdentifierFilter: typeIdentifier |
|
| 184 |
+ ) |
|
| 185 |
+ let preview = try await store.exportPreview(request) |
|
| 186 |
+ let exportURL = try await store.exportReport(request) |
|
| 187 |
+ |
|
| 188 |
+ XCTAssertEqual(preview.estimatedRecordCount, 2) |
|
| 189 |
+ XCTAssertTrue(FileManager.default.fileExists(atPath: exportURL.path)) |
|
| 190 |
+ XCTAssertEqual(try countRows(in: "export_manifests", at: url), 1) |
|
| 191 |
+ XCTAssertEqual(try countRows(in: "export_manifests WHERE record_count = 2", at: url), 1) |
|
| 192 |
+ |
|
| 193 |
+ let cache = try CoreDataArchiveCacheStore(inMemory: true) |
|
| 194 |
+ let rebuild = try cache.rebuild(fromArchiveAt: url) |
|
| 195 |
+ XCTAssertEqual(rebuild.exportManifestRows, 1) |
|
| 152 | 196 |
} |
| 153 | 197 |
|
| 154 | 198 |
func testGroupedObservationKeepsPageWritesDeletesAndVerificationTogether() async throws {
|