From c405f15ef0d0e6b84c2911ad7f9a4849f76f52c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sun, 20 Sep 2026 00:14:26 +0200 Subject: [PATCH] fix(import): find the export XML whatever iOS named it The archive member was matched against the English lowercase spelling alone. iOS writes that file name in the phone's language and some versions capitalise it, so an ordinary export could arrive as Export.xml or a translated name and was refused with a message telling the reader their export was invalid. The basename now matches in any capitalisation, and a single XML member at the top of the archive is taken as the translated spelling. Apple's clinical-document file never qualifies: it sits beside the real export and would parse to nothing. When no member qualifies the error lists the XML members the archive does hold, so the next report arrives with the answer in it. Refs #1008 --- .../__tests__/unzip-export-xml-member.test.ts | 82 +++++++++++++++++++ src/lib/import/unzip-export-xml.ts | 62 ++++++++++++-- 2 files changed, 139 insertions(+), 5 deletions(-) create mode 100644 src/lib/import/__tests__/unzip-export-xml-member.test.ts diff --git a/src/lib/import/__tests__/unzip-export-xml-member.test.ts b/src/lib/import/__tests__/unzip-export-xml-member.test.ts new file mode 100644 index 000000000..b80bcf678 --- /dev/null +++ b/src/lib/import/__tests__/unzip-export-xml-member.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from "vitest"; + +import { selectExportXmlEntry } from "../unzip-export-xml"; + +/** + * Which member of an Apple Health archive holds the export. + * + * iOS writes the file name in the phone's language and some versions + * capitalise it, so `apple_health_export/Export.xml` and translated spellings + * are ordinary archives, not broken ones. Matching the English lowercase name + * alone turned them away with a message claiming the export was invalid. + */ +const entry = (fileName: string) => ({ fileName }); + +describe("selecting the export XML member", () => { + it("takes the documented lowercase name", () => { + const result = selectExportXmlEntry([ + entry("apple_health_export/export.xml"), + entry("apple_health_export/export_cda.xml"), + ]); + expect(result).toEqual({ + entry: entry("apple_health_export/export.xml"), + }); + }); + + it("takes the same name capitalised, which iOS also writes", () => { + // The decoy matters: with only one plausible XML left, the fallback for + // translated names would pick the right file anyway and this case would + // pass even against a strictly lowercase match. + const result = selectExportXmlEntry([ + entry("apple_health_export/Export.xml"), + entry("apple_health_export/Activities.xml"), + entry("apple_health_export/export_cda.xml"), + ]); + expect(result).toEqual({ + entry: entry("apple_health_export/Export.xml"), + }); + }); + + it("takes a translated name when it is the only candidate", () => { + const result = selectExportXmlEntry([ + entry("apple_health_export/Exportar.xml"), + entry("apple_health_export/Exportar_cda.xml"), + entry("apple_health_export/electrocardiograms/ecg_2026-01-01.csv"), + ]); + expect(result).toEqual({ + entry: entry("apple_health_export/Exportar.xml"), + }); + }); + + it("never takes the clinical-document file, which parses to nothing", () => { + const result = selectExportXmlEntry([ + entry("apple_health_export/export_cda.xml"), + ]); + expect(result).toEqual({ + candidates: ["apple_health_export/export_cda.xml"], + }); + }); + + it("ignores the resource fork a macOS re-zip adds", () => { + const result = selectExportXmlEntry([ + entry("__MACOSX/apple_health_export/._export.xml"), + entry("apple_health_export/export.xml"), + ]); + expect(result).toEqual({ + entry: entry("apple_health_export/export.xml"), + }); + }); + + it("names what it did find when nothing qualifies", () => { + const result = selectExportXmlEntry([ + entry("apple_health_export/one.xml"), + entry("apple_health_export/two.xml"), + ]); + expect(result).toEqual({ + candidates: [ + "apple_health_export/one.xml", + "apple_health_export/two.xml", + ], + }); + }); +}); diff --git a/src/lib/import/unzip-export-xml.ts b/src/lib/import/unzip-export-xml.ts index 3a45b91a3..e9922a7ad 100644 --- a/src/lib/import/unzip-export-xml.ts +++ b/src/lib/import/unzip-export-xml.ts @@ -133,6 +133,56 @@ export interface ExtractExportXmlOptions { preflight?: (info: ExportXmlPreflightInfo) => void | Promise; } +/** Apple ships a second, far smaller clinical-document XML beside the export. */ +const CDA_SUFFIX = /_cda\.xml$/i; + +function baseName(path: string): string { + const cut = path.lastIndexOf("/"); + return cut === -1 ? path : path.slice(cut + 1); +} + +/** + * Find the export XML inside an Apple Health archive. + * + * The member is not always `apple_health_export/export.xml`. iOS writes the + * file name in the phone's language, and some versions capitalise it, so an + * archive can carry `Export.xml` or a translated name instead. Matching the + * English lowercase spelling alone refused those archives with a message that + * told the reader their export was invalid, which it was not. + * + * Order matters. The basename `export.xml` in any capitalisation wins, since + * that is the overwhelming case and the one Apple documents. Failing that, a + * single XML member at the top of the archive is taken as the translated + * spelling. `*_cda.xml` never qualifies: Apple ships that clinical-document + * file beside the real export, and picking it would parse to nothing. + */ +export function selectExportXmlEntry( + entries: readonly T[], +): { entry: T } | { candidates: readonly string[] } { + const usable = entries.filter( + (e) => !e.fileName.startsWith("__MACOSX/") && !e.fileName.endsWith("/"), + ); + const exact = usable.find( + (e) => baseName(e.fileName).toLowerCase() === "export.xml", + ); + if (exact) return { entry: exact }; + + const shallowXml = usable.filter( + (e) => + /\.xml$/i.test(e.fileName) && + !CDA_SUFFIX.test(baseName(e.fileName)) && + e.fileName.split("/").length <= 2, + ); + if (shallowXml.length === 1) return { entry: shallowXml[0] }; + + return { + candidates: usable + .filter((e) => /\.xml$/i.test(e.fileName)) + .map((e) => e.fileName) + .slice(0, 8), + }; +} + /** * Walk the central directory of `archivePath` and write the * `apple_health_export/export.xml` member out to a temp file. @@ -152,15 +202,17 @@ export async function extractExportXml( } const entries = await readCentralDirectoryFromFile(handle, fileSize); - const exportXmlEntry = entries.find( - (e) => e.fileName.endsWith("/export.xml") || e.fileName === "export.xml", - ); - if (!exportXmlEntry) { + const selected = selectExportXmlEntry(entries); + if ("candidates" in selected) { throw new Error( "Archive is missing the `apple_health_export/export.xml` member" + - " — is this a valid Apple Health export.zip?", + " — is this a valid Apple Health export.zip?" + + (selected.candidates.length > 0 + ? ` XML members found: ${selected.candidates.join(", ")}` + : " The archive contains no XML member at all."), ); } + const exportXmlEntry = selected.entry; if ( exportXmlEntry.compressionMethod !== 0 &&