mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
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",
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 &&
|
||||
|
||||
Reference in new issue
Block a user