mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
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
|
||||
|
||||
Reference in new issue
Block a user