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
This commit is contained in:
Marc-André Bombeck committed 2026-09-20 00:14:26 +02:00
1 parent c183d1862e
commit c405f15ef0
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 &&