Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 82 additions & 0 deletions src/lib/import/__tests__/unzip-export-xml-member.test.ts
Original file line number Diff line number Diff line change
@@ -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",
],
});
});
});
62 changes: 57 additions & 5 deletions src/lib/import/unzip-export-xml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,56 @@ export interface ExtractExportXmlOptions {
preflight?: (info: ExportXmlPreflightInfo) => void | Promise<void>;
}

/** 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<T extends { fileName: string }>(
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.
Expand All @@ -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 &&
Expand Down
Loading