From 1763ac85d9268c1e35137d5e82c09e1308155700 Mon Sep 17 00:00:00 2001 From: BernhardBuckel <33808384+BernhardBuckel@users.noreply.github.com> Date: Sun, 4 Oct 2026 11:28:08 +0200 Subject: [PATCH] fix(import): honour the origin marker on a blood pressure correlation A blood pressure reading is a wrapping its systolic and diastolic records, and the marker can sit on the correlation instead of on each record. The import only read it from a record's own metadata, so a marked correlation would have come back in full. A correlation's records are now held until it closes, then all kept or all left out together: the marker on the correlation, before or after its records, or on a record, applies. Tests cover each, the marker not spilling onto the next record, and a self-closing record. --- .../import-apple-health-export.ts | 39 +++++-- .../apple-health-import-own-samples.test.ts | 103 ++++++++++++++++++ 2 files changed, 135 insertions(+), 7 deletions(-) diff --git a/src/lib/measurements/import-apple-health-export.ts b/src/lib/measurements/import-apple-health-export.ts index 8801e4d49..43608a994 100644 --- a/src/lib/measurements/import-apple-health-export.ts +++ b/src/lib/measurements/import-apple-health-export.ts @@ -429,6 +429,13 @@ export async function streamParseExportXml( // by HealthLog itself, which has to keep it out of the import. let pendingRecord: { commit: () => void; ownOrigin: boolean } | null = null; const writtenByHealthLog = { byMarker: 0, matchedManual: 0 }; + // A `` (a blood pressure reading) wraps its records, and the + // marker may sit on the correlation itself, before or after them. Its records + // are held until it closes, then all kept or all left out together. + let currentCorrelation: { + ownOrigin: boolean; + held: Array<{ commit: () => void; ownOrigin: boolean }>; + } | null = null; // Spot-row batch awaiting flush. const spotBatch: PreparedMeasurement[] = []; // Workout-row batch awaiting flush. @@ -1091,9 +1098,16 @@ export async function streamParseExportXml( return; } + if (name === "Correlation") { + currentCorrelation = { ownOrigin: false, held: [] }; + return; + } + if (name === "MetadataEntry") { - if (pendingRecord && isHealthLogOriginEntry(attrs.key, attrs.value)) { - pendingRecord.ownOrigin = true; + if (isHealthLogOriginEntry(attrs.key, attrs.value)) { + // On a record, or on the correlation that wraps it. + if (pendingRecord) pendingRecord.ownOrigin = true; + else if (currentCorrelation) currentCorrelation.ownOrigin = true; } // Attach the SexualActivity protection flag to the open cycle record. // Apple writes `HKMetadataKeySexualActivityProtectionUsed` with a @@ -1121,16 +1135,15 @@ export async function streamParseExportXml( if ( name === "Me" || - name === "Correlation" || name === "ActivitySummary" || name === "WorkoutEvent" || name === "WorkoutRoute" || name === "FileReference" || name === "HealthData" ) { - // Known elements we intentionally ignore at the open-tag stage. - // Correlation envelopes flatten naturally because their child - // `` elements still fire their own `onopentag`. + // Known elements we intentionally ignore at the open-tag stage. A + // `` is handled above: its child `` elements fire + // their own `onopentag`, and are held until it closes. return; } @@ -1143,9 +1156,21 @@ export async function streamParseExportXml( if (tagName === "Record" && pendingRecord) { const rec = pendingRecord; pendingRecord = null; - if (rec.ownOrigin) writtenByHealthLog.byMarker += 1; + if (currentCorrelation) currentCorrelation.held.push(rec); + else if (rec.ownOrigin) writtenByHealthLog.byMarker += 1; else rec.commit(); } + if (tagName === "Correlation" && currentCorrelation) { + const correlation = currentCorrelation; + currentCorrelation = null; + for (const rec of correlation.held) { + if (correlation.ownOrigin || rec.ownOrigin) { + writtenByHealthLog.byMarker += 1; + } else { + rec.commit(); + } + } + } if (tagName === "Workout" && currentWorkout) { workoutBatch.push(currentWorkout); currentWorkout = null; diff --git a/tests/integration/apple-health-import-own-samples.test.ts b/tests/integration/apple-health-import-own-samples.test.ts index 240cf7195..8d48dc0b6 100644 --- a/tests/integration/apple-health-import-own-samples.test.ts +++ b/tests/integration/apple-health-import-own-samples.test.ts @@ -116,6 +116,109 @@ describe("a record HealthLog wrote itself (the origin marker)", () => { }); }); +const bpRecord = ( + type: "Systolic" | "Diastolic", + value: number, + at: string, + children = "", +) => + ` ${children}`; +const correlation = (inner: string, children = "") => + ` ${children}\n${inner}\n `; + +describe("blood pressure correlations and self-closing records", () => { + const AT = "2026-05-14 08:00:00 +0200"; + async function runBp(records: string) { + const user = await prisma.user.create({ + data: { username: "own-bp", email: "bp@example.test", role: "USER" }, + }); + const result = await streamParseExportXml({ + xmlPath: writeXml(records), + userId: user.id, + userTimezone: "Europe/Berlin", + prisma, + }); + const rows = await prisma.measurement.findMany({ + where: { + userId: user.id, + type: { in: ["BLOOD_PRESSURE_SYS", "BLOOD_PRESSURE_DIA"] }, + }, + orderBy: [{ type: "asc" }], + }); + return { result, values: rows.map((r) => r.value) }; + } + + it("imports an unmarked correlation's two records", async () => { + const { values, result } = await runBp( + correlation( + [bpRecord("Systolic", 121, AT), bpRecord("Diastolic", 79, AT)].join( + "\n", + ), + ), + ); + expect(values).toEqual([121, 79]); + expect(result.writtenByHealthLog.byMarker).toBe(0); + }); + + it("leaves out both records when the marker is on the correlation, before them", async () => { + const { values, result } = await runBp( + correlation( + [bpRecord("Systolic", 121, AT), bpRecord("Diastolic", 79, AT)].join( + "\n", + ), + `\n ${OWN}`, + ), + ); + expect(values).toEqual([]); + expect(result.writtenByHealthLog.byMarker).toBe(2); + }); + + it("leaves out both records when the marker is on the correlation, after them", async () => { + const records = [ + bpRecord("Systolic", 121, AT), + bpRecord("Diastolic", 79, AT), + ].join("\n"); + const { values } = await runBp( + ` \n${records}\n ${OWN}\n `, + ); + expect(values).toEqual([]); + }); + + it("leaves out only the record that carries the marker", async () => { + const { values } = await runBp( + correlation( + [ + bpRecord("Systolic", 121, AT, `\n ${OWN}\n `), + bpRecord("Diastolic", 79, AT), + ].join("\n"), + ), + ); + expect(values).toEqual([79]); + }); + + it("does not let a correlation's marker spill onto the next record", async () => { + const { values } = await runBp( + [ + correlation( + [bpRecord("Systolic", 121, AT), bpRecord("Diastolic", 79, AT)].join( + "\n", + ), + `\n ${OWN}`, + ), + bpRecord("Systolic", 130, "2026-05-14 09:00:00 +0200"), + ].join("\n"), + ); + expect(values).toEqual([130]); + }); + + it("imports a self-closing record, with no children to wait for", async () => { + const { rows } = await run( + ` `, + ); + expect(rows.map((r) => r.value)).toEqual([80.9]); + }); +}); + describe("the synthetic export fixture", () => { // `export-own-origin.synthetic.xml` is invented, in the shape Apple's DTD // documents. It shows the parser reads a file laid out like an export; it