AHDX: Apple Health exporter with SQLite + Grafana dashboard (MIT)

This commit is contained in:
Steffen Skui
2026-07-21 21:23:34 +02:00
commit 850c13777c
34 changed files with 5108 additions and 0 deletions
+68
View File
@@ -0,0 +1,68 @@
"""
One place that runs an import, shared by the upload page and the inbox scanner
so the two can't parse into the same database at once. A plain lock is enough:
this is a single-user app, imports are rare, and a second one can just wait.
"""
import os
import shutil
import threading
import zipfile
import db
import parser
_LOCK = threading.Lock()
def import_running():
if _LOCK.acquire(blocking=False):
_LOCK.release()
return False
return True
def extract_xml(path, workdir):
"""Return a path to the main export XML. If path is Apple's export zip, pull
the XML out of it.
Apple localizes the filename to the phone's language, so it is not always
'export.xml' (a Norwegian phone writes 'eksport.xml', German 'exportieren',
and so on). The one steady name is the clinical copy 'export_cda.xml', which
stays the same in every language and holds the same data in a format we don't
use. So we take the largest .xml that isn't the _cda one."""
if not zipfile.is_zipfile(path):
return path # already an XML, or something we'll fail on cleanly
with zipfile.ZipFile(path) as z:
xmls = [n for n in z.namelist() if n.lower().endswith(".xml")]
main = [n for n in xmls if not n.rsplit("/", 1)[-1].lower().endswith("_cda.xml")]
if not main:
return None
member = max(main, key=lambda n: z.getinfo(n).file_size)
out = os.path.join(workdir, "export.xml")
with z.open(member) as src, open(out, "wb") as dst:
shutil.copyfileobj(src, dst, length=1024 * 1024)
return out
def run_import(file_path, source):
"""Extract if needed, then merge into the active database. Blocks until done.
Returns False if another import already holds the lock."""
if not _LOCK.acquire(blocking=False):
return False
try:
xml_path = extract_xml(file_path, db.UPLOAD_DIR)
if not xml_path:
with db.health() as c:
c.execute(
"UPDATE import_status SET state = 'error', "
"message = 'No export.xml found in that file.' WHERE id = 1"
)
return False
parser.parse(xml_path, db.active_path(), source=source)
# GPS routes and ECG readings live only in the zip, so pull them too.
if zipfile.is_zipfile(file_path):
parser.parse_extras(file_path, db.active_path())
return True
finally:
_LOCK.release()