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 &&