Merge pull request #1009 from MBombeck/fix/import-export-xml-name

fix(import): find the export XML whatever iOS named it
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-09-20 00:33:54 +02:00
commit 6291c95b5a
2 files changed
+139 -5

No files matched your search

@@ -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",
],
});
});
});
+57 -5
View File
@@ -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.
@@ -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 &&