fix(import): honour the origin marker on a blood pressure correlation

A blood pressure reading is a <Correlation> 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.
This commit is contained in:
BernhardBuckel committed 2026-10-04 11:28:08 +02:00
1 parent 841d4e559c
commit 1763ac85d9
2 files changed
+135 -7

No files matched your search

@@ -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 `<Correlation>` (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
// `<Record>` elements still fire their own `onopentag`.
// Known elements we intentionally ignore at the open-tag stage. A
// `<Correlation>` is handled above: its child `<Record>` 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;
@@ -116,6 +116,109 @@ describe("a record HealthLog wrote itself (the origin marker)", () => {
});
});
const bpRecord = (
type: "Systolic" | "Diastolic",
value: number,
at: string,
children = "",
) =>
` <Record type="HKQuantityTypeIdentifierBloodPressure${type}" sourceName="Health" unit="mmHg" startDate="${at}" endDate="${at}" value="${value}">${children}</Record>`;
const correlation = (inner: string, children = "") =>
` <Correlation type="HKCorrelationTypeIdentifierBloodPressure" sourceName="Health" startDate="2026-05-14 08:00:00 +0200" endDate="2026-05-14 08:00:00 +0200">${children}\n${inner}\n </Correlation>`;
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(
` <Correlation type="HKCorrelationTypeIdentifierBloodPressure" sourceName="Health" startDate="${AT}" endDate="${AT}">\n${records}\n ${OWN}\n </Correlation>`,
);
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(
` <Record type="HKQuantityTypeIdentifierBodyMass" sourceName="Health" unit="kg" startDate="${AT}" endDate="${AT}" value="80.9"/>`,
);
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