From 19699ce8cea33a5fb6ad8efeb9ea56bb12ad3dbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 23 Aug 2026 18:47:37 +0200 Subject: [PATCH] fix(apple-health): accept the valueless header row an ECG export opens with MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every real export begins with a bare `Name` when the name field is blank — Apple omits the trailing comma rather than writing an empty value. The parser required a comma on every header row, so it rejected the file on line 1, before it had read anything. A valueless header row is now skipped. It contributes no metadata entry rather than an empty-string one, because `Lead` and `Unit` presence is what switches the parser into the single-column waveform mode and a key with no value must not be able to trip that switch. The relaxation is confined to the header. Inside the single-column waveform a comma-less row is a sample and is read as one; inside the paired waveform every row is lead and voltage, so a missing comma there is still malformed. --- src/lib/apple-health/ecg-csv.ts | 19 +++++++- .../__tests__/apple-health-ecg-import.test.ts | 44 +++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) diff --git a/src/lib/apple-health/ecg-csv.ts b/src/lib/apple-health/ecg-csv.ts index b32285671..dda176dd8 100644 --- a/src/lib/apple-health/ecg-csv.ts +++ b/src/lib/apple-health/ecg-csv.ts @@ -160,7 +160,24 @@ export async function parseAppleHealthEcgCsv(input: { } const pair = splitPair(line); if (!pair) { - throw parserError("row is malformed"); + // A HEADER row whose value is empty is written without the trailing + // comma. Every real export opens with a bare `Name` when the name + // field is blank, which this parser used to reject on line 1 — before + // it had read anything at all. + // + // Only the header section may do this. Inside the single-column + // waveform a comma-less row is a sample and was handled above; inside + // the paired waveform every row is `lead,voltage`, so a missing comma + // there is still malformed. + // + // The row contributes NO metadata entry rather than an empty-string + // one: `Lead` and `Unit` presence is what switches the parser into the + // single-column mode below, and a valueless key must not be able to + // trip that switch. + if (inSamples) { + throw parserError("row is malformed"); + } + continue; } const [key, value] = pair; if (!inSamples) { diff --git a/src/lib/measurements/__tests__/apple-health-ecg-import.test.ts b/src/lib/measurements/__tests__/apple-health-ecg-import.test.ts index d939c7638..b912bbbe5 100644 --- a/src/lib/measurements/__tests__/apple-health-ecg-import.test.ts +++ b/src/lib/measurements/__tests__/apple-health-ecg-import.test.ts @@ -427,3 +427,47 @@ describe("Apple Health HKElectrocardiogram CSV parser", () => { expect(spies.every((spy) => spy.mock.calls.length === 0)).toBe(true); }); }); + +/** + * The shapes real exports actually use. + * + * The fixtures above were written from the format's documentation rather than + * from a file off a watch, and they drifted from it in four independent ways. + * Each block below pins one of them, and — because the differences are dialect + * rather than version — pins BOTH dialects side by side, so a later edit cannot + * quietly trade one for the other. + */ +describe("Apple Health ECG CSV — real-export shapes", () => { + async function parse(csv: string, maxSamples = 8) { + const subject = await loadParserSubject(); + return subject.parseAppleHealthEcgCsv({ + memberName: "apple_health_export/electrocardiograms/ecg_2026-07-18.csv", + stream: Readable.from([Buffer.from(csv)]), + maxSamples, + }); + } + + describe("a header row with no value", () => { + it("accepts the bare `Name` row every observed export opens with", async () => { + // The name field is blank and Apple omits the comma entirely, so the + // parser used to fail on line 1, before reading anything. + await expect( + parse(validCsv().replace("Name,Private Patient Name", "Name")), + ).resolves.toMatchObject({ samplingFrequency: 512 }); + }); + + it("keeps a valueless header row out of the metadata", async () => { + // `Lead` + `Unit` presence is what switches the parser into the + // single-column mode; a valueless key must not be able to trip it. + await expect( + parse(`${singleColumnCsv().replace("Unit,µV", "Unit")}`), + ).rejects.toThrow(/samples are missing|malformed/i); + }); + + it("still rejects a comma-less row inside the paired waveform", async () => { + await expect( + parse(validCsv().replace("I,-0.002", "-0.002")), + ).rejects.toThrow(/malformed/i); + }); + }); +});